@knpkv/jira-cli 1.2.3 → 1.3.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.
- package/CHANGELOG.md +113 -0
- package/README.md +64 -0
- package/dist/AttachmentService.d.ts +2 -2
- package/dist/AttachmentService.d.ts.map +1 -1
- package/dist/AttachmentService.js +1 -1
- package/dist/AttachmentService.js.map +1 -1
- package/dist/IssueService.d.ts +82 -3
- package/dist/IssueService.d.ts.map +1 -1
- package/dist/IssueService.js +111 -16
- package/dist/IssueService.js.map +1 -1
- package/dist/JiraAuth.d.ts +1 -1
- package/dist/JiraAuth.d.ts.map +1 -1
- package/dist/JiraAuth.js +74 -10
- package/dist/JiraAuth.js.map +1 -1
- package/dist/MarkdownWriter.d.ts +4 -4
- package/dist/MarkdownWriter.d.ts.map +1 -1
- package/dist/MarkdownWriter.js +2 -2
- package/dist/MarkdownWriter.js.map +1 -1
- package/dist/SyncWorkspace.d.ts +2 -2
- package/dist/SyncWorkspace.d.ts.map +1 -1
- package/dist/SyncWorkspace.js.map +1 -1
- package/dist/VersionService.d.ts +81 -9
- package/dist/VersionService.d.ts.map +1 -1
- package/dist/VersionService.js +113 -17
- package/dist/VersionService.js.map +1 -1
- package/dist/commands/auth.d.ts +3 -2
- package/dist/commands/auth.d.ts.map +1 -1
- package/dist/commands/edit.d.ts +13 -0
- package/dist/commands/edit.d.ts.map +1 -0
- package/dist/commands/edit.js +49 -0
- package/dist/commands/edit.js.map +1 -0
- package/dist/commands/errorHandler.d.ts.map +1 -1
- package/dist/commands/issue.d.ts +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/layers.d.ts +3 -3
- package/dist/commands/layers.d.ts.map +1 -1
- package/dist/commands/layers.js +5 -2
- package/dist/commands/layers.js.map +1 -1
- package/dist/commands/version.d.ts +8 -1
- package/dist/commands/version.d.ts.map +1 -1
- package/dist/commands/version.js +116 -4
- package/dist/commands/version.js.map +1 -1
- package/dist/index.d.ts +4 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/internal/NodeLayers.js +1 -1
- package/dist/internal/NodeLayers.js.map +1 -1
- package/dist/internal/attachmentInsertion.d.ts.map +1 -1
- package/dist/internal/frontmatter.d.ts.map +1 -1
- package/dist/internal/frontmatter.js +2 -1
- package/dist/internal/frontmatter.js.map +1 -1
- package/dist/internal/jqlBuilder.d.ts.map +1 -1
- package/dist/internal/oauthServer.d.ts +8 -2
- package/dist/internal/oauthServer.d.ts.map +1 -1
- package/dist/internal/oauthServer.js +10 -6
- package/dist/internal/oauthServer.js.map +1 -1
- package/dist/internal/openBrowser.d.ts.map +1 -1
- package/dist/internal/sync/baseline.d.ts.map +1 -1
- package/dist/internal/sync/baseline.js +2 -2
- package/dist/internal/sync/baseline.js.map +1 -1
- package/dist/internal/sync/changes.d.ts.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 +8 -8
- package/dist/internal/sync/document.js.map +1 -1
- package/dist/internal/sync/fieldValues.d.ts +9 -9
- package/dist/internal/sync/fieldValues.d.ts.map +1 -1
- package/dist/internal/sync/fieldValues.js +12 -16
- package/dist/internal/sync/fieldValues.js.map +1 -1
- package/dist/internal/sync/manifest.d.ts.map +1 -1
- package/dist/internal/sync/manifest.js +1 -1
- package/dist/internal/sync/manifest.js.map +1 -1
- package/dist/internal/sync/paths.d.ts.map +1 -1
- package/dist/internal/sync/schemas.d.ts +17 -9
- package/dist/internal/sync/schemas.d.ts.map +1 -1
- package/dist/internal/sync/schemas.js +6 -6
- package/dist/internal/sync/schemas.js.map +1 -1
- package/dist/internal/sync/types.d.ts +4 -4
- package/dist/internal/sync/types.d.ts.map +1 -1
- package/dist/internal/sync/types.js +1 -1
- package/dist/internal/sync/types.js.map +1 -1
- package/package.json +14 -10
- package/skills/jira/SKILL.md +17 -1
- package/src/AttachmentService.ts +6 -6
- package/src/IssueService.ts +214 -27
- package/src/JiraAuth.ts +86 -14
- package/src/MarkdownWriter.ts +6 -6
- package/src/SyncWorkspace.ts +12 -11
- package/src/VersionService.ts +199 -27
- package/src/commands/edit.ts +88 -0
- package/src/commands/issue.ts +2 -1
- package/src/commands/layers.ts +5 -2
- package/src/commands/version.ts +161 -5
- package/src/index.ts +13 -3
- package/src/internal/NodeLayers.ts +1 -1
- package/src/internal/frontmatter.ts +3 -2
- package/src/internal/oauthServer.ts +25 -13
- package/src/internal/sync/baseline.ts +3 -2
- package/src/internal/sync/config.ts +2 -1
- package/src/internal/sync/document.ts +15 -14
- package/src/internal/sync/fieldValues.ts +21 -22
- package/src/internal/sync/manifest.ts +1 -1
- package/src/internal/sync/schemas.ts +6 -6
- package/src/internal/sync/types.ts +4 -4
- package/test/JiraAuthRefresh.test.ts +269 -0
- package/test/commandTree.test.ts +157 -1
- package/test/editIssuePayload.test.ts +103 -0
- package/test/frontmatter.test.ts +4 -2
- package/test/integration.test.ts +5 -3
- package/test/oauthServer.test.ts +75 -3
- package/test/publicApi.test.ts +34 -0
- package/test/relatedWorkSync.test.ts +179 -0
- package/test/remoteWrites.test.ts +220 -0
- package/test/syncChanges.test.ts +2 -2
- package/test/syncConfig.test.ts +2 -2
package/src/commands/version.ts
CHANGED
|
@@ -12,7 +12,7 @@ import * as Option from "effect/Option"
|
|
|
12
12
|
import { Argument as Args, Command, Flag as Options } from "effect/unstable/cli"
|
|
13
13
|
import { JiraApiError } from "../JiraCliError.js"
|
|
14
14
|
import type { Person, Version } from "../VersionService.js"
|
|
15
|
-
import { VersionService } from "../VersionService.js"
|
|
15
|
+
import { planRelatedWorkSync, VersionService } from "../VersionService.js"
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
18
|
* Return a copy of `version` with every resolved {@link Person.emailAddress}
|
|
@@ -105,7 +105,7 @@ const listCommand = Command.make("list", {
|
|
|
105
105
|
const versions = yield* service.listProjectVersions(project, {
|
|
106
106
|
released,
|
|
107
107
|
unreleased,
|
|
108
|
-
...(Option.isSome(max)
|
|
108
|
+
...((Option.isSome(max)) && { maxResults: max.value }),
|
|
109
109
|
customFieldNames: customFields
|
|
110
110
|
})
|
|
111
111
|
if (json) {
|
|
@@ -174,6 +174,80 @@ const descriptionOption = Options.string("description").pipe(
|
|
|
174
174
|
Options.withDescription("New version description")
|
|
175
175
|
)
|
|
176
176
|
|
|
177
|
+
// === create ===
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Jira accepts version dates only as ISO 8601 `yyyy-mm-dd`. Anything else — a
|
|
181
|
+
* locale format, a timestamp — comes back as a generic 400 that does not name the
|
|
182
|
+
* offending field, so reject it locally where the message can.
|
|
183
|
+
*/
|
|
184
|
+
export const isIsoDate = (value: string): boolean => {
|
|
185
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false
|
|
186
|
+
// Reject real-looking but non-existent dates (2026-02-30): round-tripping
|
|
187
|
+
// through Date is the cheapest calendar check.
|
|
188
|
+
const parsed = new Date(`${value}T00:00:00Z`)
|
|
189
|
+
return !Number.isNaN(parsed.getTime()) && parsed.toISOString().slice(0, 10) === value
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const ensureIsoDate = (flag: string, value: string): Effect.Effect<void, JiraApiError> =>
|
|
193
|
+
isIsoDate(value) ? Effect.void : Effect.fail(
|
|
194
|
+
new JiraApiError({ message: `Invalid --${flag} "${value}". Expected an ISO 8601 date (yyyy-mm-dd).` })
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
// No `-n` alias: it means `--dry-run` everywhere else in these CLIs, and both
|
|
198
|
+
// `getLayerType` implementations route on `argv.includes("-n")`. Reusing it for
|
|
199
|
+
// a value on a remote-write command is the wrong default.
|
|
200
|
+
const nameOption = Options.string("name").pipe(
|
|
201
|
+
Options.withDescription("Version name (e.g. \"OOB 100\")")
|
|
202
|
+
)
|
|
203
|
+
const optionalDescriptionOption = Options.string("description").pipe(
|
|
204
|
+
Options.withAlias("d"),
|
|
205
|
+
Options.withDescription("Version description"),
|
|
206
|
+
Options.optional
|
|
207
|
+
)
|
|
208
|
+
const startDateOption = Options.string("start-date").pipe(
|
|
209
|
+
Options.withDescription("Start date, ISO 8601 (yyyy-mm-dd)"),
|
|
210
|
+
Options.optional
|
|
211
|
+
)
|
|
212
|
+
const releaseDateOption = Options.string("release-date").pipe(
|
|
213
|
+
Options.withDescription("Release date, ISO 8601 (yyyy-mm-dd)"),
|
|
214
|
+
Options.optional
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
const createCommand = Command.make("create", {
|
|
218
|
+
project: projectOption,
|
|
219
|
+
name: nameOption,
|
|
220
|
+
description: optionalDescriptionOption,
|
|
221
|
+
startDate: startDateOption,
|
|
222
|
+
releaseDate: releaseDateOption,
|
|
223
|
+
json: jsonOption
|
|
224
|
+
}, ({ description, json, name, project, releaseDate, startDate }) =>
|
|
225
|
+
Effect.gen(function*() {
|
|
226
|
+
if (Option.isSome(startDate)) yield* ensureIsoDate("start-date", startDate.value)
|
|
227
|
+
if (Option.isSome(releaseDate)) yield* ensureIsoDate("release-date", releaseDate.value)
|
|
228
|
+
|
|
229
|
+
const service = yield* VersionService
|
|
230
|
+
const version = yield* service.createVersion({
|
|
231
|
+
projectKey: project,
|
|
232
|
+
name,
|
|
233
|
+
...((Option.isSome(description)) && { description: description.value }),
|
|
234
|
+
...((Option.isSome(startDate)) && { startDate: startDate.value }),
|
|
235
|
+
...((Option.isSome(releaseDate)) && { releaseDate: releaseDate.value })
|
|
236
|
+
})
|
|
237
|
+
if (json) {
|
|
238
|
+
yield* Console.log(JSON.stringify(version, null, 2))
|
|
239
|
+
return
|
|
240
|
+
}
|
|
241
|
+
yield* Console.log(`Created version ${version.name} (${version.id}) on ${project}`)
|
|
242
|
+
yield* Console.log(`startDate: ${version.startDate ?? "-"}`)
|
|
243
|
+
yield* Console.log(`releaseDate: ${version.releaseDate ?? "-"}`)
|
|
244
|
+
yield* Console.log(`description: ${version.description ?? "-"}`)
|
|
245
|
+
})).pipe(
|
|
246
|
+
Command.withDescription(
|
|
247
|
+
"Remote write: create a new unreleased version on a project (requires manage:jira-project scope)"
|
|
248
|
+
)
|
|
249
|
+
)
|
|
250
|
+
|
|
177
251
|
const updateCommand = Command.make("update", { id: idArg, description: descriptionOption, json: jsonOption }, ({
|
|
178
252
|
description,
|
|
179
253
|
id,
|
|
@@ -256,12 +330,94 @@ const relatedWorkAddCommand = Command.make("add", {
|
|
|
256
330
|
)
|
|
257
331
|
)
|
|
258
332
|
|
|
333
|
+
const linkOption = Options.string("link").pipe(
|
|
334
|
+
Options.withAlias("l"),
|
|
335
|
+
Options.withDescription("Desired link as `title=url` (repeatable). Category comes from --category."),
|
|
336
|
+
Options.atLeast(0)
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
const pruneOption = Options.boolean("prune").pipe(
|
|
340
|
+
Options.withDescription("Also remove links in the category that are not in the desired set")
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Reconcile a version's related-work links against a desired set.
|
|
345
|
+
*
|
|
346
|
+
* Re-running a release scaffold should not pile up duplicate "Release notes"
|
|
347
|
+
* links, which is what repeated `add` calls produce. Matching is by URL, the
|
|
348
|
+
* only stable identity a link has — Jira assigns the id and the title is
|
|
349
|
+
* editable.
|
|
350
|
+
*/
|
|
351
|
+
const relatedWorkSyncCommand = Command.make("sync", {
|
|
352
|
+
id: idArg,
|
|
353
|
+
link: linkOption,
|
|
354
|
+
category: categoryOption,
|
|
355
|
+
prune: pruneOption,
|
|
356
|
+
json: jsonOption
|
|
357
|
+
}, ({ category, id, json, link, prune }) =>
|
|
358
|
+
Effect.gen(function*() {
|
|
359
|
+
yield* ensureNumericId(id)
|
|
360
|
+
|
|
361
|
+
const desired: Array<{ readonly title: string; readonly url: string }> = []
|
|
362
|
+
for (const raw of link) {
|
|
363
|
+
const separator = raw.indexOf("=")
|
|
364
|
+
// Check the trimmed halves, not the separator position: `" =url"` and
|
|
365
|
+
// `"title= "` both put a non-empty span either side of the `=` and would
|
|
366
|
+
// otherwise reach Jira as a link with an empty title or url.
|
|
367
|
+
const title = separator < 0 ? "" : raw.slice(0, separator).trim()
|
|
368
|
+
const url = separator < 0 ? "" : raw.slice(separator + 1).trim()
|
|
369
|
+
if (separator < 0 || title.length === 0 || url.length === 0) {
|
|
370
|
+
return yield* Effect.fail(
|
|
371
|
+
new JiraApiError({ message: `Invalid --link ${JSON.stringify(raw)}. Expected title=url.` })
|
|
372
|
+
)
|
|
373
|
+
}
|
|
374
|
+
desired.push({ title, url })
|
|
375
|
+
}
|
|
376
|
+
if (desired.length === 0) {
|
|
377
|
+
return yield* Effect.fail(new JiraApiError({ message: "Pass at least one --link title=url." }))
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const service = yield* VersionService
|
|
381
|
+
const existing = yield* service.listRelatedWork(id)
|
|
382
|
+
const plan = planRelatedWorkSync(existing, desired, { category, prune })
|
|
383
|
+
|
|
384
|
+
for (const item of plan.toAdd) {
|
|
385
|
+
yield* service.addRelatedWork(id, { title: item.title, category, url: item.url })
|
|
386
|
+
}
|
|
387
|
+
for (const item of plan.toRemove) {
|
|
388
|
+
yield* service.deleteRelatedWork(id, item.relatedWorkId)
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const added = plan.toAdd.map((d) => d.url)
|
|
392
|
+
const removed = plan.toRemove.map((d) => d.url)
|
|
393
|
+
if (json) {
|
|
394
|
+
yield* Console.log(JSON.stringify({ added, kept: plan.kept, removed }, null, 2))
|
|
395
|
+
return
|
|
396
|
+
}
|
|
397
|
+
yield* Console.log(
|
|
398
|
+
`Version ${id} (${category}): ${added.length} added, ${plan.kept.length} unchanged, ${removed.length} removed`
|
|
399
|
+
)
|
|
400
|
+
for (const url of added) yield* Console.log(` + ${url}`)
|
|
401
|
+
for (const url of removed) yield* Console.log(` - ${url}`)
|
|
402
|
+
})).pipe(
|
|
403
|
+
// The help text is what a user reads before a remote write, so it has to
|
|
404
|
+
// say that pruning is opt-in: without --prune nothing is ever removed, and
|
|
405
|
+
// "reconcile to exactly the given set" reads as a promise that stale links
|
|
406
|
+
// were cleaned up.
|
|
407
|
+
Command.withDescription(
|
|
408
|
+
"Remote write: add any missing related-work links in a category, matched by URL (idempotent); " +
|
|
409
|
+
"pass --prune to also remove links that are not in the given set"
|
|
410
|
+
)
|
|
411
|
+
)
|
|
412
|
+
|
|
259
413
|
const relatedWorkCommand = Command.make("related-work").pipe(
|
|
260
|
-
Command.withDescription(
|
|
261
|
-
|
|
414
|
+
Command.withDescription(
|
|
415
|
+
"List, attach or reconcile version related-work links (Confluence pages on the release report)"
|
|
416
|
+
),
|
|
417
|
+
Command.withSubcommands([relatedWorkListCommand, relatedWorkAddCommand, relatedWorkSyncCommand])
|
|
262
418
|
)
|
|
263
419
|
|
|
264
420
|
export const versionCommand = Command.make("version").pipe(
|
|
265
421
|
Command.withDescription("Jira version commands"),
|
|
266
|
-
Command.withSubcommands([listCommand, getCommand, updateCommand, relatedWorkCommand])
|
|
422
|
+
Command.withSubcommands([listCommand, getCommand, createCommand, updateCommand, relatedWorkCommand])
|
|
267
423
|
)
|
package/src/index.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
export {
|
|
8
8
|
AttachmentService,
|
|
9
|
-
type
|
|
9
|
+
type AttachmentServiceContract,
|
|
10
10
|
layer as AttachmentServiceLayer,
|
|
11
11
|
type UploadAttachmentInput
|
|
12
12
|
} from "./AttachmentService.js"
|
|
@@ -15,7 +15,7 @@ export {
|
|
|
15
15
|
type Comment,
|
|
16
16
|
type Issue,
|
|
17
17
|
IssueService,
|
|
18
|
-
type
|
|
18
|
+
type IssueServiceContract,
|
|
19
19
|
layer as IssueServiceLayer,
|
|
20
20
|
type SearchOptions,
|
|
21
21
|
type SearchResult,
|
|
@@ -29,4 +29,14 @@ export {
|
|
|
29
29
|
type LoginOptions
|
|
30
30
|
} from "./JiraAuth.js"
|
|
31
31
|
export * from "./JiraCliError.js"
|
|
32
|
-
export { layer as MarkdownWriterLayer, MarkdownWriter, type
|
|
32
|
+
export { layer as MarkdownWriterLayer, MarkdownWriter, type MarkdownWriterContract } from "./MarkdownWriter.js"
|
|
33
|
+
export {
|
|
34
|
+
type DesiredRelatedWork,
|
|
35
|
+
layer as VersionServiceLayer,
|
|
36
|
+
planRelatedWorkSync,
|
|
37
|
+
type RelatedWork,
|
|
38
|
+
type RelatedWorkSyncPlan,
|
|
39
|
+
type Version,
|
|
40
|
+
VersionService,
|
|
41
|
+
type VersionServiceContract
|
|
42
|
+
} from "./VersionService.js"
|
|
@@ -13,5 +13,5 @@ import { makeHttpServerFactory } from "./oauthServer.js"
|
|
|
13
13
|
* @category Layers
|
|
14
14
|
*/
|
|
15
15
|
export const HttpServerFactoryLive = makeHttpServerFactory(
|
|
16
|
-
(
|
|
16
|
+
(options) => NodeHttpServer.layerServer(createServer, options)
|
|
17
17
|
)
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* @internal
|
|
10
10
|
*/
|
|
11
11
|
import { isPreviewableAttachment } from "@knpkv/atlassian-common/attachments"
|
|
12
|
+
import * as Predicate from "effect/Predicate"
|
|
12
13
|
import matter from "gray-matter"
|
|
13
14
|
import * as yaml from "js-yaml"
|
|
14
15
|
import type { Issue } from "../IssueService.js"
|
|
@@ -24,9 +25,9 @@ import type { Issue } from "../IssueService.js"
|
|
|
24
25
|
const yamlEngine = {
|
|
25
26
|
parse: (str: string): object => {
|
|
26
27
|
const parsed = yaml.load(str)
|
|
27
|
-
return parsed !== null &&
|
|
28
|
+
return parsed !== null && Predicate.isObjectOrArray(parsed) && !Array.isArray(parsed) ? parsed : {}
|
|
28
29
|
},
|
|
29
|
-
stringify: (data:
|
|
30
|
+
stringify: <Data extends object>(data: Data): string => yaml.dump(data)
|
|
30
31
|
}
|
|
31
32
|
|
|
32
33
|
/**
|
|
@@ -30,13 +30,25 @@ type HttpServerInstance = Effect.Success<typeof HttpServer.HttpServer>
|
|
|
30
30
|
* @category Services
|
|
31
31
|
*/
|
|
32
32
|
export interface HttpServerFactory {
|
|
33
|
-
readonly createServerLayer: (
|
|
33
|
+
readonly createServerLayer: (options: CallbackServerListenOptions) => Layer.Layer<
|
|
34
34
|
HttpServer.HttpServer,
|
|
35
35
|
HttpServerError.ServeError,
|
|
36
36
|
never
|
|
37
37
|
>
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
export interface CallbackServerListenOptions {
|
|
41
|
+
readonly host: "localhost"
|
|
42
|
+
readonly port: number
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const callbackServerListenOptions = (port: number): CallbackServerListenOptions => ({
|
|
46
|
+
host: "localhost",
|
|
47
|
+
port
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
export const callbackUrl = (port: number): string => `http://localhost:${port}/callback`
|
|
51
|
+
|
|
40
52
|
/**
|
|
41
53
|
* Tag for the HttpServerFactory service.
|
|
42
54
|
*
|
|
@@ -57,7 +69,9 @@ export class HttpServerFactoryTag extends Context.Service<
|
|
|
57
69
|
* @category Layers
|
|
58
70
|
*/
|
|
59
71
|
export const makeHttpServerFactory = (
|
|
60
|
-
createLayerFn: (
|
|
72
|
+
createLayerFn: (
|
|
73
|
+
options: CallbackServerListenOptions
|
|
74
|
+
) => Layer.Layer<HttpServer.HttpServer, HttpServerError.ServeError, never>
|
|
61
75
|
): Layer.Layer<HttpServerFactoryTag> =>
|
|
62
76
|
Layer.succeed(HttpServerFactoryTag, {
|
|
63
77
|
createServerLayer: createLayerFn
|
|
@@ -98,7 +112,7 @@ export const startCallbackServer = (
|
|
|
98
112
|
{ readonly context: Context.Context<HttpServer.HttpServer>; readonly port: number },
|
|
99
113
|
OAuthError
|
|
100
114
|
> =>
|
|
101
|
-
Layer.buildWithScope(factory.createServerLayer(port), serverScope).pipe(
|
|
115
|
+
Layer.buildWithScope(factory.createServerLayer(callbackServerListenOptions(port)), serverScope).pipe(
|
|
102
116
|
Effect.map((context) => ({ context, port })),
|
|
103
117
|
Effect.catchIf(
|
|
104
118
|
(error) => isAddressInUse(error) && port < MAX_PORT,
|
|
@@ -118,29 +132,27 @@ export const startCallbackServer = (
|
|
|
118
132
|
"/callback",
|
|
119
133
|
(req) =>
|
|
120
134
|
Effect.gen(function*() {
|
|
121
|
-
const url = new URL(req.url,
|
|
135
|
+
const url = new URL(req.url, callbackUrl(port))
|
|
122
136
|
const code = url.searchParams.get("code")
|
|
123
137
|
const state = url.searchParams.get("state")
|
|
124
138
|
const error = url.searchParams.get("error")
|
|
125
139
|
const errorDescription = url.searchParams.get("error_description")
|
|
126
140
|
|
|
127
|
-
if (
|
|
128
|
-
yield* Deferred.fail(
|
|
129
|
-
deferred,
|
|
130
|
-
new OAuthError({ step: "authorize", cause: errorDescription ?? error })
|
|
131
|
-
)
|
|
141
|
+
if (state !== expectedState) {
|
|
132
142
|
return HttpServerResponse.html(
|
|
133
|
-
"<html><body><h1>
|
|
143
|
+
"<html><body><h1>Security Error</h1><p>State verification failed.</p></body></html>"
|
|
144
|
+
).pipe(
|
|
145
|
+
HttpServerResponse.setStatus(403)
|
|
134
146
|
)
|
|
135
147
|
}
|
|
136
148
|
|
|
137
|
-
if (
|
|
149
|
+
if (error) {
|
|
138
150
|
yield* Deferred.fail(
|
|
139
151
|
deferred,
|
|
140
|
-
new OAuthError({ step: "authorize", cause:
|
|
152
|
+
new OAuthError({ step: "authorize", cause: errorDescription ?? error })
|
|
141
153
|
)
|
|
142
154
|
return HttpServerResponse.html(
|
|
143
|
-
"<html><body><h1>
|
|
155
|
+
"<html><body><h1>Authorization Failed</h1><p>You can close this window.</p></body></html>"
|
|
144
156
|
)
|
|
145
157
|
}
|
|
146
158
|
|
|
@@ -15,7 +15,7 @@ export const parseSyncBaseline = (
|
|
|
15
15
|
): Effect.Effect<SyncBaseline, SyncWorkspaceError | SyncValidationError> =>
|
|
16
16
|
Effect.gen(function*() {
|
|
17
17
|
const raw = yield* Effect.try({
|
|
18
|
-
try: ()
|
|
18
|
+
try: () => Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Json))(content),
|
|
19
19
|
catch: (cause) => new SyncWorkspaceError({ message: "Failed to parse Sync Baseline JSON", path, cause })
|
|
20
20
|
})
|
|
21
21
|
const baseline: SyncBaseline = yield* Schema.decodeUnknownEffect(SyncBaselineSchema)(raw).pipe(
|
|
@@ -24,4 +24,5 @@ export const parseSyncBaseline = (
|
|
|
24
24
|
return baseline
|
|
25
25
|
})
|
|
26
26
|
|
|
27
|
-
export const serializeSyncBaseline = (baseline: SyncBaseline): string =>
|
|
27
|
+
export const serializeSyncBaseline = (baseline: SyncBaseline): string =>
|
|
28
|
+
`${JSON.stringify(Schema.encodeSync(SyncBaselineSchema)(baseline), null, 2)}\n`
|
|
@@ -28,7 +28,8 @@ export const parseWorkspaceConfig = (
|
|
|
28
28
|
return config
|
|
29
29
|
})
|
|
30
30
|
|
|
31
|
-
export const serializeWorkspaceConfig = (config: WorkspaceConfig): string =>
|
|
31
|
+
export const serializeWorkspaceConfig = (config: WorkspaceConfig): string =>
|
|
32
|
+
yaml.dump(Schema.encodeSync(WorkspaceConfigSchema)(config), { lineWidth: 100 })
|
|
32
33
|
|
|
33
34
|
export const makeDefaultWorkspaceConfig = (siteUrl: string): WorkspaceConfig => ({
|
|
34
35
|
version: 1,
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { isPreviewableAttachment } from "@knpkv/atlassian-common/attachments"
|
|
7
7
|
import * as Predicate from "effect/Predicate"
|
|
8
|
+
import type * as Schema from "effect/Schema"
|
|
8
9
|
import matter from "gray-matter"
|
|
9
10
|
import * as yaml from "js-yaml"
|
|
10
11
|
import { SyncValidationError } from "../../JiraCliError.js"
|
|
@@ -17,19 +18,19 @@ import type {
|
|
|
17
18
|
UserFieldValue
|
|
18
19
|
} from "./types.js"
|
|
19
20
|
|
|
20
|
-
const isStringArray = (value:
|
|
21
|
-
Array.isArray(value) && value.every((item) =>
|
|
21
|
+
const isStringArray = <UnparsedInput>(value: UnparsedInput): value is UnparsedInput & ReadonlyArray<string> =>
|
|
22
|
+
Array.isArray(value) && value.every((item) => Predicate.isString(item))
|
|
22
23
|
|
|
23
|
-
const isFrontMatterCustomFields = (
|
|
24
|
-
value:
|
|
25
|
-
): value is IssueDocumentFrontMatter["customFields"] => Predicate.isReadonlyObject(value)
|
|
24
|
+
const isFrontMatterCustomFields = <UnparsedInput>(
|
|
25
|
+
value: UnparsedInput
|
|
26
|
+
): value is UnparsedInput & IssueDocumentFrontMatter["customFields"] => Predicate.isReadonlyObject(value)
|
|
26
27
|
|
|
27
28
|
const yamlEngine = {
|
|
28
29
|
parse: (str: string): object => {
|
|
29
30
|
const value = yaml.load(str)
|
|
30
31
|
return Predicate.isReadonlyObject(value) ? value : {}
|
|
31
32
|
},
|
|
32
|
-
stringify: (data:
|
|
33
|
+
stringify: <Data extends object>(data: Data): string => yaml.dump(data)
|
|
33
34
|
}
|
|
34
35
|
|
|
35
36
|
export const DESCRIPTION_SECTION = "Description"
|
|
@@ -88,17 +89,17 @@ const section = (name: string, content: string): string => {
|
|
|
88
89
|
return normalized.length > 0 ? `## ${name}\n\n${normalized}\n` : `## ${name}\n`
|
|
89
90
|
}
|
|
90
91
|
|
|
91
|
-
const parseFrontMatter = (path: string, data: Record<string,
|
|
92
|
+
const parseFrontMatter = (path: string, data: Record<string, Schema.Json>): IssueDocumentFrontMatter => {
|
|
92
93
|
const requiredString = (key: string): string => {
|
|
93
94
|
const value = data[key]
|
|
94
|
-
if (
|
|
95
|
+
if (Predicate.isString(value)) return value
|
|
95
96
|
return fail(path, `Missing or invalid front matter field "${key}"`)
|
|
96
97
|
}
|
|
97
98
|
|
|
98
99
|
const nullableString = (key: string): string | null => {
|
|
99
100
|
const value = data[key]
|
|
100
101
|
if (value === null || value === undefined) return null
|
|
101
|
-
if (
|
|
102
|
+
if (Predicate.isString(value)) return value
|
|
102
103
|
return fail(path, `Invalid front matter field "${key}"`)
|
|
103
104
|
}
|
|
104
105
|
|
|
@@ -108,7 +109,7 @@ const parseFrontMatter = (path: string, data: Record<string, unknown>): IssueDoc
|
|
|
108
109
|
const record = Predicate.isReadonlyObject(value) ? value : fail(path, `Invalid user field "${key}"`)
|
|
109
110
|
const accountId = record["accountId"]
|
|
110
111
|
const displayName = record["displayName"]
|
|
111
|
-
if (
|
|
112
|
+
if (Predicate.isString(accountId) && Predicate.isString(displayName)) {
|
|
112
113
|
return {
|
|
113
114
|
accountId,
|
|
114
115
|
displayName
|
|
@@ -244,13 +245,13 @@ const parseAttachmentMetadata = (
|
|
|
244
245
|
const parsed = JSON.parse(raw)
|
|
245
246
|
if (!Predicate.isReadonlyObject(parsed)) return {}
|
|
246
247
|
return {
|
|
247
|
-
id:
|
|
248
|
+
id: Predicate.isString(parsed["jiraAttachmentId"])
|
|
248
249
|
? parsed["jiraAttachmentId"]
|
|
249
|
-
:
|
|
250
|
+
: Predicate.isString(parsed["id"])
|
|
250
251
|
? parsed["id"]
|
|
251
252
|
: "",
|
|
252
|
-
mediaType:
|
|
253
|
-
size:
|
|
253
|
+
mediaType: Predicate.isString(parsed["mediaType"]) ? parsed["mediaType"] : null,
|
|
254
|
+
size: Predicate.isNumber(parsed["size"]) ? parsed["size"] : null
|
|
254
255
|
}
|
|
255
256
|
} catch {
|
|
256
257
|
return {}
|
|
@@ -16,7 +16,7 @@ export interface CanonicalFieldValueOptions {
|
|
|
16
16
|
|
|
17
17
|
export const explicitClear = null
|
|
18
18
|
|
|
19
|
-
export const isExplicitClear = (value:
|
|
19
|
+
export const isExplicitClear = <UnparsedInput>(value: UnparsedInput): value is UnparsedInput & null => value === null
|
|
20
20
|
|
|
21
21
|
export const userFieldValue = (accountId: string, displayName: string): UserFieldValue => ({
|
|
22
22
|
accountId,
|
|
@@ -44,24 +44,26 @@ export const completeListValue = (
|
|
|
44
44
|
|
|
45
45
|
export const makeCompleteListValue = completeListValue
|
|
46
46
|
|
|
47
|
-
export const isUserFieldValue = (value:
|
|
47
|
+
export const isUserFieldValue = <UnparsedInput>(value: UnparsedInput): value is UnparsedInput & UserFieldValue => {
|
|
48
48
|
if (!Predicate.isReadonlyObject(value)) return false
|
|
49
|
-
return
|
|
49
|
+
return Predicate.isString(value["accountId"]) && Predicate.isString(value["displayName"])
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
export const isOptionFieldValue = (value:
|
|
53
|
-
if (!Predicate.isReadonlyObject(value) ||
|
|
52
|
+
export const isOptionFieldValue = <UnparsedInput>(value: UnparsedInput): value is UnparsedInput & OptionFieldValue => {
|
|
53
|
+
if (!Predicate.isReadonlyObject(value) || !Predicate.isString(value["value"])) return false
|
|
54
54
|
const id = value["id"]
|
|
55
|
-
return id === undefined ||
|
|
55
|
+
return id === undefined || Predicate.isString(id)
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
export const isCascadingFieldValue = (
|
|
58
|
+
export const isCascadingFieldValue = <UnparsedInput>(
|
|
59
|
+
value: UnparsedInput
|
|
60
|
+
): value is UnparsedInput & CascadingFieldValue => {
|
|
59
61
|
if (!Predicate.isReadonlyObject(value) || !isOptionFieldValue(value["parent"])) return false
|
|
60
62
|
const child = value["child"]
|
|
61
63
|
return child === undefined || isOptionFieldValue(child)
|
|
62
64
|
}
|
|
63
65
|
|
|
64
|
-
export const isCompleteListValue = (value:
|
|
66
|
+
export const isCompleteListValue = <UnparsedInput>(value: UnparsedInput): value is UnparsedInput & CompleteListValue =>
|
|
65
67
|
Array.isArray(value) && value.every(isCompleteListItem)
|
|
66
68
|
|
|
67
69
|
export const canonicalFieldOrder = <A extends CompleteListItem>(
|
|
@@ -104,7 +106,7 @@ const canonicalFieldValueKey = (
|
|
|
104
106
|
options: CanonicalFieldValueOptions
|
|
105
107
|
): string => {
|
|
106
108
|
if (value === null) return "clear:"
|
|
107
|
-
if (
|
|
109
|
+
if (isCompleteListValue(value)) {
|
|
108
110
|
return `list:${canonicalFieldOrder(value, options).map(completeListItemOrderKey).join("\u0000")}`
|
|
109
111
|
}
|
|
110
112
|
if (isCascadingFieldValue(value)) {
|
|
@@ -114,13 +116,13 @@ const canonicalFieldValueKey = (
|
|
|
114
116
|
}
|
|
115
117
|
if (isUserFieldValue(value)) return userOrderKey(value)
|
|
116
118
|
if (isOptionFieldValue(value)) return optionOrderKey(value)
|
|
117
|
-
return
|
|
119
|
+
return scalarOrderKey(value)
|
|
118
120
|
}
|
|
119
121
|
|
|
120
122
|
const completeListItemOrderKey = (value: CompleteListItem): string => {
|
|
121
123
|
if (isUserFieldValue(value)) return userOrderKey(value)
|
|
122
124
|
if (isOptionFieldValue(value)) return optionOrderKey(value)
|
|
123
|
-
return
|
|
125
|
+
return scalarOrderKey(value)
|
|
124
126
|
}
|
|
125
127
|
|
|
126
128
|
const userOrderKey = (value: UserFieldValue): string =>
|
|
@@ -129,15 +131,12 @@ const userOrderKey = (value: UserFieldValue): string =>
|
|
|
129
131
|
const optionOrderKey = (value: OptionFieldValue): string =>
|
|
130
132
|
`option:${value.value.toLocaleLowerCase("en")}\u0000${value.id ?? ""}`
|
|
131
133
|
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
default:
|
|
141
|
-
return false
|
|
142
|
-
}
|
|
134
|
+
const scalarOrderKey = (value: string | number | boolean): string => {
|
|
135
|
+
const kind = Predicate.isString(value) ? "string" : Predicate.isNumber(value) ? "number" : "boolean"
|
|
136
|
+
return `${kind}:${String(value)}`
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const isCompleteListItem = <UnparsedInput>(value: UnparsedInput): value is UnparsedInput & CompleteListItem => {
|
|
140
|
+
return Predicate.isString(value) || Predicate.isNumber(value) || Predicate.isBoolean(value) ||
|
|
141
|
+
isUserFieldValue(value) || isOptionFieldValue(value)
|
|
143
142
|
}
|
|
@@ -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: ()
|
|
24
|
+
try: () => Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Json))(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(
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import * as Effect from "effect/Effect"
|
|
7
7
|
import * as Schema from "effect/Schema"
|
|
8
|
-
import {
|
|
8
|
+
import { FIELD_CONTRACTS } from "./types.js"
|
|
9
9
|
|
|
10
10
|
const SiteUrl = Schema.String.pipe(
|
|
11
11
|
Schema.check(Schema.isPattern(/^https:\/\/[a-z0-9][a-z0-9-]*\.atlassian\.net$/))
|
|
@@ -14,14 +14,14 @@ const SiteUrl = Schema.String.pipe(
|
|
|
14
14
|
const NonEmptyString = Schema.String.pipe(Schema.check(Schema.isMinLength(1)))
|
|
15
15
|
const FilenameModes: readonly ["convention", "custom"] = ["convention", "custom"]
|
|
16
16
|
|
|
17
|
-
export const
|
|
17
|
+
export const FieldContractSchema = Schema.Literals(FIELD_CONTRACTS)
|
|
18
18
|
|
|
19
19
|
export const RequestedCustomFieldSchema = Schema.Struct({
|
|
20
20
|
displayName: NonEmptyString,
|
|
21
21
|
fieldId: Schema.optional(NonEmptyString),
|
|
22
|
-
|
|
22
|
+
form: FieldContractSchema,
|
|
23
23
|
ordered: Schema.optional(Schema.Boolean)
|
|
24
|
-
})
|
|
24
|
+
}).pipe(Schema.encodeKeys({ form: "shape" }))
|
|
25
25
|
|
|
26
26
|
export const WorkspaceConfigSchema = Schema.Struct({
|
|
27
27
|
version: Schema.Literal(1).pipe(Schema.withDecodingDefaultTypeKey(Effect.succeed(1))),
|
|
@@ -82,9 +82,9 @@ export const SyncFieldValueSchema = Schema.Union([
|
|
|
82
82
|
export const BaselineCustomFieldSchema = Schema.Struct({
|
|
83
83
|
fieldId: NonEmptyString,
|
|
84
84
|
displayName: NonEmptyString,
|
|
85
|
-
|
|
85
|
+
form: FieldContractSchema,
|
|
86
86
|
value: SyncFieldValueSchema
|
|
87
|
-
})
|
|
87
|
+
}).pipe(Schema.encodeKeys({ form: "shape" }))
|
|
88
88
|
|
|
89
89
|
export const SyncBaselineSchema = Schema.Struct({
|
|
90
90
|
version: Schema.Literal(1),
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* @internal
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
export type
|
|
7
|
+
export type FieldContract =
|
|
8
8
|
| "text"
|
|
9
9
|
| "multilineText"
|
|
10
10
|
| "number"
|
|
@@ -15,7 +15,7 @@ export type FieldShape =
|
|
|
15
15
|
| "user"
|
|
16
16
|
| "cascadingSelect"
|
|
17
17
|
|
|
18
|
-
export const
|
|
18
|
+
export const FIELD_CONTRACTS: ReadonlyArray<FieldContract> = [
|
|
19
19
|
"text",
|
|
20
20
|
"multilineText",
|
|
21
21
|
"number",
|
|
@@ -30,7 +30,7 @@ export const FIELD_SHAPES: ReadonlyArray<FieldShape> = [
|
|
|
30
30
|
export interface RequestedCustomField {
|
|
31
31
|
readonly displayName: string
|
|
32
32
|
readonly fieldId?: string | undefined
|
|
33
|
-
readonly
|
|
33
|
+
readonly form: FieldContract
|
|
34
34
|
readonly ordered?: boolean | undefined
|
|
35
35
|
}
|
|
36
36
|
|
|
@@ -83,7 +83,7 @@ export type SyncFieldValue =
|
|
|
83
83
|
export interface BaselineCustomField {
|
|
84
84
|
readonly fieldId: string
|
|
85
85
|
readonly displayName: string
|
|
86
|
-
readonly
|
|
86
|
+
readonly form: FieldContract
|
|
87
87
|
readonly value: SyncFieldValue
|
|
88
88
|
}
|
|
89
89
|
|