@knpkv/jira-api-client 0.3.1 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +88 -65
  3. package/dist/JiraApiClient.d.ts +20 -86
  4. package/dist/JiraApiClient.d.ts.map +1 -1
  5. package/dist/JiraApiClient.js +26 -76
  6. package/dist/JiraApiClient.js.map +1 -1
  7. package/dist/generated/JiraApi.d.ts +103971 -0
  8. package/dist/generated/JiraApi.d.ts.map +1 -0
  9. package/dist/generated/JiraApi.js +7608 -0
  10. package/dist/generated/JiraApi.js.map +1 -0
  11. package/dist/index.d.ts +3 -12
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +3 -11
  14. package/dist/index.js.map +1 -1
  15. package/package.json +13 -16
  16. package/scripts/regenerate.ts +209 -202
  17. package/scripts/tsconfig.json +13 -0
  18. package/src/JiraApiClient.ts +74 -105
  19. package/src/generated/JiraApi.ts +19596 -0
  20. package/src/index.ts +3 -14
  21. package/test/JiraApiClient.test.ts +155 -137
  22. package/.specs/VERSION_V3 +0 -1
  23. package/.specs/jira-v3-1001.0.0-SNAPSHOT-05f20e18d9bde3de68f911b4915970482f536d4e.json +0 -56275
  24. package/.specs/jira-v3-1001.0.0-SNAPSHOT-d7cd03e966bb408ad32ffe870114289c528f56fd.json +0 -56129
  25. package/.specs/jira-v3-1001.0.0-SNAPSHOT-ea9913a8c4412d2bfb1911480b023a04544e19d8.json +0 -55618
  26. package/dist/OpenApiFetchClient.d.ts +0 -55
  27. package/dist/OpenApiFetchClient.d.ts.map +0 -1
  28. package/dist/OpenApiFetchClient.js +0 -63
  29. package/dist/OpenApiFetchClient.js.map +0 -1
  30. package/dist/generated/v3/index.d.ts +0 -7
  31. package/dist/generated/v3/index.d.ts.map +0 -1
  32. package/dist/generated/v3/index.js +0 -2
  33. package/dist/generated/v3/index.js.map +0 -1
  34. package/dist/generated/v3/schema.d.ts +0 -64454
  35. package/src/OpenApiFetchClient.ts +0 -92
  36. package/src/generated/v3/index.ts +0 -6
  37. package/src/generated/v3/schema.d.ts +0 -64454
  38. package/tsconfig.tsbuildinfo +0 -1
package/dist/index.d.ts CHANGED
@@ -1,18 +1,9 @@
1
1
  /**
2
- * Root barrel export for `@knpkv/jira-api-client` -- openapi-fetch + Effect Jira REST client.
3
- *
4
- * **Mental model**
5
- *
6
- * - **Namespaced version**: `V3` re-exports the generated OpenAPI types (paths, components, operations).
7
- * - **Config + client split**: {@link JiraApiConfig} is provided separately from the
8
- * generated client to support auth-polymorphic usage.
9
- * - **openapi-fetch wrapper**: {@link OpenApiFetchClient} provides Effect-based `execute` method
10
- * around raw openapi-fetch clients.
2
+ * Schema-validated Effect client for Jira Cloud REST API v3.
11
3
  *
12
4
  * @packageDocumentation
13
5
  */
14
- export { JiraApiClient, type JiraApiClientShape, layer } from "./JiraApiClient.js";
6
+ export { JiraApiClient, type JiraApiClientShape, layer, make, type UploadAttachmentInput } from "./JiraApiClient.js";
15
7
  export { JiraApiConfig, type JiraApiConfigShape } from "./JiraApiConfig.js";
