@knpkv/jira-cli 1.2.3 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. package/CHANGELOG.md +133 -0
  2. package/README.md +64 -0
  3. package/dist/AttachmentService.d.ts +2 -2
  4. package/dist/AttachmentService.d.ts.map +1 -1
  5. package/dist/AttachmentService.js +1 -1
  6. package/dist/AttachmentService.js.map +1 -1
  7. package/dist/IssueService.d.ts +82 -3
  8. package/dist/IssueService.d.ts.map +1 -1
  9. package/dist/IssueService.js +111 -16
  10. package/dist/IssueService.js.map +1 -1
  11. package/dist/JiraAuth.d.ts +3 -3
  12. package/dist/JiraAuth.d.ts.map +1 -1
  13. package/dist/JiraAuth.js +87 -23
  14. package/dist/JiraAuth.js.map +1 -1
  15. package/dist/MarkdownWriter.d.ts +4 -4
  16. package/dist/MarkdownWriter.d.ts.map +1 -1
  17. package/dist/MarkdownWriter.js +2 -2
  18. package/dist/MarkdownWriter.js.map +1 -1
  19. package/dist/SyncWorkspace.d.ts +2 -2
  20. package/dist/SyncWorkspace.d.ts.map +1 -1
  21. package/dist/SyncWorkspace.js.map +1 -1
  22. package/dist/VersionService.d.ts +81 -9
  23. package/dist/VersionService.d.ts.map +1 -1
  24. package/dist/VersionService.js +113 -17
  25. package/dist/VersionService.js.map +1 -1
  26. package/dist/bin.js +1 -1
  27. package/dist/bin.js.map +1 -1
  28. package/dist/commands/auth.d.ts +13 -2
  29. package/dist/commands/auth.d.ts.map +1 -1
  30. package/dist/commands/auth.js +7 -7
  31. package/dist/commands/auth.js.map +1 -1
  32. package/dist/commands/edit.d.ts +22 -0
  33. package/dist/commands/edit.d.ts.map +1 -0
  34. package/dist/commands/edit.js +49 -0
  35. package/dist/commands/edit.js.map +1 -0
  36. package/dist/commands/errorHandler.d.ts.map +1 -1
  37. package/dist/commands/get.d.ts +6 -1
  38. package/dist/commands/get.d.ts.map +1 -1
  39. package/dist/commands/get.js +3 -3
  40. package/dist/commands/get.js.map +1 -1
  41. package/dist/commands/issue.d.ts +2 -2
  42. package/dist/commands/issue.d.ts.map +1 -1
  43. package/dist/commands/issue.js +3 -2
  44. package/dist/commands/issue.js.map +1 -1
  45. package/dist/commands/issueAttachment.d.ts +1 -1
  46. package/dist/commands/issueAttachment.d.ts.map +1 -1
  47. package/dist/commands/issueAttachment.js +13 -11
  48. package/dist/commands/issueAttachment.js.map +1 -1
  49. package/dist/commands/layers.d.ts +3 -3
  50. package/dist/commands/layers.d.ts.map +1 -1
  51. package/dist/commands/layers.js +5 -2
  52. package/dist/commands/layers.js.map +1 -1
  53. package/dist/commands/search.d.ts +6 -1
  54. package/dist/commands/search.d.ts.map +1 -1
  55. package/dist/commands/search.js +7 -7
  56. package/dist/commands/search.js.map +1 -1
  57. package/dist/commands/version.d.ts +17 -2
  58. package/dist/commands/version.d.ts.map +1 -1
  59. package/dist/commands/version.js +131 -19
  60. package/dist/commands/version.js.map +1 -1
  61. package/dist/index.d.ts +4 -3
  62. package/dist/index.d.ts.map +1 -1
  63. package/dist/index.js +1 -0
  64. package/dist/index.js.map +1 -1
  65. package/dist/internal/NodeLayers.js +1 -1
  66. package/dist/internal/NodeLayers.js.map +1 -1
  67. package/dist/internal/attachmentInsertion.d.ts.map +1 -1
  68. package/dist/internal/frontmatter.d.ts.map +1 -1
  69. package/dist/internal/frontmatter.js +2 -1
  70. package/dist/internal/frontmatter.js.map +1 -1
  71. package/dist/internal/jqlBuilder.d.ts.map +1 -1
  72. package/dist/internal/oauthServer.d.ts +10 -4
  73. package/dist/internal/oauthServer.d.ts.map +1 -1
  74. package/dist/internal/oauthServer.js +13 -8
  75. package/dist/internal/oauthServer.js.map +1 -1
  76. package/dist/internal/openBrowser.d.ts +1 -1
  77. package/dist/internal/openBrowser.d.ts.map +1 -1
  78. package/dist/internal/openBrowser.js +1 -1
  79. package/dist/internal/openBrowser.js.map +1 -1
  80. package/dist/internal/sync/baseline.d.ts.map +1 -1
  81. package/dist/internal/sync/baseline.js +2 -2
  82. package/dist/internal/sync/baseline.js.map +1 -1
  83. package/dist/internal/sync/changes.d.ts.map +1 -1
  84. package/dist/internal/sync/config.d.ts.map +1 -1
  85. package/dist/internal/sync/config.js +1 -1
  86. package/dist/internal/sync/config.js.map +1 -1
  87. package/dist/internal/sync/document.d.ts.map +1 -1
  88. package/dist/internal/sync/document.js +8 -8
  89. package/dist/internal/sync/document.js.map +1 -1
  90. package/dist/internal/sync/fieldValues.d.ts +9 -9
  91. package/dist/internal/sync/fieldValues.d.ts.map +1 -1
  92. package/dist/internal/sync/fieldValues.js +12 -16
  93. package/dist/internal/sync/fieldValues.js.map +1 -1
  94. package/dist/internal/sync/manifest.d.ts.map +1 -1
  95. package/dist/internal/sync/manifest.js +1 -1
  96. package/dist/internal/sync/manifest.js.map +1 -1
  97. package/dist/internal/sync/paths.d.ts.map +1 -1
  98. package/dist/internal/sync/schemas.d.ts +17 -9
  99. package/dist/internal/sync/schemas.d.ts.map +1 -1
  100. package/dist/internal/sync/schemas.js +6 -6
  101. package/dist/internal/sync/schemas.js.map +1 -1
  102. package/dist/internal/sync/types.d.ts +4 -4
  103. package/dist/internal/sync/types.d.ts.map +1 -1
  104. package/dist/internal/sync/types.js +1 -1
  105. package/dist/internal/sync/types.js.map +1 -1
  106. package/package.json +15 -11
  107. package/skills/jira/SKILL.md +17 -1
  108. package/src/AttachmentService.ts +6 -6
  109. package/src/IssueService.ts +214 -27
  110. package/src/JiraAuth.ts +106 -42
  111. package/src/MarkdownWriter.ts +6 -6
  112. package/src/SyncWorkspace.ts +12 -11
  113. package/src/VersionService.ts +199 -27
  114. package/src/bin.ts +1 -1
  115. package/src/commands/auth.ts +7 -7
  116. package/src/commands/edit.ts +88 -0
  117. package/src/commands/get.ts +3 -3
  118. package/src/commands/issue.ts +3 -2
  119. package/src/commands/issueAttachment.ts +21 -20
  120. package/src/commands/layers.ts +5 -2
  121. package/src/commands/search.ts +7 -7
  122. package/src/commands/version.ts +176 -23
  123. package/src/index.ts +13 -3
  124. package/src/internal/NodeLayers.ts +1 -1
  125. package/src/internal/frontmatter.ts +3 -2
  126. package/src/internal/oauthServer.ts +29 -16
  127. package/src/internal/openBrowser.ts +1 -1
  128. package/src/internal/sync/baseline.ts +3 -2
  129. package/src/internal/sync/config.ts +2 -1
  130. package/src/internal/sync/document.ts +15 -14
  131. package/src/internal/sync/fieldValues.ts +21 -22
  132. package/src/internal/sync/manifest.ts +1 -1
  133. package/src/internal/sync/schemas.ts +6 -6
  134. package/src/internal/sync/types.ts +4 -4
  135. package/test/IssueService.test.ts +2 -2
  136. package/test/JiraAuthRefresh.test.ts +269 -0
  137. package/test/VersionService.test.ts +2 -2
  138. package/test/commandTree.test.ts +158 -2
  139. package/test/editIssuePayload.test.ts +103 -0
  140. package/test/frontmatter.test.ts +4 -2
  141. package/test/integration.test.ts +12 -10
  142. package/test/oauthServer.test.ts +77 -4
  143. package/test/publicApi.test.ts +34 -0
  144. package/test/relatedWorkSync.test.ts +179 -0
  145. package/test/remoteWrites.test.ts +220 -0
  146. package/test/syncChanges.test.ts +2 -2
  147. package/test/syncConfig.test.ts +2 -2
