@knpkv/jira-cli 1.1.2 → 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 +18 -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 +8 -4
  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,78 @@
1
+ import { describe, expect, it } from "@effect/vitest"
2
+ import { JiraApiClient } from "@knpkv/jira-api-client"
3
+ import * as Effect from "effect/Effect"
4
+ import * as Layer from "effect/Layer"
5
+ import { IssueService, layer as IssueServiceLayer, SiteUrl } from "../src/IssueService.js"
6
+
7
+ const JiraClientLayer = Layer.succeed(
8
+ JiraApiClient,
9
+ JiraApiClient.of({
10
+ v3: {
11
+ client: {
12
+ GET: (path: string) =>
13
+ path === "/rest/api/3/issue/{issueIdOrKey}"
14
+ ? Promise.resolve({
15
+ data: {
16
+ id: "10000",
17
+ key: "PROJ-123",
18
+ fields: {
19
+ attachment: [{
20
+ id: "30001",
21
+ filename: "diagram.svg",
22
+ content: "https://example.atlassian.net/rest/api/3/attachment/content/30001",
23
+ mimeType: "image/svg+xml",
24
+ size: 448
25
+ }, {
26
+ id: "30002",
27
+ filename: "unknown.bin",
28
+ content: "https://example.atlassian.net/rest/api/3/attachment/content/30002",
29
+ size: 99
30
+ }],
31
+ components: [],
32
+ created: "2026-01-01T00:00:00.000Z",
33
+ description: "",
34
+ fixVersions: [],
35
+ issuetype: { name: "Task" },
36
+ labels: [],
37
+ status: { name: "Done" },
38
+ summary: "Sample issue",
39
+ updated: "2026-01-01T00:00:00.000Z"
40
+ },
41
+ renderedFields: {}
42
+ },
43
+ response: { ok: true, status: 200 }
44
+ })
45
+ : Promise.resolve({ data: {}, response: { ok: false, status: 404 } })
46
+ }
47
+ }
48
+ })
49
+ )
50
+
51
+ const TestLayer = IssueServiceLayer.pipe(
52
+ Layer.provide(JiraClientLayer),
53
+ Layer.provide(Layer.succeed(SiteUrl, "https://example.atlassian.net"))
54
+ )
55
+
56
+ describe("IssueService", () => {
57
+ it.effect("keeps mimeType as a backwards-compatible attachment alias", () =>
58
+ Effect.gen(function*() {
59
+ const service = yield* IssueService
60
+ const issue = yield* service.getByKey("PROJ-123")
61
+
62
+ expect(issue.attachments).toEqual([{
63
+ id: "30001",
64
+ filename: "diagram.svg",
65
+ url: "https://example.atlassian.net/rest/api/3/attachment/content/30001",
66
+ mediaType: "image/svg+xml",
67
+ mimeType: "image/svg+xml",
68
+ size: 448
69
+ }, {
70
+ id: "30002",
71
+ filename: "unknown.bin",
72
+ url: "https://example.atlassian.net/rest/api/3/attachment/content/30002",
73
+ mediaType: null,
74
+ mimeType: "",
75
+ size: 99
76
+ }])
77
+ }).pipe(Effect.provide(TestLayer)))
78
+ })
@@ -1,7 +1,9 @@
1
1
  import { describe, expect, it } from "@effect/vitest"
2
- import { JiraApiClient } from "@knpkv/jira-api-client"
2
+ import { JiraApiClient, type JiraApiClientShape } from "@knpkv/jira-api-client"
3
3
  import * as Effect from "effect/Effect"
4
4
  import * as Layer from "effect/Layer"
5
+ import type { paths as V3Paths } from "../../jira-api-client/src/generated/v3/schema.js"
6
+ import { makeOpenApiFetchClient } from "../../jira-api-client/src/OpenApiFetchClient.js"
5
7
  import { stripEmails } from "../src/commands/version.js"
6
8
  import type { Version } from "../src/VersionService.js"
