@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.
Files changed (93) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +21 -1
  3. package/dist/AttachmentService.d.ts +22 -0
  4. package/dist/AttachmentService.d.ts.map +1 -0
  5. package/dist/AttachmentService.js +72 -0
  6. package/dist/AttachmentService.js.map +1 -0
  7. package/dist/IssueService.d.ts +2 -20
  8. package/dist/IssueService.d.ts.map +1 -1
  9. package/dist/IssueService.js +37 -24
  10. package/dist/IssueService.js.map +1 -1
  11. package/dist/JiraAuth.d.ts.map +1 -1
  12. package/dist/JiraAuth.js.map +1 -1
  13. package/dist/VersionService.d.ts.map +1 -1
  14. package/dist/VersionService.js +16 -18
  15. package/dist/VersionService.js.map +1 -1
  16. package/dist/bin.js +2 -2
  17. package/dist/bin.js.map +1 -1
  18. package/dist/commands/auth.d.ts +1 -1
  19. package/dist/commands/index.d.ts +1 -0
  20. package/dist/commands/index.d.ts.map +1 -1
  21. package/dist/commands/index.js +1 -0
  22. package/dist/commands/index.js.map +1 -1
  23. package/dist/commands/issue.d.ts.map +1 -1
  24. package/dist/commands/issue.js +2 -1
  25. package/dist/commands/issue.js.map +1 -1
  26. package/dist/commands/issueAttachment.d.ts +6 -0
  27. package/dist/commands/issueAttachment.d.ts.map +1 -0
  28. package/dist/commands/issueAttachment.js +88 -0
  29. package/dist/commands/issueAttachment.js.map +1 -0
  30. package/dist/commands/layers.d.ts +4 -3
  31. package/dist/commands/layers.d.ts.map +1 -1
  32. package/dist/commands/layers.js +11 -3
  33. package/dist/commands/layers.js.map +1 -1
  34. package/dist/commands/search.js.map +1 -1
  35. package/dist/index.d.ts +1 -0
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +1 -0
  38. package/dist/index.js.map +1 -1
  39. package/dist/internal/attachmentInsertion.d.ts +10 -0
  40. package/dist/internal/attachmentInsertion.d.ts.map +1 -0
  41. package/dist/internal/attachmentInsertion.js +22 -0
  42. package/dist/internal/attachmentInsertion.js.map +1 -0
  43. package/dist/internal/frontmatter.d.ts.map +1 -1
  44. package/dist/internal/frontmatter.js +15 -3
  45. package/dist/internal/frontmatter.js.map +1 -1
  46. package/dist/internal/sync/baseline.js +2 -1
  47. package/dist/internal/sync/baseline.js.map +1 -1
  48. package/dist/internal/sync/changes.js.map +1 -1
  49. package/dist/internal/sync/config.d.ts.map +1 -1
  50. package/dist/internal/sync/config.js +1 -1
  51. package/dist/internal/sync/config.js.map +1 -1
  52. package/dist/internal/sync/document.d.ts.map +1 -1
  53. package/dist/internal/sync/document.js +88 -29
  54. package/dist/internal/sync/document.js.map +1 -1
  55. package/dist/internal/sync/manifest.js.map +1 -1
  56. package/dist/internal/sync/schemas.d.ts +5 -5
  57. package/dist/internal/sync/schemas.d.ts.map +1 -1
  58. package/dist/internal/sync/schemas.js +2 -1
  59. package/dist/internal/sync/schemas.js.map +1 -1
  60. package/dist/internal/sync/types.d.ts +5 -2
  61. package/dist/internal/sync/types.d.ts.map +1 -1
  62. package/dist/internal/sync/types.js.map +1 -1
  63. package/package.json +12 -8
  64. package/src/AttachmentService.ts +112 -0
  65. package/src/IssueService.ts +70 -47
  66. package/src/JiraAuth.ts +7 -11
  67. package/src/VersionService.ts +139 -138
  68. package/src/bin.ts +2 -2
  69. package/src/commands/index.ts +1 -0
  70. package/src/commands/issue.ts +3 -2
  71. package/src/commands/issueAttachment.ts +146 -0
  72. package/src/commands/layers.ts +19 -2
  73. package/src/commands/search.ts +1 -1
  74. package/src/index.ts +6 -0
  75. package/src/internal/attachmentInsertion.ts +44 -0
  76. package/src/internal/frontmatter.ts +17 -3
  77. package/src/internal/sync/baseline.ts +3 -3
  78. package/src/internal/sync/changes.ts +1 -1
  79. package/src/internal/sync/config.ts +1 -2
  80. package/src/internal/sync/document.ts +100 -29
  81. package/src/internal/sync/manifest.ts +1 -1
  82. package/src/internal/sync/schemas.ts +3 -2
  83. package/src/internal/sync/types.ts +16 -4
  84. package/test/IssueService.test.ts +78 -0
  85. package/test/VersionService.test.ts +21 -16
  86. package/test/attachmentInsertion.test.ts +25 -0
  87. package/test/commandTree.test.ts +38 -0
  88. package/test/frontmatter.test.ts +20 -1
  89. package/test/integration.test.ts +92 -3
  90. package/test/layers.test.ts +12 -0
  91. package/test/syncDocument.test.ts +30 -1
  92. package/vitest.config.integration.ts +4 -0
  93. 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
