@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.
Files changed (99) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +23 -7
  3. package/dist/AttachmentService.d.ts +22 -0
  4. package/dist/AttachmentService.d.ts.map +1 -0
  5. package/dist/AttachmentService.js +67 -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 +47 -36
  10. package/dist/IssueService.js.map +1 -1
  11. package/dist/JiraAuth.d.ts +1 -1
  12. package/dist/JiraAuth.d.ts.map +1 -1
  13. package/dist/JiraAuth.js.map +1 -1
  14. package/dist/VersionService.d.ts.map +1 -1
  15. package/dist/VersionService.js +39 -45
  16. package/dist/VersionService.js.map +1 -1
  17. package/dist/bin.js +2 -2
  18. package/dist/bin.js.map +1 -1
  19. package/dist/commands/auth.d.ts +1 -1
  20. package/dist/commands/index.d.ts +1 -0
  21. package/dist/commands/index.d.ts.map +1 -1
  22. package/dist/commands/index.js +1 -0
  23. package/dist/commands/index.js.map +1 -1
  24. package/dist/commands/issue.d.ts.map +1 -1
  25. package/dist/commands/issue.js +2 -1
  26. package/dist/commands/issue.js.map +1 -1
  27. package/dist/commands/issueAttachment.d.ts +6 -0
  28. package/dist/commands/issueAttachment.d.ts.map +1 -0
  29. package/dist/commands/issueAttachment.js +88 -0
  30. package/dist/commands/issueAttachment.js.map +1 -0
  31. package/dist/commands/layers.d.ts +4 -3
  32. package/dist/commands/layers.d.ts.map +1 -1
  33. package/dist/commands/layers.js +12 -4
  34. package/dist/commands/layers.js.map +1 -1
  35. package/dist/commands/search.js.map +1 -1
  36. package/dist/index.d.ts +1 -0
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +1 -0
  39. package/dist/index.js.map +1 -1
  40. package/dist/internal/attachmentInsertion.d.ts +10 -0
  41. package/dist/internal/attachmentInsertion.d.ts.map +1 -0
  42. package/dist/internal/attachmentInsertion.js +22 -0
  43. package/dist/internal/attachmentInsertion.js.map +1 -0
  44. package/dist/internal/frontmatter.d.ts.map +1 -1
  45. package/dist/internal/frontmatter.js +15 -3
  46. package/dist/internal/frontmatter.js.map +1 -1
  47. package/dist/internal/sync/baseline.js +2 -1
  48. package/dist/internal/sync/baseline.js.map +1 -1
  49. package/dist/internal/sync/changes.js.map +1 -1
  50. package/dist/internal/sync/config.d.ts.map +1 -1
  51. package/dist/internal/sync/config.js +1 -1
  52. package/dist/internal/sync/config.js.map +1 -1
  53. package/dist/internal/sync/document.d.ts.map +1 -1
  54. package/dist/internal/sync/document.js +88 -29
  55. package/dist/internal/sync/document.js.map +1 -1
  56. package/dist/internal/sync/fieldValues.d.ts +0 -5
  57. package/dist/internal/sync/fieldValues.d.ts.map +1 -1
  58. package/dist/internal/sync/fieldValues.js +9 -4
  59. package/dist/internal/sync/fieldValues.js.map +1 -1
  60. package/dist/internal/sync/manifest.js.map +1 -1
  61. package/dist/internal/sync/schemas.d.ts +5 -5
  62. package/dist/internal/sync/schemas.d.ts.map +1 -1
  63. package/dist/internal/sync/schemas.js +2 -1
  64. package/dist/internal/sync/schemas.js.map +1 -1
  65. package/dist/internal/sync/types.d.ts +5 -2
  66. package/dist/internal/sync/types.d.ts.map +1 -1
  67. package/dist/internal/sync/types.js.map +1 -1
  68. package/package.json +13 -9
  69. package/src/AttachmentService.ts +106 -0
  70. package/src/IssueService.ts +81 -61
  71. package/src/JiraAuth.ts +7 -11
  72. package/src/VersionService.ts +142 -159
  73. package/src/bin.ts +2 -2
  74. package/src/commands/index.ts +1 -0
  75. package/src/commands/issue.ts +3 -2
  76. package/src/commands/issueAttachment.ts +146 -0
  77. package/src/commands/layers.ts +20 -3
  78. package/src/commands/search.ts +1 -1
  79. package/src/index.ts +6 -0
  80. package/src/internal/attachmentInsertion.ts +44 -0
  81. package/src/internal/frontmatter.ts +17 -3
  82. package/src/internal/sync/baseline.ts +3 -3
  83. package/src/internal/sync/changes.ts +1 -1
  84. package/src/internal/sync/config.ts +1 -2
  85. package/src/internal/sync/document.ts +98 -29
  86. package/src/internal/sync/fieldValues.ts +4 -6
  87. package/src/internal/sync/manifest.ts +1 -1
  88. package/src/internal/sync/schemas.ts +3 -2
  89. package/src/internal/sync/types.ts +16 -4
  90. package/test/IssueService.test.ts +94 -0
  91. package/test/VersionService.test.ts +41 -20
  92. package/test/attachmentInsertion.test.ts +25 -0
  93. package/test/commandTree.test.ts +38 -0
  94. package/test/frontmatter.test.ts +20 -1
  95. package/test/integration.test.ts +94 -3
  96. package/test/layers.test.ts +12 -0
  97. package/test/syncDocument.test.ts +30 -1
  98. package/vitest.config.integration.ts +4 -0
  99. package/vitest.config.ts +4 -0
@@ -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,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 => (yaml.load(str) as 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 !== "string") fail(path, `Missing or invalid front matter field "${key}"`)
82
- return value as string
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 !== "string") fail(path, `Invalid front matter field "${key}"`)
89
- return value as string
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
- 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
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
- if (!Array.isArray(labels) || labels.some((label) => typeof label !== "string")) {
108
- fail(path, `Missing or invalid front matter field "labels"`)
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
- if (customFields === null || typeof customFields !== "object" || Array.isArray(customFields)) {
113
- fail(path, `Missing or invalid front matter field "customFields"`)
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: labels as ReadonlyArray<string>,
126
- customFields: customFields as IssueDocumentFrontMatter["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) => `- [${attachment.filename}](${attachment.url})`).join("\n")
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
+ ? `![${attachment.filename}](${attachment.url})`
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/).flatMap((line) => {
187
- const match = /^- \[(.+)]\((.+)\)$/.exec(line.trim())
188
- return match?.[1] && match[2] ? [{ filename: match[1], url: match[2] }] : []
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 (!isRecord(value)) return false
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 (!isRecord(value) || typeof value["value"] !== "string") return false
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 (!isRecord(value) || !isOptionFieldValue(value["parent"])) return false
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) 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 {
@@ -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
- { id: "1", name: "1.0.0", released: true, self: "https://x/version/1" },
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 `v3.client.GET` routes by path: the version
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
- Layer.succeed(JiraApiClient, {
222
- v3: {
223
- client: {
224
- GET: (path: string) =>
225
- path === "/rest/api/3/project/{projectIdOrKey}/version"
226
- ? Promise.resolve({
227
- data: { values: versionsFixture, isLast: true },
228
- response: { ok: true, status: 200 }
229
- })
230
- : Promise.resolve({
231
- data: { issues: [], isLast: true },
232
- response: { ok: true, status: 200 }
233
- })
234
- }
235
- }
236
- } as never)
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![Architecture diagram](./diagram.svg)\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
+ })