7
9
  import {
@@ -218,22 +220,25 @@ const versionsFixture = [
218
220
  * (so the contributor scan resolves to empty without further calls).
219
221
  */
220
222
  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
- })
223
+ Layer.succeed(
224
+ JiraApiClient,
225
+ {
226
+ v3: {
227
+ client: Object.assign(makeOpenApiFetchClient<V3Paths>("https://jira.test", {}).client, {
228
+ GET: (path: string) =>
229
+ path === "/rest/api/3/project/{projectIdOrKey}/version"
230
+ ? Promise.resolve({
231
+ data: { values: versionsFixture, isLast: true },
232
+ response: { ok: true, status: 200 }
233
+ })
234
+ : Promise.resolve({
235
+ data: { issues: [], isLast: true },
236
+ response: { ok: true, status: 200 }
237
+ })
238
+ })
234
239
  }
235
- }
236
- } as never)
240
+ } satisfies JiraApiClientShape
241
+ )
237
242
 
238
243
  describe("listProjectVersions filtering", () => {
239
244
  it.effect("returns all versions when neither flag is set", () =>
@@ -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
+ })
@@ -4,6 +4,7 @@ import * as Layer from "effect/Layer"
4
4
  import * as Ref from "effect/Ref"
5
5
  import * as Terminal from "effect/Terminal"
6
6
  import { Command } from "effect/unstable/cli"
7
+ import { AttachmentService } from "../src/AttachmentService.js"
7
8
  import { issueCommand } from "../src/commands/issue.js"
8
9
  import { versionCommand } from "../src/commands/version.js"
9
10
  import { type Issue, IssueService } from "../src/IssueService.js"
@@ -13,6 +14,7 @@ import { type RelatedWork, type Version, VersionService } from "../src/VersionSe
13
14
  interface CommandCalls {
14
15
  readonly issueGet: number
15
16
  readonly issueSearch: number
17
+ readonly issueAttachmentUpload: number
16
18
  readonly versionList: number
17
19
  readonly versionGet: number
18
20
  readonly versionUpdate: number
@@ -23,6 +25,7 @@ interface CommandCalls {
23
25
 
24
26
  const emptyCalls: CommandCalls = {
25
27
  issueGet: 0,
28
+ issueAttachmentUpload: 0,
26
29
  issueSearch: 0,
27
30
  relatedWorkAdd: 0,
28
31
  relatedWorkList: 0,
@@ -52,6 +55,15 @@ const sampleIssue: Issue = {
52
55
  url: "https://example.atlassian.net/browse/PROJ-123"
53
56
  }
54
57
 
58
+ const sampleAttachment = {
59
+ id: "30001",
60
+ filename: "evidence.png",
61
+ url: "https://example.atlassian.net/rest/api/3/attachment/content/30001",
62
+ mediaType: "image/png",
63
+ mimeType: "image/png",
64
+ size: 123
65
+ }
66
+
55
67
  const sampleVersion: Version = {
56
68
  approvers: [],
57
69
  archived: false,
@@ -88,6 +100,16 @@ const CaptureTerminalLayer = (stdout: Ref.Ref<string>) =>
88
100
 
89
101
  const CommandServicesLayer = (calls: Ref.Ref<CommandCalls>) =>
90
102
  Layer.mergeAll(
103
+ Layer.succeed(
104
+ AttachmentService,
105
+ AttachmentService.of({
106
+ uploadToIssue: () =>
107
+ Ref.update(calls, (state) => ({
108
+ ...state,
109
+ issueAttachmentUpload: state.issueAttachmentUpload + 1
110
+ })).pipe(Effect.as(sampleAttachment))
111
+ })
112
+ ),
91
113
  Layer.succeed(
92
114
  IssueService,
93
115
  IssueService.of({
@@ -158,24 +180,40 @@ describe("Jira command tree", () => {
158
180
  const calls = yield* Ref.make(emptyCalls)
159
181
  const getOutput = yield* Ref.make("")
160
182
  const searchOutput = yield* Ref.make("")
183
+ const uploadOutput = yield* Ref.make("")
161
184
  const legacyGetOutput = yield* Ref.make("")
162
185
  const legacySearchOutput = yield* Ref.make("")
163
186
 
164
187
  const getExit = yield* runJiraCommand(["issue", "get", "PROJ-123"], calls, getOutput)
165
188
  const searchExit = yield* runJiraCommand(["issue", "search", "project = PROJ"], calls, searchOutput)
189
+ const uploadExit = yield* runJiraCommand(
190
+ [
191
+ "issue",
192
+ "attachment",
193
+ "upload",
194
+ "PROJ-123",
195
+ "./evidence.png",
196
+ "--no-insert"
197
+ ],
198
+ calls,
199
+ uploadOutput
200
+ )
166
201
  const legacyGetExit = yield* runJiraCommand(["get", "PROJ-123"], calls, legacyGetOutput)
167
202
  const legacySearchExit = yield* runJiraCommand(["search", "project = PROJ"], calls, legacySearchOutput)
168
203
 
169
204
  expect(getExit._tag).toBe("Success")
170
205
  expect(searchExit._tag).toBe("Success")
206
+ expect(uploadExit._tag).toBe("Success")
171
207
  expect(legacyGetExit._tag).toBe("Failure")
172
208
  expect(legacySearchExit._tag).toBe("Failure")
173
209
 
174
210
  expect(yield* Ref.get(calls)).toMatchObject({
175
211
  issueGet: 1,
212
+ issueAttachmentUpload: 1,
176
213
  issueSearch: 1,
177
214
  writeMulti: 2
178
215
  })
216
+ expect(sampleAttachment.mimeType).toBe(sampleAttachment.mediaType)
179
217
  }))
180
218
 
181
219
  it.effect("exposes canonical version commands and rejects legacy names", () =>
@@ -10,7 +10,10 @@ import type { Issue } from "../src/IssueService.js"
10
10
  */
11
11
  const parseFrontMatter = (output: string): Record<string, unknown> => {
12
12
  const match = output.match(/^---\n([\s\S]*?)\n---/)
13
- return match ? (yaml.load(match[1]) as Record<string, unknown>) : {}
13
+ const parsed = match ? yaml.load(match[1]) : {}
14
+ return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)
15
+ ? Object.fromEntries(Object.entries(parsed))
16
+ : {}
14
17
  }
15
18
 
16
19
  const makeIssue = (overrides: Partial<Issue> = {}): Issue => ({
@@ -57,6 +60,22 @@ describe("frontmatter", () => {
57
60
  expect(data.priority).toBeNull()
58
61
  expect(data.assignee).toBeNull()
59
62
  })
63
+
64
+ it("renders image and SVG attachments as inline previews with identity metadata", () => {
65
+ const output = serializeIssue(makeIssue({
66
+ attachments: [{
67
+ id: "10001",
68
+ filename: "diagram.svg",
69
+ url: "https://example.atlassian.net/rest/api/3/attachment/content/10001",
70
+ mediaType: "application/octet-stream",
71
+ size: 42
72
+ }]
73
+ }))
74
+ expect(output).toContain(
75
+ "<!-- jiraAttachment: {\"jiraAttachmentId\":\"10001\",\"mediaType\":\"application/octet-stream\",\"size\":42} -->"
76
+ )
77
+ expect(output).toContain("![diagram.svg](https://example.atlassian.net/rest/api/3/attachment/content/10001)")
78
+ })
60
79
  })
61
80
 
62
81
  describe("extractFrontMatter", () => {
@@ -18,11 +18,14 @@ import * as FileSystem from "effect/FileSystem"
18
18
  import * as Path from "effect/Path"
19
19
  import * as Redacted from "effect/Redacted"
20
20
  import * as yaml from "js-yaml"
21
+ import { AttachmentService, layer as AttachmentServiceLayer } from "../src/AttachmentService.js"
22
+ import { insertJiraAttachmentReference } from "../src/internal/attachmentInsertion.js"
21
23
  import { IssueService, layer as IssueServiceLayer, SiteUrl } from "../src/IssueService.js"
22
24
  import { layer as MarkdownWriterLayer, MarkdownWriter } from "../src/MarkdownWriter.js"
23
25
 
24
26
  const DEFAULT_BASE_URL = "https://knpkv.atlassian.net"
25
27
  const DEFAULT_ISSUE_KEY = "KAN-1"
28
+ const INTEGRATION_ATTACHMENT_FILENAME = "inline-attachment.svg"
26
29
 
27
30
  interface IntegrationConfig {
28
31
  readonly baseUrl: string
@@ -54,6 +57,20 @@ const SHOULD_RUN_INTEGRATION = Effect.runSync(
54
57
  )
55
58
  )
56
59
 
60
+ const SHOULD_RUN_ATTACHMENT_INTEGRATION = Effect.runSync(
61
+ Effect.all([
62
+ Config.option(Config.string("JIRA_INTEGRATION")),
63
+ Config.option(Config.string("JIRA_ATTACHMENT_INTEGRATION"))
64
+ ]).pipe(
65
+ Effect.map(([integration, attachment]) =>
66
+ Option.isSome(integration) &&
67
+ envFlagEnabled(integration.value) &&
68
+ Option.isSome(attachment) &&
69
+ envFlagEnabled(attachment.value)
70
+ )
71
+ )
72
+ )
73
+
57
74
  const readIntegrationConfig = Effect.gen(function*() {
58
75
  const baseUrl = yield* Config.option(Config.string("JIRA_BASE_URL"))
59
76
  const issueKey = yield* Config.option(Config.string("JIRA_ISSUE_KEY"))
@@ -72,13 +89,14 @@ const makeIntegrationLayer = (config: IntegrationConfig) => {
72
89
  const configLayer = Layer.succeed(JiraApiConfig, {
73
90
  baseUrl: config.baseUrl,
74
91
  auth: {
75
- type: "basic" as const,
92
+ type: "basic",
76
93
  email: config.email,
77
94
  apiToken: config.apiKey
78
- }
95
+ } satisfies { readonly type: "basic"; readonly email: string; readonly apiToken: typeof config.apiKey }
79
96
  })
80
97
 
81
98
  return MarkdownWriterLayer.pipe(
99
+ Layer.provideMerge(AttachmentServiceLayer),
82
100
  Layer.provideMerge(IssueServiceLayer),
83
101
  Layer.provideMerge(Layer.succeed(SiteUrl, config.baseUrl)),
84
102
  Layer.provideMerge(JiraApiClient.layer),
@@ -102,16 +120,49 @@ const readText = (filePath: string) =>
102
120
  Effect.flatMap((fs) => fs.readFileString(filePath))
103
121
  )
104
122
 
123
+ const writeText = (filePath: string, content: string) =>
124
+ FileSystem.FileSystem.pipe(
125
+ Effect.flatMap((fs) => fs.writeFileString(filePath, content))
126
+ )
127
+
105
128
  const pathExists = (filePath: string) =>
106
129
  FileSystem.FileSystem.pipe(
107
130
  Effect.flatMap((fs) => fs.exists(filePath))
108
131
  )
109
132
 
133
+ const integrationAttachmentFixturePath = Path.Path.pipe(
134
+ Effect.flatMap((path) =>
135
+ path.fromFileUrl(new URL("../../atlassian-common/test/fixtures/attachments/inline-attachment.svg", import.meta.url))
136
+ )
137
+ )
138
+
139
+ const copyIntegrationAttachmentAsset = (testDir: string) =>
140
+ Effect.gen(function*() {
141
+ const source = yield* integrationAttachmentFixturePath
142
+ const target = yield* joinPath(testDir, INTEGRATION_ATTACHMENT_FILENAME)
143
+ yield* writeText(target, yield* readText(source))
144
+ return target
145
+ })
146
+
147
+ const expectCanonicalIssueIncludesAttachmentMedia = (
148
+ config: IntegrationConfig,
149
+ issue: { readonly attachments: ReadonlyArray<{ readonly filename: string }> },
150
+ markdown: string
151
+ ) => {
152
+ if (config.issueKey !== DEFAULT_ISSUE_KEY) return
153
+
154
+ expect(issue.attachments.some((attachment) => attachment.filename === INTEGRATION_ATTACHMENT_FILENAME)).toBe(true)
155
+ expect(markdown).toContain("## Attachments")
156
+ expect(markdown).toContain(`![${INTEGRATION_ATTACHMENT_FILENAME}](`)
157
+ expect(markdown).toContain("jiraAttachmentId")
158
+ expect(markdown).toContain("image/svg+xml")
159
+ }
160
+
110
161
  const parseFrontMatter = (markdown: string): Record<string, unknown> => {
111
162
  const match = /^---\n([\s\S]*?)\n---/.exec(markdown)
112
163
  const parsed = match?.[1] ? yaml.load(match[1]) : {}
113
164
  return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)
114
- ? parsed as Record<string, unknown>
165
+ ? Object.fromEntries(Object.entries(parsed))
115
166
  : {}
116
167
  }
117
168
 
@@ -140,6 +191,7 @@ const getIssueAsMarkdown = (config: IntegrationConfig, testDir: string) =>
140
191
  expect(frontMatter.summary).toBe(issue.summary)
141
192
  expect(typeof frontMatter.status).toBe("string")
142
193
  expect(markdown).toContain(`# ${config.issueKey}:`)
194
+ expectCanonicalIssueIncludesAttachmentMedia(config, issue, markdown)
143
195
 
144
196
  return { frontMatter, issue }
145
197
  })
@@ -184,4 +236,41 @@ describe("Jira integration", () => {
184
236
  }),
185
237
  120000
186
238
  )
239
+
240
+ it.effect.skipIf(!SHOULD_RUN_ATTACHMENT_INTEGRATION)(
241
+ "uploads an SVG attachment and inserts a Markdown attachment reference",
242
+ () =>
243
+ Effect.gen(function*() {
244
+ const config = yield* readIntegrationConfig
245
+ return yield* Effect.gen(function*() {
246
+ const testDir = yield* makeTempRoot
247
+ const assetPath = yield* copyIntegrationAttachmentAsset(testDir)
248
+ const documentPath = yield* joinPath(testDir, `${config.issueKey}.md`)
249
+ yield* writeText(documentPath, `# ${config.issueKey}\n\n![Attachment proof](${assetPath})\n`)
250
+
251
+ const attachmentService = yield* AttachmentService
252
+ const issueService = yield* IssueService
253
+ const attachment = yield* attachmentService.uploadToIssue(config.issueKey, { filePath: assetPath })
254
+ expect(attachment.id).toBeTruthy()
255
+ expect(attachment.filename).toBe(INTEGRATION_ATTACHMENT_FILENAME)
256
+ expect(attachment.mediaType === null || attachment.mediaType.includes("svg")).toBe(true)
257
+
258
+ const issue = yield* issueService.getByKey(config.issueKey)
259
+ expect(issue.attachments.some((candidate) => candidate.id === attachment.id)).toBe(true)
260
+
261
+ const insertion = insertJiraAttachmentReference(yield* readText(documentPath), assetPath, attachment)
262
+ expect(insertion.replacements).toBe(1)
263
+ yield* writeText(documentPath, insertion.content)
264
+
265
+ const updated = yield* readText(documentPath)
266
+ expect(updated).toContain(`"jiraAttachmentId":"${attachment.id}"`)
267
+ expect(updated).toContain(`![${INTEGRATION_ATTACHMENT_FILENAME}](`)
268
+ expect(updated).not.toContain(`![Attachment proof](${assetPath})`)
269
+ }).pipe(
270
+ Effect.provide(makeIntegrationLayer(config)),
271
+ Effect.scoped
272
+ )
273
+ }),
274
+ 120000
275
+ )
187
276
  })
@@ -0,0 +1,12 @@
1
+ import { describe, expect, it } from "@effect/vitest"
2
+ import { getLayerType } from "../src/commands/layers.js"
3
+
4
+ describe("getLayerType", () => {
5
+ it("routes attachment upload dry-runs to the config-free minimal layer", () => {
6
+ expect(getLayerType(["issue", "attachment", "upload", "PROJ-1", "./evidence.png", "--dry-run"])).toBe(
7
+ "minimal"
8
+ )
9
+ expect(getLayerType(["issue", "attachment", "upload", "PROJ-1", "./evidence.png", "-n"])).toBe("minimal")
10
+ expect(getLayerType(["issue", "attachment", "upload", "PROJ-1", "./evidence.png"])).toBe("full")
11
+ })
12
+ })
@@ -29,7 +29,13 @@ const document: IssueDocument = {
29
29
  created: "2026-06-27",
30
30
  body: "Existing Jira comment."
31
31
  }],
32
- attachments: [{ filename: "evidence.png", url: "https://example.atlassian.net/evidence.png" }],
32
+ attachments: [{
33
+ id: "30001",
34
+ filename: "evidence.png",
35
+ url: "https://example.atlassian.net/evidence.png",
36
+ mediaType: "image/png",
37
+ size: 1234
38
+ }],
33
39
  localNotes: "Private local note."
34
40
  }
35
41
 
@@ -66,4 +72,27 @@ describe("Issue Document parsing", () => {
66
72
  const serialized = serializeIssueDocument(document).replace("<!-- draftId: draft-1 -->", "<!-- draft: draft-1 -->")
67
73
  expect(() => parseIssueDocument("PROJ-123.md", serialized)).toThrow(SyncValidationError)
68
74
  })
75
+
76
+ it("renders image attachments inline with hidden identity metadata", () => {
77
+ const serialized = serializeIssueDocument(document)
78
+ expect(serialized).toContain(
79
+ "<!-- jiraAttachment: {\"jiraAttachmentId\":\"30001\",\"mediaType\":\"image/png\",\"size\":1234} -->"
80
+ )
81
+ expect(serialized).toContain("![evidence.png](https://example.atlassian.net/evidence.png)")
82
+ })
83
+
84
+ it("keeps parsing legacy attachment link lines", () => {
85
+ const serialized = serializeIssueDocument(document).replace(
86
+ /<!-- jiraAttachment:[\s\S]*?\n!\[evidence\.png]\(https:\/\/example\.atlassian\.net\/evidence\.png\)/,
87
+ "- [evidence.png](https://example.atlassian.net/evidence.png)"
88
+ )
89
+ const parsed = parseIssueDocument("PROJ-123.md", serialized)
90
+ expect(parsed.attachments).toEqual([{
91
+ id: "",
92
+ filename: "evidence.png",
93
+ url: "https://example.atlassian.net/evidence.png",
94
+ mediaType: null,
95
+ size: null
96
+ }])
97
+ })
69
98
  })
@@ -3,6 +3,10 @@ import { defineConfig } from "vitest/config"
3
3
  export default defineConfig({
4
4
  resolve: {
5
5
  alias: {
6
+ "@knpkv/atlassian-common/attachments": new URL("../atlassian-common/src/attachments.ts", import.meta.url).pathname,
7
+ "@knpkv/atlassian-common/auth": new URL("../atlassian-common/src/auth/index.ts", import.meta.url).pathname,
8
+ "@knpkv/atlassian-common/config": new URL("../atlassian-common/src/config/index.ts", import.meta.url).pathname,
9
+ "@knpkv/agent-skills": new URL("../agent-skills/src/index.ts", import.meta.url).pathname,
6
10
  "@knpkv/jira-api-client": new URL("../jira-api-client/src/index.ts", import.meta.url).pathname
7
11
  }
8
12
  },
package/vitest.config.ts CHANGED
@@ -3,6 +3,10 @@ import { defineConfig } from "vitest/config"
3
3
  export default defineConfig({
4
4
  resolve: {
5
5
  alias: {
6
+ "@knpkv/atlassian-common/attachments": new URL("../atlassian-common/src/attachments.ts", import.meta.url).pathname,
7
+ "@knpkv/atlassian-common/auth": new URL("../atlassian-common/src/auth/index.ts", import.meta.url).pathname,
8
+ "@knpkv/atlassian-common/config": new URL("../atlassian-common/src/config/index.ts", import.meta.url).pathname,
9
+ "@knpkv/agent-skills": new URL("../agent-skills/src/index.ts", import.meta.url).pathname,
6
10
  "@knpkv/jira-api-client": new URL("../jira-api-client/src/index.ts", import.meta.url).pathname
7
11
  }
8
12
  },