@knpkv/jira-cli 1.2.2 → 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 +124 -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 +97 -21
- 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 +14 -9
- package/dist/internal/oauthServer.d.ts.map +1 -1
- package/dist/internal/oauthServer.js +21 -21
- 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 +120 -35
- 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 +46 -36
- 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 +171 -0
- 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/tsconfig.json +3 -0
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
|
/**
|
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
*
|
|
4
4
|
* **Mental model**
|
|
5
5
|
*
|
|
6
|
-
* - **
|
|
7
|
-
* (Deferred)
|
|
8
|
-
*
|
|
6
|
+
* - **Scope-owned lifecycle**: {@link startCallbackServer} returns a `codePromise`
|
|
7
|
+
* (Deferred). The server validates the CSRF `state` parameter, resolves the
|
|
8
|
+
* Deferred with the authorization code, and stops when its enclosing scope closes.
|
|
9
9
|
* - **Port auto-discovery**: Tries default port 8585, increments on conflict.
|
|
10
10
|
*
|
|
11
11
|
* @internal
|
|
@@ -14,10 +14,9 @@ import { OAuthError } from "@knpkv/atlassian-common/auth"
|
|
|
14
14
|
import * as Context from "effect/Context"
|
|
15
15
|
import * as Deferred from "effect/Deferred"
|
|
16
16
|
import * as Effect from "effect/Effect"
|
|
17
|
-
import * as Exit from "effect/Exit"
|
|
18
|
-
import * as Fiber from "effect/Fiber"
|
|
19
17
|
import * as Layer from "effect/Layer"
|
|
20
|
-
import * as
|
|
18
|
+
import * as Schema from "effect/Schema"
|
|
19
|
+
import type * as Scope from "effect/Scope"
|
|
21
20
|
import { HttpRouter, HttpServer, HttpServerResponse } from "effect/unstable/http"
|
|
22
21
|
import type * as HttpServerError from "effect/unstable/http/HttpServerError"
|
|
23
22
|
|
|
@@ -31,13 +30,25 @@ type HttpServerInstance = Effect.Success<typeof HttpServer.HttpServer>
|
|
|
31
30
|
* @category Services
|
|
32
31
|
*/
|
|
33
32
|
export interface HttpServerFactory {
|
|
34
|
-
readonly createServerLayer: (
|
|
33
|
+
readonly createServerLayer: (options: CallbackServerListenOptions) => Layer.Layer<
|
|
35
34
|
HttpServer.HttpServer,
|
|
36
35
|
HttpServerError.ServeError,
|
|
37
36
|
never
|
|
38
37
|
>
|
|
39
38
|
}
|
|
40
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
|
+
|
|
41
52
|
/**
|
|
42
53
|
* Tag for the HttpServerFactory service.
|
|
43
54
|
*
|
|
@@ -58,7 +69,9 @@ export class HttpServerFactoryTag extends Context.Service<
|
|
|
58
69
|
* @category Layers
|
|
59
70
|
*/
|
|
60
71
|
export const makeHttpServerFactory = (
|
|
61
|
-
createLayerFn: (
|
|
72
|
+
createLayerFn: (
|
|
73
|
+
options: CallbackServerListenOptions
|
|
74
|
+
) => Layer.Layer<HttpServer.HttpServer, HttpServerError.ServeError, never>
|
|
62
75
|
): Layer.Layer<HttpServerFactoryTag> =>
|
|
63
76
|
Layer.succeed(HttpServerFactoryTag, {
|
|
64
77
|
createServerLayer: createLayerFn
|
|
@@ -70,38 +83,42 @@ export const makeHttpServerFactory = (
|
|
|
70
83
|
export interface CallbackServerResult {
|
|
71
84
|
/** Promise that resolves with the authorization code */
|
|
72
85
|
readonly codePromise: Effect.Effect<string, OAuthError>
|
|
73
|
-
/** Shutdown the callback server */
|
|
74
|
-
readonly shutdown: Effect.Effect<void, never>
|
|
75
86
|
/** The port the server is listening on */
|
|
76
87
|
readonly port: number
|
|
77
88
|
}
|
|
78
89
|
|
|
90
|
+
const AddressInUseCause = Schema.Struct({
|
|
91
|
+
code: Schema.Literal("EADDRINUSE")
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
const isAddressInUse = (error: HttpServerError.ServeError): boolean => Schema.is(AddressInUseCause)(error.cause)
|
|
95
|
+
|
|
79
96
|
/**
|
|
80
97
|
* Start a local HTTP server to receive OAuth callback.
|
|
81
98
|
*
|
|
82
99
|
* @param expectedState - The state parameter to verify against CSRF
|
|
83
|
-
* @returns Server control interface with code promise
|
|
100
|
+
* @returns Server control interface with code promise and port
|
|
84
101
|
*
|
|
85
102
|
* @category OAuth
|
|
86
103
|
*/
|
|
87
104
|
export const startCallbackServer = (
|
|
88
105
|
expectedState: string
|
|
89
|
-
): Effect.Effect<CallbackServerResult, OAuthError, HttpServerFactoryTag> =>
|
|
106
|
+
): Effect.Effect<CallbackServerResult, OAuthError, HttpServerFactoryTag | Scope.Scope> =>
|
|
90
107
|
Effect.gen(function*() {
|
|
91
108
|
const factory = yield* HttpServerFactoryTag
|
|
92
109
|
const deferred = yield* Deferred.make<string, OAuthError>()
|
|
93
|
-
const serverScope = yield*
|
|
110
|
+
const serverScope = yield* Effect.scope
|
|
94
111
|
const buildServerContext = (port: number): Effect.Effect<
|
|
95
112
|
{ readonly context: Context.Context<HttpServer.HttpServer>; readonly port: number },
|
|
96
113
|
OAuthError
|
|
97
114
|
> =>
|
|
98
|
-
Layer.buildWithScope(factory.createServerLayer(port), serverScope).pipe(
|
|
115
|
+
Layer.buildWithScope(factory.createServerLayer(callbackServerListenOptions(port)), serverScope).pipe(
|
|
99
116
|
Effect.map((context) => ({ context, port })),
|
|
100
|
-
Effect.
|
|
101
|
-
port < MAX_PORT
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
)
|
|
117
|
+
Effect.catchIf(
|
|
118
|
+
(error) => isAddressInUse(error) && port < MAX_PORT,
|
|
119
|
+
() => buildServerContext(port + 1)
|
|
120
|
+
),
|
|
121
|
+
Effect.mapError((cause) => new OAuthError({ step: "authorize", cause }))
|
|
105
122
|
)
|
|
106
123
|
const { context: serverContext } = yield* buildServerContext(DEFAULT_PORT)
|
|
107
124
|
const server: HttpServerInstance = Context.get(serverContext, HttpServer.HttpServer)
|
|
@@ -115,29 +132,27 @@ export const startCallbackServer = (
|
|
|
115
132
|
"/callback",
|
|
116
133
|
(req) =>
|
|
117
134
|
Effect.gen(function*() {
|
|
118
|
-
const url = new URL(req.url,
|
|
135
|
+
const url = new URL(req.url, callbackUrl(port))
|
|
119
136
|
const code = url.searchParams.get("code")
|
|
120
137
|
const state = url.searchParams.get("state")
|
|
121
138
|
const error = url.searchParams.get("error")
|
|
122
139
|
const errorDescription = url.searchParams.get("error_description")
|
|
123
140
|
|
|
124
|
-
if (
|
|
125
|
-
yield* Deferred.fail(
|
|
126
|
-
deferred,
|
|
127
|
-
new OAuthError({ step: "authorize", cause: errorDescription ?? error })
|
|
128
|
-
)
|
|
141
|
+
if (state !== expectedState) {
|
|
129
142
|
return HttpServerResponse.html(
|
|
130
|
-
"<html><body><h1>
|
|
143
|
+
"<html><body><h1>Security Error</h1><p>State verification failed.</p></body></html>"
|
|
144
|
+
).pipe(
|
|
145
|
+
HttpServerResponse.setStatus(403)
|
|
131
146
|
)
|
|
132
147
|
}
|
|
133
148
|
|
|
134
|
-
if (
|
|
149
|
+
if (error) {
|
|
135
150
|
yield* Deferred.fail(
|
|
136
151
|
deferred,
|
|
137
|
-
new OAuthError({ step: "authorize", cause:
|
|
152
|
+
new OAuthError({ step: "authorize", cause: errorDescription ?? error })
|
|
138
153
|
)
|
|
139
154
|
return HttpServerResponse.html(
|
|
140
|
-
"<html><body><h1>
|
|
155
|
+
"<html><body><h1>Authorization Failed</h1><p>You can close this window.</p></body></html>"
|
|
141
156
|
)
|
|
142
157
|
}
|
|
143
158
|
|
|
@@ -159,18 +174,13 @@ export const startCallbackServer = (
|
|
|
159
174
|
)
|
|
160
175
|
const app = router.asHttpEffect()
|
|
161
176
|
|
|
162
|
-
|
|
177
|
+
yield* HttpServer.serveEffect(app).pipe(
|
|
163
178
|
Effect.provide(serverContext),
|
|
164
|
-
Effect.
|
|
165
|
-
Effect.forkIn(serverScope)
|
|
179
|
+
Effect.forkScoped
|
|
166
180
|
)
|
|
167
181
|
|
|
168
182
|
return {
|
|
169
183
|
codePromise: Deferred.await(deferred),
|
|
170
|
-
shutdown: Effect.gen(function*() {
|
|
171
|
-
yield* Fiber.interrupt(serverFiber)
|
|
172
|
-
yield* Scope.close(serverScope, Exit.void)
|
|
173
|
-
}),
|
|
174
184
|
port
|
|
175
185
|
}
|
|
176
186
|
})
|
|
@@ -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(
|