@knpkv/jira-cli 1.1.1 → 1.2.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 +25 -0
- package/README.md +21 -1
- package/dist/AttachmentService.d.ts +22 -0
- package/dist/AttachmentService.d.ts.map +1 -0
- package/dist/AttachmentService.js +72 -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 +37 -24
- package/dist/IssueService.js.map +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 +16 -18
- 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 +11 -3
- 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/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 +12 -8
- package/src/AttachmentService.ts +112 -0
- package/src/IssueService.ts +70 -47
- package/src/JiraAuth.ts +7 -11
- package/src/VersionService.ts +139 -138
- 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 +19 -2
- 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 +100 -29
- 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 +78 -0
- package/test/VersionService.test.ts +21 -16
- 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 +92 -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
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `jira issue attachment` resource commands.
|
|
3
|
+
*
|
|
4
|
+
* @internal
|
|
5
|
+
*/
|
|
6
|
+
import { renderAttachmentMarkdown } from "@knpkv/atlassian-common/attachments"
|
|
7
|
+
import * as Console from "effect/Console"
|
|
8
|
+
import * as Effect from "effect/Effect"
|
|
9
|
+
import * as FileSystem from "effect/FileSystem"
|
|
10
|
+
import * as Option from "effect/Option"
|
|
11
|
+
import { Argument as Args, Command, Flag as Options } from "effect/unstable/cli"
|
|
12
|
+
import { AttachmentService } from "../AttachmentService.js"
|
|
13
|
+
import { insertJiraAttachmentReference } from "../internal/attachmentInsertion.js"
|
|
14
|
+
import { JiraApiError, WriteError } from "../JiraCliError.js"
|
|
15
|
+
|
|
16
|
+
const issueArg = Args.string("issue").pipe(
|
|
17
|
+
Args.withDescription("Issue key or id, for example PROJ-123")
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
const fileArg = Args.string("file").pipe(
|
|
21
|
+
Args.withDescription("Local file to upload")
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
const documentOption = Options.file("document").pipe(
|
|
25
|
+
Options.withDescription("Local Jira Markdown document containing the attachment placeholder"),
|
|
26
|
+
Options.optional
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
const noInsertOption = Options.boolean("no-insert").pipe(
|
|
30
|
+
Options.withDescription("Only upload and print the Attachment Reference")
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
const dryRunOption = Options.boolean("dry-run").pipe(
|
|
34
|
+
Options.withAlias("n"),
|
|
35
|
+
Options.withDescription("Validate local insertion input without uploading")
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
const jsonOption = Options.boolean("json").pipe(
|
|
39
|
+
Options.withDescription("Write exactly one JSON value to stdout")
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
const readDocument = (documentPath: string) =>
|
|
43
|
+
Effect.gen(function*() {
|
|
44
|
+
const fs = yield* FileSystem.FileSystem
|
|
45
|
+
return yield* fs.readFileString(documentPath).pipe(
|
|
46
|
+
Effect.mapError((cause) =>
|
|
47
|
+
new WriteError({ path: documentPath, message: "Failed to read Jira Markdown document", cause })
|
|
48
|
+
)
|
|
49
|
+
)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
const writeDocument = (documentPath: string, content: string) =>
|
|
53
|
+
Effect.gen(function*() {
|
|
54
|
+
const fs = yield* FileSystem.FileSystem
|
|
55
|
+
return yield* fs.writeFileString(documentPath, content).pipe(
|
|
56
|
+
Effect.mapError((cause) =>
|
|
57
|
+
new WriteError({ path: documentPath, message: "Failed to write Jira Markdown document", cause })
|
|
58
|
+
)
|
|
59
|
+
)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
const fileExists = (filePath: string) =>
|
|
63
|
+
Effect.gen(function*() {
|
|
64
|
+
const fs = yield* FileSystem.FileSystem
|
|
65
|
+
return yield* fs.exists(filePath).pipe(
|
|
66
|
+
Effect.mapError((cause) => new WriteError({ path: filePath, message: "Failed to check attachment file", cause }))
|
|
67
|
+
)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
const uploadCommand = Command.make(
|
|
71
|
+
"upload",
|
|
72
|
+
{
|
|
73
|
+
issue: issueArg,
|
|
74
|
+
file: fileArg,
|
|
75
|
+
document: documentOption,
|
|
76
|
+
noInsert: noInsertOption,
|
|
77
|
+
dryRun: dryRunOption,
|
|
78
|
+
json: jsonOption
|
|
79
|
+
},
|
|
80
|
+
({ document, dryRun, file, issue, json, noInsert }) =>
|
|
81
|
+
Effect.gen(function*() {
|
|
82
|
+
const documentPath = Option.isSome(document) ? document.value : null
|
|
83
|
+
const shouldInsert = !noInsert && documentPath !== null
|
|
84
|
+
const documentInput = shouldInsert
|
|
85
|
+
? { path: documentPath, content: yield* readDocument(documentPath) }
|
|
86
|
+
: null
|
|
87
|
+
|
|
88
|
+
if (documentInput !== null) {
|
|
89
|
+
const matches = countPlaceholderMatches(documentInput.content, file)
|
|
90
|
+
if (matches !== 1) {
|
|
91
|
+
return yield* Effect.fail(
|
|
92
|
+
new JiraApiError({ message: `Expected exactly one attachment placeholder for ${file}, found ${matches}` })
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (dryRun) {
|
|
98
|
+
const exists = yield* fileExists(file)
|
|
99
|
+
if (!exists) {
|
|
100
|
+
return yield* Effect.fail(new JiraApiError({ message: `Attachment file does not exist: ${file}` }))
|
|
101
|
+
}
|
|
102
|
+
const result = { dryRun: true, issue, file, insert: shouldInsert }
|
|
103
|
+
yield* Console.log(json ? JSON.stringify(result) : `Dry run: ${file} can be uploaded to ${issue}`)
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const service = yield* AttachmentService
|
|
108
|
+
const attachment = yield* service.uploadToIssue(issue, { filePath: file })
|
|
109
|
+
let inserted = false
|
|
110
|
+
|
|
111
|
+
if (documentInput !== null) {
|
|
112
|
+
const result = insertJiraAttachmentReference(documentInput.content, file, attachment)
|
|
113
|
+
if (result.replacements !== 1) {
|
|
114
|
+
return yield* Effect.fail(
|
|
115
|
+
new JiraApiError({
|
|
116
|
+
message:
|
|
117
|
+
`Uploaded attachment ${attachment.id}, but expected exactly one local placeholder for ${file}; found ${result.replacements}`
|
|
118
|
+
})
|
|
119
|
+
)
|
|
120
|
+
}
|
|
121
|
+
yield* writeDocument(documentInput.path, result.content)
|
|
122
|
+
inserted = true
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (json) {
|
|
126
|
+
yield* Console.log(JSON.stringify({ attachment, inserted }))
|
|
127
|
+
} else {
|
|
128
|
+
yield* Console.log(renderAttachmentMarkdown(attachment))
|
|
129
|
+
if (inserted) yield* Console.log(`Inserted into ${documentPath}`)
|
|
130
|
+
}
|
|
131
|
+
})
|
|
132
|
+
).pipe(Command.withDescription("Remote write: upload a local file as a Jira issue attachment"))
|
|
133
|
+
|
|
134
|
+
export const attachmentCommand = Command.make(
|
|
135
|
+
"attachment",
|
|
136
|
+
{},
|
|
137
|
+
() => Console.log("Usage: jira issue attachment upload <issue> <file>")
|
|
138
|
+
).pipe(
|
|
139
|
+
Command.withDescription("Jira issue attachment commands"),
|
|
140
|
+
Command.withSubcommands([uploadCommand])
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
const countPlaceholderMatches = (content: string, placeholderPath: string): number => {
|
|
144
|
+
const escapedPath = placeholderPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
|
145
|
+
return [...content.matchAll(new RegExp(`!?\\[[^\\]]*\\]\\(${escapedPath}\\)`, "g"))].length
|
|
146
|
+
}
|
package/src/commands/layers.ts
CHANGED
|
@@ -16,6 +16,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"
|
|
|
16
16
|
import { JiraApiClient, JiraApiConfig } from "@knpkv/jira-api-client"
|
|
17
17
|
import * as Effect from "effect/Effect"
|
|
18
18
|
import * as Layer from "effect/Layer"
|
|
19
|
+
import { AttachmentService, layer as AttachmentServiceLayer } from "../AttachmentService.js"
|
|
19
20
|
import { IssueService, layer as IssueServiceLayer, SiteUrl } from "../IssueService.js"
|
|
20
21
|
import { JiraAuth, layer as JiraAuthLayer } from "../JiraAuth.js"
|
|
21
22
|
import { layer as MarkdownWriterLayer, MarkdownWriter } from "../MarkdownWriter.js"
|
|
@@ -31,6 +32,13 @@ const DummyIssueServiceLayer = Layer.succeed(
|
|
|
31
32
|
})
|
|
32
33
|
)
|
|
33
34
|
|
|
35
|
+
const DummyAttachmentServiceLayer = Layer.succeed(
|
|
36
|
+
AttachmentService,
|
|
37
|
+
AttachmentService.of({
|
|
38
|
+
uploadToIssue: () => Effect.die(new Error("Not configured - run 'jira auth login' first"))
|
|
39
|
+
})
|
|
40
|
+
)
|
|
41
|
+
|
|
34
42
|
const DummyMarkdownWriterLayer = Layer.succeed(
|
|
35
43
|
MarkdownWriter,
|
|
36
44
|
MarkdownWriter.of({
|
|
@@ -82,10 +90,10 @@ const JiraConfigLive = Layer.unwrap(
|
|
|
82
90
|
return Layer.succeed(JiraApiConfig, {
|
|
83
91
|
baseUrl: "",
|
|
84
92
|
auth: {
|
|
85
|
-
type: "oauth2"
|
|
93
|
+
type: "oauth2",
|
|
86
94
|
accessToken,
|
|
87
95
|
cloudId
|
|
88
|
-
}
|
|
96
|
+
} satisfies { readonly type: "oauth2"; readonly accessToken: typeof accessToken; readonly cloudId: string }
|
|
89
97
|
})
|
|
90
98
|
})
|
|
91
99
|
)
|
|
@@ -110,6 +118,7 @@ const SiteUrlLive = Layer.unwrap(
|
|
|
110
118
|
* @category Layers
|
|
111
119
|
*/
|
|
112
120
|
export const AppLayer = MarkdownWriterLayer.pipe(
|
|
121
|
+
Layer.provideMerge(AttachmentServiceLayer),
|
|
113
122
|
Layer.provideMerge(IssueServiceLayer),
|
|
114
123
|
Layer.provideMerge(VersionServiceLayer),
|
|
115
124
|
Layer.provideMerge(SiteUrlLive),
|
|
@@ -125,6 +134,7 @@ export const AppLayer = MarkdownWriterLayer.pipe(
|
|
|
125
134
|
* @category Layers
|
|
126
135
|
*/
|
|
127
136
|
export const AuthOnlyLayer = DummyIssueServiceLayer.pipe(
|
|
137
|
+
Layer.provideMerge(DummyAttachmentServiceLayer),
|
|
128
138
|
Layer.provideMerge(DummyMarkdownWriterLayer),
|
|
129
139
|
Layer.provideMerge(DummyVersionServiceLayer),
|
|
130
140
|
Layer.provideMerge(AuthLive),
|
|
@@ -138,6 +148,7 @@ export const AuthOnlyLayer = DummyIssueServiceLayer.pipe(
|
|
|
138
148
|
* @category Layers
|
|
139
149
|
*/
|
|
140
150
|
export const MinimalLayer = DummyIssueServiceLayer.pipe(
|
|
151
|
+
Layer.provideMerge(DummyAttachmentServiceLayer),
|
|
141
152
|
Layer.provideMerge(DummyMarkdownWriterLayer),
|
|
142
153
|
Layer.provideMerge(DummyVersionServiceLayer),
|
|
143
154
|
Layer.provideMerge(DummyJiraAuthLayer),
|
|
@@ -157,6 +168,12 @@ export const getLayerType = (args: ReadonlyArray<string>): "full" | "auth" | "mi
|
|
|
157
168
|
if (cmd === "auth") {
|
|
158
169
|
return "auth"
|
|
159
170
|
}
|
|
171
|
+
if (
|
|
172
|
+
cmd === "issue" && args[1] === "attachment" && args[2] === "upload" &&
|
|
173
|
+
(args.includes("--dry-run") || args.includes("-n"))
|
|
174
|
+
) {
|
|
175
|
+
return "minimal"
|
|
176
|
+
}
|
|
160
177
|
if (!cmd || cmd === "skills" || cmd === "--help" || cmd === "-h" || cmd === "--version") {
|
|
161
178
|
return "minimal"
|
|
162
179
|
}
|
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,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* @internal
|
|
5
5
|
*/
|
|
6
|
+
import { isPreviewableAttachment } from "@knpkv/atlassian-common/attachments"
|
|
6
7
|
import matter from "gray-matter"
|
|
7
8
|
import * as yaml from "js-yaml"
|
|
8
9
|
import { SyncValidationError } from "../../JiraCliError.js"
|
|
@@ -11,11 +12,25 @@ import type {
|
|
|
11
12
|
AttachmentReference,
|
|
12
13
|
CommentDraft,
|
|
13
14
|
IssueDocument,
|
|
14
|
-
IssueDocumentFrontMatter
|
|
15
|
+
IssueDocumentFrontMatter,
|
|
16
|
+
UserFieldValue
|
|
15
17
|
} from "./types.js"
|
|
16
18
|
|
|
19
|
+
const isRecord = (value: unknown): value is Readonly<Record<string, unknown>> =>
|
|
20
|
+
typeof value === "object" && value !== null && !Array.isArray(value)
|
|
21
|
+
|
|
22
|
+
const isStringArray = (value: unknown): value is ReadonlyArray<string> =>
|
|
23
|
+
Array.isArray(value) && value.every((item) => typeof item === "string")
|
|
24
|
+
|
|
25
|
+
const isFrontMatterCustomFields = (
|
|
26
|
+
value: unknown
|
|
27
|
+
): value is IssueDocumentFrontMatter["customFields"] => isRecord(value)
|
|
28
|
+
|
|
17
29
|
const yamlEngine = {
|
|
18
|
-
parse: (str: string): object =>
|
|
30
|
+
parse: (str: string): object => {
|
|
31
|
+
const value = yaml.load(str)
|
|
32
|
+
return isRecord(value) ? value : {}
|
|
33
|
+
},
|
|
19
34
|
stringify: (data: object): string => yaml.dump(data)
|
|
20
35
|
}
|
|
21
36
|
|
|
@@ -78,40 +93,41 @@ const section = (name: string, content: string): string => {
|
|
|
78
93
|
const parseFrontMatter = (path: string, data: Record<string, unknown>): IssueDocumentFrontMatter => {
|
|
79
94
|
const requiredString = (key: string): string => {
|
|
80
95
|
const value = data[key]
|
|
81
|
-
if (typeof value
|
|
82
|
-
return
|
|
96
|
+
if (typeof value === "string") return value
|
|
97
|
+
return fail(path, `Missing or invalid front matter field "${key}"`)
|
|
83
98
|
}
|
|
84
99
|
|
|
85
100
|
const nullableString = (key: string): string | null => {
|
|
86
101
|
const value = data[key]
|
|
87
102
|
if (value === null || value === undefined) return null
|
|
88
|
-
if (typeof value
|
|
89
|
-
return
|
|
103
|
+
if (typeof value === "string") return value
|
|
104
|
+
return fail(path, `Invalid front matter field "${key}"`)
|
|
90
105
|
}
|
|
91
106
|
|
|
92
|
-
const userValue = (key: string) => {
|
|
107
|
+
const userValue = (key: string): UserFieldValue | null => {
|
|
93
108
|
const value = data[key]
|
|
94
109
|
if (value === null || value === undefined) return null
|
|
95
|
-
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
110
|
+
const record = isRecord(value) ? value : fail(path, `Invalid user field "${key}"`)
|
|
111
|
+
const accountId = record["accountId"]
|
|
112
|
+
const displayName = record["displayName"]
|
|
113
|
+
if (typeof accountId === "string" && typeof displayName === "string") {
|
|
114
|
+
return {
|
|
115
|
+
accountId,
|
|
116
|
+
displayName
|
|
117
|
+
}
|
|
103
118
|
}
|
|
119
|
+
return fail(path, `Invalid user field "${key}"`)
|
|
104
120
|
}
|
|
105
121
|
|
|
106
122
|
const labels = data["labels"]
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
123
|
+
const parsedLabels = isStringArray(labels)
|
|
124
|
+
? labels
|
|
125
|
+
: fail(path, `Missing or invalid front matter field "labels"`)
|
|
110
126
|
|
|
111
127
|
const customFields = data["customFields"]
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
128
|
+
const parsedCustomFields = isFrontMatterCustomFields(customFields)
|
|
129
|
+
? customFields
|
|
130
|
+
: fail(path, `Missing or invalid front matter field "customFields"`)
|
|
115
131
|
|
|
116
132
|
return {
|
|
117
133
|
issueId: requiredString("issueId"),
|
|
@@ -122,8 +138,8 @@ const parseFrontMatter = (path: string, data: Record<string, unknown>): IssueDoc
|
|
|
122
138
|
priority: nullableString("priority"),
|
|
123
139
|
assignee: userValue("assignee"),
|
|
124
140
|
reporter: userValue("reporter"),
|
|
125
|
-
labels:
|
|
126
|
-
customFields:
|
|
141
|
+
labels: parsedLabels,
|
|
142
|
+
customFields: parsedCustomFields
|
|
127
143
|
}
|
|
128
144
|
}
|
|
129
145
|
|
|
@@ -180,13 +196,68 @@ const parseAcceptedComments = (content: string): ReadonlyArray<AcceptedComment>
|
|
|
180
196
|
}
|
|
181
197
|
|
|
182
198
|
const serializeAttachments = (attachments: ReadonlyArray<AttachmentReference>): string =>
|
|
183
|
-
attachments.map((attachment) =>
|
|
199
|
+
attachments.map((attachment) => {
|
|
200
|
+
const metadata = {
|
|
201
|
+
jiraAttachmentId: attachment.id,
|
|
202
|
+
mediaType: attachment.mediaType,
|
|
203
|
+
size: attachment.size
|
|
204
|
+
}
|
|
205
|
+
const reference = isPreviewableAttachment(attachment)
|
|
206
|
+
? ``
|
|
207
|
+
: `[${attachment.filename}](${attachment.url})`
|
|
208
|
+
return `<!-- jiraAttachment: ${JSON.stringify(metadata)} -->\n${reference}`
|
|
209
|
+
}).join("\n\n")
|
|
184
210
|
|
|
185
|
-
const parseAttachments = (content: string): ReadonlyArray<AttachmentReference> =>
|
|
186
|
-
content.split(/\r?\n/)
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
211
|
+
const parseAttachments = (content: string): ReadonlyArray<AttachmentReference> => {
|
|
212
|
+
const lines = content.split(/\r?\n/)
|
|
213
|
+
const attachments: Array<AttachmentReference> = []
|
|
214
|
+
let pendingMetadata: Partial<Pick<AttachmentReference, "id" | "mediaType" | "size">> = {}
|
|
215
|
+
|
|
216
|
+
for (const rawLine of lines) {
|
|
217
|
+
const line = rawLine.trim()
|
|
218
|
+
if (line.length === 0) continue
|
|
219
|
+
|
|
220
|
+
const metadataMatch = /^<!--\s*jiraAttachment:\s*(\{.*})\s*-->$/.exec(line)
|
|
221
|
+
if (metadataMatch?.[1]) {
|
|
222
|
+
pendingMetadata = parseAttachmentMetadata(metadataMatch[1])
|
|
223
|
+
continue
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const markdownMatch = /^(?:-\s*)?!?\[(.+)]\((.+)\)$/.exec(line)
|
|
227
|
+
if (markdownMatch?.[1] && markdownMatch[2]) {
|
|
228
|
+
attachments.push({
|
|
229
|
+
id: pendingMetadata.id ?? "",
|
|
230
|
+
filename: markdownMatch[1],
|
|
231
|
+
url: markdownMatch[2],
|
|
232
|
+
mediaType: pendingMetadata.mediaType ?? null,
|
|
233
|
+
size: pendingMetadata.size ?? null
|
|
234
|
+
})
|
|
235
|
+
pendingMetadata = {}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return attachments
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const parseAttachmentMetadata = (
|
|
243
|
+
raw: string
|
|
244
|
+
): Partial<Pick<AttachmentReference, "id" | "mediaType" | "size">> => {
|
|
245
|
+
try {
|
|
246
|
+
const parsed = JSON.parse(raw)
|
|
247
|
+
if (!isRecord(parsed)) return {}
|
|
248
|
+
return {
|
|
249
|
+
id: typeof parsed["jiraAttachmentId"] === "string"
|
|
250
|
+
? parsed["jiraAttachmentId"]
|
|
251
|
+
: typeof parsed["id"] === "string"
|
|
252
|
+
? parsed["id"]
|
|
253
|
+
: "",
|
|
254
|
+
mediaType: typeof parsed["mediaType"] === "string" ? parsed["mediaType"] : null,
|
|
255
|
+
size: typeof parsed["size"] === "number" ? parsed["size"] : null
|
|
256
|
+
}
|
|
257
|
+
} catch {
|
|
258
|
+
return {}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
190
261
|
|
|
191
262
|
const trimOuterBlankLines = (lines: ReadonlyArray<string>): ReadonlyArray<string> => {
|
|
192
263
|
let start = 0
|
|
@@ -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 {
|