16
- export { FetchClientError, type OpenApiFetchClient, type SuccessData, toEffect } from "./OpenApiFetchClient.js";
17
- export type * as V3 from "./generated/v3/index.js";
8
+ export * as JiraApi from "./generated/JiraApi.js";
18
9
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,EAAE,aAAa,EAAE,KAAK,kBAAkB,EAAE,KAAK,EAAE,MAAM,oBAAoB,CAAA;AAElF,OAAO,EAAE,aAAa,EAAE,KAAK,kBAAkB,EAAE,MAAM,oBAAoB,CAAA;AAE3E,OAAO,EAAE,gBAAgB,EAAE,KAAK,kBAAkB,EAAE,KAAK,WAAW,EAAE,QAAQ,EAAE,MAAM,yBAAyB,CAAA;AAG/G,YAAY,KAAK,EAAE,MAAM,yBAAyB,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAE,aAAa,EAAE,KAAK,kBAAkB,EAAE,KAAK,EAAE,IAAI,EAAE,KAAK,qBAAqB,EAAE,MAAM,oBAAoB,CAAA;AAEpH,OAAO,EAAE,aAAa,EAAE,KAAK,kBAAkB,EAAE,MAAM,oBAAoB,CAAA;AAE3E,OAAO,KAAK,OAAO,MAAM,wBAAwB,CAAA"}
package/dist/index.js CHANGED
@@ -1,17 +1,9 @@
1
1
  /**
2
- * Root barrel export for `@knpkv/jira-api-client` -- openapi-fetch + Effect Jira REST client.
3
- *
4
- * **Mental model**
5
- *
6
- * - **Namespaced version**: `V3` re-exports the generated OpenAPI types (paths, components, operations).
7
- * - **Config + client split**: {@link JiraApiConfig} is provided separately from the
8
- * generated client to support auth-polymorphic usage.
9
- * - **openapi-fetch wrapper**: {@link OpenApiFetchClient} provides Effect-based `execute` method
10
- * around raw openapi-fetch clients.
2
+ * Schema-validated Effect client for Jira Cloud REST API v3.
11
3
  *
12
4
  * @packageDocumentation
13
5
  */
14
- export { JiraApiClient, layer } from "./JiraApiClient.js";
6
+ export { JiraApiClient, layer, make } from "./JiraApiClient.js";
15
7
  export { JiraApiConfig } from "./JiraApiConfig.js";
16
- export { FetchClientError, toEffect } from "./OpenApiFetchClient.js";
8
+ export * as JiraApi from "./generated/JiraApi.js";
17
9
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,EAAE,aAAa,EAA2B,KAAK,EAAE,MAAM,oBAAoB,CAAA;AAElF,OAAO,EAAE,aAAa,EAA2B,MAAM,oBAAoB,CAAA;AAE3E,OAAO,EAAE,gBAAgB,EAA6C,QAAQ,EAAE,MAAM,yBAAyB,CAAA"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAE,aAAa,EAA2B,KAAK,EAAE,IAAI,EAA8B,MAAM,oBAAoB,CAAA;AAEpH,OAAO,EAAE,aAAa,EAA2B,MAAM,oBAAoB,CAAA;AAE3E,OAAO,KAAK,OAAO,MAAM,wBAAwB,CAAA"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@knpkv/jira-api-client",
3
- "version": "0.3.1",
3
+ "version": "1.0.0",
4
4
  "description": "Effect-based Jira Cloud REST API v3 client",
5
5
  "license": "MIT",
6
6
  "author": "knpkv",
@@ -30,27 +30,27 @@
30
30
  "import": "./dist/JiraApiConfig.js",
31
31
  "types": "./dist/JiraApiConfig.d.ts"
32
32
  },
33
- "./v3": {
34
- "import": "./dist/generated/v3/index.js",
35
- "types": "./dist/generated/v3/index.d.ts"
33
+ "./generated": {
34
+ "import": "./dist/generated/JiraApi.js",
35
+ "types": "./dist/generated/JiraApi.d.ts"
36
36
  }
37
37
  },
38
38
  "publishConfig": {
39
39
  "access": "public"
40
40
  },
41
41
  "peerDependencies": {
42
- "effect": "4.0.0-beta.93"
42
+ "effect": "4.0.0-beta.98"
43
43
  },
44
44
  "devDependencies": {
45
- "@effect/platform-node": "4.0.0-beta.93",
46
- "@effect/vitest": "4.0.0-beta.93",
45
+ "@effect/openapi-generator": "4.0.0-beta.98",
46
+ "@effect/platform-node": "4.0.0-beta.98",
47
+ "@effect/vitest": "4.0.0-beta.98",
47
48
  "@types/node": "latest",
48
- "effect": "4.0.0-beta.93",
49
- "openapi-typescript": "^7.13.0",
50
- "openapi-typescript-helpers": "^0.1.0",
49
+ "effect": "4.0.0-beta.98",
50
+ "prettier": "^3.9.5",
51
51
  "tsx": "^4.23.0",
52
52
  "typescript": "~6.0.3",
53
- "vitest": "^4.1.9"
53
+ "vitest": "^4.1.10"
54
54
  },
