@knpkv/jira-cli 1.1.2 → 1.2.1
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 +37 -0
- package/README.md +23 -7
- package/dist/AttachmentService.d.ts +22 -0
- package/dist/AttachmentService.d.ts.map +1 -0
- package/dist/AttachmentService.js +67 -0
- package/dist/AttachmentService.js.map +1 -0
- package/dist/IssueService.d.ts +2 -20
- package/dist/IssueService.d.ts.map +1 -1
- package/dist/IssueService.js +47 -36
- package/dist/IssueService.js.map +1 -1
- package/dist/JiraAuth.d.ts +1 -1
- package/dist/JiraAuth.d.ts.map +1 -1
- package/dist/JiraAuth.js.map +1 -1
- package/dist/VersionService.d.ts.map +1 -1
- package/dist/VersionService.js +39 -45
- package/dist/VersionService.js.map +1 -1
- package/dist/bin.js +2 -2
- package/dist/bin.js.map +1 -1
- package/dist/commands/auth.d.ts +1 -1
- package/dist/commands/index.d.ts +1 -0
- package/dist/commands/index.d.ts.map +1 -1
- package/dist/commands/index.js +1 -0
- package/dist/commands/index.js.map +1 -1
- package/dist/commands/issue.d.ts.map +1 -1
- package/dist/commands/issue.js +2 -1
- package/dist/commands/issue.js.map +1 -1
- package/dist/commands/issueAttachment.d.ts +6 -0
- package/dist/commands/issueAttachment.d.ts.map +1 -0
- package/dist/commands/issueAttachment.js +88 -0
- package/dist/commands/issueAttachment.js.map +1 -0
- package/dist/commands/layers.d.ts +4 -3
- package/dist/commands/layers.d.ts.map +1 -1
- package/dist/commands/layers.js +12 -4
- package/dist/commands/layers.js.map +1 -1
- package/dist/commands/search.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/internal/attachmentInsertion.d.ts +10 -0
- package/dist/internal/attachmentInsertion.d.ts.map +1 -0
- package/dist/internal/attachmentInsertion.js +22 -0
- package/dist/internal/attachmentInsertion.js.map +1 -0
- package/dist/internal/frontmatter.d.ts.map +1 -1
- package/dist/internal/frontmatter.js +15 -3
- package/dist/internal/frontmatter.js.map +1 -1
- package/dist/internal/sync/baseline.js +2 -1
- package/dist/internal/sync/baseline.js.map +1 -1
- package/dist/internal/sync/changes.js.map +1 -1
- package/dist/internal/sync/config.d.ts.map +1 -1
- package/dist/internal/sync/config.js +1 -1
- package/dist/internal/sync/config.js.map +1 -1
- package/dist/internal/sync/document.d.ts.map +1 -1
- package/dist/internal/sync/document.js +88 -29
- package/dist/internal/sync/document.js.map +1 -1
- package/dist/internal/sync/fieldValues.d.ts +0 -5
- package/dist/internal/sync/fieldValues.d.ts.map +1 -1
- package/dist/internal/sync/fieldValues.js +9 -4
- package/dist/internal/sync/fieldValues.js.map +1 -1
- package/dist/internal/sync/manifest.js.map +1 -1
- package/dist/internal/sync/schemas.d.ts +5 -5
- package/dist/internal/sync/schemas.d.ts.map +1 -1
- package/dist/internal/sync/schemas.js +2 -1
- package/dist/internal/sync/schemas.js.map +1 -1
- package/dist/internal/sync/types.d.ts +5 -2
- package/dist/internal/sync/types.d.ts.map +1 -1
- package/dist/internal/sync/types.js.map +1 -1
- package/package.json +13 -9
- package/src/AttachmentService.ts +106 -0
- package/src/IssueService.ts +81 -61
- package/src/JiraAuth.ts +7 -11
- package/src/VersionService.ts +142 -159
- package/src/bin.ts +2 -2
- package/src/commands/index.ts +1 -0
- package/src/commands/issue.ts +3 -2
- package/src/commands/issueAttachment.ts +146 -0
- package/src/commands/layers.ts +20 -3
- package/src/commands/search.ts +1 -1
- package/src/index.ts +6 -0
- package/src/internal/attachmentInsertion.ts +44 -0
- package/src/internal/frontmatter.ts +17 -3
- package/src/internal/sync/baseline.ts +3 -3
- package/src/internal/sync/changes.ts +1 -1
- package/src/internal/sync/config.ts +1 -2
- package/src/internal/sync/document.ts +98 -29
- package/src/internal/sync/fieldValues.ts +4 -6
- package/src/internal/sync/manifest.ts +1 -1
- package/src/internal/sync/schemas.ts +3 -2
- package/src/internal/sync/types.ts +16 -4
- package/test/IssueService.test.ts +94 -0
- package/test/VersionService.test.ts +41 -20
- package/test/attachmentInsertion.test.ts +25 -0
- package/test/commandTree.test.ts +38 -0
- package/test/frontmatter.test.ts +20 -1
- package/test/integration.test.ts +94 -3
- package/test/layers.test.ts +12 -0
- package/test/syncDocument.test.ts +30 -1
- package/vitest.config.integration.ts +4 -0
- package/vitest.config.ts +4 -0
package/src/commands/search.ts
CHANGED
|
@@ -38,7 +38,7 @@ const outputDirOption = Options.directory("output-dir").pipe(
|
|
|
38
38
|
const formatOption = Options.choice("format", ["multi", "single"]).pipe(
|
|
39
39
|
Options.withAlias("f"),
|
|
40
40
|
Options.withDescription("Output format: multi (one file per issue) or single (combined file)"),
|
|
41
|
-
Options.withDefault("multi"
|
|
41
|
+
Options.withDefault("multi")
|
|
42
42
|
)
|
|
43
43
|
|
|
44
44
|
const maxResultsOption = Options.integer("max-results").pipe(
|
package/src/index.ts
CHANGED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local Attachment Insertion helpers for Jira Markdown files.
|
|
3
|
+
*
|
|
4
|
+
* @internal
|
|
5
|
+
*/
|
|
6
|
+
import { renderAttachmentMarkdown, replaceAttachmentPlaceholder } from "@knpkv/atlassian-common/attachments"
|
|
7
|
+
import type { Attachment } from "../IssueService.js"
|
|
8
|
+
|
|
9
|
+
export interface InsertAttachmentResult {
|
|
10
|
+
readonly content: string
|
|
11
|
+
readonly replacements: number
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const renderJiraAttachmentReference = (
|
|
15
|
+
attachment: Attachment,
|
|
16
|
+
options?: { readonly label?: string | null | undefined }
|
|
17
|
+
): string =>
|
|
18
|
+
renderAttachmentMarkdown({
|
|
19
|
+
id: attachment.id,
|
|
20
|
+
filename: attachment.filename,
|
|
21
|
+
url: attachment.url,
|
|
22
|
+
mediaType: attachment.mediaType,
|
|
23
|
+
size: attachment.size
|
|
24
|
+
}, {
|
|
25
|
+
label: options?.label,
|
|
26
|
+
metadata: `<!-- jiraAttachment: ${
|
|
27
|
+
JSON.stringify({
|
|
28
|
+
jiraAttachmentId: attachment.id,
|
|
29
|
+
mediaType: attachment.mediaType,
|
|
30
|
+
size: attachment.size
|
|
31
|
+
})
|
|
32
|
+
} -->`
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
export const insertJiraAttachmentReference = (
|
|
36
|
+
content: string,
|
|
37
|
+
placeholderPath: string,
|
|
38
|
+
attachment: Attachment
|
|
39
|
+
): InsertAttachmentResult =>
|
|
40
|
+
replaceAttachmentPlaceholder(
|
|
41
|
+
content,
|
|
42
|
+
placeholderPath,
|
|
43
|
+
({ label }) => renderJiraAttachmentReference(attachment, { label })
|
|
44
|
+
)
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*
|
|
9
9
|
* @internal
|
|
10
10
|
*/
|
|
11
|
+
import { isPreviewableAttachment } from "@knpkv/atlassian-common/attachments"
|
|
11
12
|
import matter from "gray-matter"
|
|
12
13
|
import * as yaml from "js-yaml"
|
|
13
14
|
import type { Issue } from "../IssueService.js"
|
|
@@ -21,7 +22,10 @@ import type { Issue } from "../IssueService.js"
|
|
|
21
22
|
* @internal
|
|
22
23
|
*/
|
|
23
24
|
const yamlEngine = {
|
|
24
|
-
parse: (str: string): object =>
|
|
25
|
+
parse: (str: string): object => {
|
|
26
|
+
const parsed = yaml.load(str)
|
|
27
|
+
return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {}
|
|
28
|
+
},
|
|
25
29
|
stringify: (data: object): string => yaml.dump(data)
|
|
26
30
|
}
|
|
27
31
|
|
|
@@ -114,9 +118,19 @@ export const buildMarkdownContent = (issue: Issue): string => {
|
|
|
114
118
|
parts.push("## Attachments")
|
|
115
119
|
parts.push("")
|
|
116
120
|
for (const attachment of issue.attachments) {
|
|
117
|
-
|
|
121
|
+
const reference = isPreviewableAttachment(attachment)
|
|
122
|
+
? ``
|
|
123
|
+
: `[${attachment.filename}](${attachment.url})`
|
|
124
|
+
parts.push(`<!-- jiraAttachment: ${
|
|
125
|
+
JSON.stringify({
|
|
126
|
+
jiraAttachmentId: attachment.id,
|
|
127
|
+
mediaType: attachment.mediaType,
|
|
128
|
+
size: attachment.size
|
|
129
|
+
})
|
|
130
|
+
} -->`)
|
|
131
|
+
parts.push(reference)
|
|
132
|
+
parts.push("")
|
|
118
133
|
}
|
|
119
|
-
parts.push("")
|
|
120
134
|
}
|
|
121
135
|
|
|
122
136
|
// Comments
|
|
@@ -15,13 +15,13 @@ export const parseSyncBaseline = (
|
|
|
15
15
|
): Effect.Effect<SyncBaseline, SyncWorkspaceError | SyncValidationError> =>
|
|
16
16
|
Effect.gen(function*() {
|
|
17
17
|
const raw = yield* Effect.try({
|
|
18
|
-
try: () => JSON.parse(content)
|
|
18
|
+
try: (): unknown => JSON.parse(content),
|
|
19
19
|
catch: (cause) => new SyncWorkspaceError({ message: "Failed to parse Sync Baseline JSON", path, cause })
|
|
20
20
|
})
|
|
21
|
-
|
|
22
|
-
Effect.map((baseline) => baseline as SyncBaseline),
|
|
21
|
+
const baseline: SyncBaseline = yield* Schema.decodeUnknownEffect(SyncBaselineSchema)(raw).pipe(
|
|
23
22
|
Effect.mapError((cause) => new SyncValidationError({ message: "Invalid Sync Baseline", path, cause }))
|
|
24
23
|
)
|
|
24
|
+
return baseline
|
|
25
25
|
})
|
|
26
26
|
|
|
27
27
|
export const serializeSyncBaseline = (baseline: SyncBaseline): string => `${JSON.stringify(baseline, null, 2)}\n`
|
|
@@ -47,7 +47,7 @@ export const compareIssueFields = (input: CompareIssueFieldsInput): CompareIssue
|
|
|
47
47
|
])
|
|
48
48
|
|
|
49
49
|
for (const name of customFieldNames) {
|
|
50
|
-
const field = `customFields.${name}`
|
|
50
|
+
const field: SyncFieldPath = `customFields.${name}`
|
|
51
51
|
const baseline = input.baseline.customFields[name]?.value
|
|
52
52
|
const jira = input.jira.customFields[name]?.value
|
|
53
53
|
const document = input.document.customFields[name]?.value
|
|
@@ -19,8 +19,7 @@ export const parseWorkspaceConfig = (
|
|
|
19
19
|
try: () => yaml.load(content) ?? {},
|
|
20
20
|
catch: (cause) => new SyncWorkspaceError({ message: "Failed to parse workspace config YAML", path, cause })
|
|
21
21
|
})
|
|
22
|
-
const config = yield* Schema.decodeUnknownEffect(WorkspaceConfigSchema)(raw).pipe(
|
|
23
|
-
Effect.map((config) => config as WorkspaceConfig),
|
|
22
|
+
const config: WorkspaceConfig = yield* Schema.decodeUnknownEffect(WorkspaceConfigSchema)(raw).pipe(
|
|
24
23
|
Effect.mapError((cause) =>
|
|
25
24
|
new SyncValidationError({ message: "Invalid Jira Markdown Sync workspace config", path, cause })
|
|
26
25
|
)
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
*
|
|
4
4
|
* @internal
|
|
5
5
|
*/
|
|
6
|
+
import { isPreviewableAttachment } from "@knpkv/atlassian-common/attachments"
|
|
7
|
+
import * as Predicate from "effect/Predicate"
|
|
6
8
|
import matter from "gray-matter"
|
|
7
9
|
import * as yaml from "js-yaml"
|
|
8
10
|
import { SyncValidationError } from "../../JiraCliError.js"
|
|
@@ -11,11 +13,22 @@ import type {
|
|
|
11
13
|
AttachmentReference,
|
|
12
14
|
CommentDraft,
|
|
13
15
|
IssueDocument,
|
|
14
|
-
IssueDocumentFrontMatter
|
|
16
|
+
IssueDocumentFrontMatter,
|
|
17
|
+
UserFieldValue
|
|
15
18
|
} from "./types.js"
|
|
16
19
|
|
|
20
|
+
const isStringArray = (value: unknown): value is ReadonlyArray<string> =>
|
|
21
|
+
Array.isArray(value) && value.every((item) => typeof item === "string")
|
|
22
|
+
|
|
23
|
+
const isFrontMatterCustomFields = (
|
|
24
|
+
value: unknown
|
|
25
|
+
): value is IssueDocumentFrontMatter["customFields"] => Predicate.isReadonlyObject(value)
|
|
26
|
+
|
|
17
27
|
const yamlEngine = {
|
|
18
|
-
parse: (str: string): object =>
|
|
28
|
+
parse: (str: string): object => {
|
|
29
|
+
const value = yaml.load(str)
|
|
30
|
+
return Predicate.isReadonlyObject(value) ? value : {}
|
|
31
|
+
},
|
|
19
32
|
stringify: (data: object): string => yaml.dump(data)
|
|
20
33
|
}
|
|
21
34
|
|
|
@@ -78,40 +91,41 @@ const section = (name: string, content: string): string => {
|
|
|
78
91
|
const parseFrontMatter = (path: string, data: Record<string, unknown>): IssueDocumentFrontMatter => {
|
|
79
92
|
const requiredString = (key: string): string => {
|
|
80
93
|
const value = data[key]
|
|
81
|
-
if (typeof value
|
|
82
|
-
return
|
|
94
|
+
if (typeof value === "string") return value
|
|
95
|
+
return fail(path, `Missing or invalid front matter field "${key}"`)
|
|
83
96
|
}
|
|
84
97
|
|
|
85
98
|
const nullableString = (key: string): string | null => {
|
|
86
99
|
const value = data[key]
|
|
87
100
|
if (value === null || value === undefined) return null
|
|
88
|
-
if (typeof value
|
|
89
|
-
return
|
|
101
|
+
if (typeof value === "string") return value
|
|
102
|
+
return fail(path, `Invalid front matter field "${key}"`)
|
|
90
103
|
}
|
|
91
104
|
|
|
92
|
-
const userValue = (key: string) => {
|
|
105
|
+
const userValue = (key: string): UserFieldValue | null => {
|
|
93
106
|
const value = data[key]
|
|
94
107
|
if (value === null || value === undefined) return null
|
|
95
|
-
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
108
|
+
const record = Predicate.isReadonlyObject(value) ? value : fail(path, `Invalid user field "${key}"`)
|
|
109
|
+
const accountId = record["accountId"]
|
|
110
|
+
const displayName = record["displayName"]
|
|
111
|
+
if (typeof accountId === "string" && typeof displayName === "string") {
|
|
112
|
+
return {
|
|
113
|
+
accountId,
|
|
114
|
+
displayName
|
|
115
|
+
}
|
|
103
116
|
}
|
|
117
|
+
return fail(path, `Invalid user field "${key}"`)
|
|
104
118
|
}
|
|
105
119
|
|
|
106
120
|
const labels = data["labels"]
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
121
|
+
const parsedLabels = isStringArray(labels)
|
|
122
|
+
? labels
|
|
123
|
+
: fail(path, `Missing or invalid front matter field "labels"`)
|
|
110
124
|
|
|
111
125
|
const customFields = data["customFields"]
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
126
|
+
const parsedCustomFields = isFrontMatterCustomFields(customFields)
|
|
127
|
+
? customFields
|
|
128
|
+
: fail(path, `Missing or invalid front matter field "customFields"`)
|
|
115
129
|
|
|
116
130
|
return {
|
|
117
131
|
issueId: requiredString("issueId"),
|
|
@@ -122,8 +136,8 @@ const parseFrontMatter = (path: string, data: Record<string, unknown>): IssueDoc
|
|
|
122
136
|
priority: nullableString("priority"),
|
|
123
137
|
assignee: userValue("assignee"),
|
|
124
138
|
reporter: userValue("reporter"),
|
|
125
|
-
labels:
|
|
126
|
-
customFields:
|
|
139
|
+
labels: parsedLabels,
|
|
140
|
+
customFields: parsedCustomFields
|
|
127
141
|
}
|
|
128
142
|
}
|
|
129
143
|
|
|
@@ -180,13 +194,68 @@ const parseAcceptedComments = (content: string): ReadonlyArray<AcceptedComment>
|
|
|
180
194
|
}
|
|
181
195
|
|
|
182
196
|
const serializeAttachments = (attachments: ReadonlyArray<AttachmentReference>): string =>
|
|
183
|
-
attachments.map((attachment) =>
|
|
197
|
+
attachments.map((attachment) => {
|
|
198
|
+
const metadata = {
|
|
199
|
+
jiraAttachmentId: attachment.id,
|
|
200
|
+
mediaType: attachment.mediaType,
|
|
201
|
+
size: attachment.size
|
|
202
|
+
}
|
|
203
|
+
const reference = isPreviewableAttachment(attachment)
|
|
204
|
+
? ``
|
|
205
|
+
: `[${attachment.filename}](${attachment.url})`
|
|
206
|
+
return `<!-- jiraAttachment: ${JSON.stringify(metadata)} -->\n${reference}`
|
|
207
|
+
}).join("\n\n")
|
|
184
208
|
|
|
185
|
-
const parseAttachments = (content: string): ReadonlyArray<AttachmentReference> =>
|
|
186
|
-
content.split(/\r?\n/)
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
209
|
+
const parseAttachments = (content: string): ReadonlyArray<AttachmentReference> => {
|
|
210
|
+
const lines = content.split(/\r?\n/)
|
|
211
|
+
const attachments: Array<AttachmentReference> = []
|
|
212
|
+
let pendingMetadata: Partial<Pick<AttachmentReference, "id" | "mediaType" | "size">> = {}
|
|
213
|
+
|
|
214
|
+
for (const rawLine of lines) {
|
|
215
|
+
const line = rawLine.trim()
|
|
216
|
+
if (line.length === 0) continue
|
|
217
|
+
|
|
218
|
+
const metadataMatch = /^<!--\s*jiraAttachment:\s*(\{.*})\s*-->$/.exec(line)
|
|
219
|
+
if (metadataMatch?.[1]) {
|
|
220
|
+
pendingMetadata = parseAttachmentMetadata(metadataMatch[1])
|
|
221
|
+
continue
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const markdownMatch = /^(?:-\s*)?!?\[(.+)]\((.+)\)$/.exec(line)
|
|
225
|
+
if (markdownMatch?.[1] && markdownMatch[2]) {
|
|
226
|
+
attachments.push({
|
|
227
|
+
id: pendingMetadata.id ?? "",
|
|
228
|
+
filename: markdownMatch[1],
|
|
229
|
+
url: markdownMatch[2],
|
|
230
|
+
mediaType: pendingMetadata.mediaType ?? null,
|
|
231
|
+
size: pendingMetadata.size ?? null
|
|
232
|
+
})
|
|
233
|
+
pendingMetadata = {}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return attachments
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const parseAttachmentMetadata = (
|
|
241
|
+
raw: string
|
|
242
|
+
): Partial<Pick<AttachmentReference, "id" | "mediaType" | "size">> => {
|
|
243
|
+
try {
|
|
244
|
+
const parsed = JSON.parse(raw)
|
|
245
|
+
if (!Predicate.isReadonlyObject(parsed)) return {}
|
|
246
|
+
return {
|
|
247
|
+
id: typeof parsed["jiraAttachmentId"] === "string"
|
|
248
|
+
? parsed["jiraAttachmentId"]
|
|
249
|
+
: typeof parsed["id"] === "string"
|
|
250
|
+
? parsed["id"]
|
|
251
|
+
: "",
|
|
252
|
+
mediaType: typeof parsed["mediaType"] === "string" ? parsed["mediaType"] : null,
|
|
253
|
+
size: typeof parsed["size"] === "number" ? parsed["size"] : null
|
|
254
|
+
}
|
|
255
|
+
} catch {
|
|
256
|
+
return {}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
190
259
|
|
|
191
260
|
const trimOuterBlankLines = (lines: ReadonlyArray<string>): ReadonlyArray<string> => {
|
|
192
261
|
let start = 0
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* @internal
|
|
5
5
|
*/
|
|
6
|
+
import * as Predicate from "effect/Predicate"
|
|
6
7
|
import type { CascadingFieldValue, OptionFieldValue, SyncFieldValue, UserFieldValue } from "./types.js"
|
|
7
8
|
|
|
8
9
|
export type CompleteListItem = string | number | boolean | UserFieldValue | OptionFieldValue
|
|
@@ -44,18 +45,18 @@ export const completeListValue = (
|
|
|
44
45
|
export const makeCompleteListValue = completeListValue
|
|
45
46
|
|
|
46
47
|
export const isUserFieldValue = (value: unknown): value is UserFieldValue => {
|
|
47
|
-
if (!
|
|
48
|
+
if (!Predicate.isReadonlyObject(value)) return false
|
|
48
49
|
return typeof value["accountId"] === "string" && typeof value["displayName"] === "string"
|
|
49
50
|
}
|
|
50
51
|
|
|
51
52
|
export const isOptionFieldValue = (value: unknown): value is OptionFieldValue => {
|
|
52
|
-
if (!
|
|
53
|
+
if (!Predicate.isReadonlyObject(value) || typeof value["value"] !== "string") return false
|
|
53
54
|
const id = value["id"]
|
|
54
55
|
return id === undefined || typeof id === "string"
|
|
55
56
|
}
|
|
56
57
|
|
|
57
58
|
export const isCascadingFieldValue = (value: unknown): value is CascadingFieldValue => {
|
|
58
|
-
if (!
|
|
59
|
+
if (!Predicate.isReadonlyObject(value) || !isOptionFieldValue(value["parent"])) return false
|
|
59
60
|
const child = value["child"]
|
|
60
61
|
return child === undefined || isOptionFieldValue(child)
|
|
61
62
|
}
|
|
@@ -140,6 +141,3 @@ const isCompleteListItem = (value: unknown): value is CompleteListItem => {
|
|
|
140
141
|
return false
|
|
141
142
|
}
|
|
142
143
|
}
|
|
143
|
-
|
|
144
|
-
const isRecord = (value: unknown): value is Readonly<Record<string, unknown>> =>
|
|
145
|
-
value !== null && typeof value === "object" && !Array.isArray(value)
|
|
@@ -21,7 +21,7 @@ export const parseSyncManifest = (
|
|
|
21
21
|
): Effect.Effect<SyncManifest, SyncWorkspaceError | SyncValidationError> =>
|
|
22
22
|
Effect.gen(function*() {
|
|
23
23
|
const raw = yield* Effect.try({
|
|
24
|
-
try: () => JSON.parse(content)
|
|
24
|
+
try: (): unknown => JSON.parse(content),
|
|
25
25
|
catch: (cause) => new SyncWorkspaceError({ message: "Failed to parse Sync Manifest JSON", path, cause })
|
|
26
26
|
})
|
|
27
27
|
return yield* Schema.decodeUnknownEffect(SyncManifestSchema)(raw).pipe(
|
|
@@ -12,6 +12,7 @@ const SiteUrl = Schema.String.pipe(
|
|
|
12
12
|
)
|
|
13
13
|
|
|
14
14
|
const NonEmptyString = Schema.String.pipe(Schema.check(Schema.isMinLength(1)))
|
|
15
|
+
const FilenameModes: readonly ["convention", "custom"] = ["convention", "custom"]
|
|
15
16
|
|
|
16
17
|
export const FieldShapeSchema = Schema.Literals(FIELD_SHAPES)
|
|
17
18
|
|
|
@@ -23,7 +24,7 @@ export const RequestedCustomFieldSchema = Schema.Struct({
|
|
|
23
24
|
})
|
|
24
25
|
|
|
25
26
|
export const WorkspaceConfigSchema = Schema.Struct({
|
|
26
|
-
version: Schema.Literal(1).pipe(Schema.withDecodingDefaultTypeKey(Effect.succeed(1
|
|
27
|
+
version: Schema.Literal(1).pipe(Schema.withDecodingDefaultTypeKey(Effect.succeed(1))),
|
|
27
28
|
siteUrl: SiteUrl,
|
|
28
29
|
documentsDir: NonEmptyString.pipe(Schema.withDecodingDefaultTypeKey(Effect.succeed("issues"))),
|
|
29
30
|
customFields: Schema.Array(RequestedCustomFieldSchema).pipe(
|
|
@@ -35,7 +36,7 @@ export const ManifestIssueSchema = Schema.Struct({
|
|
|
35
36
|
issueId: NonEmptyString,
|
|
36
37
|
issueKey: NonEmptyString,
|
|
37
38
|
documentPath: NonEmptyString,
|
|
38
|
-
filenameMode: Schema.Literals(
|
|
39
|
+
filenameMode: Schema.Literals(FilenameModes)
|
|
39
40
|
})
|
|
40
41
|
|
|
41
42
|
export const SyncManifestSchema = Schema.Struct({
|
|
@@ -4,7 +4,18 @@
|
|
|
4
4
|
* @internal
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
export
|
|
7
|
+
export type FieldShape =
|
|
8
|
+
| "text"
|
|
9
|
+
| "multilineText"
|
|
10
|
+
| "number"
|
|
11
|
+
| "boolean"
|
|
12
|
+
| "date"
|
|
13
|
+
| "singleSelect"
|
|
14
|
+
| "multiSelect"
|
|
15
|
+
| "user"
|
|
16
|
+
| "cascadingSelect"
|
|
17
|
+
|
|
18
|
+
export const FIELD_SHAPES: ReadonlyArray<FieldShape> = [
|
|
8
19
|
"text",
|
|
9
20
|
"multilineText",
|
|
10
21
|
"number",
|
|
@@ -14,9 +25,7 @@ export const FIELD_SHAPES = [
|
|
|
14
25
|
"multiSelect",
|
|
15
26
|
"user",
|
|
16
27
|
"cascadingSelect"
|
|
17
|
-
]
|
|
18
|
-
|
|
19
|
-
export type FieldShape = typeof FIELD_SHAPES[number]
|
|
28
|
+
]
|
|
20
29
|
|
|
21
30
|
export interface RequestedCustomField {
|
|
22
31
|
readonly displayName: string
|
|
@@ -123,8 +132,11 @@ export interface AcceptedComment {
|
|
|
123
132
|
}
|
|
124
133
|
|
|
125
134
|
export interface AttachmentReference {
|
|
135
|
+
readonly id: string
|
|
126
136
|
readonly filename: string
|
|
127
137
|
readonly url: string
|
|
138
|
+
readonly mediaType: string | null
|
|
139
|
+
readonly size: number | null
|
|
128
140
|
}
|
|
129
141
|
|
|
130
142
|
export interface IssueDocument {
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { describe, expect, it } from "@effect/vitest"
|
|
2
|
+
import { JiraApiClient, make } from "@knpkv/jira-api-client"
|
|
3
|
+
import * as Effect from "effect/Effect"
|
|
4
|
+
import * as Layer from "effect/Layer"
|
|
5
|
+
import * as Redacted from "effect/Redacted"
|
|
6
|
+
import * as HttpClient from "effect/unstable/http/HttpClient"
|
|
7
|
+
import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"
|
|
8
|
+
import { IssueService, layer as IssueServiceLayer, SiteUrl } from "../src/IssueService.js"
|
|
9
|
+
|
|
10
|
+
const httpClient = HttpClient.make((request) =>
|
|
11
|
+
Effect.succeed(HttpClientResponse.fromWeb(
|
|
12
|
+
request,
|
|
13
|
+
new Response(
|
|
14
|
+
JSON.stringify(
|
|
15
|
+
request.url.includes("/rest/api/3/issue/")
|
|
16
|
+
? {
|
|
17
|
+
id: "10000",
|
|
18
|
+
key: "PROJ-123",
|
|
19
|
+
fields: {
|
|
20
|
+
attachment: [{
|
|
21
|
+
id: "30001",
|
|
22
|
+
filename: "diagram.svg",
|
|
23
|
+
content: "https://example.atlassian.net/rest/api/3/attachment/content/30001",
|
|
24
|
+
mimeType: "image/svg+xml",
|
|
25
|
+
size: 448
|
|
26
|
+
}, {
|
|
27
|
+
id: "30002",
|
|
28
|
+
filename: "unknown.bin",
|
|
29
|
+
content: "https://example.atlassian.net/rest/api/3/attachment/content/30002",
|
|
30
|
+
size: 99
|
|
31
|
+
}],
|
|
32
|
+
components: [],
|
|
33
|
+
created: "2026-01-01T00:00:00.000Z",
|
|
34
|
+
description: "",
|
|
35
|
+
fixVersions: [],
|
|
36
|
+
issuetype: { name: "Task" },
|
|
37
|
+
labels: [],
|
|
38
|
+
status: { name: "Done" },
|
|
39
|
+
summary: "Sample issue",
|
|
40
|
+
updated: "2026-01-01T00:00:00.000Z"
|
|
41
|
+
},
|
|
42
|
+
renderedFields: {}
|
|
43
|
+
}
|
|
44
|
+
: {}
|
|
45
|
+
),
|
|
46
|
+
{
|
|
47
|
+
status: request.url.includes("/rest/api/3/issue/") ? 200 : 404,
|
|
48
|
+
headers: { "content-type": "application/json" }
|
|
49
|
+
}
|
|
50
|
+
)
|
|
51
|
+
))
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
const api = make(httpClient, {
|
|
55
|
+
baseUrl: "https://jira.test",
|
|
56
|
+
auth: { type: "basic", email: "test@example.com", apiToken: Redacted.make("token") }
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
const JiraClientLayer = Layer.succeed(
|
|
60
|
+
JiraApiClient,
|
|
61
|
+
JiraApiClient.of({
|
|
62
|
+
...api,
|
|
63
|
+
uploadAttachment: () => Effect.die("unused Jira upload mock")
|
|
64
|
+
})
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
const TestLayer = IssueServiceLayer.pipe(
|
|
68
|
+
Layer.provide(JiraClientLayer),
|
|
69
|
+
Layer.provide(Layer.succeed(SiteUrl, "https://example.atlassian.net"))
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
describe("IssueService", () => {
|
|
73
|
+
it.effect("keeps mimeType as a backwards-compatible attachment alias", () =>
|
|
74
|
+
Effect.gen(function*() {
|
|
75
|
+
const service = yield* IssueService
|
|
76
|
+
const issue = yield* service.getByKey("PROJ-123")
|
|
77
|
+
|
|
78
|
+
expect(issue.attachments).toEqual([{
|
|
79
|
+
id: "30001",
|
|
80
|
+
filename: "diagram.svg",
|
|
81
|
+
url: "https://example.atlassian.net/rest/api/3/attachment/content/30001",
|
|
82
|
+
mediaType: "image/svg+xml",
|
|
83
|
+
mimeType: "image/svg+xml",
|
|
84
|
+
size: 448
|
|
85
|
+
}, {
|
|
86
|
+
id: "30002",
|
|
87
|
+
filename: "unknown.bin",
|
|
88
|
+
url: "https://example.atlassian.net/rest/api/3/attachment/content/30002",
|
|
89
|
+
mediaType: null,
|
|
90
|
+
mimeType: "",
|
|
91
|
+
size: 99
|
|
92
|
+
}])
|
|
93
|
+
}).pipe(Effect.provide(TestLayer)))
|
|
94
|
+
})
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { describe, expect, it } from "@effect/vitest"
|
|
2
|
-
import { JiraApiClient } from "@knpkv/jira-api-client"
|
|
2
|
+
import { JiraApiClient, make } from "@knpkv/jira-api-client"
|
|
3
3
|
import * as Effect from "effect/Effect"
|
|
4
4
|
import * as Layer from "effect/Layer"
|
|
5
|
+
import * as Redacted from "effect/Redacted"
|
|
6
|
+
import * as HttpClient from "effect/unstable/http/HttpClient"
|
|
7
|
+
import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"
|
|
5
8
|
import { stripEmails } from "../src/commands/version.js"
|
|
6
9
|
import type { Version } from "../src/VersionService.js"
|
|
7
10
|
import {
|
|
@@ -206,34 +209,45 @@ describe("stripEmails", () => {
|
|
|
206
209
|
// ---------------------------------------------------------------------------
|
|
207
210
|
|
|
208
211
|
const versionsFixture = [
|
|
209
|
-
{
|
|
212
|
+
{
|
|
213
|
+
id: "1",
|
|
214
|
+
name: "1.0.0",
|
|
215
|
+
released: true,
|
|
216
|
+
self: "https://x/version/1",
|
|
217
|
+
contributors: ["account-1", { accountId: "account-2", displayName: "Ada" }]
|
|
218
|
+
},
|
|
210
219
|
{ id: "2", name: "2.0.0", released: false, self: "https://x/version/2" },
|
|
211
220
|
{ id: "3", name: "3.0.0", released: true, self: "https://x/version/3" },
|
|
212
221
|
{ id: "4", name: "4.0.0", released: false, self: "https://x/version/4" }
|
|
213
222
|
]
|
|
214
223
|
|
|
215
224
|
/**
|
|
216
|
-
* Build a JiraApiClient mock whose
|
|
225
|
+
* Build a JiraApiClient mock whose HTTP transport routes by path: the version
|
|
217
226
|
* list endpoint returns the fixture; the JQL search endpoint returns no issues
|
|
218
227
|
* (so the contributor scan resolves to empty without further calls).
|
|
219
228
|
*/
|
|
220
|
-
const makeJiraLayer = () =>
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
229
|
+
const makeJiraLayer = () => {
|
|
230
|
+
const httpClient = HttpClient.make((request) => {
|
|
231
|
+
const body = request.url.includes("/project/PROJ/version")
|
|
232
|
+
? { values: versionsFixture, isLast: true }
|
|
233
|
+
: { issues: [], isLast: true }
|
|
234
|
+
return Effect.succeed(HttpClientResponse.fromWeb(
|
|
235
|
+
request,
|
|
236
|
+
new Response(JSON.stringify(body), { status: 200, headers: { "content-type": "application/json" } })
|
|
237
|
+
))
|
|
238
|
+
})
|
|
239
|
+
const api = make(httpClient, {
|
|
240
|
+
baseUrl: "https://jira.test",
|
|
241
|
+
auth: { type: "basic", email: "test@example.com", apiToken: Redacted.make("token") }
|
|
242
|
+
})
|
|
243
|
+
return Layer.succeed(
|
|
244
|
+
JiraApiClient,
|
|
245
|
+
JiraApiClient.of({
|
|
246
|
+
...api,
|
|
247
|
+
uploadAttachment: () => Effect.die("unused Jira upload mock")
|
|
248
|
+
})
|
|
249
|
+
)
|
|
250
|
+
}
|
|
237
251
|
|
|
238
252
|
describe("listProjectVersions filtering", () => {
|
|
239
253
|
it.effect("returns all versions when neither flag is set", () =>
|
|
@@ -263,4 +277,11 @@ describe("listProjectVersions filtering", () => {
|
|
|
263
277
|
const list = yield* service.listProjectVersions("PROJ", { maxResults: 2 })
|
|
264
278
|
expect(list.map((v) => v.id)).toEqual(["1", "2"])
|
|
265
279
|
}).pipe(Effect.provide(VersionServiceLayer), Effect.provide(makeJiraLayer())))
|
|
280
|
+
|
|
281
|
+
it.effect("preserves Jira Premium contributors decoded from the generated Version schema", () =>
|
|
282
|
+
Effect.gen(function*() {
|
|
283
|
+
const service = yield* VersionService
|
|
284
|
+
const list = yield* service.listProjectVersions("PROJ", { maxResults: 1 })
|
|
285
|
+
expect(list[0]?.contributors.map((person) => person.accountId)).toEqual(["account-1", "account-2"])
|
|
286
|
+
}).pipe(Effect.provide(VersionServiceLayer), Effect.provide(makeJiraLayer())))
|
|
266
287
|
})
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import { insertJiraAttachmentReference } from "../src/internal/attachmentInsertion.js"
|
|
3
|
+
import type { Attachment } from "../src/IssueService.js"
|
|
4
|
+
|
|
5
|
+
const attachment: Attachment = {
|
|
6
|
+
id: "30001",
|
|
7
|
+
filename: "diagram.svg",
|
|
8
|
+
url: "https://example.atlassian.net/rest/api/3/attachment/content/30001",
|
|
9
|
+
mediaType: "image/svg+xml",
|
|
10
|
+
mimeType: "image/svg+xml",
|
|
11
|
+
size: 448
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe("Jira attachment insertion", () => {
|
|
15
|
+
it("preserves the placeholder label as rendered alt text", () => {
|
|
16
|
+
const result = insertJiraAttachmentReference(
|
|
17
|
+
"before\n\nafter",
|
|
18
|
+
"./diagram.svg",
|
|
19
|
+
attachment
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
expect(result.content).toContain("![Architecture diagram]")
|
|
23
|
+
expect(result.content).not.toContain("![diagram.svg]")
|
|
24
|
+
})
|
|
25
|
+
})
|