@@ -0,0 +1,88 @@
1
+ /**
2
+ * `jira issue edit <key>` command — edit an issue's list-valued fields
3
+ * (fixVersions, labels).
4
+ *
5
+ * Incremental flags (`--add-*`, `--remove-*`) are the ones to reach for: both
6
+ * fields are sets, so the replacing form silently drops anything not listed.
7
+ *
8
+ * @internal
9
+ */
10
+ import { Argument as Args, Command, Flag as Options } from "effect/cli"
11
+ import * as Console from "effect/Console"
12
+ import * as Effect from "effect/Effect"
13
+ import { IssueService } from "../IssueService.js"
14
+
15
+ const keyArg = Args.String("key").pipe(
16
+ Args.withDescription("Issue key (e.g., PROJ-123)")
17
+ )
18
+
19
+ const addFixVersionOption = Options.String("add-fix-version").pipe(
20
+ Options.withDescription("Add a fix version by name, keeping existing ones (repeatable)"),
21
+ Options.atLeast(0)
22
+ )
23
+ const removeFixVersionOption = Options.String("remove-fix-version").pipe(
24
+ Options.withDescription("Remove a fix version by name (repeatable)"),
25
+ Options.atLeast(0)
26
+ )
27
+ const fixVersionOption = Options.String("fix-version").pipe(
28
+ Options.withDescription(
29
+ "Replace the fix versions with exactly these names (repeatable). Drops any not listed — prefer --add-fix-version"
30
+ ),
31
+ Options.atLeast(0)
32
+ )
33
+ const addLabelOption = Options.String("add-label").pipe(
34
+ Options.withDescription("Add a label, keeping existing ones (repeatable)"),
35
+ Options.atLeast(0)
36
+ )
37
+ const removeLabelOption = Options.String("remove-label").pipe(
38
+ Options.withDescription("Remove a label (repeatable)"),
39
+ Options.atLeast(0)
40
+ )
41
+ const labelOption = Options.String("label").pipe(
42
+ Options.withDescription(
43
+ "Replace the labels with exactly these (repeatable). Drops any not listed — prefer --add-label"
44
+ ),
45
+ Options.atLeast(0)
46
+ )
47
+ const jsonOption = Options.Boolean("json").pipe(
48
+ Options.withDescription("Output as JSON"),
49
+ Options.withDefault(false)
50
+ )
51
+
52
+ export const editCommand = Command.make(
53
+ "edit",
54
+ {
55
+ key: keyArg,
56
+ addFixVersion: addFixVersionOption,
57
+ removeFixVersion: removeFixVersionOption,
58
+ fixVersion: fixVersionOption,
59
+ addLabel: addLabelOption,
60
+ removeLabel: removeLabelOption,
61
+ label: labelOption,
62
+ json: jsonOption
63
+ },
64
+ ({ addFixVersion, addLabel, fixVersion, json, key, label, removeFixVersion, removeLabel }) =>
65
+ Effect.gen(function*() {
66
+ const issueService = yield* IssueService
67
+ const issue = yield* issueService.edit(key, {
68
+ addFixVersions: addFixVersion,
69
+ removeFixVersions: removeFixVersion,
70
+ setFixVersions: fixVersion,
71
+ addLabels: addLabel,
72
+ removeLabels: removeLabel,
73
+ setLabels: label
74
+ })
75
+
76
+ if (json) {
77
+ yield* Console.log(JSON.stringify(issue, null, 2))
78
+ return
79
+ }
80
+ yield* Console.log(`Updated ${issue.key}`)
81
+ yield* Console.log(`fixVersions: ${issue.fixVersions.join(", ") || "-"}`)
82
+ yield* Console.log(`labels: ${issue.labels.join(", ") || "-"}`)
83
+ })
84
+ ).pipe(
85
+ Command.withDescription(
86
+ "Remote write: edit an issue's fix versions and labels (requires write:jira-work scope)"
87
+ )
88
+ )
@@ -3,17 +3,17 @@
3
3
  *