+ }
@@ -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" as const,
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
  }
@@ -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" as const)
41
+ Options.withDefault("multi")
42
42
  )
43
43
 
44
44
  const maxResultsOption = Options.integer("max-results").pipe(
package/src/index.ts CHANGED
@@ -4,6 +4,12 @@
4
4
  * @module
5
5
  */
6
6
 
7
+ export {
8
+ AttachmentService,
9
+ type AttachmentServiceShape,
10
+ layer as AttachmentServiceLayer,
11
+ type UploadAttachmentInput
12
+ } from "./AttachmentService.js"
7
13
  export {
8
14
  type Attachment,
9
15
  type Comment,
@@ -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 => (yaml.load(str) as 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
- parts.push(`- [${attachment.filename}](${attachment.url})`)
121
+ const reference = isPreviewableAttachment(attachment)
122
+ ? `![${attachment.filename}](${attachment.url})`
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) as unknown,
18
+ try: (): unknown => JSON.parse(content),
19
19
  catch: (cause) => new SyncWorkspaceError({ message: "Failed to parse Sync Baseline JSON", path, cause })
20
20
  })
21
- return yield* Schema.decodeUnknownEffect(SyncBaselineSchema)(raw).pipe(
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}` as const
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 => (yaml.load(str) as 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 !== "string") fail(path, `Missing or invalid front matter field "${key}"`)
82
- return value as string
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 !== "string") fail(path, `Invalid front matter field "${key}"`)
89
- return value as string
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
- if (typeof value !== "object") fail(path, `Invalid user field "${key}"`)
96
- const record = value as Record<string, unknown>
97
- if (typeof record["accountId"] !== "string" || typeof record["displayName"] !== "string") {
98
- fail(path, `Invalid user field "${key}"`)
99
- }
100
- return {
101
- accountId: record["accountId"] as string,
102
- displayName: record["displayName"] as string
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
- if (!Array.isArray(labels) || labels.some((label) => typeof label !== "string")) {
108
- fail(path, `Missing or invalid front matter field "labels"`)
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
- if (customFields === null || typeof customFields !== "object" || Array.isArray(customFields)) {
113
- fail(path, `Missing or invalid front matter field "customFields"`)
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: labels as ReadonlyArray<string>,
126
- customFields: customFields as IssueDocumentFrontMatter["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) => `- [${attachment.filename}](${attachment.url})`).join("\n")
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
+ ? `![${attachment.filename}](${attachment.url})`
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/).flatMap((line) => {
187
- const match = /^- \[(.+)]\((.+)\)$/.exec(line.trim())
188
- return match?.[1] && match[2] ? [{ filename: match[1], url: match[2] }] : []
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) as unknown,
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 as const))),
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(["convention", "custom"] as const)
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 const FIELD_SHAPES = [
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
- ] as const
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 {