55
55
  "keywords": [
56
56
  "effect",
@@ -60,17 +60,14 @@
60
60
  "api-client",
61
61
  "openapi"
62
62
  ],
63
- "dependencies": {
64
- "openapi-fetch": "^0.17.0"
65
- },
66
63
  "scripts": {
67
- "build": "tsc && cp src/generated/v3/schema.d.ts dist/generated/v3/schema.d.ts",
64
+ "build": "tsc",
68
65
  "clean": "rimraf dist dist-test .tsbuildinfo",
69
66
  "test": "vitest run",
70
67
  "test:watch": "vitest",
71
68
  "lint": "eslint src",
72
69
  "lint:fix": "eslint src --fix",
73
- "check": "tsc --noEmit",
70
+ "check": "tsc --noEmit && tsc -p scripts/tsconfig.json",
74
71
  "regenerate": "tsx scripts/regenerate.ts",
75
72
  "regenerate:check": "tsx scripts/regenerate.ts --check"
76
73
  }
@@ -1,39 +1,30 @@
1
1
  #!/usr/bin/env tsx
2
2
  /**
3
- * Regeneration script for Jira API types.
3
+ * Fetch, compare, patch, normalize, and regenerate the Jira Cloud API client.
4
4
  *
5
- * Fetches OpenAPI specs from Atlassian and regenerates openapi-typescript types.
5
+ * The committed OpenAPI document is the unmodified upstream document. RFC 6902
6
+ * patches and deterministic compatibility normalizations are applied in memory.
6
7
  */
7
- import { Command, Flag as Options } from "effect/unstable/cli"
8
- import { NodeRuntime, NodeServices } from "@effect/platform-node"
9
- import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient"
8
+ import * as OpenApiGenerator from "@effect/openapi-generator/OpenApiGenerator"
9
+ import * as OpenApiPatch from "@effect/openapi-generator/OpenApiPatch"
10
+ import { NodeHttpClient, NodeRuntime, NodeServices } from "@effect/platform-node"
10
11
  import * as Console from "effect/Console"
11
12
  import * as Data from "effect/Data"
12
13
  import * as Effect from "effect/Effect"
13
14
  import * as FileSystem from "effect/FileSystem"
15
+ import type * as JsonSchema from "effect/JsonSchema"
16
+ import * as Layer from "effect/Layer"
14
17
  import * as Path from "effect/Path"
15
- import * as PlatformError from "effect/PlatformError"
18
+ import * as Predicate from "effect/Predicate"
16
19
  import * as Schema from "effect/Schema"
17
- import { HttpClient } from "effect/unstable/http"
18
- import * as ChildProcess from "effect/unstable/process/ChildProcess"
19
- import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"
20
- import pkg from "../package.json" with { type: "json" }
21
-
22
- const SPEC_URLS = {
23
- v3: "https://dac-static.atlassian.com/cloud/jira/platform/swagger-v3.v3.json"
24
- } as const
25
-
26
- interface SpecInfo {
27
- version: string
28
- title: string
29
- }
20
+ import * as Command from "effect/unstable/cli/Command"
21
+ import * as Flag from "effect/unstable/cli/Flag"
22
+ import * as HttpClient from "effect/unstable/http/HttpClient"
23
+ import type { OpenAPISpec } from "effect/unstable/httpapi/OpenApi"
24
+ import { format as formatSource } from "prettier"
30
25
 
31
- interface ScriptPaths {
32
- readonly packageDir: string
33
- readonly specsDir: string
34
- readonly versionV3File: string
35
- readonly generatedDir: string
36
- }
26
+ const SPEC_URL = "https://dac-static.atlassian.com/cloud/jira/platform/swagger-v3.v3.json"
27
+ const METHODS = new Set(["get", "put", "post", "delete", "options", "head", "patch", "trace"])
37
28
 
