@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.
- package/CHANGELOG.md +18 -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 +8 -4
- 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,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(
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
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\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
|
+
})
|
package/test/commandTree.test.ts
CHANGED
|
@@ -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", () =>
|
package/test/frontmatter.test.ts
CHANGED
|
@@ -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
|
-
|
|
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("")
|
|
78
|
+
})
|
|
60
79
|
})
|
|
61
80
|
|
|
62
81
|
describe("extractFrontMatter", () => {
|
package/test/integration.test.ts
CHANGED
|
@@ -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"
|
|
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(`
|
|
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
|
|
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\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(`
|
|
268
|
+
expect(updated).not.toContain(``)
|
|
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: [{
|
|
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("")
|
|
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
|
},
|