4
4
  * @internal
5
5
  */
6
+ import { Argument as Args, Command, Flag as Options } from "effect/cli"
6
7
  import * as Console from "effect/Console"
7
8
  import * as Effect from "effect/Effect"
8
- import { Argument as Args, Command, Flag as Options } from "effect/unstable/cli"
9
9
  import { IssueService } from "../IssueService.js"
10
10
  import { MarkdownWriter } from "../MarkdownWriter.js"
11
11
 
12
- const keyArg = Args.string("key").pipe(
12
+ const keyArg = Args.String("key").pipe(
13
13
  Args.withDescription("Issue key (e.g., PROJ-123)")
14
14
  )
15
15
 
16
- const outputDirOption = Options.directory("output-dir").pipe(
16
+ const outputDirOption = Options.Directory("output-dir").pipe(
17
17
  Options.withAlias("o"),
18
18
  Options.withDescription("Output directory for markdown file"),
19
19
  Options.withDefault("./jira-tickets")
@@ -3,12 +3,13 @@
3
3
  *
4
4
  * @internal
5
5
  */
6
- import { Command } from "effect/unstable/cli"
6
+ import { Command } from "effect/cli"
7
+ import { editCommand } from "./edit.js"
7
8
  import { getCommand } from "./get.js"
8
9
  import { attachmentCommand } from "./issueAttachment.js"
9
10
  import { searchCommand } from "./search.js"
10
11
 
11
12
  export const issueCommand = Command.make("issue").pipe(
12
13
  Command.withDescription("Jira issue resource commands"),
13
- Command.withSubcommands([attachmentCommand, getCommand, searchCommand])
14
+ Command.withSubcommands([attachmentCommand, getCommand, searchCommand, editCommand])
14
15
  )
@@ -4,39 +4,42 @@
4
4
  * @internal
5
5
  */
6
6
  import { renderAttachmentMarkdown } from "@knpkv/atlassian-common/attachments"
7
+ import { Argument as Args, Command, Flag as Options } from "effect/cli"
7
8
  import * as Console from "effect/Console"
8
9
  import * as Effect from "effect/Effect"
9
10
  import * as FileSystem from "effect/FileSystem"
10
11
  import * as Option from "effect/Option"
11
- import { Argument as Args, Command, Flag as Options } from "effect/unstable/cli"
12
12
  import { AttachmentService } from "../AttachmentService.js"
13
13
  import { insertJiraAttachmentReference } from "../internal/attachmentInsertion.js"
14
14
  import { JiraApiError, WriteError } from "../JiraCliError.js"
15
15
 
16
- const issueArg = Args.string("issue").pipe(
16
+ const issueArg = Args.String("issue").pipe(
17
17
  Args.withDescription("Issue key or id, for example PROJ-123")
18
18
  )
19
19
 
20
- const fileArg = Args.string("file").pipe(
20
+ const fileArg = Args.String("file").pipe(
21
21
  Args.withDescription("Local file to upload")
22
22
  )
23
23
 
24
- const documentOption = Options.file("document").pipe(
24
+ const documentOption = Options.File("document").pipe(
25
25
  Options.withDescription("Local Jira Markdown document containing the attachment placeholder"),
26
26
  Options.optional
27
27
  )
28
28
 
29
- const noInsertOption = Options.boolean("no-insert").pipe(
30
- Options.withDescription("Only upload and print the Attachment Reference")
29
+ const noInsertOption = Options.Boolean("no-insert").pipe(
30
+ Options.withDescription("Only upload and print the Attachment Reference"),
31
+ Options.withDefault(false)
31
32
  )
32
33
 
33
- const dryRunOption = Options.boolean("dry-run").pipe(
34
+ const dryRunOption = Options.Boolean("dry-run").pipe(
34
35
  Options.withAlias("n"),
35
- Options.withDescription("Validate local insertion input without uploading")
36
+ Options.withDescription("Validate local insertion input without uploading"),
37
+ Options.withDefault(false)
36
38
  )
37
39
 
38
- const jsonOption = Options.boolean("json").pipe(
39
- Options.withDescription("Write exactly one JSON value to stdout")
40
+ const jsonOption = Options.Boolean("json").pipe(
41
+ Options.withDescription("Write exactly one JSON value to stdout"),
42
+ Options.withDefault(false)
40
43
  )
41
44
 
42
45
  const readDocument = (documentPath: string) =>
@@ -88,16 +91,16 @@ const uploadCommand = Command.make(
88
91
  if (documentInput !== null) {
89
92
  const matches = countPlaceholderMatches(documentInput.content, file)
90
93
  if (matches !== 1) {
91
- return yield* Effect.fail(
92
- new JiraApiError({ message: `Expected exactly one attachment placeholder for ${file}, found ${matches}` })
93
- )
94
+ return yield* new JiraApiError({
95
+ message: `Expected exactly one attachment placeholder for ${file}, found ${matches}`
96
+ })
94
97
  }
95
98
  }
96
99
 
97
100
  if (dryRun) {
98
101
  const exists = yield* fileExists(file)
99
102
  if (!exists) {
100
- return yield* Effect.fail(new JiraApiError({ message: `Attachment file does not exist: ${file}` }))
103
+ return yield* new JiraApiError({ message: `Attachment file does not exist: ${file}` })
101
104
  }
102
105
  const result = { dryRun: true, issue, file, insert: shouldInsert }
103
106
  yield* Console.log(json ? JSON.stringify(result) : `Dry run: ${file} can be uploaded to ${issue}`)
@@ -111,12 +114,10 @@ const uploadCommand = Command.make(
111
114
  if (documentInput !== null) {
112
115
  const result = insertJiraAttachmentReference(documentInput.content, file, attachment)
113
116
  if (result.replacements !== 1) {
114
- return yield* Effect.fail(
115
- new JiraApiError({
116
- message:
117
- `Uploaded attachment ${attachment.id}, but expected exactly one local placeholder for ${file}; found ${result.replacements}`
118
- })
119
- )
117
+ return yield* new JiraApiError({
118
+ message:
119
+ `Uploaded attachment ${attachment.id}, but expected exactly one local placeholder for ${file}; found ${result.replacements}`
120
+ })
120
121
  }
121
122
  yield* writeDocument(documentInput.path, result.content)
122
123
  inserted = true
@@ -28,7 +28,8 @@ const DummyIssueServiceLayer = Layer.succeed(
28
28
  IssueService.of({
29
29
  getByKey: () => Effect.die(new Error("Not configured - run 'jira auth login' first")),
30
30
  search: () => Effect.die(new Error("Not configured - run 'jira auth login' first")),
31
- searchAll: () => Effect.die(new Error("Not configured - run 'jira auth login' first"))
31
+ searchAll: () => Effect.die(new Error("Not configured - run 'jira auth login' first")),
32
+ edit: () => Effect.die(new Error("Not configured - run 'jira auth login' first"))
32
33
  })
33
34
  )
34
35
 
@@ -52,9 +53,11 @@ const DummyVersionServiceLayer = Layer.succeed(
52
53
  VersionService.of({
53
54
  listProjectVersions: () => Effect.die(new Error("Not configured - run 'jira auth login' first")),
54
55
  getVersion: () => Effect.die(new Error("Not configured - run 'jira auth login' first")),
56
+ createVersion: () => Effect.die(new Error("Not configured - run 'jira auth login' first")),
55
57
  updateVersion: () => Effect.die(new Error("Not configured - run 'jira auth login' first")),
56
58
  listRelatedWork: () => Effect.die(new Error("Not configured - run 'jira auth login' first")),
57
- addRelatedWork: () => Effect.die(new Error("Not configured - run 'jira auth login' first"))
59
+ addRelatedWork: () => Effect.die(new Error("Not configured - run 'jira auth login' first")),
60
+ deleteRelatedWork: () => Effect.die(new Error("Not configured - run 'jira auth login' first"))
58
61
  })
59
62
  )
60
63
 
@@ -3,45 +3,45 @@
3
3
  *
4
4
  * @internal
5
5
  */
6
+ import { Argument as Args, Command, Flag as Options } from "effect/cli"
6
7
  import * as Console from "effect/Console"
7
8
  import * as Effect from "effect/Effect"
8
9
  import * as Option from "effect/Option"
9
- import { Argument as Args, Command, Flag as Options } from "effect/unstable/cli"
10
10
  import { buildByVersionJql } from "../internal/jqlBuilder.js"
11
11
  import { IssueService } from "../IssueService.js"
12
12
  import { MarkdownWriter } from "../MarkdownWriter.js"
13
13
 
14
14
  // === Options ===
15
- const jqlArg = Args.string("jql").pipe(
15
+ const jqlArg = Args.String("jql").pipe(
16
16
  Args.withDescription("JQL query to search for issues"),
17
17
  Args.optional
18
18
  )
19
19
 
20
- const byVersionOption = Options.string("by-version").pipe(
20
+ const byVersionOption = Options.String("by-version").pipe(
21
21
  Options.withAlias("v"),
22
22
  Options.withDescription("Search by fix version (pre-defined query)"),
23
23
  Options.optional
24
24
  )
25
25
 
26
- const projectOption = Options.string("project").pipe(
26
+ const projectOption = Options.String("project").pipe(
27
27
  Options.withAlias("p"),
28
28
  Options.withDescription("Filter by project key"),
29
29
  Options.optional
30
30
  )
31
31
 
32
- const outputDirOption = Options.directory("output-dir").pipe(
32
+ const outputDirOption = Options.Directory("output-dir").pipe(
33
33
  Options.withAlias("o"),
34
34
  Options.withDescription("Output directory for markdown files"),
35
35
  Options.withDefault("./jira-tickets")
36
36
  )
37
37
 
38
- const formatOption = Options.choice("format", ["multi", "single"]).pipe(
38
+ const formatOption = Options.Literals("format", ["multi", "single"]).pipe(
39
39
  Options.withAlias("f"),
40
40
  Options.withDescription("Output format: multi (one file per issue) or single (combined file)"),
41
41
  Options.withDefault("multi")
42
42
  )
43
43
 
44
- const maxResultsOption = Options.integer("max-results").pipe(
44
+ const maxResultsOption = Options.Int("max-results").pipe(
45
45
  Options.withAlias("m"),
46
46
  Options.withDescription("Maximum number of results to fetch"),
47
47
  Options.withDefault(100)
@@ -6,13 +6,13 @@
6
6
  *
7
7
  * @internal
8
8
  */
9
+ import { Argument as Args, Command, Flag as Options } from "effect/cli"
9
10
  import * as Console from "effect/Console"
10
11
  import * as Effect from "effect/Effect"
11
12
  import * as Option from "effect/Option"
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}
@@ -48,27 +48,27 @@ const ensureNumericId = (id: string): Effect.Effect<void, JiraApiError> =>
48
48
  })
49
49
  )
50
50
 
51
- const projectOption = Options.string("project").pipe(
51
+ const projectOption = Options.String("project").pipe(
52
52
  Options.withAlias("p"),
53
53
  Options.withDescription("Jira project key (e.g. RPS)")
54
54
  )
55
- const releasedOption = Options.boolean("released").pipe(
55
+ const releasedOption = Options.Boolean("released").pipe(
56
56
  Options.withDescription("Only list released versions"),
57
57
  Options.withDefault(false)
58
58
  )
59
- const unreleasedOption = Options.boolean("unreleased").pipe(
59
+ const unreleasedOption = Options.Boolean("unreleased").pipe(
60
60
  Options.withDescription("Only list unreleased versions"),
61
61
  Options.withDefault(false)
62
62
  )
63
- const jsonOption = Options.boolean("json").pipe(
63
+ const jsonOption = Options.Boolean("json").pipe(
64
64
  Options.withDescription("Output as JSON"),
65
65
  Options.withDefault(false)
66
66
  )
67
- const emailsOption = Options.boolean("emails").pipe(
67
+ const emailsOption = Options.Boolean("emails").pipe(
68
68
  Options.withDescription("Include resolved user email addresses in --json output"),
69
69
  Options.withDefault(false)
70
70
  )
71
- const customFieldOption = Options.string("custom-field").pipe(
71
+ const customFieldOption = Options.String("custom-field").pipe(
72
72
  Options.withDescription(
73
73
  "Custom field display name to include on each ticket (repeatable, e.g. " +
74
74
  "--custom-field \"Security & Compliance Impact\"). Values are exposed in " +
@@ -76,13 +76,13 @@ const customFieldOption = Options.string("custom-field").pipe(
76
76
  ),
77
77
  Options.atLeast(0)
78
78
  )
79
- const maxOption = Options.integer("max").pipe(
79
+ const maxOption = Options.Int("max").pipe(
80
80
  Options.withAlias("m"),
81
81
  Options.withDescription("Maximum number of versions to fetch (default: all)"),
82
82
  Options.optional
83
83
  )
84
84
 
85
- const idArg = Args.string("id").pipe(Args.withDescription("Version id (numeric)"))
85
+ const idArg = Args.String("id").pipe(Args.withDescription("Version id (numeric)"))
86
86
 
87
87
  const listCommand = Command.make("list", {
88
88
  project: projectOption,
@@ -95,17 +95,15 @@ const listCommand = Command.make("list", {
95
95
  }, ({ customFields, emails, json, max, project, released, unreleased }) =>
96
96
  Effect.gen(function*() {
97
97
  if (released && unreleased) {
98
- return yield* Effect.fail(
99
- new JiraApiError({
100
- message: "--released and --unreleased are mutually exclusive; pass at most one (omit both to list all)."
101
- })
102
- )
98
+ return yield* new JiraApiError({
99
+ message: "--released and --unreleased are mutually exclusive; pass at most one (omit both to list all)."
100
+ })
103
101
  }
104
102
  const service = yield* VersionService
105
103
  const versions = yield* service.listProjectVersions(project, {
106
104
  released,
107
105
  unreleased,
108
- ...(Option.isSome(max) ? { maxResults: max.value } : {}),
106
+ ...((Option.isSome(max)) && { maxResults: max.value }),
109
107
  customFieldNames: customFields
110
108
  })
111
109
  if (json) {
@@ -169,11 +167,85 @@ const formatTicketKeys = (tickets: Version["tickets"]): string => {
169
167
  return remaining > 0 ? `${shown} (+${remaining} more)` : shown
170
168
  }
171
169
 
172
- const descriptionOption = Options.string("description").pipe(
170
+ const descriptionOption = Options.String("description").pipe(
173
171
  Options.withAlias("d"),
174
172
  Options.withDescription("New version description")
175
173
  )
176
174
 
175
+ // === create ===
176
+
177
+ /**
178
+ * Jira accepts version dates only as ISO 8601 `yyyy-mm-dd`. Anything else — a
179
+ * locale format, a timestamp — comes back as a generic 400 that does not name the
180
+ * offending field, so reject it locally where the message can.
181
+ */
182
+ export const isIsoDate = (value: string): boolean => {
183
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false
184
+ // Reject real-looking but non-existent dates (2026-02-30): round-tripping
185
+ // through Date is the cheapest calendar check.
186
+ const parsed = new Date(`${value}T00:00:00Z`)
187
+ return !Number.isNaN(parsed.getTime()) && parsed.toISOString().slice(0, 10) === value
188
+ }
189
+
190
+ const ensureIsoDate = (flag: string, value: string): Effect.Effect<void, JiraApiError> =>
191
+ isIsoDate(value) ? Effect.void : Effect.fail(
192
+ new JiraApiError({ message: `Invalid --${flag} "${value}". Expected an ISO 8601 date (yyyy-mm-dd).` })
193
+ )
194
+
195
+ // No `-n` alias: it means `--dry-run` everywhere else in these CLIs, and both
196
+ // `getLayerType` implementations route on `argv.includes("-n")`. Reusing it for
197
+ // a value on a remote-write command is the wrong default.
198
+ const nameOption = Options.String("name").pipe(
199
+ Options.withDescription("Version name (e.g. \"OOB 100\")")
200
+ )
201
+ const optionalDescriptionOption = Options.String("description").pipe(
202
+ Options.withAlias("d"),
203
+ Options.withDescription("Version description"),
204
+ Options.optional
205
+ )
206
+ const startDateOption = Options.String("start-date").pipe(
207
+ Options.withDescription("Start date, ISO 8601 (yyyy-mm-dd)"),
208
+ Options.optional
209
+ )
210
+ const releaseDateOption = Options.String("release-date").pipe(
211
+ Options.withDescription("Release date, ISO 8601 (yyyy-mm-dd)"),
212
+ Options.optional
213
+ )
214
+
215
+ const createCommand = Command.make("create", {
216
+ project: projectOption,
217
+ name: nameOption,
218
+ description: optionalDescriptionOption,
219
+ startDate: startDateOption,
220
+ releaseDate: releaseDateOption,
221
+ json: jsonOption
222
+ }, ({ description, json, name, project, releaseDate, startDate }) =>
223
+ Effect.gen(function*() {
224
+ if (Option.isSome(startDate)) yield* ensureIsoDate("start-date", startDate.value)
225
+ if (Option.isSome(releaseDate)) yield* ensureIsoDate("release-date", releaseDate.value)
226
+
227
+ const service = yield* VersionService
228
+ const version = yield* service.createVersion({
229
+ projectKey: project,
230
+ name,
231
+ ...((Option.isSome(description)) && { description: description.value }),
232
+ ...((Option.isSome(startDate)) && { startDate: startDate.value }),
233
+ ...((Option.isSome(releaseDate)) && { releaseDate: releaseDate.value })
234
+ })
235
+ if (json) {
236
+ yield* Console.log(JSON.stringify(version, null, 2))
237
+ return
238
+ }
239
+ yield* Console.log(`Created version ${version.name} (${version.id}) on ${project}`)
240
+ yield* Console.log(`startDate: ${version.startDate ?? "-"}`)
241
+ yield* Console.log(`releaseDate: ${version.releaseDate ?? "-"}`)
242
+ yield* Console.log(`description: ${version.description ?? "-"}`)
243
+ })).pipe(
244
+ Command.withDescription(
245
+ "Remote write: create a new unreleased version on a project (requires manage:jira-project scope)"
246
+ )
247
+ )
248
+
177
249
  const updateCommand = Command.make("update", { id: idArg, description: descriptionOption, json: jsonOption }, ({
178
250
  description,
179
251
  id,
@@ -195,15 +267,15 @@ const updateCommand = Command.make("update", { id: idArg, description: descripti
195
267
 
196
268
  // === related-work ===
197
269
 
198
- const titleOption = Options.string("title").pipe(
270
+ const titleOption = Options.String("title").pipe(
199
271
  Options.withAlias("t"),
200
272
  Options.withDescription("Related-work link title (e.g. \"Release notes\")")
201
273
  )
202
- const urlOption = Options.string("url").pipe(
274
+ const urlOption = Options.String("url").pipe(
203
275
  Options.withAlias("u"),
204
276
  Options.withDescription("Related-work link URL (e.g. a Confluence page)")
205
277
  )
206
- const categoryOption = Options.string("category").pipe(
278
+ const categoryOption = Options.String("category").pipe(
207
279
  Options.withAlias("c"),
208
280
  Options.withDescription("Related-work category (Jira groups by this; e.g. Communication, Testing, Design)"),
209
281
  Options.withDefault("Communication")
@@ -256,12 +328,93 @@ const relatedWorkAddCommand = Command.make("add", {
256
328
  )
257
329
  )
258
330
 
331
+ const linkOption = Options.String("link").pipe(
332
+ Options.withAlias("l"),
333
+ Options.withDescription("Desired link as `title=url` (repeatable). Category comes from --category."),
334
+ Options.atLeast(0)
335
+ )
336
+
337
+ const pruneOption = Options.Boolean("prune").pipe(
338
+ Options.withDescription("Also remove links in the category that are not in the desired set"),
339
+ Options.withDefault(false)
340
+ )
341
+
342
+ /**
343
+ * Reconcile a version's related-work links against a desired set.
344
+ *
345
+ * Re-running a release scaffold should not pile up duplicate "Release notes"
346
+ * links, which is what repeated `add` calls produce. Matching is by URL, the
347
+ * only stable identity a link has — Jira assigns the id and the title is
348
+ * editable.
349
+ */
350
+ const relatedWorkSyncCommand = Command.make("sync", {
351
+ id: idArg,
352
+ link: linkOption,
353
+ category: categoryOption,
354
+ prune: pruneOption,
355
+ json: jsonOption
356
+ }, ({ category, id, json, link, prune }) =>
357
+ Effect.gen(function*() {
358
+ yield* ensureNumericId(id)
359
+
360
+ const desired: Array<{ readonly title: string; readonly url: string }> = []
361
+ for (const raw of link) {
362
+ const separator = raw.indexOf("=")
363
+ // Check the trimmed halves, not the separator position: `" =url"` and
364
+ // `"title= "` both put a non-empty span either side of the `=` and would
365
+ // otherwise reach Jira as a link with an empty title or url.
366
+ const title = separator < 0 ? "" : raw.slice(0, separator).trim()
367
+ const url = separator < 0 ? "" : raw.slice(separator + 1).trim()
368
+ if (separator < 0 || title.length === 0 || url.length === 0) {
369
+ return yield* new JiraApiError({ message: `Invalid --link ${JSON.stringify(raw)}. Expected title=url.` })
370
+ }
371
+ desired.push({ title, url })
372
+ }
373
+ if (desired.length === 0) {
374
+ return yield* new JiraApiError({ message: "Pass at least one --link title=url." })
375
+ }
376
+
377
+ const service = yield* VersionService
378
+ const existing = yield* service.listRelatedWork(id)
379
+ const plan = planRelatedWorkSync(existing, desired, { category, prune })
380
+
381
+ for (const item of plan.toAdd) {
382
+ yield* service.addRelatedWork(id, { title: item.title, category, url: item.url })
383
+ }
384
+ for (const item of plan.toRemove) {
385
+ yield* service.deleteRelatedWork(id, item.relatedWorkId)
386
+ }
387
+
388
+ const added = plan.toAdd.map((d) => d.url)
389
+ const removed = plan.toRemove.map((d) => d.url)
390
+ if (json) {
391
+ yield* Console.log(JSON.stringify({ added, kept: plan.kept, removed }, null, 2))
392
+ return
393
+ }
394
+ yield* Console.log(
395
+ `Version ${id} (${category}): ${added.length} added, ${plan.kept.length} unchanged, ${removed.length} removed`
396
+ )
397
+ for (const url of added) yield* Console.log(` + ${url}`)
398
+ for (const url of removed) yield* Console.log(` - ${url}`)
399
+ })).pipe(
400
+ // The help text is what a user reads before a remote write, so it has to
401
+ // say that pruning is opt-in: without --prune nothing is ever removed, and
402
+ // "reconcile to exactly the given set" reads as a promise that stale links
403
+ // were cleaned up.
404
+ Command.withDescription(
405
+ "Remote write: add any missing related-work links in a category, matched by URL (idempotent); " +
406
+ "pass --prune to also remove links that are not in the given set"
407
+ )
408
+ )
409
+
259
410
  const relatedWorkCommand = Command.make("related-work").pipe(
260
- Command.withDescription("List or attach version related-work links (Confluence pages on the release report)"),
261
- Command.withSubcommands([relatedWorkListCommand, relatedWorkAddCommand])
411
+ Command.withDescription(
412
+ "List, attach or reconcile version related-work links (Confluence pages on the release report)"
413
+ ),
414
+ Command.withSubcommands([relatedWorkListCommand, relatedWorkAddCommand, relatedWorkSyncCommand])
262
415
  )
263
416
 
264
417
  export const versionCommand = Command.make("version").pipe(
265
418
  Command.withDescription("Jira version commands"),
266
- Command.withSubcommands([listCommand, getCommand, updateCommand, relatedWorkCommand])
419
+ Command.withSubcommands([listCommand, getCommand, createCommand, updateCommand, relatedWorkCommand])
267
420
  )
package/src/index.ts CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  export {
8
8
  AttachmentService,
9
- type AttachmentServiceShape,
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 IssueServiceShape,
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 MarkdownWriterShape } from "./MarkdownWriter.js"
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
- (port) => NodeHttpServer.layerServer(createServer, { port })
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 && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {}
28
+ return parsed !== null && Predicate.isObjectOrArray(parsed) && !Array.isArray(parsed) ? parsed : {}
28
29
  },
29
- stringify: (data: object): string => yaml.dump(data)
30
+ stringify: <Data extends object>(data: Data): string => yaml.dump(data)
30
31
  }
31
32
 
32
33
  /**