38
29
  class RegenerateError extends Data.TaggedError("RegenerateError")<{
39
30
  readonly message: string
@@ -42,208 +33,224 @@ class RegenerateError extends Data.TaggedError("RegenerateError")<{
42
33
 
43
34
  const JsonString = Schema.fromJsonString(Schema.Json)
44
35
 
45
- const SpecInfoResponse = Schema.Struct({
46
- info: Schema.Struct({
47
- version: Schema.String,
48
- title: Schema.String
49
- })
50
- })
51
-
52
- const fetchText = (url: string): Effect.Effect<string, RegenerateError, HttpClient.HttpClient> =>
53
- HttpClient.get(url).pipe(
54
- Effect.flatMap((response) =>
55
- response.status >= 200 && response.status < 300
56
- ? response.text
57
- : Effect.fail(new RegenerateError({ message: `Failed to fetch ${url}`, cause: { status: response.status } }))
58
- ),
59
- Effect.mapError((cause) => new RegenerateError({ message: `Fetch failed: ${url}`, cause }))
60
- )
36
+ const OpenApiDocument = Schema.declare<OpenAPISpec>(
37
+ (value): value is OpenAPISpec =>
38
+ Predicate.hasProperty(value, "paths") &&
39
+ Predicate.isObject(value.paths) &&
40
+ Predicate.hasProperty(value, "openapi"),
41
+ { identifier: "OpenApiDocument" }
42
+ )
61
43
 
62
- const fetchSpecInfo = (url: string): Effect.Effect<SpecInfo, RegenerateError, HttpClient.HttpClient> =>
63
- fetchText(url).pipe(
64
- Effect.flatMap(Schema.decodeUnknownEffect(Schema.fromJsonString(SpecInfoResponse))),
65
- Effect.map(({ info }) => ({ version: info.version, title: info.title })),
66
- Effect.mapError((cause) => new RegenerateError({ message: "JSON decode failed", cause }))
67
- )
44
+ const SpecInfo = Schema.Struct({ info: Schema.Struct({ version: Schema.String }) })
68
45
 
69
- const formatJson = (text: string): Effect.Effect<string, RegenerateError> =>
70
- Schema.decodeUnknownEffect(JsonString)(text).pipe(
71
- Effect.flatMap(Schema.encodeEffect(JsonString)),
72
- Effect.map((encoded) => `${encoded}\n`),
73
- Effect.mapError((cause) => new RegenerateError({ message: "JSON format failed", cause }))
74
- )
46
+ interface ScriptPaths {
47
+ readonly specFile: string
48
+ readonly patchFile: string
49
+ readonly outputFile: string
50
+ }
75
51
 
76
- const scriptPaths: Effect.Effect<ScriptPaths, RegenerateError, Path.Path> = Effect.gen(function*() {
52
+ const paths = Effect.gen(function*() {
77
53
  const path = yield* Path.Path
78
54
  const scriptFile = yield* path.fromFileUrl(new URL(import.meta.url)).pipe(
79
- Effect.mapError((cause) => new RegenerateError({ message: "Script path resolution failed", cause }))
55
+ Effect.mapError((cause) => new RegenerateError({ message: "Could not resolve script path", cause }))
80
56
  )
81
- const scriptDir = path.dirname(scriptFile)
82
- const packageDir = path.join(scriptDir, "..")
57
+ const packageDir = path.join(path.dirname(scriptFile), "..")
83
58
  const specsDir = path.join(packageDir, ".specs")
84
-
85
59
  return {
86
- packageDir,
87
- specsDir,
88
- versionV3File: path.join(specsDir, "VERSION_V3"),
89
- generatedDir: path.join(packageDir, "src", "generated", "v3")
90
- }
60
+ specFile: path.join(specsDir, "jira-v3.json"),
61
+ patchFile: path.join(specsDir, "jira-v3.patch.json"),
62
+ outputFile: path.join(packageDir, "src", "generated", "JiraApi.ts")
63
+ } satisfies ScriptPaths
91
64
  })
92
65
 
93
- const fetchAndSaveSpec = (
94
- packageDir: string,
95
- url: string,
96
- outputPath: string
97
- ): Effect.Effect<
98
- void,
99
- RegenerateError,
100
- ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | HttpClient.HttpClient
101
- > =>
102
- Effect.gen(function*() {
103
- const fs = yield* FileSystem.FileSystem
104
- const spec = yield* fetchText(url)
105
- const formattedSpec = yield* formatJson(spec)
106
- yield* fs.writeFileString(outputPath, formattedSpec).pipe(
107
- Effect.mapError((cause) => new RegenerateError({ message: "Save failed", cause }))
108
- )
109
- yield* formatFile(packageDir, outputPath)
110
- })
66
+ const fetchUpstream = HttpClient.get(SPEC_URL).pipe(
67
+ Effect.flatMap((response) =>
68
+ response.status >= 200 && response.status < 300
69
+ ? response.text.pipe(
70
+ Effect.mapError((cause) => new RegenerateError({ message: "Could not read Jira response", cause }))
71
+ )
72
+ : Effect.fail(new RegenerateError({ message: `Jira spec request failed with status ${response.status}` }))
73
+ ),
74
+ Effect.flatMap(Schema.decodeUnknownEffect(JsonString)),
75
+ Effect.mapError((cause) => new RegenerateError({ message: "Could not fetch or decode Jira spec", cause }))
76
+ )
111
77
 
112
- const readVersion = (file: string): Effect.Effect<string | null, RegenerateError, FileSystem.FileSystem> =>
113
- Effect.gen(function*() {
114
- const fs = yield* FileSystem.FileSystem
115
- const exists = yield* fs.exists(file).pipe(
116
- Effect.mapError((cause) => new RegenerateError({ message: "Version lookup failed", cause }))
117
- )
118
- if (!exists) return null
119
-
120
- return yield* fs.readFileString(file).pipe(
121
- Effect.map((version) => version.trim()),
122
- Effect.mapError((cause) => new RegenerateError({ message: "Version read failed", cause }))
123
- )
124
- })
78
+ const canonicalJson = (value: Schema.Json) =>
79
+ Schema.encodeEffect(JsonString)(value).pipe(
80
+ Effect.map((json) => `${json}\n`),
81
+ Effect.mapError((cause) => new RegenerateError({ message: "Could not encode Jira spec", cause }))
82
+ )
125
83
 
126
- const writeVersion = (file: string, version: string): Effect.Effect<void, RegenerateError, FileSystem.FileSystem> =>
127
- Effect.gen(function*() {
128
- const fs = yield* FileSystem.FileSystem
129
- yield* fs.writeFileString(file, version).pipe(
130
- Effect.mapError((cause) => new RegenerateError({ message: "Version write failed", cause }))
131
- )
132
- })
84
+ const formattedJson = (value: Schema.Json) =>
85
+ Schema.encodeEffect(JsonString)(value).pipe(
86
+ Effect.flatMap((source) =>
87
+ Effect.tryPromise({
88
+ try: () => formatSource(source, { parser: "json", printWidth: 120, trailingComma: "none", semi: false }),
89
+ catch: (cause) => new RegenerateError({ message: "Could not format Jira spec", cause })
90
+ })
91
+ ),
92
+ Effect.mapError((cause) => new RegenerateError({ message: "Could not format Jira spec", cause }))
93
+ )
133
94
 
134
- const ensureDir = (dir: string): Effect.Effect<void, RegenerateError, FileSystem.FileSystem> =>
135
- Effect.gen(function*() {
136
- const fs = yield* FileSystem.FileSystem
137
- yield* fs.makeDirectory(dir, { recursive: true }).pipe(
138
- Effect.mapError((cause) => new RegenerateError({ message: "Directory creation failed", cause }))
139
- )
140
- })
95
+ /**
96
+ * Jira marks mixed objects as open. The generator's intersection type cannot
97
+ * combine an unrestricted JSON index signature with optional known fields, so
98
+ * close only those mixed objects. Pure map schemas such as IssueBean.fields
99
+ * retain their schema-valued additionalProperties and remain dynamic.
100
+ *
101
+ * Jira also publishes defaults that contradict enum/array schemas. Defaults
102
+ * and examples are documentation metadata, not response-validation rules.
103
+ */
104
+ const normalizeSchema = (schema: JsonSchema.JsonSchema): JsonSchema.JsonSchema => {
105
+ const { default: _default, examples: _examples, ...normalized } = schema
106
+ return normalized.additionalProperties === true && Predicate.isObject(normalized.properties)
107
+ ? { ...normalized, additionalProperties: false }
108
+ : normalized
109
+ }
141
110
 
142
- const generateTypes = (
143
- packageDir: string,
144
- generatedDir: string,
145
- specPath: string
146
- ): Effect.Effect<void, RegenerateError, ChildProcessSpawner.ChildProcessSpawner | Path.Path> =>
147
- Effect.gen(function*() {
148
- const path = yield* Path.Path
149
- const outputFile = path.join(generatedDir, "schema.d.ts")
150
- const command = ChildProcess.make("pnpm", ["exec", "openapi-typescript", specPath, "-o", outputFile], {
151
- cwd: packageDir,
152
- stdin: "inherit",
153
- stdout: "inherit",
154
- stderr: "inherit"
155
- })
156
- const exitCode = yield* commandExitCode(command).pipe(
157
- Effect.mapError((cause) => new RegenerateError({ message: "Type generation failed", cause }))
158
- )
111
+ const bodylessErrorContent: Schema.Json = {
112
+ "application/json": { schema: {} }
113
+ }
159
114
 
160
- if (exitCode !== 0) {
161
- return yield* Effect.fail(new RegenerateError({ message: "Type generation failed", cause: { exitCode } }))
162
- }
163
- })
115
+ /**
116
+ * A 204 response can never contain a message body, but Jira attaches empty JSON
117
+ * schemas to many of them. Remove that misleading content so the generator
118
+ * emits `void` handlers instead of JSON decoders.
119
+ *
120
+ * Atlassian also omits bodies from many documented 4xx/5xx responses. Without
121
+ * a content schema the generator treats those statuses as successful voids.
122
+ * Give them a permissive JSON body so they remain failures with status-bearing
123
+ * generated JiraApiError values.
124
+ */
125
+ const normalizeResponses = (document: Schema.Json): Schema.Json => {
126
+ if (!Predicate.isObject(document)) return document
127
+ const root = document
128
+ const rootPaths = root.paths
129
+ if (!Predicate.isObject(rootPaths)) return document
130
+
131
+ const normalizedPaths = Object.fromEntries(
132
+ Object.entries(rootPaths).map(([path, pathItem]) => {
133
+ if (!Predicate.isObject(pathItem)) return [path, pathItem]
134
+ const normalizedPathItem = Object.fromEntries(
135
+ Object.entries(pathItem).map(([key, operation]) => {
136
+ if (!METHODS.has(key) || !Predicate.isObject(operation) || !Predicate.isObject(operation.responses)) {
137
+ return [key, operation]
138
+ }
139
+ const responses = Object.fromEntries(
140
+ Object.entries(operation.responses).map(([status, response]) => {
141
+ const code = Number(status)
142
+ if (code === 204 && Predicate.isObject(response)) {
143
+ return [
144
+ status,
145
+ Object.fromEntries(
146
+ Object.entries(response).filter(([key]) => key !== "content")
147
+ )
148
+ ]
149
+ }
150
+ if (!Number.isInteger(code) || code < 400 || !Predicate.isObject(response) || "content" in response) {
151
+ return [status, response]
152
+ }
153
+ return [status, { ...response, content: bodylessErrorContent }]
154
+ })
155
+ )
156
+ return [key, { ...operation, responses }]
157
+ })
158
+ )
159
+ return [path, normalizedPathItem]
160
+ })
161
+ )
164
162
 
165
- const commandExitCode = (
166
- command: ChildProcess.Command
167
- ): Effect.Effect<ChildProcessSpawner.ExitCode, PlatformError.PlatformError, ChildProcessSpawner.ChildProcessSpawner> =>
168
- ChildProcessSpawner.ChildProcessSpawner.pipe(
169
- Effect.flatMap((spawner) => spawner.exitCode(command))
163
+ return Schema.decodeUnknownSync(Schema.Json)(
164
+ Object.fromEntries([...Object.entries(root), ["paths", normalizedPaths]])
170
165
  )
166
+ }
171
167
 
172
- const formatFile = (
173
- packageDir: string,
174
- file: string
175
- ): Effect.Effect<void, RegenerateError, ChildProcessSpawner.ChildProcessSpawner> =>
176
- Effect.gen(function*() {
177
- const command = ChildProcess.make("pnpm", ["exec", "prettier", "--write", file], {
178
- cwd: packageDir,
179
- stdin: "inherit",
180
- stdout: "inherit",
181
- stderr: "inherit"
182
- })
183
- const exitCode = yield* commandExitCode(command).pipe(
184
- Effect.mapError((cause) => new RegenerateError({ message: "Formatting failed", cause }))
185
- )
168
+ const stripTrailingWhitespace = (source: string): string => source.replace(/[ \t]+$/gm, "")
186
169
 
187
- if (exitCode !== 0) {
188
- return yield* Effect.fail(new RegenerateError({ message: "Formatting failed", cause: { exitCode } }))
189
- }
170
+ const generate = Effect.fn("Jira.regenerate.generate")(function*(scriptPaths: ScriptPaths, upstream: Schema.Json) {
171
+ const fs = yield* FileSystem.FileSystem
172
+ const patch = yield* OpenApiPatch.parsePatchInput(scriptPaths.patchFile).pipe(
173
+ Effect.mapError((cause) => new RegenerateError({ message: "Could not parse Jira JSON patch", cause }))
174
+ )
175
+ const patched = yield* OpenApiPatch.applyPatches(
176
+ [{ source: scriptPaths.patchFile, patch }],
177
+ upstream
178
+ ).pipe(
179
+ Effect.mapError((cause) => new RegenerateError({ message: "Could not apply Jira JSON patch", cause }))
180
+ )
181
+ const normalized = normalizeResponses(patched)
182
+ const document = yield* Schema.decodeUnknownEffect(OpenApiDocument)(normalized).pipe(
183
+ Effect.mapError((cause) => new RegenerateError({ message: "Normalized Jira document is not OpenAPI", cause }))
184
+ )
185
+ const generator = yield* OpenApiGenerator.OpenApiGenerator
186
+ const warnings: Array<OpenApiGenerator.OpenApiGeneratorWarning> = []
187
+ const generated = yield* generator.generate(document, {
188
+ name: "JiraApi",
189
+ format: "httpclient",
190
+ onEnter: normalizeSchema,
191
+ onWarning: (warning) => warnings.push(warning)
190
192
  })
193
+ yield* Effect.forEach(
194
+ warnings,
195
+ (warning) => Console.warn(`[${warning.code}] ${warning.method ?? ""} ${warning.path ?? ""}: ${warning.message}`),
196
+ { discard: true }
197
+ )
198
+ yield* fs.writeFileString(scriptPaths.outputFile, stripTrailingWhitespace(generated)).pipe(
199
+ Effect.mapError((cause) => new RegenerateError({ message: "Could not write generated Jira client", cause }))
200
+ )
201
+ })
191
202
 
192
- const checkOnly = Options.boolean("check").pipe(
193
- Options.withDescription("Check only, exit 1 if outdated"),
194
- Options.withDefault(false)
203
+ const check = Flag.boolean("check").pipe(
204
+ Flag.withDescription("Exit non-zero when the committed upstream spec differs from Jira")
195
205
  )
196
206
 
197
- const regenerate = Command.make("regenerate", { checkOnly }, ({ checkOnly }) =>
198
- Effect.gen(function*() {
199
- const path = yield* Path.Path
200
- const paths = yield* scriptPaths
201
-
202
- yield* Console.log("Fetching Jira API spec version...")
203
-
204
- const v3Info = yield* fetchSpecInfo(SPEC_URLS.v3)
205
- const currentV3 = yield* readVersion(paths.versionV3File)
206
-
207
- yield* Console.log(`V3: current=${currentV3 ?? "none"}, remote=${v3Info.version}`)
208
-
209
- const v3Changed = currentV3 !== v3Info.version
210
-
211
- if (!v3Changed) {
212
- yield* Console.log("Spec is up to date.")
213
- return
214
- }
215
-
216
- if (checkOnly) {
217
- yield* Console.log("Spec is outdated!")
218
- yield* Console.log(` V3: ${currentV3} -> ${v3Info.version}`)
219
- return yield* Effect.fail(new RegenerateError({ message: "Spec outdated" }))
220
- }
221
-
222
- yield* ensureDir(paths.specsDir)
223
- yield* ensureDir(paths.generatedDir)
224
-
225
- const specPath = path.join(paths.specsDir, `jira-v3-${v3Info.version}.json`)
226
-
227
- yield* Console.log(`Fetching V3 spec (${v3Info.version})...`)
228
- yield* fetchAndSaveSpec(paths.packageDir, SPEC_URLS.v3, specPath)
229
- yield* Console.log(`Saved: ${specPath}`)
230
-
231
- yield* Console.log("Generating types...")
232
- yield* generateTypes(paths.packageDir, paths.generatedDir, specPath)
233
- yield* Console.log(`Generated: src/generated/v3/schema.d.ts`)
207
+ const local = Flag.boolean("local").pipe(
208
+ Flag.withDescription("Regenerate from the committed spec without contacting Jira")
209
+ )
234
210
 
235
- yield* writeVersion(paths.versionV3File, v3Info.version)
236
- yield* Console.log("Done!")
237
- })
238
- ).pipe(Command.withDescription("Regenerate Jira API types from OpenAPI specs"))
211
+ const root = Command.make("jira-api-regenerate", { check, local }).pipe(
212
+ Command.withHandler(
213
+ Effect.fn("Jira.regenerate")(function*({ check, local }) {
214
+ const fs = yield* FileSystem.FileSystem
215
+ const scriptPaths = yield* paths
216
+ const upstream = local
217
+ ? yield* fs.readFileString(scriptPaths.specFile).pipe(
218
+ Effect.flatMap(Schema.decodeUnknownEffect(JsonString)),
219
+ Effect.mapError((cause) => new RegenerateError({ message: "Could not read committed Jira spec", cause }))
220
+ )
221
+ : yield* fetchUpstream
222
+ const encoded = yield* canonicalJson(upstream)
223
+ const current = yield* fs.readFileString(scriptPaths.specFile).pipe(
224
+ Effect.flatMap(Schema.decodeUnknownEffect(JsonString)),
225
+ Effect.flatMap(canonicalJson),
226
+ Effect.orElseSucceed(() => "")
227
+ )
228
+
229
+ if (check) {
230
+ if (local) return yield* new RegenerateError({ message: "--check and --local cannot be combined" })
231
+ if (current === encoded) return yield* Console.log("Jira OpenAPI spec is current.")
232
+ return yield* new RegenerateError({
233
+ message: "Jira OpenAPI spec changed; run `pnpm --filter @knpkv/jira-api-client regenerate`"
234
+ })
235
+ }
236
+
237
+ const { info } = yield* Schema.decodeUnknownEffect(SpecInfo)(upstream).pipe(
238
+ Effect.mapError((cause) => new RegenerateError({ message: "Jira spec has no version", cause }))
239
+ )
240
+ yield* fs.writeFileString(scriptPaths.specFile, yield* formattedJson(upstream))
241
+ yield* generate(scriptPaths, upstream)
242
+ yield* Console.log(`Generated Jira ${info.version} client from ${local ? "the committed spec" : SPEC_URL}`)
243
+ })
244
+ )
245
+ )
239
246
 
240
- const cli = Command.run(regenerate, {
241
- name: pkg.name,
242
- version: pkg.version
243
- })
247
+ const MainLayer = Layer.mergeAll(
248
+ OpenApiGenerator.layerTransformerSchema,
249
+ NodeHttpClient.layerFetch,
250
+ NodeServices.layer
251
+ )
244
252
 
245
- cli.pipe(
246
- Effect.provide(NodeHttpClient.layerFetch),
247
- Effect.provide(NodeServices.layer),
253
+ Command.run(root, { version: "1.0.0" }).pipe(
254
+ Effect.provide(MainLayer),
248
255
  NodeRuntime.runMain
249
256
  )
@@ -0,0 +1,13 @@
1
+ {
2
+ "extends": "../../../tsconfig.base.jsonc",
3
+ "compilerOptions": {
4
+ "composite": false,
5
+ "declaration": false,
6
+ "declarationMap": false,
7
+ "incremental": false,
8
+ "noEmit": true,
9
+ "rootDir": "..",
10
+ "types": ["node"]
11
+ },
12
+ "include": ["./**/*.ts"]
13
+ }