@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.
- package/CHANGELOG.md +67 -0
- package/README.md +66 -4
- package/dist/IssueService.d.ts +2 -2
- package/dist/IssueService.d.ts.map +1 -1
- package/dist/IssueService.js +3 -3
- package/dist/IssueService.js.map +1 -1
- package/dist/JiraAuth.d.ts +14 -14
- package/dist/JiraAuth.d.ts.map +1 -1
- package/dist/JiraAuth.js +18 -10
- package/dist/JiraAuth.js.map +1 -1
- 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/MarkdownWriter.d.ts +4 -4
- package/dist/MarkdownWriter.d.ts.map +1 -1
- package/dist/MarkdownWriter.js +6 -6
- package/dist/MarkdownWriter.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/VersionService.d.ts +206 -0
- package/dist/VersionService.d.ts.map +1 -0
- package/dist/VersionService.js +426 -0
- package/dist/VersionService.js.map +1 -0
- package/dist/bin.js +29 -22
- package/dist/bin.js.map +1 -1
- package/dist/commands/auth.d.ts +2 -21
- package/dist/commands/auth.d.ts.map +1 -1
- package/dist/commands/auth.js +6 -6
- package/dist/commands/auth.js.map +1 -1
- package/dist/commands/get.d.ts +3 -8
- package/dist/commands/get.d.ts.map +1 -1
- package/dist/commands/get.js +4 -4
- package/dist/commands/get.js.map +1 -1
- package/dist/commands/index.d.ts +2 -2
- package/dist/commands/index.d.ts.map +1 -1
- package/dist/commands/index.js +2 -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 +6 -18
- package/dist/commands/layers.d.ts.map +1 -1
- package/dist/commands/layers.js +35 -24
- package/dist/commands/layers.js.map +1 -1
- package/dist/commands/search.d.ts +3 -8
- package/dist/commands/search.d.ts.map +1 -1
- package/dist/commands/search.js +8 -8
- package/dist/commands/search.js.map +1 -1
- package/dist/commands/version.d.ts +12 -0
- package/dist/commands/version.d.ts.map +1 -0
- package/dist/commands/version.js +179 -0
- package/dist/commands/version.js.map +1 -0
- 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/oauthServer.d.ts +17 -5
- package/dist/internal/oauthServer.d.ts.map +1 -1
- package/dist/internal/oauthServer.js +23 -40
- package/dist/internal/oauthServer.js.map +1 -1
- package/dist/internal/openBrowser.d.ts +10 -0
- package/dist/internal/openBrowser.d.ts.map +1 -0
- package/dist/internal/openBrowser.js +17 -0
- package/dist/internal/openBrowser.js.map +1 -0
- 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 +13 -12
- package/skills/jira/SKILL.md +90 -0
- package/skills/jira/agents/openai.yaml +4 -0
- package/src/IssueService.ts +34 -28
- package/src/JiraAuth.ts +53 -39
- package/src/JiraCliError.ts +24 -0
- package/src/MarkdownWriter.ts +7 -11
- package/src/SyncWorkspace.ts +185 -0
- package/src/VersionService.ts +647 -0
- package/src/bin.ts +39 -29
- package/src/commands/auth.ts +6 -12
- package/src/commands/get.ts +4 -4
- package/src/commands/index.ts +2 -2
- package/src/commands/issue.ts +13 -0
- package/src/commands/layers.ts +44 -26
- package/src/commands/search.ts +8 -8
- package/src/commands/version.ts +267 -0
- package/src/internal/frontmatter.ts +15 -1
- package/src/internal/oauthServer.ts +43 -70
- package/src/internal/openBrowser.ts +31 -0
- 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/VersionService.test.ts +266 -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 +6 -0
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-only integration tests for Jira issue fetching and Markdown export.
|
|
3
|
+
*
|
|
4
|
+
* Requires:
|
|
5
|
+
* - JIRA_INTEGRATION=1
|
|
6
|
+
* - JIRA_EMAIL
|
|
7
|
+
* - JIRA_API_KEY
|
|
8
|
+
*
|
|
9
|
+
* Optional overrides:
|
|
10
|
+
* - JIRA_BASE_URL defaults to https://knpkv.atlassian.net
|
|
11
|
+
* - JIRA_ISSUE_KEY defaults to KAN-1
|
|
12
|
+
*/
|
|
13
|
+
import * as NodeServices from "@effect/platform-node/NodeServices"
|
|
14
|
+
import { describe, expect, it } from "@effect/vitest"
|
|
15
|
+
import { JiraApiClient, JiraApiConfig } from "@knpkv/jira-api-client"
|
|
16
|
+
import { Config, Effect, Layer, Option } from "effect"
|
|
17
|
+
import * as FileSystem from "effect/FileSystem"
|
|
18
|
+
import * as Path from "effect/Path"
|
|
19
|
+
import * as Redacted from "effect/Redacted"
|
|
20
|
+
import * as yaml from "js-yaml"
|
|
21
|
+
import { IssueService, layer as IssueServiceLayer, SiteUrl } from "../src/IssueService.js"
|
|
22
|
+
import { layer as MarkdownWriterLayer, MarkdownWriter } from "../src/MarkdownWriter.js"
|
|
23
|
+
|
|
24
|
+
const DEFAULT_BASE_URL = "https://knpkv.atlassian.net"
|
|
25
|
+
const DEFAULT_ISSUE_KEY = "KAN-1"
|
|
26
|
+
|
|
27
|
+
interface IntegrationConfig {
|
|
28
|
+
readonly baseUrl: string
|
|
29
|
+
readonly issueKey: string
|
|
30
|
+
readonly email: string
|
|
31
|
+
readonly apiKey: Redacted.Redacted<string>
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const envFlagEnabled = (value: string): boolean => ["1", "true", "yes"].includes(value.toLowerCase())
|
|
35
|
+
|
|
36
|
+
const nonEmptyOrDefault = (value: Option.Option<string>, fallback: string): string =>
|
|
37
|
+
Option.match(value, {
|
|
38
|
+
onNone: () => fallback,
|
|
39
|
+
onSome: (some) => some.trim().length > 0 ? some : fallback
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
const requireNonEmpty = (name: string, value: string): Effect.Effect<string, Error> =>
|
|
43
|
+
value.trim().length > 0 ? Effect.succeed(value) : Effect.fail(new Error(`${name} must be set`))
|
|
44
|
+
|
|
45
|
+
const requireNonEmptyRedacted = (
|
|
46
|
+
name: string,
|
|
47
|
+
value: Redacted.Redacted<string>
|
|
48
|
+
): Effect.Effect<Redacted.Redacted<string>, Error> =>
|
|
49
|
+
Redacted.value(value).trim().length > 0 ? Effect.succeed(value) : Effect.fail(new Error(`${name} must be set`))
|
|
50
|
+
|
|
51
|
+
const SHOULD_RUN_INTEGRATION = Effect.runSync(
|
|
52
|
+
Config.option(Config.string("JIRA_INTEGRATION")).pipe(
|
|
53
|
+
Effect.map((enabled) => Option.isSome(enabled) && envFlagEnabled(enabled.value))
|
|
54
|
+
)
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
const readIntegrationConfig = Effect.gen(function*() {
|
|
58
|
+
const baseUrl = yield* Config.option(Config.string("JIRA_BASE_URL"))
|
|
59
|
+
const issueKey = yield* Config.option(Config.string("JIRA_ISSUE_KEY"))
|
|
60
|
+
const email = yield* Config.string("JIRA_EMAIL")
|
|
61
|
+
const apiKey = yield* Config.redacted("JIRA_API_KEY")
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
baseUrl: nonEmptyOrDefault(baseUrl, DEFAULT_BASE_URL).replace(/\/+$/, ""),
|
|
65
|
+
issueKey: nonEmptyOrDefault(issueKey, DEFAULT_ISSUE_KEY),
|
|
66
|
+
email: yield* requireNonEmpty("JIRA_EMAIL", email),
|
|
67
|
+
apiKey: yield* requireNonEmptyRedacted("JIRA_API_KEY", apiKey)
|
|
68
|
+
} satisfies IntegrationConfig
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
const makeIntegrationLayer = (config: IntegrationConfig) => {
|
|
72
|
+
const configLayer = Layer.succeed(JiraApiConfig, {
|
|
73
|
+
baseUrl: config.baseUrl,
|
|
74
|
+
auth: {
|
|
75
|
+
type: "basic" as const,
|
|
76
|
+
email: config.email,
|
|
77
|
+
apiToken: config.apiKey
|
|
78
|
+
}
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
return MarkdownWriterLayer.pipe(
|
|
82
|
+
Layer.provideMerge(IssueServiceLayer),
|
|
83
|
+
Layer.provideMerge(Layer.succeed(SiteUrl, config.baseUrl)),
|
|
84
|
+
Layer.provideMerge(JiraApiClient.layer),
|
|
85
|
+
Layer.provideMerge(configLayer),
|
|
86
|
+
Layer.provideMerge(NodeServices.layer)
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const makeTempRoot = Effect.gen(function*() {
|
|
91
|
+
const fs = yield* FileSystem.FileSystem
|
|
92
|
+
return yield* fs.makeTempDirectoryScoped()
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
const joinPath = (...parts: ReadonlyArray<string>) =>
|
|
96
|
+
Path.Path.pipe(
|
|
97
|
+
Effect.map((path) => path.join(...parts))
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
const readText = (filePath: string) =>
|
|
101
|
+
FileSystem.FileSystem.pipe(
|
|
102
|
+
Effect.flatMap((fs) => fs.readFileString(filePath))
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
const pathExists = (filePath: string) =>
|
|
106
|
+
FileSystem.FileSystem.pipe(
|
|
107
|
+
Effect.flatMap((fs) => fs.exists(filePath))
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
const parseFrontMatter = (markdown: string): Record<string, unknown> => {
|
|
111
|
+
const match = /^---\n([\s\S]*?)\n---/.exec(markdown)
|
|
112
|
+
const parsed = match?.[1] ? yaml.load(match[1]) : {}
|
|
113
|
+
return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)
|
|
114
|
+
? parsed as Record<string, unknown>
|
|
115
|
+
: {}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const getIssueAsMarkdown = (config: IntegrationConfig, testDir: string) =>
|
|
119
|
+
Effect.gen(function*() {
|
|
120
|
+
const issueService = yield* IssueService
|
|
121
|
+
const writer = yield* MarkdownWriter
|
|
122
|
+
const outputDir = yield* joinPath(testDir, "get-output")
|
|
123
|
+
|
|
124
|
+
const issue = yield* issueService.getByKey(config.issueKey)
|
|
125
|
+
yield* writer.writeMulti([issue], outputDir)
|
|
126
|
+
|
|
127
|
+
expect(issue.key).toBe(config.issueKey)
|
|
128
|
+
expect(issue.url).toBe(`${config.baseUrl}/browse/${config.issueKey}`)
|
|
129
|
+
expect(issue.summary.length).toBeGreaterThan(0)
|
|
130
|
+
|
|
131
|
+
const issueFile = yield* joinPath(outputDir, `${config.issueKey}.md`)
|
|
132
|
+
expect(yield* pathExists(issueFile)).toBe(true)
|
|
133
|
+
|
|
134
|
+
const markdown = yield* readText(issueFile)
|
|
135
|
+
const frontMatter = parseFrontMatter(markdown)
|
|
136
|
+
|
|
137
|
+
expect(frontMatter.key).toBe(config.issueKey)
|
|
138
|
+
expect(frontMatter.url).toBe(`${config.baseUrl}/browse/${config.issueKey}`)
|
|
139
|
+
expect(frontMatter.id).toBe(issue.id)
|
|
140
|
+
expect(frontMatter.summary).toBe(issue.summary)
|
|
141
|
+
expect(typeof frontMatter.status).toBe("string")
|
|
142
|
+
expect(markdown).toContain(`# ${config.issueKey}:`)
|
|
143
|
+
|
|
144
|
+
return { frontMatter, issue }
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
const searchIssueAsSingleMarkdown = (config: IntegrationConfig, testDir: string) =>
|
|
148
|
+
Effect.gen(function*() {
|
|
149
|
+
const issueService = yield* IssueService
|
|
150
|
+
const writer = yield* MarkdownWriter
|
|
151
|
+
const outputDir = yield* joinPath(testDir, "search-output")
|
|
152
|
+
const jql = `issuekey = ${config.issueKey}`
|
|
153
|
+
|
|
154
|
+
const issues = yield* issueService.searchAll(jql, { maxResults: 1 })
|
|
155
|
+
expect(issues.map((issue) => issue.key)).toContain(config.issueKey)
|
|
156
|
+
|
|
157
|
+
yield* writer.writeSingle(issues, outputDir, jql)
|
|
158
|
+
|
|
159
|
+
const exportFile = yield* joinPath(outputDir, "jira-export.md")
|
|
160
|
+
expect(yield* pathExists(exportFile)).toBe(true)
|
|
161
|
+
|
|
162
|
+
const markdown = yield* readText(exportFile)
|
|
163
|
+
expect(markdown).toContain("# Jira Export")
|
|
164
|
+
expect(markdown).toContain(`Query: \`${jql}\``)
|
|
165
|
+
expect(markdown).toContain(`## ${config.issueKey}:`)
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
describe("Jira integration", () => {
|
|
169
|
+
it.effect.skipIf(!SHOULD_RUN_INTEGRATION)(
|
|
170
|
+
"fetches KAN-1 with an API key and exports it as markdown",
|
|
171
|
+
() =>
|
|
172
|
+
Effect.gen(function*() {
|
|
173
|
+
const config = yield* readIntegrationConfig
|
|
174
|
+
return yield* Effect.gen(function*() {
|
|
175
|
+
const testDir = yield* makeTempRoot
|
|
176
|
+
|
|
177
|
+
const { frontMatter, issue } = yield* getIssueAsMarkdown(config, testDir)
|
|
178
|
+
yield* searchIssueAsSingleMarkdown(config, testDir)
|
|
179
|
+
expect(frontMatter.key).toBe(issue.key)
|
|
180
|
+
}).pipe(
|
|
181
|
+
Effect.provide(makeIntegrationLayer(config)),
|
|
182
|
+
Effect.scoped
|
|
183
|
+
)
|
|
184
|
+
}),
|
|
185
|
+
120000
|
|
186
|
+
)
|
|
187
|
+
})
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { describe, expect, it } from "@effect/vitest"
|
|
2
|
+
import { compareIssueFields } from "../src/internal/sync/changes.js"
|
|
3
|
+
import type { SyncBaselineFields } from "../src/internal/sync/types.js"
|
|
4
|
+
|
|
5
|
+
const fields = (overrides: Partial<SyncBaselineFields> = {}): SyncBaselineFields => ({
|
|
6
|
+
summary: "Baseline summary",
|
|
7
|
+
description: "Baseline description",
|
|
8
|
+
labels: ["a", "b"],
|
|
9
|
+
customFields: {
|
|
10
|
+
Risk: {
|
|
11
|
+
fieldId: "customfield_1",
|
|
12
|
+
displayName: "Risk",
|
|
13
|
+
shape: "singleSelect",
|
|
14
|
+
value: { id: "1", value: "Low" }
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
...overrides
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
const compare = (
|
|
21
|
+
jira: SyncBaselineFields,
|
|
22
|
+
document: SyncBaselineFields,
|
|
23
|
+
baseline: SyncBaselineFields = fields()
|
|
24
|
+
) =>
|
|
25
|
+
compareIssueFields({
|
|
26
|
+
issueId: "100123",
|
|
27
|
+
issueKey: "PROJ-123",
|
|
28
|
+
baseline,
|
|
29
|
+
jira,
|
|
30
|
+
document
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
describe("compareIssueFields", () => {
|
|
34
|
+
it("returns no changes when Jira and document match the baseline", () => {
|
|
35
|
+
const result = compare(fields(), fields())
|
|
36
|
+
expect(result.changes).toEqual([])
|
|
37
|
+
expect(result.validationFailures).toEqual([])
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it("classifies remote-only changes", () => {
|
|
41
|
+
const result = compare(fields({ summary: "Remote summary" }), fields())
|
|
42
|
+
expect(result.changes).toEqual([{
|
|
43
|
+
_tag: "RemoteOnly",
|
|
44
|
+
issueId: "100123",
|
|
45
|
+
issueKey: "PROJ-123",
|
|
46
|
+
field: "summary",
|
|
47
|
+
jiraValue: "Remote summary"
|
|
48
|
+
}])
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it("classifies local-only changes", () => {
|
|
52
|
+
const result = compare(fields(), fields({ description: "Local description" }))
|
|
53
|
+
expect(result.changes).toEqual([{
|
|
54
|
+
_tag: "LocalOnly",
|
|
55
|
+
issueId: "100123",
|
|
56
|
+
issueKey: "PROJ-123",
|
|
57
|
+
field: "description",
|
|
58
|
+
documentValue: "Local description"
|
|
59
|
+
}])
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it("classifies sync conflicts", () => {
|
|
63
|
+
const result = compare(fields({ summary: "Remote summary" }), fields({ summary: "Local summary" }))
|
|
64
|
+
expect(result.changes).toEqual([{
|
|
65
|
+
_tag: "Conflict",
|
|
66
|
+
issueId: "100123",
|
|
67
|
+
issueKey: "PROJ-123",
|
|
68
|
+
field: "summary",
|
|
69
|
+
baselineValue: "Baseline summary",
|
|
70
|
+
jiraValue: "Remote summary",
|
|
71
|
+
documentValue: "Local summary"
|
|
72
|
+
}])
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it("compares custom fields", () => {
|
|
76
|
+
const result = compare(
|
|
77
|
+
fields(),
|
|
78
|
+
fields({
|
|
79
|
+
customFields: {
|
|
80
|
+
Risk: {
|
|
81
|
+
fieldId: "customfield_1",
|
|
82
|
+
displayName: "Risk",
|
|
83
|
+
shape: "singleSelect",
|
|
84
|
+
value: { id: "2", value: "High" }
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
})
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
expect(result.changes[0]).toMatchObject({
|
|
91
|
+
_tag: "LocalOnly",
|
|
92
|
+
field: "customFields.Risk",
|
|
93
|
+
documentValue: { id: "2", value: "High" }
|
|
94
|
+
})
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it("reports missing custom fields as validation failures", () => {
|
|
98
|
+
const result = compare(fields(), fields({ customFields: {} }))
|
|
99
|
+
expect(result.validationFailures).toEqual([{
|
|
100
|
+
_tag: "ValidationFailure",
|
|
101
|
+
issueKey: "PROJ-123",
|
|
102
|
+
field: "customFields.Risk",
|
|
103
|
+
message: "Missing reconciled custom field \"Risk\""
|
|
104
|
+
}])
|
|
105
|
+
})
|
|
106
|
+
})
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { describe, expect, it } from "@effect/vitest"
|
|
2
|
+
import * as Effect from "effect/Effect"
|
|
3
|
+
import * as Exit from "effect/Exit"
|
|
4
|
+
import { parseSyncBaseline, serializeSyncBaseline } from "../src/internal/sync/baseline.js"
|
|
5
|
+
import { parseWorkspaceConfig, serializeWorkspaceConfig } from "../src/internal/sync/config.js"
|
|
6
|
+
import { makeEmptyManifest, parseSyncManifest, serializeSyncManifest } from "../src/internal/sync/manifest.js"
|
|
7
|
+
import type { SyncBaseline } from "../src/internal/sync/types.js"
|
|
8
|
+
|
|
9
|
+
const runExit = <A, E>(effect: Effect.Effect<A, E>): Promise<Exit.Exit<A, E>> => Effect.runPromiseExit(effect)
|
|
10
|
+
|
|
11
|
+
describe("Jira Markdown Sync local data", () => {
|
|
12
|
+
describe("WorkspaceConfig", () => {
|
|
13
|
+
it("parses defaults and requested custom fields", async () => {
|
|
14
|
+
const result = await Effect.runPromise(parseWorkspaceConfig(
|
|
15
|
+
"config.yaml",
|
|
16
|
+
`
|
|
17
|
+
siteUrl: https://example.atlassian.net
|
|
18
|
+
customFields:
|
|
19
|
+
- displayName: Security & Compliance Impact
|
|
20
|
+
shape: singleSelect
|
|
21
|
+
- displayName: Reviewer
|
|
22
|
+
fieldId: customfield_12345
|
|
23
|
+
shape: user
|
|
24
|
+
`
|
|
25
|
+
))
|
|
26
|
+
|
|
27
|
+
expect(result.documentsDir).toBe("issues")
|
|
28
|
+
expect(result.customFields).toHaveLength(2)
|
|
29
|
+
expect(result.customFields[1]).toMatchObject({
|
|
30
|
+
displayName: "Reviewer",
|
|
31
|
+
fieldId: "customfield_12345",
|
|
32
|
+
shape: "user"
|
|
33
|
+
})
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it("rejects unsupported field shapes", async () => {
|
|
37
|
+
const result = await runExit(parseWorkspaceConfig(
|
|
38
|
+
"config.yaml",
|
|
39
|
+
`
|
|
40
|
+
siteUrl: https://example.atlassian.net
|
|
41
|
+
customFields:
|
|
42
|
+
- displayName: Mystery
|
|
43
|
+
shape: unsupported
|
|
44
|
+
`
|
|
45
|
+
))
|
|
46
|
+
|
|
47
|
+
expect(Exit.isFailure(result)).toBe(true)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it("rejects duplicate display names without field ids", async () => {
|
|
51
|
+
const result = await runExit(parseWorkspaceConfig(
|
|
52
|
+
"config.yaml",
|
|
53
|
+
`
|
|
54
|
+
siteUrl: https://example.atlassian.net
|
|
55
|
+
customFields:
|
|
56
|
+
- displayName: Reviewer
|
|
57
|
+
shape: user
|
|
58
|
+
- displayName: Reviewer
|
|
59
|
+
shape: user
|
|
60
|
+
`
|
|
61
|
+
))
|
|
62
|
+
|
|
63
|
+
expect(Exit.isFailure(result)).toBe(true)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it("allows duplicate display names when field ids disambiguate", async () => {
|
|
67
|
+
const config = await Effect.runPromise(parseWorkspaceConfig(
|
|
68
|
+
"config.yaml",
|
|
69
|
+
`
|
|
70
|
+
siteUrl: https://example.atlassian.net
|
|
71
|
+
customFields:
|
|
72
|
+
- displayName: Reviewer
|
|
73
|
+
fieldId: customfield_1
|
|
74
|
+
shape: user
|
|
75
|
+
- displayName: Reviewer
|
|
76
|
+
fieldId: customfield_2
|
|
77
|
+
shape: user
|
|
78
|
+
`
|
|
79
|
+
))
|
|
80
|
+
|
|
81
|
+
expect(config.customFields).toHaveLength(2)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it("round-trips YAML serialization", async () => {
|
|
85
|
+
const config = await Effect.runPromise(parseWorkspaceConfig(
|
|
86
|
+
"config.yaml",
|
|
87
|
+
`
|
|
88
|
+
siteUrl: https://example.atlassian.net
|
|
89
|
+
documentsDir: issue-docs
|
|
90
|
+
customFields: []
|
|
91
|
+
`
|
|
92
|
+
))
|
|
93
|
+
|
|
94
|
+
const parsed = await Effect.runPromise(parseWorkspaceConfig("config.yaml", serializeWorkspaceConfig(config)))
|
|
95
|
+
expect(parsed).toEqual(config)
|
|
96
|
+
})
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
describe("SyncManifest", () => {
|
|
100
|
+
it("round-trips an empty manifest", async () => {
|
|
101
|
+
const manifest = makeEmptyManifest("https://example.atlassian.net")
|
|
102
|
+
const parsed = await Effect.runPromise(parseSyncManifest("manifest.json", serializeSyncManifest(manifest)))
|
|
103
|
+
expect(parsed).toEqual(manifest)
|
|
104
|
+
})
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
describe("SyncBaseline", () => {
|
|
108
|
+
const baseline: SyncBaseline = {
|
|
109
|
+
version: 1,
|
|
110
|
+
issueId: "100123",
|
|
111
|
+
issueKey: "PROJ-123",
|
|
112
|
+
fields: {
|
|
113
|
+
summary: "Fix checkout copy",
|
|
114
|
+
description: "Editable description",
|
|
115
|
+
labels: ["checkout", "copy"],
|
|
116
|
+
customFields: {
|
|
117
|
+
"Security & Compliance Impact": {
|
|
118
|
+
fieldId: "customfield_10001",
|
|
119
|
+
displayName: "Security & Compliance Impact",
|
|
120
|
+
shape: "singleSelect",
|
|
121
|
+
value: { id: "10423", value: "Low" }
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
comments: [{ id: "20001" }]
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
it("round-trips baseline JSON", async () => {
|
|
129
|
+
const parsed = await Effect.runPromise(parseSyncBaseline("100123.json", serializeSyncBaseline(baseline)))
|
|
130
|
+
expect(parsed).toEqual(baseline)
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it("rejects corrupt baseline JSON", async () => {
|
|
134
|
+
const result = await runExit(parseSyncBaseline("100123.json", "{nope"))
|
|
135
|
+
expect(Exit.isFailure(result)).toBe(true)
|
|
136
|
+
})
|
|
137
|
+
})
|
|
138
|
+
})
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { describe, expect, it } from "@effect/vitest"
|
|
2
|
+
import { parseIssueDocument, serializeIssueDocument } from "../src/internal/sync/document.js"
|
|
3
|
+
import type { IssueDocument } from "../src/internal/sync/types.js"
|
|
4
|
+
import { SyncValidationError } from "../src/JiraCliError.js"
|
|
5
|
+
|
|
6
|
+
const document: IssueDocument = {
|
|
7
|
+
frontMatter: {
|
|
8
|
+
issueId: "100123",
|
|
9
|
+
issueKey: "PROJ-123",
|
|
10
|
+
summary: "Fix checkout copy",
|
|
11
|
+
status: "In Progress",
|
|
12
|
+
issueType: "Story",
|
|
13
|
+
priority: "Medium",
|
|
14
|
+
assignee: { accountId: "abc123", displayName: "Alice Example" },
|
|
15
|
+
reporter: null,
|
|
16
|
+
labels: ["checkout", "copy"],
|
|
17
|
+
customFields: {
|
|
18
|
+
"Security & Compliance Impact": { id: "10423", value: "Low" }
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
description: "Editable description markdown.",
|
|
22
|
+
multilineCustomFields: {
|
|
23
|
+
"Release Notes": "Multiline release notes."
|
|
24
|
+
},
|
|
25
|
+
commentDrafts: [{ draftId: "draft-1", body: "Please review this." }],
|
|
26
|
+
acceptedComments: [{
|
|
27
|
+
id: "20001",
|
|
28
|
+
author: "Alice Example",
|
|
29
|
+
created: "2026-06-27",
|
|
30
|
+
body: "Existing Jira comment."
|
|
31
|
+
}],
|
|
32
|
+
attachments: [{ filename: "evidence.png", url: "https://example.atlassian.net/evidence.png" }],
|
|
33
|
+
localNotes: "Private local note."
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe("Issue Document parsing", () => {
|
|
37
|
+
it("round-trips a representative issue document", () => {
|
|
38
|
+
const serialized = serializeIssueDocument(document)
|
|
39
|
+
const parsed = parseIssueDocument("PROJ-123.md", serialized)
|
|
40
|
+
expect(parsed).toEqual(document)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it("preserves local notes body text", () => {
|
|
44
|
+
const serialized = serializeIssueDocument({
|
|
45
|
+
...document,
|
|
46
|
+
localNotes: "line one\n\n- private bullet"
|
|
47
|
+
})
|
|
48
|
+
const parsed = parseIssueDocument("PROJ-123.md", serialized)
|
|
49
|
+
expect(parsed.localNotes).toBe("line one\n\n- private bullet")
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it("rejects missing description section", () => {
|
|
53
|
+
const serialized = serializeIssueDocument(document).replace(
|
|
54
|
+
/## Description[\s\S]*?## Release Notes/,
|
|
55
|
+
"## Release Notes"
|
|
56
|
+
)
|
|
57
|
+
expect(() => parseIssueDocument("PROJ-123.md", serialized)).toThrow(SyncValidationError)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it("rejects duplicate sections", () => {
|
|
61
|
+
const serialized = `${serializeIssueDocument(document)}\n## Description\n\nagain\n`
|
|
62
|
+
expect(() => parseIssueDocument("PROJ-123.md", serialized)).toThrow(SyncValidationError)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it("rejects malformed comment draft markers", () => {
|
|
66
|
+
const serialized = serializeIssueDocument(document).replace("<!-- draftId: draft-1 -->", "<!-- draft: draft-1 -->")
|
|
67
|
+
expect(() => parseIssueDocument("PROJ-123.md", serialized)).toThrow(SyncValidationError)
|
|
68
|
+
})
|
|
69
|
+
})
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { describe, expect, it } from "@effect/vitest"
|
|
2
|
+
import {
|
|
3
|
+
canonicalizeFieldValue,
|
|
4
|
+
cascadingFieldValue,
|
|
5
|
+
completeListValue,
|
|
6
|
+
explicitClear,
|
|
7
|
+
fieldValuesEqual,
|
|
8
|
+
isCascadingFieldValue,
|
|
9
|
+
isCompleteListValue,
|
|
10
|
+
isExplicitClear,
|
|
11
|
+
isOptionFieldValue,
|
|
12
|
+
isUserFieldValue,
|
|
13
|
+
optionFieldValue,
|
|
14
|
+
userFieldValue
|
|
15
|
+
} from "../src/internal/sync/fieldValues.js"
|
|
16
|
+
import type { SyncFieldValue } from "../src/internal/sync/types.js"
|
|
17
|
+
|
|
18
|
+
describe("Jira Markdown Sync field value helpers", () => {
|
|
19
|
+
it("represents explicit clear as a deliberate null field value", () => {
|
|
20
|
+
const value: SyncFieldValue = explicitClear
|
|
21
|
+
|
|
22
|
+
expect(value).toBeNull()
|
|
23
|
+
expect(isExplicitClear(value)).toBe(true)
|
|
24
|
+
expect(isExplicitClear("")).toBe(false)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it("represents user field values with display name and stable account id", () => {
|
|
28
|
+
const value = userFieldValue("account-123", "Jane Doe")
|
|
29
|
+
|
|
30
|
+
expect(value).toEqual({ accountId: "account-123", displayName: "Jane Doe" })
|
|
31
|
+
expect(isUserFieldValue(value)).toBe(true)
|
|
32
|
+
expect(isUserFieldValue({ displayName: "Jane Doe" })).toBe(false)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it("represents option field values with readable value and optional Jira option id", () => {
|
|
36
|
+
expect(optionFieldValue("High")).toEqual({ value: "High" })
|
|
37
|
+
expect(optionFieldValue("High", "10001")).toEqual({ id: "10001", value: "High" })
|
|
38
|
+
expect(isOptionFieldValue(optionFieldValue("High", "10001"))).toBe(true)
|
|
39
|
+
expect(isOptionFieldValue({ id: "10001" })).toBe(false)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it("represents cascading field values as parent and optional child options", () => {
|
|
43
|
+
const parentOnly = cascadingFieldValue(optionFieldValue("Security"))
|
|
44
|
+
const parentAndChild = cascadingFieldValue(
|
|
45
|
+
optionFieldValue("Security", "10"),
|
|
46
|
+
optionFieldValue("Customer Data", "11")
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
expect(parentOnly).toEqual({ parent: { value: "Security" } })
|
|
50
|
+
expect(parentAndChild).toEqual({
|
|
51
|
+
parent: { id: "10", value: "Security" },
|
|
52
|
+
child: { id: "11", value: "Customer Data" }
|
|
53
|
+
})
|
|
54
|
+
expect(isCascadingFieldValue(parentAndChild)).toBe(true)
|
|
55
|
+
expect(isCascadingFieldValue({ parent: { id: "10" } })).toBe(false)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it("represents list values as complete field values, not patches", () => {
|
|
59
|
+
const values = completeListValue(["backend", "api", "backend"])
|
|
60
|
+
|
|
61
|
+
expect(values).toEqual(["api", "backend", "backend"])
|
|
62
|
+
expect(isCompleteListValue(values)).toBe(true)
|
|
63
|
+
expect(isCompleteListValue([{ parent: optionFieldValue("A") }])).toBe(false)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it("applies canonical field order to unordered list-like values", () => {
|
|
67
|
+
const first = completeListValue([
|
|
68
|
+
optionFieldValue("Beta", "2"),
|
|
69
|
+
userFieldValue("account-2", "Zoe"),
|
|
70
|
+
optionFieldValue("Alpha", "1"),
|
|
71
|
+
userFieldValue("account-1", "Amy")
|
|
72
|
+
])
|
|
73
|
+
|
|
74
|
+
expect(first).toEqual([
|
|
75
|
+
optionFieldValue("Alpha", "1"),
|
|
76
|
+
optionFieldValue("Beta", "2"),
|
|
77
|
+
userFieldValue("account-1", "Amy"),
|
|
78
|
+
userFieldValue("account-2", "Zoe")
|
|
79
|
+
])
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it("can preserve configured field order for ordered list-like values", () => {
|
|
83
|
+
const value = completeListValue(["third", "first", "second"], { ordered: true })
|
|
84
|
+
|
|
85
|
+
expect(value).toEqual(["third", "first", "second"])
|
|
86
|
+
expect(canonicalizeFieldValue(value, { ordered: true })).toEqual(["third", "first", "second"])
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it("compares unordered complete list values by canonical value", () => {
|
|
90
|
+
const left: SyncFieldValue = ["frontend", "backend"]
|
|
91
|
+
const right: SyncFieldValue = ["backend", "frontend"]
|
|
92
|
+
|
|
93
|
+
expect(fieldValuesEqual(left, right)).toBe(true)
|
|
94
|
+
expect(fieldValuesEqual(left, right, { ordered: true })).toBe(false)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it("compares explicit clears distinctly from empty complete list values", () => {
|
|
98
|
+
expect(fieldValuesEqual(explicitClear, [])).toBe(false)
|
|
99
|
+
expect(fieldValuesEqual([], [])).toBe(true)
|
|
100
|
+
})
|
|
101
|
+
})
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { defineConfig } from "vitest/config"
|
|
2
|
+
|
|
3
|
+
export default defineConfig({
|
|
4
|
+
resolve: {
|
|
5
|
+
alias: {
|
|
6
|
+
"@knpkv/jira-api-client": new URL("../jira-api-client/src/index.ts", import.meta.url).pathname
|
|
7
|
+
}
|
|
8
|
+
},
|
|
9
|
+
test: {
|
|
10
|
+
include: ["test/integration.test.ts"],
|
|
11
|
+
globals: true,
|
|
12
|
+
environment: "node",
|
|
13
|
+
testTimeout: 120000,
|
|
14
|
+
hookTimeout: 60000,
|
|
15
|
+
teardownTimeout: 60000
|
|
16
|
+
}
|
|
17
|
+
})
|
package/vitest.config.ts
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
import { defineConfig } from "vitest/config"
|
|
2
2
|
|
|
3
3
|
export default defineConfig({
|
|
4
|
+
resolve: {
|
|
5
|
+
alias: {
|
|
6
|
+
"@knpkv/jira-api-client": new URL("../jira-api-client/src/index.ts", import.meta.url).pathname
|
|
7
|
+
}
|
|
8
|
+
},
|
|
4
9
|
test: {
|
|
5
10
|
include: ["test/**/*.test.ts"],
|
|
11
|
+
exclude: ["test/integration.test.ts"],
|
|
6
12
|
globals: true,
|
|
7
13
|
environment: "node",
|
|
8
14
|
testTimeout: 30000,
|