@knpkv/jira-cli 0.1.1 → 1.0.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 +67 -0
- package/README.md +66 -4
- package/dist/IssueService.d.ts +2 -2
- package/dist/IssueService.d.ts.map +1 -1
- package/dist/IssueService.js +3 -3
- package/dist/IssueService.js.map +1 -1
- package/dist/JiraAuth.d.ts +14 -14
- package/dist/JiraAuth.d.ts.map +1 -1
- package/dist/JiraAuth.js +18 -10
- package/dist/JiraAuth.js.map +1 -1
- package/dist/JiraCliError.d.ts +30 -0
- package/dist/JiraCliError.d.ts.map +1 -1
- package/dist/JiraCliError.js +14 -0
- package/dist/JiraCliError.js.map +1 -1
- package/dist/MarkdownWriter.d.ts +4 -4
- package/dist/MarkdownWriter.d.ts.map +1 -1
- package/dist/MarkdownWriter.js +6 -6
- package/dist/MarkdownWriter.js.map +1 -1
- package/dist/SyncWorkspace.d.ts +34 -0
- package/dist/SyncWorkspace.d.ts.map +1 -0
- package/dist/SyncWorkspace.js +105 -0
- package/dist/SyncWorkspace.js.map +1 -0
- package/dist/VersionService.d.ts +206 -0
- package/dist/VersionService.d.ts.map +1 -0
- package/dist/VersionService.js +426 -0
- package/dist/VersionService.js.map +1 -0
- package/dist/bin.js +29 -22
- package/dist/bin.js.map +1 -1
- package/dist/commands/auth.d.ts +2 -21
- package/dist/commands/auth.d.ts.map +1 -1
- package/dist/commands/auth.js +6 -6
- package/dist/commands/auth.js.map +1 -1
- package/dist/commands/get.d.ts +3 -8
- package/dist/commands/get.d.ts.map +1 -1
- package/dist/commands/get.js +4 -4
- package/dist/commands/get.js.map +1 -1
- package/dist/commands/index.d.ts +2 -2
- package/dist/commands/index.d.ts.map +1 -1
- package/dist/commands/index.js +2 -2
- package/dist/commands/index.js.map +1 -1
- package/dist/commands/issue.d.ts +8 -0
- package/dist/commands/issue.d.ts.map +1 -0
- package/dist/commands/issue.js +10 -0
- package/dist/commands/issue.js.map +1 -0
- package/dist/commands/layers.d.ts +6 -18
- package/dist/commands/layers.d.ts.map +1 -1
- package/dist/commands/layers.js +35 -24
- package/dist/commands/layers.js.map +1 -1
- package/dist/commands/search.d.ts +3 -8
- package/dist/commands/search.d.ts.map +1 -1
- package/dist/commands/search.js +8 -8
- package/dist/commands/search.js.map +1 -1
- package/dist/commands/version.d.ts +12 -0
- package/dist/commands/version.d.ts.map +1 -0
- package/dist/commands/version.js +179 -0
- package/dist/commands/version.js.map +1 -0
- package/dist/internal/frontmatter.d.ts.map +1 -1
- package/dist/internal/frontmatter.js +14 -1
- package/dist/internal/frontmatter.js.map +1 -1
- package/dist/internal/oauthServer.d.ts +17 -5
- package/dist/internal/oauthServer.d.ts.map +1 -1
- package/dist/internal/oauthServer.js +23 -40
- package/dist/internal/oauthServer.js.map +1 -1
- package/dist/internal/openBrowser.d.ts +10 -0
- package/dist/internal/openBrowser.d.ts.map +1 -0
- package/dist/internal/openBrowser.js +17 -0
- package/dist/internal/openBrowser.js.map +1 -0
- package/dist/internal/sync/baseline.d.ts +11 -0
- package/dist/internal/sync/baseline.d.ts.map +1 -0
- package/dist/internal/sync/baseline.js +18 -0
- package/dist/internal/sync/baseline.js.map +1 -0
- package/dist/internal/sync/changes.d.ts +15 -0
- package/dist/internal/sync/changes.d.ts.map +1 -0
- package/dist/internal/sync/changes.js +72 -0
- package/dist/internal/sync/changes.js.map +1 -0
- package/dist/internal/sync/config.d.ts +12 -0
- package/dist/internal/sync/config.d.ts.map +1 -0
- package/dist/internal/sync/config.js +53 -0
- package/dist/internal/sync/config.js.map +1 -0
- package/dist/internal/sync/document.d.ts +9 -0
- package/dist/internal/sync/document.d.ts.map +1 -0
- package/dist/internal/sync/document.js +173 -0
- package/dist/internal/sync/document.js.map +1 -0
- package/dist/internal/sync/fieldValues.d.ts +30 -0
- package/dist/internal/sync/fieldValues.d.ts.map +1 -0
- package/dist/internal/sync/fieldValues.js +91 -0
- package/dist/internal/sync/fieldValues.js.map +1 -0
- package/dist/internal/sync/manifest.d.ts +12 -0
- package/dist/internal/sync/manifest.d.ts.map +1 -0
- package/dist/internal/sync/manifest.js +23 -0
- package/dist/internal/sync/manifest.js.map +1 -0
- package/dist/internal/sync/paths.d.ts +26 -0
- package/dist/internal/sync/paths.d.ts.map +1 -0
- package/dist/internal/sync/paths.js +22 -0
- package/dist/internal/sync/paths.js.map +1 -0
- package/dist/internal/sync/schemas.d.ts +128 -0
- package/dist/internal/sync/schemas.d.ts.map +1 -0
- package/dist/internal/sync/schemas.js +82 -0
- package/dist/internal/sync/schemas.js.map +1 -0
- package/dist/internal/sync/types.d.ts +144 -0
- package/dist/internal/sync/types.d.ts.map +1 -0
- package/dist/internal/sync/types.js +17 -0
- package/dist/internal/sync/types.js.map +1 -0
- package/package.json +13 -12
- package/skills/jira/SKILL.md +90 -0
- package/skills/jira/agents/openai.yaml +4 -0
- package/src/IssueService.ts +34 -28
- package/src/JiraAuth.ts +53 -39
- package/src/JiraCliError.ts +24 -0
- package/src/MarkdownWriter.ts +7 -11
- package/src/SyncWorkspace.ts +185 -0
- package/src/VersionService.ts +647 -0
- package/src/bin.ts +39 -29
- package/src/commands/auth.ts +6 -12
- package/src/commands/get.ts +4 -4
- package/src/commands/index.ts +2 -2
- package/src/commands/issue.ts +13 -0
- package/src/commands/layers.ts +44 -26
- package/src/commands/search.ts +8 -8
- package/src/commands/version.ts +267 -0
- package/src/internal/frontmatter.ts +15 -1
- package/src/internal/oauthServer.ts +43 -70
- package/src/internal/openBrowser.ts +31 -0
- package/src/internal/sync/baseline.ts +27 -0
- package/src/internal/sync/changes.ts +118 -0
- package/src/internal/sync/config.ts +76 -0
- package/src/internal/sync/document.ts +201 -0
- package/src/internal/sync/fieldValues.ts +145 -0
- package/src/internal/sync/manifest.ts +32 -0
- package/src/internal/sync/paths.ts +48 -0
- package/src/internal/sync/schemas.ts +103 -0
- package/src/internal/sync/types.ts +192 -0
- package/test/SyncWorkspace.test.ts +76 -0
- package/test/VersionService.test.ts +266 -0
- package/test/commandTree.test.ts +266 -0
- package/test/frontmatter.test.ts +69 -0
- package/test/integration.test.ts +187 -0
- package/test/syncChanges.test.ts +106 -0
- package/test/syncConfig.test.ts +138 -0
- package/test/syncDocument.test.ts +69 -0
- package/test/syncFieldValues.test.ts +101 -0
- package/vitest.config.integration.ts +17 -0
- package/vitest.config.ts +6 -0
|
@@ -9,8 +9,22 @@
|
|
|
9
9
|
* @internal
|
|
10
10
|
*/
|
|
11
11
|
import matter from "gray-matter"
|
|
12
|
+
import * as yaml from "js-yaml"
|
|
12
13
|
import type { Issue } from "../IssueService.js"
|
|
13
14
|
|
|
15
|
+
/**
|
|
16
|
+
* `gray-matter` ships a default YAML engine that calls `js-yaml`'s `safeDump`/
|
|
17
|
+
* `safeLoad`, both removed in `js-yaml` 4. The workspace pins `js-yaml` to 4.x
|
|
18
|
+
* (security override), so we supply an engine backed by the 4.x `dump`/`load`
|
|
19
|
+
* API, which is safe by default.
|
|
20
|
+
*
|
|
21
|
+
* @internal
|
|
22
|
+
*/
|
|
23
|
+
const yamlEngine = {
|
|
24
|
+
parse: (str: string): object => (yaml.load(str) as object) ?? {},
|
|
25
|
+
stringify: (data: object): string => yaml.dump(data)
|
|
26
|
+
}
|
|
27
|
+
|
|
14
28
|
/**
|
|
15
29
|
* Front-matter data for a Jira issue.
|
|
16
30
|
*
|
|
@@ -69,7 +83,7 @@ export const extractFrontMatter = (issue: Issue): IssueFrontMatter => ({
|
|
|
69
83
|
export const serializeIssue = (issue: Issue): string => {
|
|
70
84
|
const frontMatter = extractFrontMatter(issue)
|
|
71
85
|
const content = buildMarkdownContent(issue)
|
|
72
|
-
return matter.stringify(content, frontMatter)
|
|
86
|
+
return matter.stringify(content, frontMatter, { engines: { yaml: yamlEngine } })
|
|
73
87
|
}
|
|
74
88
|
|
|
75
89
|
/**
|
|
@@ -10,21 +10,20 @@
|
|
|
10
10
|
*
|
|
11
11
|
* @internal
|
|
12
12
|
*/
|
|
13
|
-
import * as HttpRouter from "@effect/platform/HttpRouter"
|
|
14
|
-
import * as HttpServer from "@effect/platform/HttpServer"
|
|
15
|
-
import type { ServeError } from "@effect/platform/HttpServerError"
|
|
16
|
-
import * as HttpServerRequest from "@effect/platform/HttpServerRequest"
|
|
17
|
-
import * as HttpServerResponse from "@effect/platform/HttpServerResponse"
|
|
18
13
|
import { OAuthError } from "@knpkv/atlassian-common/auth"
|
|
19
14
|
import * as Context from "effect/Context"
|
|
20
15
|
import * as Deferred from "effect/Deferred"
|
|
21
16
|
import * as Effect from "effect/Effect"
|
|
17
|
+
import * as Exit from "effect/Exit"
|
|
22
18
|
import * as Fiber from "effect/Fiber"
|
|
23
19
|
import * as Layer from "effect/Layer"
|
|
20
|
+
import * as Scope from "effect/Scope"
|
|
21
|
+
import { HttpRouter, HttpServer, HttpServerResponse } from "effect/unstable/http"
|
|
22
|
+
import type * as HttpServerError from "effect/unstable/http/HttpServerError"
|
|
24
23
|
|
|
25
24
|
const DEFAULT_PORT = 8585
|
|
26
|
-
const
|
|
27
|
-
|
|
25
|
+
const MAX_PORT = 8594
|
|
26
|
+
type HttpServerInstance = Effect.Success<typeof HttpServer.HttpServer>
|
|
28
27
|
/**
|
|
29
28
|
* Factory service for creating HTTP servers.
|
|
30
29
|
* This allows mocking the server creation in tests.
|
|
@@ -34,7 +33,7 @@ const MAX_PORT_ATTEMPTS = 10
|
|
|
34
33
|
export interface HttpServerFactory {
|
|
35
34
|
readonly createServerLayer: (port: number) => Layer.Layer<
|
|
36
35
|
HttpServer.HttpServer,
|
|
37
|
-
ServeError,
|
|
36
|
+
HttpServerError.ServeError,
|
|
38
37
|
never
|
|
39
38
|
>
|
|
40
39
|
}
|
|
@@ -44,10 +43,10 @@ export interface HttpServerFactory {
|
|
|
44
43
|
*
|
|
45
44
|
* @category Services
|
|
46
45
|
*/
|
|
47
|
-
export class HttpServerFactoryTag extends Context.
|
|
46
|
+
export class HttpServerFactoryTag extends Context.Service<
|
|
48
47
|
HttpServerFactoryTag,
|
|
49
48
|
HttpServerFactory
|
|
50
|
-
>() {}
|
|
49
|
+
>()("@knpkv/jira-cli/HttpServerFactory") {}
|
|
51
50
|
|
|
52
51
|
/**
|
|
53
52
|
* Create a HttpServerFactory layer from a layer factory function.
|
|
@@ -59,50 +58,12 @@ export class HttpServerFactoryTag extends Context.Tag("@knpkv/jira-cli/HttpServe
|
|
|
59
58
|
* @category Layers
|
|
60
59
|
*/
|
|
61
60
|
export const makeHttpServerFactory = (
|
|
62
|
-
createLayerFn: (port: number) => Layer.Layer<HttpServer.HttpServer, ServeError, never>
|
|
61
|
+
createLayerFn: (port: number) => Layer.Layer<HttpServer.HttpServer, HttpServerError.ServeError, never>
|
|
63
62
|
): Layer.Layer<HttpServerFactoryTag> =>
|
|
64
63
|
Layer.succeed(HttpServerFactoryTag, {
|
|
65
64
|
createServerLayer: createLayerFn
|
|
66
65
|
})
|
|
67
66
|
|
|
68
|
-
/**
|
|
69
|
-
* Check if a port is available by attempting to start a server.
|
|
70
|
-
*/
|
|
71
|
-
const isPortAvailable = (port: number): Effect.Effect<boolean, never, HttpServerFactoryTag> =>
|
|
72
|
-
Effect.gen(function*() {
|
|
73
|
-
const factory = yield* HttpServerFactoryTag
|
|
74
|
-
const serverLayer = factory.createServerLayer(port)
|
|
75
|
-
|
|
76
|
-
const result = yield* Layer.build(serverLayer).pipe(
|
|
77
|
-
Effect.scoped,
|
|
78
|
-
Effect.as(true),
|
|
79
|
-
Effect.catchAll(() => Effect.succeed(false))
|
|
80
|
-
)
|
|
81
|
-
return result
|
|
82
|
-
})
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* Find an available port starting from the default.
|
|
86
|
-
*/
|
|
87
|
-
const findAvailablePort = (): Effect.Effect<number, OAuthError, HttpServerFactoryTag> =>
|
|
88
|
-
Effect.gen(function*() {
|
|
89
|
-
for (let attempt = 0; attempt < MAX_PORT_ATTEMPTS; attempt++) {
|
|
90
|
-
const port = DEFAULT_PORT + attempt
|
|
91
|
-
const available = yield* isPortAvailable(port)
|
|
92
|
-
if (available) {
|
|
93
|
-
return port
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
return yield* Effect.fail(
|
|
97
|
-
new OAuthError({
|
|
98
|
-
step: "authorize",
|
|
99
|
-
cause: `Could not find available port (tried ${DEFAULT_PORT}-${
|
|
100
|
-
DEFAULT_PORT + MAX_PORT_ATTEMPTS - 1
|
|
101
|
-
}). Close other applications using these ports.`
|
|
102
|
-
})
|
|
103
|
-
)
|
|
104
|
-
})
|
|
105
|
-
|
|
106
67
|
/**
|
|
107
68
|
* Result from the OAuth callback server.
|
|
108
69
|
*/
|
|
@@ -128,15 +89,32 @@ export const startCallbackServer = (
|
|
|
128
89
|
): Effect.Effect<CallbackServerResult, OAuthError, HttpServerFactoryTag> =>
|
|
129
90
|
Effect.gen(function*() {
|
|
130
91
|
const factory = yield* HttpServerFactoryTag
|
|
131
|
-
const port = yield* findAvailablePort()
|
|
132
92
|
const deferred = yield* Deferred.make<string, OAuthError>()
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
93
|
+
const serverScope = yield* Scope.make()
|
|
94
|
+
const buildServerContext = (port: number): Effect.Effect<
|
|
95
|
+
{ readonly context: Context.Context<HttpServer.HttpServer>; readonly port: number },
|
|
96
|
+
OAuthError
|
|
97
|
+
> =>
|
|
98
|
+
Layer.buildWithScope(factory.createServerLayer(port), serverScope).pipe(
|
|
99
|
+
Effect.map((context) => ({ context, port })),
|
|
100
|
+
Effect.catchCause((cause) =>
|
|
101
|
+
port < MAX_PORT
|
|
102
|
+
? buildServerContext(port + 1)
|
|
103
|
+
: Effect.fail(new OAuthError({ step: "authorize", cause }))
|
|
104
|
+
)
|
|
105
|
+
)
|
|
106
|
+
const { context: serverContext } = yield* buildServerContext(DEFAULT_PORT)
|
|
107
|
+
const server: HttpServerInstance = Context.get(serverContext, HttpServer.HttpServer)
|
|
108
|
+
const port = yield* (server.address._tag === "TcpAddress"
|
|
109
|
+
? Effect.succeed(server.address.port)
|
|
110
|
+
: Effect.fail(new OAuthError({ step: "authorize", cause: "OAuth callback server must listen on a TCP port" })))
|
|
111
|
+
|
|
112
|
+
const router = yield* HttpRouter.make
|
|
113
|
+
yield* router.add(
|
|
114
|
+
"GET",
|
|
115
|
+
"/callback",
|
|
116
|
+
(req) =>
|
|
138
117
|
Effect.gen(function*() {
|
|
139
|
-
const req = yield* HttpServerRequest.HttpServerRequest
|
|
140
118
|
const url = new URL(req.url, `http://localhost:${port}`)
|
|
141
119
|
const code = url.searchParams.get("code")
|
|
142
120
|
const state = url.searchParams.get("state")
|
|
@@ -178,26 +156,21 @@ export const startCallbackServer = (
|
|
|
178
156
|
"<html><body><h1>Success!</h1><p>You can close this window and return to the terminal.</p></body></html>"
|
|
179
157
|
)
|
|
180
158
|
})
|
|
181
|
-
)
|
|
182
159
|
)
|
|
160
|
+
const app = router.asHttpEffect()
|
|
183
161
|
|
|
184
|
-
const
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
Layer.build,
|
|
189
|
-
Effect.tap(() => Deferred.succeed(readyDeferred, undefined)),
|
|
190
|
-
Effect.tapError((err) => Deferred.fail(readyDeferred, new OAuthError({ step: "authorize", cause: err }))),
|
|
191
|
-
Effect.flatMap(() => Effect.never),
|
|
192
|
-
Effect.scoped,
|
|
193
|
-
Effect.fork
|
|
162
|
+
const serverFiber = yield* HttpServer.serveEffect(app).pipe(
|
|
163
|
+
Effect.provide(serverContext),
|
|
164
|
+
Effect.provideService(Scope.Scope, serverScope),
|
|
165
|
+
Effect.forkIn(serverScope)
|
|
194
166
|
)
|
|
195
167
|
|
|
196
|
-
yield* Deferred.await(readyDeferred)
|
|
197
|
-
|
|
198
168
|
return {
|
|
199
169
|
codePromise: Deferred.await(deferred),
|
|
200
|
-
shutdown:
|
|
170
|
+
shutdown: Effect.gen(function*() {
|
|
171
|
+
yield* Fiber.interrupt(serverFiber)
|
|
172
|
+
yield* Scope.close(serverScope, Exit.void)
|
|
173
|
+
}),
|
|
201
174
|
port
|
|
202
175
|
}
|
|
203
176
|
})
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-platform browser launcher backed by Effect v4 child process services.
|
|
3
|
+
*
|
|
4
|
+
* @internal
|
|
5
|
+
*/
|
|
6
|
+
import * as Effect from "effect/Effect"
|
|
7
|
+
import type * as PlatformError from "effect/PlatformError"
|
|
8
|
+
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
|
9
|
+
|
|
10
|
+
const run = (
|
|
11
|
+
command: string,
|
|
12
|
+
args: ReadonlyArray<string>
|
|
13
|
+
): Effect.Effect<void, PlatformError.PlatformError, ChildProcessSpawner.ChildProcessSpawner> =>
|
|
14
|
+
Effect.gen(function*() {
|
|
15
|
+
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
|
16
|
+
yield* spawner.exitCode(
|
|
17
|
+
ChildProcess.make(command, args, {
|
|
18
|
+
stdin: "ignore",
|
|
19
|
+
stdout: "ignore",
|
|
20
|
+
stderr: "ignore"
|
|
21
|
+
})
|
|
22
|
+
)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
export const openBrowser = (
|
|
26
|
+
url: string
|
|
27
|
+
): Effect.Effect<void, PlatformError.PlatformError, ChildProcessSpawner.ChildProcessSpawner> =>
|
|
28
|
+
run("open", [url]).pipe(
|
|
29
|
+
Effect.catch(() => run("xdg-open", [url])),
|
|
30
|
+
Effect.catch(() => run("rundll32.exe", ["url.dll,FileProtocolHandler", url]))
|
|
31
|
+
)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sync Baseline helpers.
|
|
3
|
+
*
|
|
4
|
+
* @internal
|
|
5
|
+
*/
|
|
6
|
+
import * as Effect from "effect/Effect"
|
|
7
|
+
import * as Schema from "effect/Schema"
|
|
8
|
+
import { SyncValidationError, SyncWorkspaceError } from "../../JiraCliError.js"
|
|
9
|
+
import { SyncBaselineSchema } from "./schemas.js"
|
|
10
|
+
import type { SyncBaseline } from "./types.js"
|
|
11
|
+
|
|
12
|
+
export const parseSyncBaseline = (
|
|
13
|
+
path: string,
|
|
14
|
+
content: string
|
|
15
|
+
): Effect.Effect<SyncBaseline, SyncWorkspaceError | SyncValidationError> =>
|
|
16
|
+
Effect.gen(function*() {
|
|
17
|
+
const raw = yield* Effect.try({
|
|
18
|
+
try: () => JSON.parse(content) as unknown,
|
|
19
|
+
catch: (cause) => new SyncWorkspaceError({ message: "Failed to parse Sync Baseline JSON", path, cause })
|
|
20
|
+
})
|
|
21
|
+
return yield* Schema.decodeUnknownEffect(SyncBaselineSchema)(raw).pipe(
|
|
22
|
+
Effect.map((baseline) => baseline as SyncBaseline),
|
|
23
|
+
Effect.mapError((cause) => new SyncValidationError({ message: "Invalid Sync Baseline", path, cause }))
|
|
24
|
+
)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
export const serializeSyncBaseline = (baseline: SyncBaseline): string => `${JSON.stringify(baseline, null, 2)}\n`
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Baseline comparison for Jira Markdown Sync planning.
|
|
3
|
+
*
|
|
4
|
+
* @internal
|
|
5
|
+
*/
|
|
6
|
+
import { fieldValuesEqual } from "./fieldValues.js"
|
|
7
|
+
import type {
|
|
8
|
+
PlannedFieldChange,
|
|
9
|
+
SyncBaselineFields,
|
|
10
|
+
SyncFieldPath,
|
|
11
|
+
SyncFieldValue,
|
|
12
|
+
SyncValidationFailure
|
|
13
|
+
} from "./types.js"
|
|
14
|
+
|
|
15
|
+
export interface CompareIssueFieldsInput {
|
|
16
|
+
readonly issueId: string
|
|
17
|
+
readonly issueKey: string
|
|
18
|
+
readonly baseline: SyncBaselineFields
|
|
19
|
+
readonly jira: SyncBaselineFields
|
|
20
|
+
readonly document: SyncBaselineFields
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface CompareIssueFieldsResult {
|
|
24
|
+
readonly changes: ReadonlyArray<PlannedFieldChange>
|
|
25
|
+
readonly validationFailures: ReadonlyArray<SyncValidationFailure>
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const compareIssueFields = (input: CompareIssueFieldsInput): CompareIssueFieldsResult => {
|
|
29
|
+
const changes: Array<PlannedFieldChange> = []
|
|
30
|
+
const validationFailures: Array<SyncValidationFailure> = []
|
|
31
|
+
|
|
32
|
+
compareField(input, "summary", input.baseline.summary, input.jira.summary, input.document.summary, changes)
|
|
33
|
+
compareField(
|
|
34
|
+
input,
|
|
35
|
+
"description",
|
|
36
|
+
input.baseline.description,
|
|
37
|
+
input.jira.description,
|
|
38
|
+
input.document.description,
|
|
39
|
+
changes
|
|
40
|
+
)
|
|
41
|
+
compareField(input, "labels", input.baseline.labels, input.jira.labels, input.document.labels, changes)
|
|
42
|
+
|
|
43
|
+
const customFieldNames = new Set([
|
|
44
|
+
...Object.keys(input.baseline.customFields),
|
|
45
|
+
...Object.keys(input.jira.customFields),
|
|
46
|
+
...Object.keys(input.document.customFields)
|
|
47
|
+
])
|
|
48
|
+
|
|
49
|
+
for (const name of customFieldNames) {
|
|
50
|
+
const field = `customFields.${name}` as const
|
|
51
|
+
const baseline = input.baseline.customFields[name]?.value
|
|
52
|
+
const jira = input.jira.customFields[name]?.value
|
|
53
|
+
const document = input.document.customFields[name]?.value
|
|
54
|
+
|
|
55
|
+
if (baseline === undefined || jira === undefined || document === undefined) {
|
|
56
|
+
validationFailures.push({
|
|
57
|
+
_tag: "ValidationFailure",
|
|
58
|
+
issueKey: input.issueKey,
|
|
59
|
+
field,
|
|
60
|
+
message: `Missing reconciled custom field "${name}"`
|
|
61
|
+
})
|
|
62
|
+
continue
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
compareField(input, field, baseline, jira, document, changes)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return { changes, validationFailures }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const compareField = (
|
|
72
|
+
input: Pick<CompareIssueFieldsInput, "issueId" | "issueKey">,
|
|
73
|
+
field: SyncFieldPath,
|
|
74
|
+
baselineValue: SyncFieldValue,
|
|
75
|
+
jiraValue: SyncFieldValue,
|
|
76
|
+
documentValue: SyncFieldValue,
|
|
77
|
+
changes: Array<PlannedFieldChange>
|
|
78
|
+
) => {
|
|
79
|
+
const jiraChanged = !syncFieldValueEquals(jiraValue, baselineValue)
|
|
80
|
+
const documentChanged = !syncFieldValueEquals(documentValue, baselineValue)
|
|
81
|
+
|
|
82
|
+
if (!jiraChanged && !documentChanged) return
|
|
83
|
+
|
|
84
|
+
if (jiraChanged && !documentChanged) {
|
|
85
|
+
changes.push({
|
|
86
|
+
_tag: "RemoteOnly",
|
|
87
|
+
issueId: input.issueId,
|
|
88
|
+
issueKey: input.issueKey,
|
|
89
|
+
field,
|
|
90
|
+
jiraValue
|
|
91
|
+
})
|
|
92
|
+
return
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (!jiraChanged && documentChanged) {
|
|
96
|
+
changes.push({
|
|
97
|
+
_tag: "LocalOnly",
|
|
98
|
+
issueId: input.issueId,
|
|
99
|
+
issueKey: input.issueKey,
|
|
100
|
+
field,
|
|
101
|
+
documentValue
|
|
102
|
+
})
|
|
103
|
+
return
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
changes.push({
|
|
107
|
+
_tag: "Conflict",
|
|
108
|
+
issueId: input.issueId,
|
|
109
|
+
issueKey: input.issueKey,
|
|
110
|
+
field,
|
|
111
|
+
baselineValue,
|
|
112
|
+
jiraValue,
|
|
113
|
+
documentValue
|
|
114
|
+
})
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export const syncFieldValueEquals = (left: SyncFieldValue, right: SyncFieldValue): boolean =>
|
|
118
|
+
fieldValuesEqual(left, right)
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workspace Config parsing and serialization.
|
|
3
|
+
*
|
|
4
|
+
* @internal
|
|
5
|
+
*/
|
|
6
|
+
import * as Effect from "effect/Effect"
|
|
7
|
+
import * as Schema from "effect/Schema"
|
|
8
|
+
import * as yaml from "js-yaml"
|
|
9
|
+
import { SyncValidationError, SyncWorkspaceError } from "../../JiraCliError.js"
|
|
10
|
+
import { WorkspaceConfigSchema } from "./schemas.js"
|
|
11
|
+
import type { WorkspaceConfig } from "./types.js"
|
|
12
|
+
|
|
13
|
+
export const parseWorkspaceConfig = (
|
|
14
|
+
path: string,
|
|
15
|
+
content: string
|
|
16
|
+
): Effect.Effect<WorkspaceConfig, SyncWorkspaceError | SyncValidationError> =>
|
|
17
|
+
Effect.gen(function*() {
|
|
18
|
+
const raw = yield* Effect.try({
|
|
19
|
+
try: () => yaml.load(content) ?? {},
|
|
20
|
+
catch: (cause) => new SyncWorkspaceError({ message: "Failed to parse workspace config YAML", path, cause })
|
|
21
|
+
})
|
|
22
|
+
const config = yield* Schema.decodeUnknownEffect(WorkspaceConfigSchema)(raw).pipe(
|
|
23
|
+
Effect.map((config) => config as WorkspaceConfig),
|
|
24
|
+
Effect.mapError((cause) =>
|
|
25
|
+
new SyncValidationError({ message: "Invalid Jira Markdown Sync workspace config", path, cause })
|
|
26
|
+
)
|
|
27
|
+
)
|
|
28
|
+
yield* validateCustomFieldDeclarations(path, config)
|
|
29
|
+
return config
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
export const serializeWorkspaceConfig = (config: WorkspaceConfig): string => yaml.dump(config, { lineWidth: 100 })
|
|
33
|
+
|
|
34
|
+
export const makeDefaultWorkspaceConfig = (siteUrl: string): WorkspaceConfig => ({
|
|
35
|
+
version: 1,
|
|
36
|
+
siteUrl,
|
|
37
|
+
documentsDir: "issues",
|
|
38
|
+
customFields: []
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
const validateCustomFieldDeclarations = (
|
|
42
|
+
path: string,
|
|
43
|
+
config: WorkspaceConfig
|
|
44
|
+
): Effect.Effect<void, SyncValidationError> =>
|
|
45
|
+
Effect.gen(function*() {
|
|
46
|
+
const displayNames = new Map<string, number>()
|
|
47
|
+
const fieldIds = new Set<string>()
|
|
48
|
+
|
|
49
|
+
for (const field of config.customFields) {
|
|
50
|
+
displayNames.set(field.displayName, (displayNames.get(field.displayName) ?? 0) + 1)
|
|
51
|
+
if (field.fieldId) {
|
|
52
|
+
if (fieldIds.has(field.fieldId)) {
|
|
53
|
+
return yield* Effect.fail(
|
|
54
|
+
new SyncValidationError({
|
|
55
|
+
message: `Duplicate Requested Custom Field id "${field.fieldId}"`,
|
|
56
|
+
field: field.displayName,
|
|
57
|
+
path
|
|
58
|
+
})
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
fieldIds.add(field.fieldId)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
for (const field of config.customFields) {
|
|
66
|
+
if ((displayNames.get(field.displayName) ?? 0) > 1 && !field.fieldId) {
|
|
67
|
+
return yield* Effect.fail(
|
|
68
|
+
new SyncValidationError({
|
|
69
|
+
message: `Duplicate Requested Custom Field "${field.displayName}" must specify fieldId`,
|
|
70
|
+
field: field.displayName,
|
|
71
|
+
path
|
|
72
|
+
})
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
})
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parser and serializer for strict Jira Markdown Sync Issue Documents.
|
|
3
|
+
*
|
|
4
|
+
* @internal
|
|
5
|
+
*/
|
|
6
|
+
import matter from "gray-matter"
|
|
7
|
+
import * as yaml from "js-yaml"
|
|
8
|
+
import { SyncValidationError } from "../../JiraCliError.js"
|
|
9
|
+
import type {
|
|
10
|
+
AcceptedComment,
|
|
11
|
+
AttachmentReference,
|
|
12
|
+
CommentDraft,
|
|
13
|
+
IssueDocument,
|
|
14
|
+
IssueDocumentFrontMatter
|
|
15
|
+
} from "./types.js"
|
|
16
|
+
|
|
17
|
+
const yamlEngine = {
|
|
18
|
+
parse: (str: string): object => (yaml.load(str) as object) ?? {},
|
|
19
|
+
stringify: (data: object): string => yaml.dump(data)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const DESCRIPTION_SECTION = "Description"
|
|
23
|
+
export const NEW_COMMENTS_SECTION = "New Comments"
|
|
24
|
+
export const COMMENTS_SECTION = "Comments"
|
|
25
|
+
export const ATTACHMENTS_SECTION = "Attachments"
|
|
26
|
+
export const LOCAL_NOTES_SECTION = "Local Notes"
|
|
27
|
+
|
|
28
|
+
const BUILT_IN_SECTIONS = new Set([
|
|
29
|
+
DESCRIPTION_SECTION,
|
|
30
|
+
NEW_COMMENTS_SECTION,
|
|
31
|
+
COMMENTS_SECTION,
|
|
32
|
+
ATTACHMENTS_SECTION,
|
|
33
|
+
LOCAL_NOTES_SECTION
|
|
34
|
+
])
|
|
35
|
+
|
|
36
|
+
export const serializeIssueDocument = (document: IssueDocument): string => {
|
|
37
|
+
const body = [
|
|
38
|
+
`# ${document.frontMatter.issueKey}: ${document.frontMatter.summary}`,
|
|
39
|
+
"",
|
|
40
|
+
section(DESCRIPTION_SECTION, document.description),
|
|
41
|
+
...Object.entries(document.multilineCustomFields).map(([name, content]) => section(name, content)),
|
|
42
|
+
section(NEW_COMMENTS_SECTION, serializeCommentDrafts(document.commentDrafts)),
|
|
43
|
+
section(COMMENTS_SECTION, serializeAcceptedComments(document.acceptedComments)),
|
|
44
|
+
section(ATTACHMENTS_SECTION, serializeAttachments(document.attachments)),
|
|
45
|
+
section(LOCAL_NOTES_SECTION, document.localNotes)
|
|
46
|
+
].filter((part) => part.length > 0).join("\n")
|
|
47
|
+
|
|
48
|
+
return matter.stringify(body, document.frontMatter, { engines: { yaml: yamlEngine } })
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const parseIssueDocument = (path: string, content: string): IssueDocument => {
|
|
52
|
+
const parsed = matter(content, { engines: { yaml: yamlEngine } })
|
|
53
|
+
const frontMatter = parseFrontMatter(path, parsed.data)
|
|
54
|
+
const sections = parseSections(path, parsed.content)
|
|
55
|
+
|
|
56
|
+
const description = sections.get(DESCRIPTION_SECTION) ?? fail(path, `Missing ${DESCRIPTION_SECTION} section`)
|
|
57
|
+
const localNotes = sections.get(LOCAL_NOTES_SECTION) ?? ""
|
|
58
|
+
const multilineCustomFields = Object.fromEntries(
|
|
59
|
+
[...sections.entries()].filter(([name]) => !BUILT_IN_SECTIONS.has(name))
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
frontMatter,
|
|
64
|
+
description,
|
|
65
|
+
multilineCustomFields,
|
|
66
|
+
commentDrafts: parseCommentDrafts(path, sections.get(NEW_COMMENTS_SECTION) ?? ""),
|
|
67
|
+
acceptedComments: parseAcceptedComments(sections.get(COMMENTS_SECTION) ?? ""),
|
|
68
|
+
attachments: parseAttachments(sections.get(ATTACHMENTS_SECTION) ?? ""),
|
|
69
|
+
localNotes
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const section = (name: string, content: string): string => {
|
|
74
|
+
const normalized = content.trim()
|
|
75
|
+
return normalized.length > 0 ? `## ${name}\n\n${normalized}\n` : `## ${name}\n`
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const parseFrontMatter = (path: string, data: Record<string, unknown>): IssueDocumentFrontMatter => {
|
|
79
|
+
const requiredString = (key: string): string => {
|
|
80
|
+
const value = data[key]
|
|
81
|
+
if (typeof value !== "string") fail(path, `Missing or invalid front matter field "${key}"`)
|
|
82
|
+
return value as string
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const nullableString = (key: string): string | null => {
|
|
86
|
+
const value = data[key]
|
|
87
|
+
if (value === null || value === undefined) return null
|
|
88
|
+
if (typeof value !== "string") fail(path, `Invalid front matter field "${key}"`)
|
|
89
|
+
return value as string
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const userValue = (key: string) => {
|
|
93
|
+
const value = data[key]
|
|
94
|
+
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
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
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
|
+
}
|
|
110
|
+
|
|
111
|
+
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
|
+
}
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
issueId: requiredString("issueId"),
|
|
118
|
+
issueKey: requiredString("issueKey"),
|
|
119
|
+
summary: requiredString("summary"),
|
|
120
|
+
status: requiredString("status"),
|
|
121
|
+
issueType: requiredString("issueType"),
|
|
122
|
+
priority: nullableString("priority"),
|
|
123
|
+
assignee: userValue("assignee"),
|
|
124
|
+
reporter: userValue("reporter"),
|
|
125
|
+
labels: labels as ReadonlyArray<string>,
|
|
126
|
+
customFields: customFields as IssueDocumentFrontMatter["customFields"]
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const parseSections = (path: string, content: string): Map<string, string> => {
|
|
131
|
+
const lines = content.split(/\r?\n/)
|
|
132
|
+
const sections = new Map<string, Array<string>>()
|
|
133
|
+
let current: string | null = null
|
|
134
|
+
|
|
135
|
+
for (const line of lines) {
|
|
136
|
+
const match = /^## (.+)$/.exec(line)
|
|
137
|
+
if (match?.[1]) {
|
|
138
|
+
current = match[1].trim()
|
|
139
|
+
if (sections.has(current)) fail(path, `Duplicate section "${current}"`)
|
|
140
|
+
sections.set(current, [])
|
|
141
|
+
} else if (current) {
|
|
142
|
+
sections.get(current)?.push(line)
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return new Map([...sections.entries()].map(([name, body]) => [name, trimOuterBlankLines(body).join("\n")]))
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const serializeCommentDrafts = (drafts: ReadonlyArray<CommentDraft>): string =>
|
|
150
|
+
drafts.map((draft) => `<!-- draftId: ${draft.draftId} -->\n${draft.body.trim()}`).join("\n\n")
|
|
151
|
+
|
|
152
|
+
const parseCommentDrafts = (path: string, content: string): ReadonlyArray<CommentDraft> => {
|
|
153
|
+
if (content.trim().length === 0) return []
|
|
154
|
+
const parts = content.split(/(?=<!-- draftId: )/g).filter((part) => part.trim().length > 0)
|
|
155
|
+
return parts.map((part) => {
|
|
156
|
+
const match = /^<!-- draftId: ([^ ]+) -->\n?([\s\S]*)$/.exec(part.trim())
|
|
157
|
+
const draftId = match?.[1] ?? fail(path, "Malformed comment draft marker")
|
|
158
|
+
return { draftId, body: match?.[2]?.trim() ?? "" }
|
|
159
|
+
})
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const serializeAcceptedComments = (comments: ReadonlyArray<AcceptedComment>): string =>
|
|
163
|
+
comments.map((comment) =>
|
|
164
|
+
`### ${comment.author} - ${comment.created}\n<!-- jiraCommentId: ${comment.id} -->\n\n${comment.body.trim()}`
|
|
165
|
+
).join("\n\n")
|
|
166
|
+
|
|
167
|
+
const parseAcceptedComments = (content: string): ReadonlyArray<AcceptedComment> => {
|
|
168
|
+
if (content.trim().length === 0) return []
|
|
169
|
+
const parts = content.split(/(?=^### )/gm).filter((part) => part.trim().length > 0)
|
|
170
|
+
return parts.flatMap((part) => {
|
|
171
|
+
const match = /^### (.+) - (.+)\n<!-- jiraCommentId: ([^ ]+) -->\n?([\s\S]*)$/.exec(part.trim())
|
|
172
|
+
if (!match?.[1] || !match[2] || !match[3]) return []
|
|
173
|
+
return [{
|
|
174
|
+
author: match[1],
|
|
175
|
+
created: match[2],
|
|
176
|
+
id: match[3],
|
|
177
|
+
body: match[4]?.trim() ?? ""
|
|
178
|
+
}]
|
|
179
|
+
})
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const serializeAttachments = (attachments: ReadonlyArray<AttachmentReference>): string =>
|
|
183
|
+
attachments.map((attachment) => `- [${attachment.filename}](${attachment.url})`).join("\n")
|
|
184
|
+
|
|
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
|
+
})
|
|
190
|
+
|
|
191
|
+
const trimOuterBlankLines = (lines: ReadonlyArray<string>): ReadonlyArray<string> => {
|
|
192
|
+
let start = 0
|
|
193
|
+
let end = lines.length
|
|
194
|
+
while (start < end && lines[start]?.trim() === "") start++
|
|
195
|
+
while (end > start && lines[end - 1]?.trim() === "") end--
|
|
196
|
+
return lines.slice(start, end)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const fail = (path: string, message: string): never => {
|
|
200
|
+
throw new SyncValidationError({ message, path })
|
|
201
|
+
}
|