diff --git a/.github/workflows/validation.yml b/.github/workflows/validation.yml new file mode 100644 index 0000000..e788ece --- /dev/null +++ b/.github/workflows/validation.yml @@ -0,0 +1,55 @@ +name: Validation + +on: + pull_request: + push: + branches: + - "**" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test-and-build: + name: Test and build + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.33.0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Check formatting + run: pnpm run format:check + + - name: Lint + run: pnpm run lint + + - name: Typecheck + run: pnpm run typecheck + + - name: Test + run: pnpm test + + - name: Build + run: pnpm run build diff --git a/.gitignore b/.gitignore index 871805e..e7ee34d 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,6 @@ coverage/ dist/ node_modules/ *.tsbuildinfo -.idea \ No newline at end of file +.idea +.env +intercom-for-jira-export*.jsonl.gz \ No newline at end of file diff --git a/.prettierignore b/.prettierignore index 758cab9..ffa5238 100644 --- a/.prettierignore +++ b/.prettierignore @@ -2,3 +2,4 @@ coverage dist node_modules pnpm-lock.yaml +prd/ diff --git a/README.md b/README.md index e69de29..7a20d28 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,73 @@ +# Intercom for Jira Migration CLI + +`ifj` is a command line utility to support Cloud-to-Cloud migrations for Intercom for Jira Cloud (ifj). It exports Intercom for Jira Cloud data from a source Jira Cloud site and then imports it into a target site. + +## Requirements + +### Source Jira site + +- **Administrator user:** This user should have access to all Jira spaces on the source site. +- **Jira API token:** Follow the [Atlassian documentation on how to create a scoped Jira API token](https://support.atlassian.com/atlassian-account/docs/manage-api-tokens-for-your-atlassian-account/#Create-an-API-token-with-scopes) for the administrator user. Include the following Classic scopes: + - `read:jira-work` + +### Target Jira site + +- **Administrator user:** This user should have access to all Jira spaces on the target site. +- **Jira API token:** Follow the [Atlassian documentation on how to create a scoped Jira API token](https://support.atlassian.com/atlassian-account/docs/manage-api-tokens-for-your-atlassian-account/#Create-an-API-token-with-scopes) for the administrator user. Including the following Classic scope: + - `write:jira-work` + +If the user has access to both the source and target sites, you can include both source and target scopes in the same API token and use the same token for both import and export. + +## Commands + +```sh +ifj export --source https://example.atlassian.net --user admin@example.com --api-token "$TOKEN" +ifj inspect intercom-for-jira-export.jsonl.gz +``` + +### Export + +`ifj export` authenticates with Jira Cloud basic auth using an Atlassian account +email and API token. It verifies that the user associated with the API token has Jira global admin permission before exporting. + +By default, export discovers spaces that are currently connected to Intercom. If +none are found, export fails with a "nothing to export" message; pass +`--space` to select spaces explicitly. Explicit spaces are validated for +existence and may be exported even when no Intercom configuration exists. + +Flags: + +- `--source URL`: source Jira Cloud URL +- `--user EMAIL`: Atlassian account email +- `--api-token TOKEN`: Atlassian API token +- `--out PATH`: optional output file path. Must end with `.jsonl.gz`. + Defaults to `intercom-for-jira-export.jsonl.gz` in the current working directory. + If the file exists the app will pick a unique name, e.g., `intercom-for-jira-export1.jsonl.gz`. +- `--space KEY`: optional space key. Repeat to select multiple spaces +- `--json`: print the final summary as JSON + +Environment variables: + +- `EXPORT_SOURCE` +- `EXPORT_USER` +- `EXPORT_API_TOKEN` +- `EXPORT_OUT` +- `EXPORT_SPACES`: comma-separated space keys. + +Exports are written as compressed JSON Lines files with the `.jsonl.gz` +extension. + +### Inspect + +`ifj inspect ` validates a `.jsonl.gz` artifact and prints aggregate +counts: + +```sh +ifj inspect migration.jsonl.gz +ifj inspect migration.jsonl.gz --json +``` + +## Configuration + +Configuration precedence is flags, then process environment, then `.env` from +the current working directory, then defaults. diff --git a/docs/artifact.md b/docs/artifact.md new file mode 100644 index 0000000..636587e --- /dev/null +++ b/docs/artifact.md @@ -0,0 +1,32 @@ +# Artifact Contract + +The CLI-owned artifact is compressed UTF-8 JSON Lines. Every line is compact +single-line JSON, and blank lines are invalid. + +The first record is required: + +```json +{ + "type": "manifest", + "createdAt": "2026-06-05T00:00:00.000Z", + "source": "https://example.atlassian.net" +} +``` + +Data records: + +```json +{"type":"spaceConfiguration","spaceKey":"ENG","configuration":{"enabled":true}} +{"type":"workItemConversationLinks","spaceKey":"ENG","workItemKey":"ENG-1","conversationIds":["abc","def"]} +``` + +Invariants: + +- Record types are `manifest`, `spaceConfiguration`, and `workItemConversationLinks`. +- The manifest contains only `type`, `createdAt`, and `source`. +- Records never include Jira numeric space IDs or work-item IDs. +- `configuration` is opaque JSON. +- `conversationIds` are opaque strings, deduplicated in first-seen order during export. +- The writer validates every record before writing. +- The reader validates every record while reading. +- Future import code should consume this shared reader and apply records through idempotent upserts. diff --git a/eslint.config.js b/eslint.config.js index e6d0d16..a804e98 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -9,6 +9,8 @@ const tsconfigRootDir = path.dirname(fileURLToPath(import.meta.url)); export default tseslint.config( { ignores: ["coverage/**", "dist/**", "node_modules/**"], + }, + { linterOptions: { reportUnusedDisableDirectives: "error", }, diff --git a/package.json b/package.json index 3964865..2e89be2 100644 --- a/package.json +++ b/package.json @@ -5,8 +5,8 @@ "type": "module", "packageManager": "pnpm@10.33.0", "scripts": { - "build": "tsc -p tsconfig.json", - "typecheck": "tsc -p tsconfig.json", + "build": "tsc -p tsconfig.build.json", + "typecheck": "tsc -p tsconfig.json --noEmit", "lint": "eslint . --max-warnings=0", "format": "prettier --write .", "format:check": "prettier --check .", @@ -16,10 +16,15 @@ "effect:lsp:patch": "effect-language-service patch" }, "dependencies": { + "@effect/platform-node": "4.0.0-beta.78", "effect": "4.0.0-beta.78" }, + "bin": { + "ifj": "./dist/src/ifj.js" + }, "devDependencies": { "@effect/language-service": "^0.86.2", + "@effect/vitest": "4.0.0-beta.78", "@eslint/js": "^10.0.1", "@types/node": "^25.9.1", "eslint": "^10.4.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 69c29b7..c149871 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@effect/platform-node': + specifier: 4.0.0-beta.78 + version: 4.0.0-beta.78(effect@4.0.0-beta.78)(ioredis@5.11.1) effect: specifier: 4.0.0-beta.78 version: 4.0.0-beta.78 @@ -15,6 +18,9 @@ importers: '@effect/language-service': specifier: ^0.86.2 version: 0.86.2 + '@effect/vitest': + specifier: 4.0.0-beta.78 + version: 4.0.0-beta.78(effect@4.0.0-beta.78)(vitest@4.1.8(@types/node@25.9.1)(vite@8.0.16(@types/node@25.9.1)(yaml@2.9.0))) '@eslint/js': specifier: ^10.0.1 version: 10.0.1(eslint@10.4.1) @@ -46,6 +52,25 @@ packages: resolution: {integrity: sha512-SaPln+8srOqDJDUwNTDmP5e+IYpEDr9+1epGznnsLqu8xvo6VnxyWARdeLpqvZJlb0Pgy9ca7ppqvvdWbHPXAg==} hasBin: true + '@effect/platform-node-shared@4.0.0-beta.78': + resolution: {integrity: sha512-mo0ddTPATyCMyqzQasYDL7+NI29vozoMplom+qu9f/onDTd4xG5hvEEfGxfL0Ljygui6keG/YE/E9OZVf2z5WA==} + engines: {node: '>=18.0.0'} + peerDependencies: + effect: ^4.0.0-beta.78 + + '@effect/platform-node@4.0.0-beta.78': + resolution: {integrity: sha512-8ONrIS5/R9dq+0BJ6v3kUXNEkfjU6S3GzIYCH5gmHdiriRvIoBhXYNAITfRvZpfx1JPrKuP70cHyuQDjmJcDkQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + effect: ^4.0.0-beta.78 + ioredis: ^5.7.0 + + '@effect/vitest@4.0.0-beta.78': + resolution: {integrity: sha512-5KQsQYrQ/o7mfOVAxRtNnfD9M0W4OI6yQd0n/m2N7OOLxTdX4FwN4s/X4obykBC7ZEwH+bzMrFJiB4pq9lrQKQ==} + peerDependencies: + effect: ^4.0.0-beta.78 + vitest: ^3.0.0 || ^4.0.0 + '@emnapi/core@1.10.0': resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} @@ -114,6 +139,9 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} + '@ioredis/commands@1.10.0': + resolution: {integrity: sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q==} + '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} @@ -278,6 +306,9 @@ packages: '@types/node@25.9.1': resolution: {integrity: sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==} + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@typescript-eslint/eslint-plugin@8.60.1': resolution: {integrity: sha512-JQ4S5GB0tfjO8BuJ4fcX+HodkzJjYBV+7OJ+wLygaX7OGQ7FudyHL4NSCA6ob+w3Yn+5MkKIozOwQhXeM7opVg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -395,6 +426,10 @@ packages: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} + cluster-key-slot@1.1.1: + resolution: {integrity: sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==} + engines: {node: '>=0.10.0'} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} @@ -414,6 +449,10 @@ packages: deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + denque@2.1.0: + resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} + engines: {node: '>=0.10'} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -548,6 +587,10 @@ packages: resolution: {integrity: sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w==} engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + ioredis@5.11.1: + resolution: {integrity: sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A==} + engines: {node: '>=12.22.0'} + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -659,6 +702,11 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + mime@4.1.0: + resolution: {integrity: sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw==} + engines: {node: '>=16'} + hasBin: true + minimatch@10.2.5: resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} engines: {node: 18 || 20 || >=22} @@ -742,6 +790,14 @@ packages: pure-rand@8.4.0: resolution: {integrity: sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A==} + redis-errors@1.2.0: + resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} + engines: {node: '>=4'} + + redis-parser@3.0.0: + resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} + engines: {node: '>=4'} + rolldown@1.0.3: resolution: {integrity: sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==} engines: {node: ^20.19.0 || >=22.12.0} @@ -770,6 +826,9 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + standard-as-callback@2.1.0: + resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} + std-env@4.1.0: resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} @@ -820,6 +879,10 @@ packages: undici-types@7.24.6: resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + undici@8.3.0: + resolution: {integrity: sha512-TkUDgb6tl7KOGZ+7e8E3d2FYgUQgF6z5YypqjWmixVQSQERFcVrVg0ySADm2LVLRh5ljAaHTCR5Fmz3Q34rB7Q==} + engines: {node: '>=22.19.0'} + uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} @@ -925,6 +988,18 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + yaml@2.9.0: resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} engines: {node: '>= 14.6'} @@ -938,6 +1013,31 @@ snapshots: '@effect/language-service@0.86.2': {} + '@effect/platform-node-shared@4.0.0-beta.78(effect@4.0.0-beta.78)': + dependencies: + '@types/ws': 8.18.1 + effect: 4.0.0-beta.78 + ws: 8.21.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@effect/platform-node@4.0.0-beta.78(effect@4.0.0-beta.78)(ioredis@5.11.1)': + dependencies: + '@effect/platform-node-shared': 4.0.0-beta.78(effect@4.0.0-beta.78) + effect: 4.0.0-beta.78 + ioredis: 5.11.1 + mime: 4.1.0 + undici: 8.3.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@effect/vitest@4.0.0-beta.78(effect@4.0.0-beta.78)(vitest@4.1.8(@types/node@25.9.1)(vite@8.0.16(@types/node@25.9.1)(yaml@2.9.0)))': + dependencies: + effect: 4.0.0-beta.78 + vitest: 4.1.8(@types/node@25.9.1)(vite@8.0.16(@types/node@25.9.1)(yaml@2.9.0)) + '@emnapi/core@1.10.0': dependencies: '@emnapi/wasi-threads': 1.2.1 @@ -1004,6 +1104,8 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} + '@ioredis/commands@1.10.0': {} + '@jridgewell/sourcemap-codec@1.5.5': {} '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': @@ -1108,6 +1210,10 @@ snapshots: dependencies: undici-types: 7.24.6 + '@types/ws@8.18.1': + dependencies: + '@types/node': 25.9.1 + '@typescript-eslint/eslint-plugin@8.60.1(@typescript-eslint/parser@8.60.1(eslint@10.4.1)(typescript@6.0.3))(eslint@10.4.1)(typescript@6.0.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -1263,6 +1369,8 @@ snapshots: chai@6.2.2: {} + cluster-key-slot@1.1.1: {} + convert-source-map@2.0.0: {} cross-spawn@7.0.6: @@ -1277,6 +1385,8 @@ snapshots: deep-is@0.1.4: {} + denque@2.1.0: {} + detect-libc@2.1.2: {} effect@4.0.0-beta.78: @@ -1417,6 +1527,18 @@ snapshots: ini@7.0.0: {} + ioredis@5.11.1: + dependencies: + '@ioredis/commands': 1.10.0 + cluster-key-slot: 1.1.1 + debug: 4.4.3 + denque: 2.1.0 + redis-errors: 1.2.0 + redis-parser: 3.0.0 + standard-as-callback: 2.1.0 + transitivePeerDependencies: + - supports-color + is-extglob@2.1.1: {} is-glob@4.0.3: @@ -1499,6 +1621,8 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + mime@4.1.0: {} + minimatch@10.2.5: dependencies: brace-expansion: 5.0.6 @@ -1575,6 +1699,12 @@ snapshots: pure-rand@8.4.0: {} + redis-errors@1.2.0: {} + + redis-parser@3.0.0: + dependencies: + redis-errors: 1.2.0 + rolldown@1.0.3: dependencies: '@oxc-project/types': 0.133.0 @@ -1610,6 +1740,8 @@ snapshots: stackback@0.0.2: {} + standard-as-callback@2.1.0: {} + std-env@4.1.0: {} tinybench@2.9.0: {} @@ -1651,6 +1783,8 @@ snapshots: undici-types@7.24.6: {} + undici@8.3.0: {} + uri-js@4.4.1: dependencies: punycode: 2.3.1 @@ -1707,6 +1841,8 @@ snapshots: word-wrap@1.2.5: {} + ws@8.21.0: {} + yaml@2.9.0: {} yocto-queue@0.1.0: {} diff --git a/src/cli.test.ts b/src/cli.test.ts new file mode 100644 index 0000000..d4d440a --- /dev/null +++ b/src/cli.test.ts @@ -0,0 +1,222 @@ +import { NodeServices } from "@effect/platform-node"; +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { ConfigProvider, Effect, Layer, Stream } from "effect"; +import { TestConsole } from "effect/testing"; +import { describe, expect, it } from "@effect/vitest"; + +import { CliService } from "./cli/index.js"; +import { + conversationLinksPropertyKey, + ExportService, + spaceConfigurationPropertyKey, +} from "./export/index.js"; +import { InspectService } from "./inspect/index.js"; +import { JiraClient } from "./shared/jira/index.js"; +import { type ArtifactRecord, ArtifactWriterService } from "./shared/artifact/index.js"; + +const tempPath = async (name: string) => join(await mkdtemp(join(tmpdir(), "ifj-")), name); +const artifactWriterLayer = ArtifactWriterService.layer.pipe(Layer.provide(NodeServices.layer)); +const inspectLayer = InspectService.layer.pipe(Layer.provide(NodeServices.layer)); +const inspectWrittenArtifact = (path: string) => + InspectService.use((service) => service.run(path)).pipe(Effect.provide(inspectLayer)); +const writeArtifactFixture = (path: string, records: readonly ArtifactRecord[]) => + ArtifactWriterService.use((service) => service.write(path, Stream.fromIterable(records))).pipe( + Effect.provide(artifactWriterLayer), + ); +const runCli = (args: readonly string[]) => CliService.use((cli) => cli.run(args)); +const linkedWorkItemStream = () => + Stream.fromIterable([ + { + key: "ENG-1", + properties: { + [conversationLinksPropertyKey]: { count: 1, conversationIds: ["abc"] }, + }, + }, + ]); + +const defaultJiraClient: JiraClient["Service"] = { + getMyPermissions: () => Effect.succeed({ ADMINISTER: { havePermission: true } }), + searchProjectSpaces: () => + Stream.fromIterable([ + { + key: "ENG", + properties: { [spaceConfigurationPropertyKey]: { enabled: true } }, + }, + ]), + approximateSearchCount: () => Effect.succeed(1), + searchWorkItems: linkedWorkItemStream, +}; + +const exportServiceLayerFromClient = + (jiraClient: JiraClient["Service"]) => (config: Parameters[0]) => + ExportService.layerNoDeps(config).pipe( + Layer.provide( + Layer.mergeAll(artifactWriterLayer, Layer.succeed(JiraClient, JiraClient.of(jiraClient))), + ), + ); + +const cliEnvironmentLayer = ( + configProviderLayer: Layer.Layer = Layer.empty, +): Layer.Layer => + Layer.mergeAll(InspectService.layer, configProviderLayer).pipe( + Layer.provideMerge(NodeServices.layer), + ); + +const cliLayerFromExportServiceLayer = ( + exportServiceLayer: ( + config: Parameters[0], + ) => Layer.Layer, + configProviderLayer: Layer.Layer = Layer.empty, +) => + CliService.layerNoDeps(exportServiceLayer).pipe( + Layer.provideMerge(Layer.mergeAll(TestConsole.layer, cliEnvironmentLayer(configProviderLayer))), + ); + +describe("CLI", () => { + it.effect("exports through the CLI with a provided Jira client layer", () => + Effect.gen(function* () { + const cwd = yield* Effect.promise(() => mkdtemp(join(tmpdir(), "ifj-cli-export-"))); + const outputPath = join(cwd, "cli-export.jsonl.gz"); + + yield* runCli([ + "export", + "--source", + "https://example.atlassian.net", + "--user", + "admin@example.com", + "--api-token", + "secret", + "--out", + outputPath, + ]); + const stdout = yield* TestConsole.logLines; + + expect(stdout.join("\n")).toContain("Export complete"); + const artifact = yield* inspectWrittenArtifact(outputPath); + expect(artifact).toMatchObject({ + source: "https://example.atlassian.net", + conversationIds: 1, + }); + }).pipe( + Effect.provide( + cliLayerFromExportServiceLayer( + exportServiceLayerFromClient(defaultJiraClient), + ConfigProvider.layer( + ConfigProvider.fromEnv({ + env: { + EXPORT_SOURCE: "https://ignored.atlassian.net", + EXPORT_USER: "ignored@example.com", + EXPORT_API_TOKEN: "ignored", + EXPORT_OUT: join(tmpdir(), "ignored.jsonl.gz"), + }, + }), + ), + ), + ), + ), + ); + + it.effect("falls back to export config when export flags are omitted", () => + Effect.gen(function* () { + const cwd = yield* Effect.promise(() => mkdtemp(join(tmpdir(), "ifj-cli-export-env-"))); + const outputPath = join(cwd, "cli-export-env.jsonl.gz"); + const jiraClient: JiraClient["Service"] = { + ...defaultJiraClient, + searchProjectSpaces: (params) => + params.keys === undefined + ? Stream.die("explicit spaces should be validated") + : Stream.fromIterable( + params.keys.map((key) => ({ + key, + properties: { [spaceConfigurationPropertyKey]: { enabled: true } }, + })), + ), + }; + + yield* Effect.gen(function* () { + yield* runCli(["export"]); + const stdout = yield* TestConsole.logLines; + + expect(stdout.join("\n")).toContain("Export complete"); + const artifact = yield* inspectWrittenArtifact(outputPath); + expect(artifact).toMatchObject({ + source: "https://env.atlassian.net", + spacesProcessed: 1, + }); + }).pipe( + Effect.provide( + cliLayerFromExportServiceLayer( + exportServiceLayerFromClient(jiraClient), + ConfigProvider.layer( + ConfigProvider.fromEnv({ + env: { + EXPORT_SOURCE: "https://env.atlassian.net/browse/ENG-1", + EXPORT_USER: "admin@example.com", + EXPORT_API_TOKEN: "secret", + EXPORT_OUT: outputPath, + EXPORT_SPACES: "ENG", + }, + }), + ), + ), + ), + ); + }), + ); + + it.effect("inspects an artifact and writes a human summary to stdout", () => + Effect.gen(function* () { + const path = yield* Effect.promise(() => tempPath("cli.jsonl.gz")); + yield* writeArtifactFixture(path, [ + { + type: "manifest", + createdAt: "2026-06-05T00:00:00.000Z", + source: "https://example.atlassian.net", + }, + ]); + + yield* runCli(["inspect", path]); + const stdout = yield* TestConsole.logLines; + + expect(stdout.join("\n")).toContain("Artifact valid"); + expect(stdout.join("\n")).toContain("Source: https://example.atlassian.net"); + }).pipe(Effect.provide(cliLayerFromExportServiceLayer(ExportService.layer))), + ); + + it.effect("falls back to inspect config without loading export config", () => + Effect.gen(function* () { + const path = yield* Effect.promise(() => tempPath("cli-inspect-env.jsonl.gz")); + yield* writeArtifactFixture(path, [ + { + type: "manifest", + createdAt: "2026-06-05T00:00:00.000Z", + source: "https://inspect.atlassian.net", + }, + ]); + + yield* Effect.gen(function* () { + yield* runCli(["inspect"]); + const stdout = yield* TestConsole.logLines; + + expect(stdout.join("\n")).toContain("Artifact valid"); + expect(stdout.join("\n")).toContain("Source: https://inspect.atlassian.net"); + }).pipe( + Effect.provide( + cliLayerFromExportServiceLayer( + ExportService.layer, + ConfigProvider.layer( + ConfigProvider.fromEnv({ + env: { + EXPORT_SOURCE: "not a jira cloud url", + INSPECT_ARTIFACT_PATH: path, + }, + }), + ), + ), + ), + ); + }), + ); +}); diff --git a/src/cli/cli.service.ts b/src/cli/cli.service.ts new file mode 100644 index 0000000..acccf70 --- /dev/null +++ b/src/cli/cli.service.ts @@ -0,0 +1,124 @@ +import { NodeServices } from "@effect/platform-node"; +import { ConfigProvider, Console, Context, Effect, Layer } from "effect"; +import { Argument, type CliError, Command, Flag } from "effect/unstable/cli"; + +import { + apiTokenConfig, + type ExportConfig, + inspectArtifactPathConfig, + JiraCloudSource, + normalizeSpaceList, + outConfig, + RedactedNonEmptyString, + runtimeConfigProvider, + sourceConfig, + spacesConfig, + TrimmedNonEmptyString, + userConfig, +} from "../shared/config/index.js"; +import type { AppError } from "../errors.js"; +import { ExportService, formatExportSummary } from "../export/index.js"; +import { formatInspectSummary, InspectService } from "../inspect/index.js"; + +type ExportServiceLayerFactory = ( + config: ExportConfig, +) => Layer.Layer; + +const formatJson = (value: unknown): string => JSON.stringify(value, null, 2); + +const inspectCommand = Command.make( + "inspect", + { + artifactPath: Argument.string("artifact").pipe( + Argument.withSchema(TrimmedNonEmptyString), + Argument.withFallbackConfig(inspectArtifactPathConfig), + ), + json: Flag.boolean("json"), + }, + (config) => + InspectService.use((service) => service.run(config.artifactPath)).pipe( + Effect.flatMap((summary) => + Console.log(config.json ? formatJson(summary) : formatInspectSummary(summary)), + ), + ), +).pipe(Command.withShortDescription("Validate and summarize an export artifact")); + +const exportCommand = Command.make( + "export", + { + source: Flag.string("source").pipe( + Flag.withSchema(JiraCloudSource), + Flag.withFallbackConfig(sourceConfig), + ), + user: Flag.string("user").pipe( + Flag.withSchema(TrimmedNonEmptyString), + Flag.withFallbackConfig(userConfig), + ), + apiToken: Flag.redacted("api-token").pipe( + Flag.withSchema(RedactedNonEmptyString), + Flag.withFallbackConfig(apiTokenConfig), + ), + out: Flag.string("out").pipe( + Flag.withSchema(TrimmedNonEmptyString), + Flag.withFallbackConfig(outConfig), + ), + spaces: Flag.string("space").pipe( + Flag.between(1, 1_000), + Flag.map(normalizeSpaceList), + Flag.withFallbackConfig(spacesConfig), + ), + json: Flag.boolean("json"), + }, + (config) => + ExportService.use((service) => service.run).pipe( + Effect.flatMap((summary) => + Console.log(config.json ? formatJson(summary) : formatExportSummary(summary)), + ), + ), +).pipe(Command.withShortDescription("Export Intercom for Jira data")); + +const makeCommand = (exportServiceLayer: ExportServiceLayerFactory) => + Command.make("ifj").pipe( + Command.withDescription("Intercom for Jira migration CLI"), + Command.withSubcommands([ + exportCommand.pipe(Command.provide((config) => exportServiceLayer(config))), + inspectCommand, + ]), + ); + +const runProgram = ( + args: readonly string[], + exportServiceLayer: ExportServiceLayerFactory, +): Effect.Effect => + runtimeConfigProvider.pipe( + Effect.flatMap((provider) => + Command.runWith(makeCommand(exportServiceLayer), { version: "0.1.0" })(args).pipe( + Effect.provideService(ConfigProvider.ConfigProvider, provider), + ), + ), + ); + +export class CliService extends Context.Service< + CliService, + { + readonly run: (args: readonly string[]) => Effect.Effect; + } +>()("ifj/CliService") { + static readonly layerNoDeps = ( + exportServiceLayer: ExportServiceLayerFactory, + ): Layer.Layer => + Layer.effect( + CliService, + Effect.context().pipe( + Effect.map((context) => + CliService.of({ + run: (args) => runProgram(args, exportServiceLayer).pipe(Effect.provide(context)), + }), + ), + ), + ); + + static readonly layer: Layer.Layer = CliService.layerNoDeps(ExportService.layer).pipe( + Layer.provide(InspectService.layer.pipe(Layer.provideMerge(NodeServices.layer))), + ); +} diff --git a/src/cli/index.ts b/src/cli/index.ts new file mode 100644 index 0000000..66eb0ca --- /dev/null +++ b/src/cli/index.ts @@ -0,0 +1 @@ +export { CliService } from "./cli.service.js"; diff --git a/src/errors.ts b/src/errors.ts new file mode 100644 index 0000000..1ac0bad --- /dev/null +++ b/src/errors.ts @@ -0,0 +1,64 @@ +import { Data } from "effect"; + +type AppErrorCode = + | "artifact.blankLine" + | "artifact.invalidExtension" + | "artifact.invalidGzip" + | "artifact.invalidJson" + | "artifact.invalidRecord" + | "artifact.manifestMissing" + | "artifact.manifestMisplaced" + | "config.missing" + | "export.emptyDefaultScope" + | "export.emptyExplicitScope" + | "export.invalidOutput" + | "export.malformedLinkProperty" + | "export.outputExists" + | "export.outputParentMissing" + | "jira.auth" + | "jira.malformed" + | "jira.permission" + | "jira.request" + | "jira.transient" + | "links.malformed"; + +interface AppErrorOptions { + readonly context?: Record; + readonly exitCode?: number; + readonly cause?: unknown; +} + +export class AppError extends Data.TaggedError("AppError")<{ + readonly code: AppErrorCode; + readonly message: string; + readonly context: Record; + readonly exitCode: number; + readonly cause?: unknown; +}> { + constructor(code: AppErrorCode, message: string, options: AppErrorOptions = {}) { + super({ + code, + message, + context: options.context ?? {}, + exitCode: options.exitCode ?? 1, + ...(options.cause === undefined ? {} : { cause: options.cause }), + }); + } +} + +export const errorMessage = (error: unknown): string => { + if (error instanceof AppError) { + return `${error.code}: ${error.message}`; + } + if (error instanceof Error) { + return error.message; + } + return String(error); +}; + +export const errorDetails = (error: unknown): Record => { + if (error instanceof AppError) { + return error.context; + } + return {}; +}; diff --git a/src/export/export.formatter.ts b/src/export/export.formatter.ts new file mode 100644 index 0000000..faa9752 --- /dev/null +++ b/src/export/export.formatter.ts @@ -0,0 +1,16 @@ +import type { ExportSummary } from "./export.model.js"; + +export const formatExportSummary = (summary: ExportSummary): string => { + const lines = ["Export complete", `Output: ${summary.outputPath}`, `Source: ${summary.source}`]; + if (summary.approximateLinkedWorkItemCount !== undefined) { + lines.push(`Approximate linked work items: ${String(summary.approximateLinkedWorkItemCount)}`); + } + lines.push( + `Spaces processed: ${String(summary.spacesProcessed)}`, + `Space configuration records: ${String(summary.spaceConfigurationRecords)}`, + `Work-item conversation-link records: ${String(summary.workItemConversationLinkRecords)}`, + `Conversation IDs exported: ${String(summary.conversationIds)}`, + `Warnings: ${String(summary.warningCount)}${summary.warningTruncated ? " (truncated)" : ""}`, + ); + return lines.join("\n"); +}; diff --git a/src/export/export.model.ts b/src/export/export.model.ts new file mode 100644 index 0000000..101f33e --- /dev/null +++ b/src/export/export.model.ts @@ -0,0 +1,23 @@ +import type { Schema } from "effect"; + +import type { ArtifactCounts } from "../shared/artifact/index.js"; + +export interface ExportSummary extends ArtifactCounts { + readonly outputPath: string; + readonly source: string; + readonly approximateLinkedWorkItemCount?: number; + readonly warningCount: number; + readonly warningTruncated: boolean; +} + +export interface MutableCounts { + spacesProcessed: number; + spaceConfigurationRecords: number; + workItemConversationLinkRecords: number; + conversationIds: number; +} + +export interface LinkPropertyDecodeFailure { + readonly currentSchemaError: Schema.SchemaError; + readonly legacySchemaError: Schema.SchemaError; +} diff --git a/src/export/export.service.test.ts b/src/export/export.service.test.ts new file mode 100644 index 0000000..2cacf55 --- /dev/null +++ b/src/export/export.service.test.ts @@ -0,0 +1,313 @@ +import { NodeServices } from "@effect/platform-node"; +import { mkdtemp, readFile, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Effect, Layer, Redacted, Stream } from "effect"; +import { TestConsole } from "effect/testing"; +import { describe, expect, it } from "@effect/vitest"; + +import { InspectService } from "../inspect/index.js"; +import { JiraClient } from "../shared/jira/index.js"; +import { type ArtifactRecord, ArtifactWriterService } from "../shared/artifact/index.js"; +import { + conversationLinksPropertyKey, + ExportService, + spaceConfigurationPropertyKey, +} from "./index.js"; +import type { ExportConfig } from "../shared/config/index.js"; + +const tempPath = async (name: string) => join(await mkdtemp(join(tmpdir(), "ifj-")), name); +const inspectLayer = InspectService.layer.pipe(Layer.provide(NodeServices.layer)); +const inspectWrittenArtifact = (path: string) => + InspectService.use((service) => service.run(path)).pipe(Effect.provide(inspectLayer)); +const artifactWriterLayer = ArtifactWriterService.layer.pipe(Layer.provide(NodeServices.layer)); +const exportServiceTestDeps = (jiraClient: JiraClient["Service"]) => + Layer.mergeAll(artifactWriterLayer, Layer.succeed(JiraClient, JiraClient.of(jiraClient))); +const runExport = (config: ExportConfig, jiraClient: JiraClient["Service"]) => + ExportService.use((service) => service.run).pipe( + Effect.provide( + ExportService.layerNoDeps(config).pipe(Layer.provide(exportServiceTestDeps(jiraClient))), + ), + ); + +describe("export orchestration", () => { + it.effect("exports through an injected artifact writer service", () => + Effect.gen(function* () { + const writtenRecords: ArtifactRecord[] = []; + const jiraClient: JiraClient["Service"] = { + getMyPermissions: () => Effect.succeed({ ADMINISTER: { havePermission: true } }), + searchProjectSpaces: () => + Stream.fromIterable([ + { key: "ENG", properties: { [spaceConfigurationPropertyKey]: { enabled: true } } }, + ]), + approximateSearchCount: () => Effect.succeed(1), + searchWorkItems: () => + Stream.fromIterable([ + { + key: "ENG-1", + properties: { + [conversationLinksPropertyKey]: { count: 1, conversationIds: ["abc"] }, + }, + }, + ]), + }; + const artifactWriterLayer = Layer.succeed( + ArtifactWriterService, + ArtifactWriterService.of({ + write: (requestedPath, records) => + records.pipe( + Stream.runForEach((record) => + Effect.sync(() => { + writtenRecords.push(record); + }), + ), + Effect.as(`memory:${requestedPath}`), + ), + }), + ); + + const exportServiceLayer = ExportService.layerNoDeps({ + source: "https://example.atlassian.net", + user: "admin@example.com", + apiToken: Redacted.make("secret"), + out: "export.jsonl.gz", + spaces: [], + json: false, + }).pipe( + Layer.provide( + Layer.mergeAll(artifactWriterLayer, Layer.succeed(JiraClient, JiraClient.of(jiraClient))), + ), + ); + + const summary = yield* ExportService.use((service) => service.run).pipe( + Effect.provide(Layer.mergeAll(exportServiceLayer, TestConsole.layer)), + ); + + expect(summary.outputPath).toBe("memory:export.jsonl.gz"); + expect(writtenRecords).toMatchObject([ + { + type: "manifest", + source: "https://example.atlassian.net", + }, + { + type: "spaceConfiguration", + spaceKey: "ENG", + configuration: { enabled: true }, + }, + { + type: "workItemConversationLinks", + spaceKey: "ENG", + workItemKey: "ENG-1", + conversationIds: ["abc"], + }, + ]); + }), + ); + + it.effect("exports discovered spaces to an artifact with aggregate summaries and warnings", () => + Effect.gen(function* () { + const outputPath = yield* Effect.promise(() => tempPath("export.jsonl.gz")); + const jiraClient: JiraClient["Service"] = { + getMyPermissions: () => Effect.succeed({ ADMINISTER: { havePermission: true } }), + searchProjectSpaces: () => + Stream.fromIterable([ + { key: "ENG", properties: { [spaceConfigurationPropertyKey]: { enabled: true } } }, + ]), + approximateSearchCount: () => Effect.succeed(7), + searchWorkItems: () => + Stream.fromIterable([ + { + key: "ENG-1", + properties: { + [conversationLinksPropertyKey]: { + count: 3, + conversationIds: ["abc", "def", "abc"], + }, + }, + }, + { + key: "ENG-2", + properties: { + [conversationLinksPropertyKey]: { count: 0, conversationIds: [] }, + }, + }, + ]), + }; + + const summary = yield* runExport( + { + source: "https://example.atlassian.net", + user: "admin@example.com", + apiToken: Redacted.make("secret"), + out: outputPath, + spaces: [], + json: false, + }, + jiraClient, + ); + const warningLines = (yield* TestConsole.errorLines).map(String); + + expect(summary).toEqual({ + outputPath, + source: "https://example.atlassian.net", + approximateLinkedWorkItemCount: 7, + spacesProcessed: 1, + spaceConfigurationRecords: 1, + workItemConversationLinkRecords: 1, + conversationIds: 2, + warningCount: 1, + warningTruncated: false, + }); + expect(warningLines.some((line) => line.includes("EMPTY_LINK_PROPERTY"))).toBe(true); + + const artifact = yield* inspectWrittenArtifact(outputPath); + expect(artifact).toMatchObject({ + source: "https://example.atlassian.net", + spacesProcessed: 1, + spaceConfigurationRecords: 1, + workItemConversationLinkRecords: 1, + conversationIds: 2, + }); + }).pipe(Effect.provide(TestConsole.layer)), + ); + + it.effect("writes to the next numbered output path when the requested file exists", () => + Effect.gen(function* () { + const outputPath = yield* Effect.promise(() => tempPath("export.jsonl.gz")); + const outputPath1 = outputPath.replace(/\.jsonl\.gz$/u, "1.jsonl.gz"); + const outputPath2 = outputPath.replace(/\.jsonl\.gz$/u, "2.jsonl.gz"); + yield* Effect.promise(() => writeFile(outputPath, "existing")); + yield* Effect.promise(() => writeFile(outputPath1, "existing numbered")); + const jiraClient: JiraClient["Service"] = { + getMyPermissions: () => Effect.succeed({ ADMINISTER: { havePermission: true } }), + searchProjectSpaces: () => + Stream.fromIterable([ + { key: "ENG", properties: { [spaceConfigurationPropertyKey]: { enabled: true } } }, + ]), + approximateSearchCount: () => Effect.succeed(1), + searchWorkItems: () => + Stream.fromIterable([ + { + key: "ENG-1", + properties: { + [conversationLinksPropertyKey]: { count: 1, conversationIds: ["abc"] }, + }, + }, + ]), + }; + + const summary = yield* runExport( + { + source: "https://example.atlassian.net", + user: "admin@example.com", + apiToken: Redacted.make("secret"), + out: outputPath, + spaces: [], + json: false, + }, + jiraClient, + ); + + expect(summary.outputPath).toBe(outputPath2); + const artifact = yield* inspectWrittenArtifact(outputPath2); + expect(artifact).toMatchObject({ + source: "https://example.atlassian.net", + conversationIds: 1, + }); + expect(yield* Effect.promise(() => readFile(outputPath, "utf8"))).toBe("existing"); + expect(yield* Effect.promise(() => readFile(outputPath1, "utf8"))).toBe("existing numbered"); + }).pipe(Effect.provide(TestConsole.layer)), + ); + + it.effect("exports legacy conversation-link arrays through the schema migration", () => + Effect.gen(function* () { + const outputPath = yield* Effect.promise(() => tempPath("legacy-export.jsonl.gz")); + const jiraClient: JiraClient["Service"] = { + getMyPermissions: () => Effect.succeed({ ADMINISTER: { havePermission: true } }), + searchProjectSpaces: () => + Stream.fromIterable([ + { key: "ENG", properties: { [spaceConfigurationPropertyKey]: { enabled: true } } }, + ]), + approximateSearchCount: () => Effect.succeed(1), + searchWorkItems: () => + Stream.fromIterable([ + { + key: "ENG-1", + properties: { + [conversationLinksPropertyKey]: [{ id: "abc" }, { id: "def" }, { id: "abc" }], + }, + }, + ]), + }; + + const summary = yield* runExport( + { + source: "https://example.atlassian.net", + user: "admin@example.com", + apiToken: Redacted.make("secret"), + out: outputPath, + spaces: [], + json: false, + }, + jiraClient, + ); + + expect(summary.workItemConversationLinkRecords).toBe(1); + expect(summary.conversationIds).toBe(2); + const artifact = yield* inspectWrittenArtifact(outputPath); + expect(artifact).toMatchObject({ + workItemConversationLinkRecords: 1, + conversationIds: 2, + }); + }).pipe(Effect.provide(TestConsole.layer)), + ); + + it.effect("fails and cleans up when a link property is malformed", () => + Effect.gen(function* () { + const outputPath = yield* Effect.promise(() => tempPath("malformed-export.jsonl.gz")); + const jiraClient: JiraClient["Service"] = { + getMyPermissions: () => Effect.succeed({ ADMINISTER: { havePermission: true } }), + searchProjectSpaces: () => + Stream.fromIterable([ + { key: "ENG", properties: { [spaceConfigurationPropertyKey]: { enabled: true } } }, + ]), + approximateSearchCount: () => Effect.succeed(1), + searchWorkItems: () => + Stream.fromIterable([ + { + key: "ENG-1", + properties: { + [conversationLinksPropertyKey]: { count: 1, conversationIds: [123] }, + }, + }, + ]), + }; + + const error = yield* runExport( + { + source: "https://example.atlassian.net", + user: "admin@example.com", + apiToken: Redacted.make("secret"), + out: outputPath, + spaces: [], + json: false, + }, + jiraClient, + ).pipe(Effect.flip); + const warningLines = (yield* TestConsole.errorLines).map(String); + + expect(error).toMatchObject({ + code: "export.malformedLinkProperty", + context: { + spaceKey: "ENG", + workItemKey: "ENG-1", + }, + }); + expect(warningLines.some((line) => line.includes("LINK_PROPERTY_MALFORMED"))).toBe(true); + expect(warningLines.some((line) => line.includes("EMPTY_LINK_PROPERTY"))).toBe(false); + expect(warningLines.some((line) => line.includes("currentSchemaError"))).toBe(true); + expect(warningLines.some((line) => line.includes("legacySchemaError"))).toBe(true); + yield* Effect.tryPromise(() => readFile(outputPath)).pipe(Effect.flip); + }).pipe(Effect.provide(TestConsole.layer)), + ); +}); diff --git a/src/export/export.service.ts b/src/export/export.service.ts new file mode 100644 index 0000000..1bece6c --- /dev/null +++ b/src/export/export.service.ts @@ -0,0 +1,325 @@ +import { Console, Context, Effect, Inspectable, Layer, Option, Schema, Stream } from "effect"; + +import { AppError, errorMessage } from "../errors.js"; +import type { ExportConfig } from "../shared/config/index.js"; +import { JiraClient } from "../shared/jira/index.js"; +import { type ArtifactRecord, ArtifactWriterService } from "../shared/artifact/index.js"; +import { + ConversationLinkMigrationValue, + LegacyConversationLinkPropertyValue, +} from "../shared/app/index.js"; +import { formatWarning, type WarningCode, WarningCollector } from "../warnings.js"; +import type { ExportSummary, LinkPropertyDecodeFailure, MutableCounts } from "./export.model.js"; +import { type ExportJiraSpace, JiraService } from "./jira.service.js"; + +const emptyCounts = (): MutableCounts => ({ + spacesProcessed: 0, + spaceConfigurationRecords: 0, + workItemConversationLinkRecords: 0, + conversationIds: 0, +}); + +const decodeJson = (value: unknown): Effect.Effect> => + Schema.decodeUnknownEffect(Schema.Json)(value).pipe(Effect.option); + +const warn = ( + warnings: WarningCollector, + code: WarningCode, + context: Parameters[1] = {}, +): Effect.Effect => + Effect.sync(() => warnings.add(code, context)).pipe( + Effect.flatMap((warning) => Console.error(formatWarning(warning))), + ); + +const warningReason = (error: unknown): string => errorMessage(error); + +interface ExportRecordPlan { + readonly records: Stream.Stream; + readonly approximateCount: Option.Option; +} + +const exportProgram = ( + config: ExportConfig, +): Effect.Effect => + Effect.gen(function* () { + const jiraService = yield* JiraService; + const artifactWriter = yield* ArtifactWriterService; + const warnings = new WarningCollector(100); + + const counts = emptyCounts(); + const plan = yield* planExportRecords(config, jiraService, warnings, counts); + const outputPath = yield* artifactWriter.write(config.out, plan.records); + + return exportSummary({ ...config, out: outputPath }, counts, warnings, plan.approximateCount); + }); + +const planExportRecords = ( + config: ExportConfig, + jiraService: JiraService["Service"], + warnings: WarningCollector, + counts: MutableCounts, +): Effect.Effect => + Effect.gen(function* () { + yield* Console.error( + `Verifying Jira global admin permission for export source ${config.source}`, + ); + yield* jiraService.verifyGlobalAdmin; + + const spaces = yield* resolveSpaces(config, jiraService, warnings); + const spaceKeys = spaces.map((space) => space.key); + if (spaceKeys.length === 0) { + const explicitSpacesProvided = config.spaces.length > 0; + const errorCode = explicitSpacesProvided + ? "export.emptyExplicitScope" + : "export.emptyDefaultScope"; + const errorMessage = explicitSpacesProvided + ? `Nothing to export: none of the explicitly provided spaces (${config.spaces.join(", ")}) were found or have accessible work items.` + : "Nothing to export: no spaces with Intercom configuration were discovered. Pass --space to select spaces explicitly."; + return yield* new AppError(errorCode, errorMessage); + } + + yield* Console.error("Counting linked work items approximately"); + const approximateCount = yield* jiraService.approximateLinkedWorkItemCount(spaceKeys).pipe( + Effect.map(Option.some), + Effect.catch((error) => + warn(warnings, "APPROXIMATE_COUNT_FAILED", { + reason: warningReason(error), + }).pipe(Effect.as(Option.none())), + ), + ); + + const records = Stream.succeed({ + type: "manifest", + createdAt: new Date().toISOString(), + source: config.source, + }).pipe( + Stream.concat( + Stream.fromIterable(spaces).pipe( + Stream.flatMap((space) => + exportSpaceRecords(config, space, jiraService, warnings, counts), + ), + ), + ), + ); + + return { records, approximateCount }; + }); + +const exportSummary = ( + config: ExportConfig, + counts: MutableCounts, + warnings: WarningCollector, + approximateCount: Option.Option, +): ExportSummary => { + const warningSummary = warnings.summary(); + const summaryBase = { + outputPath: config.out, + source: config.source, + spacesProcessed: counts.spacesProcessed, + spaceConfigurationRecords: counts.spaceConfigurationRecords, + workItemConversationLinkRecords: counts.workItemConversationLinkRecords, + conversationIds: counts.conversationIds, + warningCount: warningSummary.count, + warningTruncated: warningSummary.truncated, + }; + return Option.isNone(approximateCount) + ? summaryBase + : { ...summaryBase, approximateLinkedWorkItemCount: approximateCount.value }; +}; + +const resolveSpaces = ( + config: ExportConfig, + jiraService: JiraService["Service"], + warnings: WarningCollector, +): Effect.Effect => { + if (config.spaces.length > 0) { + return jiraService.validateSpaces(config.spaces); + } + + return jiraService.discoverConfiguredSpaces.pipe( + Effect.catch((error) => + warn(warnings, "DEFAULT_SCOPE_DISCOVERY_FAILED", { + reason: warningReason(error), + }).pipe(Effect.as([])), + ), + ); +}; + +const exportSpaceRecords = ( + config: ExportConfig, + space: ExportJiraSpace, + jiraService: JiraService["Service"], + warnings: WarningCollector, + counts: MutableCounts, +): Stream.Stream => + Stream.unwrap( + Effect.gen(function* () { + yield* Console.error(`Exporting space ${space.key}`); + counts.spacesProcessed += 1; + const beforeConfig = counts.spaceConfigurationRecords; + const beforeLinks = counts.workItemConversationLinkRecords; + + const records = exportSpaceConfigurationRecord(space, warnings, counts).pipe( + Stream.concat(exportSpaceLinkRecords(space.key, jiraService, warnings, counts)), + Stream.concat( + Stream.fromEffect( + Effect.suspend(() => + config.spaces.length > 0 && + beforeConfig === counts.spaceConfigurationRecords && + beforeLinks === counts.workItemConversationLinkRecords + ? warn(warnings, "EMPTY_EXPLICIT_SPACE", { spaceKey: space.key }) + : Effect.void, + ), + ).pipe(Stream.drain), + ), + ); + + return records; + }), + ); + +const exportSpaceConfigurationRecord = ( + space: ExportJiraSpace, + warnings: WarningCollector, + counts: MutableCounts, +): Stream.Stream => + Stream.unwrap( + Effect.gen(function* () { + const configuration = space.configuration ?? Option.none(); + if (Option.isNone(configuration)) { + return Stream.empty; + } + + const jsonConfiguration = yield* decodeJson(configuration.value); + if (Option.isNone(jsonConfiguration)) { + yield* warn(warnings, "CONFIGURATION_MALFORMED", { spaceKey: space.key }); + return Stream.empty; + } + + counts.spaceConfigurationRecords += 1; + return Stream.succeed({ + type: "spaceConfiguration", + spaceKey: space.key, + configuration: jsonConfiguration.value, + }); + }), + ); + +const decodeLinkProperty = ( + value: unknown, +): Effect.Effect => + Schema.decodeUnknownEffect(ConversationLinkMigrationValue)(value).pipe( + Effect.catch((currentSchemaError) => + Schema.decodeUnknownEffect(LegacyConversationLinkPropertyValue)(value).pipe( + Effect.mapError((legacySchemaError) => ({ currentSchemaError, legacySchemaError })), + ), + ), + ); + +const warnMalformedLinkProperty = ( + warnings: WarningCollector, + spaceKey: string, + workItemKey: string, + failure: LinkPropertyDecodeFailure, +): Effect.Effect => + warn(warnings, "LINK_PROPERTY_MALFORMED", { + spaceKey, + workItemKey, + currentSchemaError: Inspectable.toStringUnknown(failure.currentSchemaError), + legacySchemaError: Inspectable.toStringUnknown(failure.legacySchemaError), + }); + +const exportSpaceLinkRecords = ( + spaceKey: string, + jiraService: JiraService["Service"], + warnings: WarningCollector, + counts: MutableCounts, +): Stream.Stream => + jiraService.searchWorkItemConversationLinks(spaceKey).pipe( + Stream.mapEffect((hit) => + Effect.gen(function* () { + const maybeLinkProperty = yield* decodeLinkProperty(hit.propertyValue).pipe( + Effect.matchEffect({ + onFailure: (failure) => + Effect.gen(function* () { + yield* warnMalformedLinkProperty(warnings, spaceKey, hit.key, failure); + return yield* new AppError( + "export.malformedLinkProperty", + `Malformed conversation-link property on work item ${spaceKey}/${hit.key}. Unable to decode as current or legacy format.`, + { + context: { + spaceKey, + workItemKey: hit.key, + currentSchemaError: Inspectable.toStringUnknown(failure.currentSchemaError), + legacySchemaError: Inspectable.toStringUnknown(failure.legacySchemaError), + }, + cause: failure, + }, + ); + }), + onSuccess: (linkProperty) => Effect.succeed(Option.some(linkProperty)), + }), + ); + if (Option.isNone(maybeLinkProperty)) { + return Option.none(); + } + if (maybeLinkProperty.value.conversationIds.size === 0) { + yield* warn(warnings, "EMPTY_LINK_PROPERTY", { + spaceKey, + workItemKey: hit.key, + }); + return Option.none(); + } + const conversationIds = [...maybeLinkProperty.value.conversationIds]; + counts.workItemConversationLinkRecords += 1; + counts.conversationIds += conversationIds.length; + return Option.some({ + type: "workItemConversationLinks", + spaceKey, + workItemKey: hit.key, + conversationIds, + }); + }), + ), + Stream.flatMap((record) => + Option.isSome(record) ? Stream.succeed(record.value) : Stream.empty, + ), + ); + +export class ExportService extends Context.Service< + ExportService, + { + readonly run: Effect.Effect; + } +>()("ifj/ExportService") { + static readonly layerNoDeps = ( + config: ExportConfig, + ): Layer.Layer => + Layer.effect( + ExportService, + Effect.gen(function* () { + const jiraService = yield* JiraService; + const artifactWriter = yield* ArtifactWriterService; + return ExportService.of({ + run: exportProgram(config).pipe( + Effect.provideService(JiraService, jiraService), + Effect.provideService(ArtifactWriterService, artifactWriter), + ), + }); + }), + ).pipe(Layer.provide(JiraService.layer)); + + static readonly layer = (config: ExportConfig) => + ExportService.layerNoDeps(config).pipe( + Layer.provide( + Layer.mergeAll( + ArtifactWriterService.layer, + JiraClient.layer({ + source: config.source, + user: config.user, + apiToken: config.apiToken, + }), + ), + ), + ); +} diff --git a/src/export/index.ts b/src/export/index.ts new file mode 100644 index 0000000..c07d015 --- /dev/null +++ b/src/export/index.ts @@ -0,0 +1,4 @@ +export * from "./export.formatter.js"; +export * from "./export.model.js"; +export * from "./export.service.js"; +export * from "./jira.service.js"; diff --git a/src/export/jira.service.test.ts b/src/export/jira.service.test.ts new file mode 100644 index 0000000..d845509 --- /dev/null +++ b/src/export/jira.service.test.ts @@ -0,0 +1,48 @@ +import { Effect, Schema } from "effect"; +import { describe, expect, it } from "@effect/vitest"; + +import { + ConversationLinkMigrationValue, + ConversationLinkPropertyValue, + LegacyConversationLinkPropertyValue, +} from "../shared/app/index.js"; + +describe("export Jira domain helpers", () => { + it.effect("decodes current and legacy conversation-link property values with schemas", () => + Effect.gen(function* () { + const current = yield* Schema.decodeUnknownEffect(ConversationLinkMigrationValue)({ + count: 3, + conversationIds: ["abc", " def ", "abc"], + }); + expect([...current.conversationIds]).toEqual(["abc", "def"]); + + const legacy = yield* Schema.decodeUnknownEffect(LegacyConversationLinkPropertyValue)([ + { id: "abc" }, + { id: "def" }, + { id: "abc" }, + ]); + expect([...legacy.conversationIds]).toEqual(["abc", "def"]); + + const currentForImport = ConversationLinkPropertyValue.make({ + count: 1, + conversationIds: new Set(["abc"]), + }); + expect(currentForImport.count).toBe(1); + expect([...currentForImport.conversationIds]).toEqual(["abc"]); + }), + ); + + it.effect("rejects blank conversation IDs", () => + Effect.gen(function* () { + yield* Schema.decodeUnknownEffect(ConversationLinkMigrationValue)({ + count: 1, + conversationIds: [" "], + }).pipe(Effect.flip); + + yield* Schema.decodeUnknownEffect(ConversationLinkPropertyValue)({ + count: 1, + conversationIds: new Set(["\t"]), + }).pipe(Effect.flip); + }), + ); +}); diff --git a/src/export/jira.service.ts b/src/export/jira.service.ts new file mode 100644 index 0000000..5f8d7af --- /dev/null +++ b/src/export/jira.service.ts @@ -0,0 +1,150 @@ +import { Array as Arr, Context, Effect, Layer, Option, Order, Stream } from "effect"; +import { AppError } from "../errors.js"; +import { JiraClient, type JiraProjectSpace, type JiraWorkItem } from "../shared/jira/index.js"; + +export const spaceConfigurationPropertyKey = "intercom.connection.configuration"; +export const conversationLinksPropertyKey = "intercom.conversation.links"; + +const intercomIntegrationStatusPropertyQuery = "[intercomIntegrationStatus]=true"; +const projectSearchKeyChunkSize = 50; + +export interface ExportJiraSpace { + readonly key: string; + readonly configuration?: Option.Option; +} + +export interface JiraWorkItemLinkHit { + readonly key: string; + readonly propertyValue: unknown; +} + +export class JiraService extends Context.Service< + JiraService, + { + readonly verifyGlobalAdmin: Effect.Effect; + readonly discoverConfiguredSpaces: Effect.Effect; + readonly validateSpaces: ( + spaceKeys: readonly string[], + ) => Effect.Effect; + readonly approximateLinkedWorkItemCount: ( + spaceKeys: readonly string[], + ) => Effect.Effect; + readonly searchWorkItemConversationLinks: ( + spaceKey: string, + ) => Stream.Stream; + } +>()("ifj/export/JiraService") { + static readonly layer: Layer.Layer = Layer.effect( + JiraService, + makeJiraService(), + ); +} + +const quoteJqlString = (value: string): string => + `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`; + +const scopeJql = (spaceKeys: readonly string[]): string => + spaceKeys.length === 0 + ? "linkedIntercomConversationCount > 0" + : `project in (${spaceKeys.map(quoteJqlString).join(", ")}) AND linkedIntercomConversationCount > 0`; + +const sortSpaces = (spaces: readonly A[]): readonly A[] => + Arr.sortWith(spaces, (space) => space.key, Order.String); + +const exportSpace = (project: JiraProjectSpace): ExportJiraSpace => ({ + key: project.key, + configuration: Option.fromNullishOr(project.properties[spaceConfigurationPropertyKey]), +}); + +const verifyGlobalAdmin = (jiraClient: JiraClient["Service"]): Effect.Effect => + Effect.gen(function* () { + const permissions = yield* jiraClient.getMyPermissions(["ADMINISTER"]); + if (!permissions["ADMINISTER"]?.havePermission) { + return yield* new AppError( + "jira.permission", + "User does not have Jira global admin permission.", + { + context: { path: "/rest/api/3/mypermissions", permission: "ADMINISTER" }, + }, + ); + } + }); + +const discoverConfiguredSpaces = ( + jiraClient: JiraClient["Service"], +): Effect.Effect => + jiraClient + .searchProjectSpaces({ + propertyQuery: intercomIntegrationStatusPropertyQuery, + properties: [spaceConfigurationPropertyKey], + }) + .pipe(Stream.map(exportSpace), Stream.runCollect, Effect.map(sortSpaces)); + +const validateSpaces = ( + jiraClient: JiraClient["Service"], + spaceKeys: readonly string[], +): Effect.Effect => + Effect.gen(function* () { + const found = yield* Effect.forEach( + Arr.chunksOf(spaceKeys, projectSearchKeyChunkSize), + (chunk) => + jiraClient + .searchProjectSpaces({ + keys: chunk, + properties: [spaceConfigurationPropertyKey], + }) + .pipe(Stream.map(exportSpace), Stream.runCollect), + ).pipe(Effect.map(Arr.flatten)); + + const foundKeys = new Set(found.map((space) => space.key.toLowerCase())); + const missingSpaceKeys = spaceKeys.filter((key) => !foundKeys.has(key.toLowerCase())); + if (missingSpaceKeys.length > 0) { + return yield* new AppError("jira.request", "Jira did not return all selected spaces.", { + context: { + path: "/rest/api/3/project/search", + missingSpaceKeys, + }, + }); + } + return sortSpaces(found); + }); + +const approximateLinkedWorkItemCount = ( + jiraClient: JiraClient["Service"], + spaceKeys: readonly string[], +): Effect.Effect => jiraClient.approximateSearchCount(scopeJql(spaceKeys)); + +const linkedWorkItemsJql = (spaceKey: string): string => `${scopeJql([spaceKey])} ORDER BY key ASC`; + +const linkHit = (issue: JiraWorkItem): JiraWorkItemLinkHit => ({ + key: issue.key, + propertyValue: issue.properties[conversationLinksPropertyKey], +}); + +const searchWorkItemConversationLinks = ( + jiraClient: JiraClient["Service"], + spaceKey: string, +): Stream.Stream => + jiraClient + .searchWorkItems({ + jql: linkedWorkItemsJql(spaceKey), + fields: ["key"], + properties: [conversationLinksPropertyKey], + }) + .pipe(Stream.map(linkHit)); + +function makeJiraService(): Effect.Effect { + return Effect.gen(function* () { + const jiraClient = yield* JiraClient; + + return JiraService.of({ + verifyGlobalAdmin: verifyGlobalAdmin(jiraClient), + discoverConfiguredSpaces: discoverConfiguredSpaces(jiraClient), + validateSpaces: (spaceKeys) => validateSpaces(jiraClient, spaceKeys), + approximateLinkedWorkItemCount: (spaceKeys) => + approximateLinkedWorkItemCount(jiraClient, spaceKeys), + searchWorkItemConversationLinks: (spaceKey) => + searchWorkItemConversationLinks(jiraClient, spaceKey), + }); + }); +} diff --git a/src/ifj.ts b/src/ifj.ts new file mode 100644 index 0000000..c59b9ea --- /dev/null +++ b/src/ifj.ts @@ -0,0 +1,24 @@ +#!/usr/bin/env node +import { NodeRuntime } from "@effect/platform-node"; +import { Console, Effect } from "effect"; + +import { CliService } from "./cli/index.js"; +import { AppError, errorDetails, errorMessage } from "./errors.js"; + +const main = CliService.use((cli) => cli.run(process.argv.slice(2))).pipe( + Effect.provide(CliService.layer), + Effect.catch((error: unknown) => + Effect.gen(function* () { + yield* Console.error(errorMessage(error)); + const details = errorDetails(error); + if (Object.keys(details).length > 0) { + yield* Console.error(JSON.stringify(details)); + } + yield* Effect.sync(() => { + process.exitCode = error instanceof AppError ? error.exitCode : 1; + }); + }), + ), +); + +NodeRuntime.runMain(main, { disableErrorReporting: true }); diff --git a/src/index.ts b/src/index.ts deleted file mode 100644 index 3c0700e..0000000 --- a/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { Effect } from "effect"; - -export const greet = (name: string) => Effect.succeed(`Hello, ${name}!`); diff --git a/src/inspect/index.ts b/src/inspect/index.ts new file mode 100644 index 0000000..aa4e970 --- /dev/null +++ b/src/inspect/index.ts @@ -0,0 +1,3 @@ +export * from "./inspect.formatter.js"; +export * from "./inspect.model.js"; +export * from "./inspect.service.js"; diff --git a/src/inspect/inspect.formatter.ts b/src/inspect/inspect.formatter.ts new file mode 100644 index 0000000..a729814 --- /dev/null +++ b/src/inspect/inspect.formatter.ts @@ -0,0 +1,13 @@ +import type { InspectSummary } from "./inspect.model.js"; + +export const formatInspectSummary = (summary: InspectSummary): string => + [ + "Artifact valid", + `Path: ${summary.artifactPath}`, + `Source: ${summary.source}`, + `Created at: ${summary.createdAt}`, + `Spaces processed: ${String(summary.spacesProcessed)}`, + `Space configuration records: ${String(summary.spaceConfigurationRecords)}`, + `Work-item conversation-link records: ${String(summary.workItemConversationLinkRecords)}`, + `Conversation IDs: ${String(summary.conversationIds)}`, + ].join("\n"); diff --git a/src/inspect/inspect.model.ts b/src/inspect/inspect.model.ts new file mode 100644 index 0000000..6f01d7b --- /dev/null +++ b/src/inspect/inspect.model.ts @@ -0,0 +1,15 @@ +import type { ArtifactCounts, ManifestRecord } from "../shared/artifact/index.js"; + +export interface InspectSummary extends ArtifactCounts { + readonly source: string; + readonly createdAt: string; + readonly artifactPath: string; +} + +export interface InspectState { + readonly manifest: ManifestRecord | undefined; + readonly spaceKeys: Set; + readonly spaceConfigurationRecords: number; + readonly workItemConversationLinkRecords: number; + readonly conversationIds: number; +} diff --git a/src/inspect/inspect.service.test.ts b/src/inspect/inspect.service.test.ts new file mode 100644 index 0000000..dbdaaaa --- /dev/null +++ b/src/inspect/inspect.service.test.ts @@ -0,0 +1,98 @@ +import { NodeServices } from "@effect/platform-node"; +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Effect, Layer, Stream } from "effect"; +import { describe, expect, it } from "@effect/vitest"; + +import { + ArtifactReaderService, + type ArtifactRecord, + ArtifactWriterService, +} from "../shared/artifact/index.js"; +import { InspectService } from "./index.js"; + +const tempPath = async (name: string) => join(await mkdtemp(join(tmpdir(), "ifj-")), name); +const artifactWriterLayer = ArtifactWriterService.layer.pipe(Layer.provide(NodeServices.layer)); +const inspectLayer = InspectService.layer.pipe(Layer.provide(NodeServices.layer)); +const writeArtifactFixture = (path: string, records: readonly ArtifactRecord[]) => + ArtifactWriterService.use((service) => service.write(path, Stream.fromIterable(records))).pipe( + Effect.provide(artifactWriterLayer), + ); + +describe("inspect service", () => { + it.effect("inspects through an injected artifact reader", () => + Effect.gen(function* () { + const readerLayer = Layer.succeed( + ArtifactReaderService, + ArtifactReaderService.of({ + read: () => + Stream.fromIterable([ + { + type: "manifest", + createdAt: "2026-06-05T00:00:00.000Z", + source: "https://example.atlassian.net", + }, + { + type: "workItemConversationLinks", + spaceKey: "ENG", + workItemKey: "ENG-1", + conversationIds: ["abc"], + }, + ]), + }), + ); + + const summary = yield* InspectService.use((service) => service.run("memory.jsonl.gz")).pipe( + Effect.provide(InspectService.layerNoDeps.pipe(Layer.provide(readerLayer))), + ); + + expect(summary).toMatchObject({ + artifactPath: "memory.jsonl.gz", + source: "https://example.atlassian.net", + spacesProcessed: 1, + workItemConversationLinkRecords: 1, + conversationIds: 1, + }); + }), + ); + + it.effect("inspects aggregate counts from an artifact", () => + Effect.gen(function* () { + const path = yield* Effect.promise(() => tempPath("migration.jsonl.gz")); + + yield* writeArtifactFixture(path, [ + { + type: "manifest", + createdAt: "2026-06-05T00:00:00.000Z", + source: "https://example.atlassian.net", + }, + { + type: "spaceConfiguration", + spaceKey: "ENG", + configuration: { enabled: true }, + }, + { + type: "workItemConversationLinks", + spaceKey: "ENG", + workItemKey: "ENG-1", + conversationIds: ["abc", "def"], + }, + ]); + + const summary = yield* InspectService.use((service) => service.run(path)).pipe( + Effect.provide(inspectLayer), + ); + + expect(summary).toEqual({ + artifactPath: path, + source: "https://example.atlassian.net", + createdAt: "2026-06-05T00:00:00.000Z", + spacesProcessed: 1, + spaceConfigurationRecords: 1, + workItemConversationLinkRecords: 1, + conversationIds: 2, + }); + }), + ); +}); diff --git a/src/inspect/inspect.service.ts b/src/inspect/inspect.service.ts new file mode 100644 index 0000000..9d35561 --- /dev/null +++ b/src/inspect/inspect.service.ts @@ -0,0 +1,90 @@ +import { Context, Effect, Layer, Stream } from "effect"; + +import { AppError } from "../errors.js"; +import { ArtifactReaderService, type ArtifactRecord } from "../shared/artifact/index.js"; +import type { InspectState, InspectSummary } from "./inspect.model.js"; + +const emptyInspectState = (): InspectState => ({ + manifest: undefined, + spaceKeys: new Set(), + spaceConfigurationRecords: 0, + workItemConversationLinkRecords: 0, + conversationIds: 0, +}); + +const accumulateInspectState = (state: InspectState, record: ArtifactRecord): InspectState => { + switch (record.type) { + case "manifest": + return { ...state, manifest: record }; + case "spaceConfiguration": { + const spaceKeys = new Set(state.spaceKeys).add(record.spaceKey); + return { + ...state, + spaceKeys, + spaceConfigurationRecords: state.spaceConfigurationRecords + 1, + }; + } + case "workItemConversationLinks": { + const spaceKeys = new Set(state.spaceKeys).add(record.spaceKey); + return { + ...state, + spaceKeys, + workItemConversationLinkRecords: state.workItemConversationLinkRecords + 1, + conversationIds: state.conversationIds + record.conversationIds.length, + }; + } + } +}; + +const inspectArtifact = ( + path: string, +): Effect.Effect => + Effect.gen(function* () { + const reader = yield* ArtifactReaderService; + + return yield* reader.read(path).pipe( + Stream.runFold(emptyInspectState, accumulateInspectState), + Effect.flatMap((state) => + state.manifest === undefined + ? Effect.fail( + new AppError( + "artifact.manifestMissing", + "Artifact is empty or missing its manifest.", + ), + ) + : Effect.succeed({ + artifactPath: path, + source: state.manifest.source, + createdAt: state.manifest.createdAt, + spacesProcessed: state.spaceKeys.size, + spaceConfigurationRecords: state.spaceConfigurationRecords, + workItemConversationLinkRecords: state.workItemConversationLinkRecords, + conversationIds: state.conversationIds, + }), + ), + ); + }); + +export class InspectService extends Context.Service< + InspectService, + { + readonly run: (path: string) => Effect.Effect; + } +>()("ifj/InspectService") { + static readonly layerNoDeps: Layer.Layer = + Layer.effect( + InspectService, + ArtifactReaderService.pipe( + Effect.map((reader) => + InspectService.of({ + run: (path) => + inspectArtifact(path).pipe(Effect.provideService(ArtifactReaderService, reader)), + }), + ), + ), + ); + + static readonly layer = InspectService.layerNoDeps.pipe( + Layer.provide(ArtifactReaderService.layer), + ); +} diff --git a/src/shared/app/app.model.ts b/src/shared/app/app.model.ts new file mode 100644 index 0000000..7284be0 --- /dev/null +++ b/src/shared/app/app.model.ts @@ -0,0 +1,54 @@ +import { Schema, SchemaGetter, SchemaTransformation } from "effect"; + +const ConversationId = Schema.String.pipe( + Schema.decode(SchemaTransformation.trim()), + Schema.check(Schema.isNonEmpty()), +); +const ConversationIds = Schema.ReadonlySet(ConversationId); +const ConversationIdsFromArray = Schema.Array(ConversationId).pipe( + Schema.decodeTo(ConversationIds, { + decode: SchemaGetter.transform((conversationIds) => new Set(conversationIds)), + encode: SchemaGetter.transform((conversationIds) => [...conversationIds]), + }), +); + +/** + * Current Jira issue property value used by the app to store linked Intercom conversations. + */ +export const ConversationLinkPropertyValue = Schema.Struct({ + count: Schema.Number, + conversationIds: ConversationIds, +}); + +/** + * Normalized migration representation of linked Intercom conversations. + * + * Jira stores conversation IDs as an array in JSON, while application code works with a readonly + * set to deduplicate IDs during export and import. + */ +export const ConversationLinkMigrationValue = Schema.Struct({ + conversationIds: ConversationIdsFromArray, +}); + +export type ConversationLinkMigrationValue = Schema.Schema.Type< + typeof ConversationLinkMigrationValue +>; + +/** + * Legacy Jira issue property value where linked conversations were stored as objects with IDs. + * + * Decoding this schema returns the normalized migration representation so old exports can be + * processed alongside the current property format. + */ +export const LegacyConversationLinkPropertyValue = Schema.Array( + Schema.Struct({ + id: Schema.String, + }), +).pipe( + Schema.decodeTo(ConversationLinkMigrationValue, { + decode: SchemaGetter.transform((links) => ({ + conversationIds: links.map((link) => link.id), + })), + encode: SchemaGetter.transform((value) => [...value.conversationIds].map((id) => ({ id }))), + }), +); diff --git a/src/shared/app/index.ts b/src/shared/app/index.ts new file mode 100644 index 0000000..193d7ea --- /dev/null +++ b/src/shared/app/index.ts @@ -0,0 +1 @@ +export * from "./app.model.js"; diff --git a/src/shared/artifact/artifact.model.ts b/src/shared/artifact/artifact.model.ts new file mode 100644 index 0000000..4f5955b --- /dev/null +++ b/src/shared/artifact/artifact.model.ts @@ -0,0 +1,37 @@ +import { Schema } from "effect"; + +export const artifactExtension = ".jsonl.gz"; + +export const ManifestRecord = Schema.Struct({ + type: Schema.Literal("manifest"), + createdAt: Schema.String, + source: Schema.String, +}); +export type ManifestRecord = Schema.Schema.Type; + +export const SpaceConfigurationRecord = Schema.Struct({ + type: Schema.Literal("spaceConfiguration"), + spaceKey: Schema.String, + configuration: Schema.Json, +}); + +export const WorkItemConversationLinksRecord = Schema.Struct({ + type: Schema.Literal("workItemConversationLinks"), + spaceKey: Schema.String, + workItemKey: Schema.String, + conversationIds: Schema.Array(Schema.String), +}); + +export const ArtifactRecord = Schema.Union([ + ManifestRecord, + SpaceConfigurationRecord, + WorkItemConversationLinksRecord, +]); +export type ArtifactRecord = Schema.Schema.Type; + +export interface ArtifactCounts { + readonly spacesProcessed: number; + readonly spaceConfigurationRecords: number; + readonly workItemConversationLinkRecords: number; + readonly conversationIds: number; +} diff --git a/src/shared/artifact/artifact.path.ts b/src/shared/artifact/artifact.path.ts new file mode 100644 index 0000000..1ea9146 --- /dev/null +++ b/src/shared/artifact/artifact.path.ts @@ -0,0 +1,123 @@ +import { Effect, FileSystem, Option, Path } from "effect"; + +import { AppError } from "../../errors.js"; +import { artifactExtension } from "./artifact.model.js"; + +export const assertArtifactPath = (path: string): Effect.Effect => + path.endsWith(artifactExtension) + ? Effect.void + : Effect.fail( + new AppError( + "artifact.invalidExtension", + `Artifact path must end with ${artifactExtension}.`, + { context: { path } }, + ), + ); + +export const removeArtifactFile = ( + path: string, +): Effect.Effect => + FileSystem.FileSystem.pipe( + Effect.flatMap((fs) => fs.remove(path, { force: true })), + Effect.ignore, + ); + +const statRequiredDirectory = ( + path: string, + outputPath: string, +): Effect.Effect => + FileSystem.FileSystem.pipe( + Effect.flatMap((fs) => fs.stat(path)), + Effect.mapError( + (cause) => + new AppError("export.outputParentMissing", "Output parent directory does not exist.", { + context: { outputPath, parent: path }, + cause, + }), + ), + ); + +const statOptional = ( + path: string, +): Effect.Effect, AppError, FileSystem.FileSystem> => + FileSystem.FileSystem.pipe( + Effect.flatMap((fs) => + fs + .exists(path) + .pipe( + Effect.flatMap((exists) => + exists ? fs.stat(path).pipe(Effect.map(Option.some)) : Effect.succeed(Option.none()), + ), + ), + ), + Effect.mapError( + (cause) => + new AppError("export.invalidOutput", "Failed to inspect output path.", { + context: { outputPath: path }, + cause, + }), + ), + ); + +export const ensureOutputTarget = ( + outputPath: string, +): Effect.Effect => + Effect.gen(function* () { + yield* assertArtifactPath(outputPath); + const path = yield* Path.Path; + const parent = path.dirname(outputPath); + const parentStat = yield* statRequiredDirectory(parent, outputPath); + if (parentStat.type !== "Directory") { + return yield* new AppError( + "export.outputParentMissing", + "Output parent path is not a directory.", + { + context: { outputPath, parent }, + }, + ); + } + + const existing = yield* statOptional(outputPath); + if (Option.isNone(existing)) { + return outputPath; + } + if (existing.value.type === "Directory") { + return yield* new AppError("export.invalidOutput", "Output path points to a directory.", { + context: { outputPath }, + }); + } + + const stem = path.basename(outputPath).slice(0, -artifactExtension.length); + const findAvailableNumberedOutputPath = ( + suffix: number, + ): Effect.Effect => + Effect.gen(function* () { + const candidate = path.join(parent, `${stem}${String(suffix)}${artifactExtension}`); + const candidateExisting = yield* statOptional(candidate); + if (Option.isNone(candidateExisting)) { + return candidate; + } + return yield* findAvailableNumberedOutputPath(suffix + 1); + }); + + return yield* findAvailableNumberedOutputPath(1); + }); + +export const moveTempArtifact = ( + tempPath: string, + outputPath: string, +): Effect.Effect => + FileSystem.FileSystem.pipe( + Effect.flatMap((fs) => fs.rename(tempPath, outputPath)), + Effect.mapError( + (cause) => + new AppError("export.invalidOutput", "Failed to move completed artifact into place.", { + context: { tempPath, outputPath }, + cause, + }), + ), + ); + +export const removeTempArtifact = ( + tempPath: string, +): Effect.Effect => removeArtifactFile(tempPath); diff --git a/src/shared/artifact/artifact.reader.test.ts b/src/shared/artifact/artifact.reader.test.ts new file mode 100644 index 0000000..27def7c --- /dev/null +++ b/src/shared/artifact/artifact.reader.test.ts @@ -0,0 +1,48 @@ +import { mkdtemp, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { gzipSync } from "node:zlib"; +import { NodeServices } from "@effect/platform-node"; +import { Effect, Layer, Stream } from "effect"; +import { describe, expect, it } from "@effect/vitest"; + +import { ArtifactReaderService } from "./index.js"; + +const tempPath = async (name: string) => join(await mkdtemp(join(tmpdir(), "ifj-")), name); +const artifactReaderLayer = ArtifactReaderService.layer.pipe(Layer.provide(NodeServices.layer)); + +describe("artifact reader", () => { + it.effect("rejects blank JSONL lines with a record number", () => + Effect.gen(function* () { + const path = yield* Effect.promise(() => tempPath("blank.jsonl.gz")); + yield* Effect.promise(() => + writeFile( + path, + gzipSync( + [ + JSON.stringify({ + type: "manifest", + createdAt: "2026-06-05T00:00:00.000Z", + source: "https://example.atlassian.net", + }), + "", + JSON.stringify({ + type: "spaceConfiguration", + spaceKey: "ENG", + configuration: {}, + }), + ].join("\n"), + ), + ), + ); + + const error = yield* ArtifactReaderService.use((service) => + service.read(path).pipe(Stream.runDrain), + ).pipe(Effect.flip, Effect.provide(artifactReaderLayer)); + expect(error).toMatchObject({ + code: "artifact.blankLine", + context: { recordNumber: 2 }, + }); + }), + ); +}); diff --git a/src/shared/artifact/artifact.reader.ts b/src/shared/artifact/artifact.reader.ts new file mode 100644 index 0000000..c764ece --- /dev/null +++ b/src/shared/artifact/artifact.reader.ts @@ -0,0 +1,156 @@ +import { createGunzip } from "node:zlib"; +import { NodeStream } from "@effect/platform-node"; +import { Context, Effect, FileSystem, Layer, Ref, Schema, Stream } from "effect"; + +import { AppError } from "../../errors.js"; +import { ArtifactRecord } from "./artifact.model.js"; +import { assertArtifactPath } from "./artifact.path.js"; + +const decodeArtifactRecord = ( + record: unknown, + recordNumber: number, +): Effect.Effect => + Schema.decodeUnknownEffect(ArtifactRecord)(record).pipe( + Effect.mapError( + (cause) => + new AppError( + "artifact.invalidRecord", + `Invalid artifact record at line ${String(recordNumber)}.`, + { + context: { recordNumber }, + cause, + }, + ), + ), + ); + +const validateManifestPosition = ( + record: ArtifactRecord, + recordNumber: number, +): Effect.Effect => { + if (recordNumber === 1 && record.type !== "manifest") { + return Effect.fail( + new AppError("artifact.manifestMissing", "First artifact record must be a manifest.", { + context: { recordNumber }, + }), + ); + } + if (recordNumber > 1 && record.type === "manifest") { + return Effect.fail( + new AppError( + "artifact.manifestMisplaced", + `Manifest record is only allowed at record 1, found at record ${String(recordNumber)}.`, + { context: { recordNumber } }, + ), + ); + } + return Effect.succeed(record); +}; + +const parseArtifactLine = ( + line: string, + recordNumber: number, +): Effect.Effect => { + if (line.length === 0) { + return Effect.fail( + new AppError("artifact.blankLine", `Blank line at record ${String(recordNumber)}.`, { + context: { recordNumber }, + }), + ); + } + + return Effect.try({ + try: () => JSON.parse(line) as unknown, + catch: (cause) => + new AppError("artifact.invalidJson", `Invalid JSON at record ${String(recordNumber)}.`, { + context: { recordNumber }, + cause, + }), + }).pipe( + Effect.flatMap((parsed) => decodeArtifactRecord(parsed, recordNumber)), + Effect.flatMap((record) => validateManifestPosition(record, recordNumber)), + ); +}; + +const invalidGzipError = (cause: unknown): AppError => + new AppError("artifact.invalidGzip", "Artifact is not valid gzip JSON Lines.", { cause }); + +const artifactLineStream = (path: string): Stream.Stream => + Stream.unwrap( + Effect.gen(function* () { + yield* assertArtifactPath(path); + const fs = yield* FileSystem.FileSystem; + + return fs.stream(path).pipe( + Stream.mapError((cause) => invalidGzipError(cause)), + NodeStream.pipeThroughDuplex({ + evaluate: createGunzip, + onError: invalidGzipError, + }), + Stream.decodeText, + Stream.splitLines, + ); + }), + ); + +const readArtifactRecords = ( + path: string, +): Stream.Stream => + Stream.unwrap( + Effect.gen(function* () { + const sawManifest = yield* Ref.make(false); + let recordNumber = 0; + const records = artifactLineStream(path).pipe( + Stream.mapEffect((line) => + Effect.sync(() => { + recordNumber += 1; + return recordNumber; + }).pipe( + Effect.flatMap((recordNumber) => + parseArtifactLine(line, recordNumber).pipe( + Effect.tap((record) => + record.type === "manifest" ? Ref.set(sawManifest, true) : Effect.void, + ), + ), + ), + ), + ), + ); + const requireManifest = Stream.fromEffect( + Ref.get(sawManifest).pipe( + Effect.flatMap((saw) => + saw + ? Effect.void + : Effect.fail( + new AppError( + "artifact.manifestMissing", + "Artifact is empty or missing its manifest.", + ), + ), + ), + ), + ).pipe(Stream.drain); + + return records.pipe(Stream.concat(requireManifest)); + }), + ); + +export class ArtifactReaderService extends Context.Service< + ArtifactReaderService, + { + readonly read: (path: string) => Stream.Stream; + } +>()("ifj/artifact/ArtifactReaderService") { + static readonly layer: Layer.Layer = + Layer.effect( + ArtifactReaderService, + FileSystem.FileSystem.pipe( + Effect.map((fs) => + ArtifactReaderService.of({ + read: (path) => + readArtifactRecords(path).pipe(Stream.provideService(FileSystem.FileSystem, fs)), + }), + ), + ), + ); +} diff --git a/src/shared/artifact/artifact.writer.test.ts b/src/shared/artifact/artifact.writer.test.ts new file mode 100644 index 0000000..0cf3053 --- /dev/null +++ b/src/shared/artifact/artifact.writer.test.ts @@ -0,0 +1,56 @@ +import { NodeServices } from "@effect/platform-node"; +import { mkdtemp, readFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Effect, Layer, Stream } from "effect"; +import { describe, expect, it } from "@effect/vitest"; + +import { ArtifactReaderService, ArtifactWriterService } from "./index.js"; + +const tempPath = async (name: string) => join(await mkdtemp(join(tmpdir(), "ifj-")), name); +const artifactReaderLayer = ArtifactReaderService.layer.pipe(Layer.provide(NodeServices.layer)); +const artifactWriterLayer = ArtifactWriterService.layer.pipe(Layer.provide(NodeServices.layer)); + +describe("artifact writer", () => { + it.effect("writes compressed JSONL records", () => + Effect.gen(function* () { + const path = yield* Effect.promise(() => tempPath("migration.jsonl.gz")); + + yield* ArtifactWriterService.use((service) => + service.write( + path, + Stream.fromIterable([ + { + type: "manifest", + createdAt: "2026-06-05T00:00:00.000Z", + source: "https://example.atlassian.net", + }, + { + type: "spaceConfiguration", + spaceKey: "ENG", + configuration: { enabled: true }, + }, + { + type: "workItemConversationLinks", + spaceKey: "ENG", + workItemKey: "ENG-1", + conversationIds: ["abc", "def"], + }, + ]), + ), + ).pipe(Effect.provide(artifactWriterLayer)); + + const records = yield* ArtifactReaderService.use((service) => + service.read(path).pipe(Stream.runCollect), + ).pipe(Effect.provide(artifactReaderLayer)); + expect(records).toHaveLength(3); + expect(records[0]).toMatchObject({ + type: "manifest", + source: "https://example.atlassian.net", + }); + + const raw = yield* Effect.promise(() => readFile(path)); + expect(raw[0]).not.toBe("{".charCodeAt(0)); + }), + ); +}); diff --git a/src/shared/artifact/artifact.writer.ts b/src/shared/artifact/artifact.writer.ts new file mode 100644 index 0000000..df41980 --- /dev/null +++ b/src/shared/artifact/artifact.writer.ts @@ -0,0 +1,121 @@ +import { createGzip } from "node:zlib"; +import { NodeStream } from "@effect/platform-node"; +import { Context, Crypto, Effect, FileSystem, Layer, Path, Schema, Stream } from "effect"; + +import { AppError } from "../../errors.js"; +import { ArtifactRecord } from "./artifact.model.js"; +import { + ensureOutputTarget, + moveTempArtifact, + removeArtifactFile, + removeTempArtifact, +} from "./artifact.path.js"; + +const encodeArtifactRecord = (record: ArtifactRecord): Effect.Effect => + Schema.encodeUnknownEffect(ArtifactRecord)(record).pipe( + Effect.mapError( + (cause) => + new AppError("artifact.invalidRecord", "Artifact writer received an invalid record.", { + cause, + }), + ), + ); + +const invalidArtifactWriteError = (cause: unknown): AppError => + cause instanceof AppError + ? cause + : new AppError("artifact.invalidRecord", "Failed to write artifact record.", { cause }); + +const tempSiblingPath = ( + outputPath: string, +): Effect.Effect => + Effect.gen(function* () { + const path = yield* Path.Path; + const crypto = yield* Crypto.Crypto; + const nonce = yield* crypto.randomUUIDv4.pipe( + Effect.mapError( + (cause) => + new AppError("export.invalidOutput", "Failed to create a temporary artifact path.", { + context: { outputPath }, + cause, + }), + ), + ); + return path.join(path.dirname(outputPath), `.${path.basename(outputPath)}.${nonce}.tmp`); + }); + +const recordsToCompressedBytes = ( + records: Stream.Stream, +): Stream.Stream => + records.pipe( + Stream.mapEffect(encodeArtifactRecord), + Stream.map((record) => `${JSON.stringify(record)}\n`), + Stream.encodeText, + NodeStream.pipeThroughDuplex({ + evaluate: createGzip, + onError: invalidArtifactWriteError, + }), + ); + +const writeCompressedArtifact = ( + path: string, + records: Stream.Stream, +): Effect.Effect => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* recordsToCompressedBytes(records).pipe( + Stream.run(fs.sink(path, { flag: "wx" })), + Effect.mapError(invalidArtifactWriteError), + ); + }).pipe( + Effect.catch((error) => removeArtifactFile(path).pipe(Effect.andThen(Effect.fail(error)))), + ); + +const writeArtifactRecords = ( + requestedPath: string, + records: Stream.Stream, +): Effect.Effect => + Effect.gen(function* () { + const outputPath = yield* ensureOutputTarget(requestedPath); + const tempPath = yield* tempSiblingPath(outputPath); + + return yield* writeCompressedArtifact(tempPath, records).pipe( + Effect.andThen(moveTempArtifact(tempPath, outputPath)), + Effect.as(outputPath), + Effect.catch((error) => + removeTempArtifact(tempPath).pipe(Effect.andThen(Effect.fail(error))), + ), + ); + }); + +export class ArtifactWriterService extends Context.Service< + ArtifactWriterService, + { + readonly write: ( + requestedPath: string, + records: Stream.Stream, + ) => Effect.Effect; + } +>()("ifj/artifact/ArtifactWriterService") { + static readonly layer: Layer.Layer< + ArtifactWriterService, + never, + Crypto.Crypto | FileSystem.FileSystem | Path.Path + > = Layer.effect( + ArtifactWriterService, + Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + return ArtifactWriterService.of({ + write: (requestedPath, records) => + writeArtifactRecords(requestedPath, records).pipe( + Effect.provideService(Crypto.Crypto, crypto), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ), + }); + }), + ); +} diff --git a/src/shared/artifact/index.ts b/src/shared/artifact/index.ts new file mode 100644 index 0000000..14e949a --- /dev/null +++ b/src/shared/artifact/index.ts @@ -0,0 +1,3 @@ +export * from "./artifact.model.js"; +export * from "./artifact.reader.js"; +export * from "./artifact.writer.js"; diff --git a/src/shared/config/config.model.ts b/src/shared/config/config.model.ts new file mode 100644 index 0000000..52f8b9f --- /dev/null +++ b/src/shared/config/config.model.ts @@ -0,0 +1,38 @@ +import { Schema, SchemaGetter } from "effect"; + +export const TrimmedNonEmptyString = Schema.Trim.pipe(Schema.decodeTo(Schema.NonEmptyString)); + +const canonicalizeJiraCloudSourceUrl = (url: URL): string => `${url.protocol}//${url.hostname}`; + +export const JiraCloudSource: Schema.Codec = Schema.Trim.pipe( + Schema.decodeTo(Schema.URLFromString), +) + .check( + Schema.makeFilter((url) => url.protocol === "https:" || "Source must use HTTPS."), + Schema.makeFilter( + (url) => + url.hostname.endsWith(".atlassian.net") || + "Source host must be a Jira Cloud atlassian.net site.", + ), + ) + .pipe( + Schema.decodeTo(Schema.String, { + decode: SchemaGetter.transform(canonicalizeJiraCloudSourceUrl), + encode: SchemaGetter.transform((source: string) => new URL(source)), + }), + ); + +export const RedactedNonEmptyString = Schema.Redacted(TrimmedNonEmptyString); + +export const normalizeSpaceList = (values: readonly string[]): readonly string[] => { + const seen = new Set(); + for (const value of values) { + for (const piece of value.split(",")) { + const space = piece.trim(); + if (space.length > 0) { + seen.add(space); + } + } + } + return [...seen].sort((a, b) => a.localeCompare(b)); +}; diff --git a/src/shared/config/config.provider.ts b/src/shared/config/config.provider.ts new file mode 100644 index 0000000..fb061cc --- /dev/null +++ b/src/shared/config/config.provider.ts @@ -0,0 +1,34 @@ +import { ConfigProvider, Effect, type FileSystem } from "effect"; + +import { AppError } from "../../errors.js"; + +const emptyConfigProvider = ConfigProvider.fromUnknown({}); + +const optionalDotEnvProvider: Effect.Effect< + ConfigProvider.ConfigProvider, + AppError, + FileSystem.FileSystem +> = ConfigProvider.fromDotEnv().pipe( + Effect.catchIf( + (error) => error.reason._tag === "NotFound", + () => Effect.succeed(emptyConfigProvider), + ), + Effect.mapError( + (cause) => + new AppError("config.missing", "Failed to read .env file.", { + context: { path: ".env" }, + cause, + }), + ), +); + +export const runtimeConfigProvider: Effect.Effect< + ConfigProvider.ConfigProvider, + AppError, + FileSystem.FileSystem +> = Effect.gen(function* () { + const envProvider = yield* ConfigProvider.ConfigProvider; + const dotenvProvider = yield* optionalDotEnvProvider; + + return envProvider.pipe(ConfigProvider.orElse(dotenvProvider)); +}); diff --git a/src/shared/config/export.config.ts b/src/shared/config/export.config.ts new file mode 100644 index 0000000..60041f6 --- /dev/null +++ b/src/shared/config/export.config.ts @@ -0,0 +1,41 @@ +import { Config, Schema, type Redacted } from "effect"; + +import { + JiraCloudSource, + normalizeSpaceList, + RedactedNonEmptyString, + TrimmedNonEmptyString, +} from "./config.model.js"; + +const defaultExportFileName = "intercom-for-jira-export.jsonl.gz"; + +export interface ExportConfig { + readonly source: string; + readonly user: string; + readonly apiToken: Redacted.Redacted; + readonly out: string; + readonly spaces: readonly string[]; + readonly json: boolean; +} + +export const sourceConfig: Config.Config = Config.schema(JiraCloudSource, "EXPORT_SOURCE"); + +export const userConfig: Config.Config = Config.schema( + TrimmedNonEmptyString, + "EXPORT_USER", +); + +export const apiTokenConfig: Config.Config = Config.schema( + RedactedNonEmptyString, + "EXPORT_API_TOKEN", +); + +export const outConfig: Config.Config = Config.schema( + TrimmedNonEmptyString, + "EXPORT_OUT", +).pipe(Config.withDefault(defaultExportFileName)); + +export const spacesConfig: Config.Config = Config.schema( + Config.Array(Schema.String), + "EXPORT_SPACES", +).pipe(Config.map(normalizeSpaceList), Config.withDefault([])); diff --git a/src/shared/config/index.ts b/src/shared/config/index.ts new file mode 100644 index 0000000..9d786c9 --- /dev/null +++ b/src/shared/config/index.ts @@ -0,0 +1,4 @@ +export * from "./config.model.js"; +export * from "./config.provider.js"; +export * from "./export.config.js"; +export * from "./inspect.config.js"; diff --git a/src/shared/config/inspect.config.ts b/src/shared/config/inspect.config.ts new file mode 100644 index 0000000..5a12997 --- /dev/null +++ b/src/shared/config/inspect.config.ts @@ -0,0 +1,8 @@ +import { Config } from "effect"; + +import { TrimmedNonEmptyString } from "./config.model.js"; + +export const inspectArtifactPathConfig: Config.Config = Config.schema( + TrimmedNonEmptyString, + "INSPECT_ARTIFACT_PATH", +); diff --git a/src/shared/jira/index.ts b/src/shared/jira/index.ts new file mode 100644 index 0000000..b75554c --- /dev/null +++ b/src/shared/jira/index.ts @@ -0,0 +1,3 @@ +export * from "./jira.client.js"; +export * from "./jira.model.js"; +export * from "./retry.util.js"; diff --git a/src/shared/jira/jira.client.test.ts b/src/shared/jira/jira.client.test.ts new file mode 100644 index 0000000..41dead6 --- /dev/null +++ b/src/shared/jira/jira.client.test.ts @@ -0,0 +1,453 @@ +import { Effect, Fiber, Layer, Redacted, Stream } from "effect"; +import { HttpClient, HttpClientError, HttpClientResponse } from "effect/unstable/http"; +import { TestClock } from "effect/testing"; +import { describe, expect, it } from "@effect/vitest"; + +import { JiraClient } from "./jira.client.js"; + +const testCredentials = { + source: "https://example.atlassian.net", + user: "admin@example.com", + apiToken: Redacted.make("secret"), +}; + +const jiraClientLayer = ( + httpLayer: Layer.Layer, +): Layer.Layer => + JiraClient.layerNoDeps(testCredentials).pipe(Layer.provide(httpLayer)); + +describe("Jira HTTP client", () => { + it.effect("uses the Effect HTTP client with auth headers and decodes Jira responses", () => + Effect.gen(function* () { + const requests: { readonly url: string; readonly authorization: string | undefined }[] = []; + const httpLayer = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request, url) => + Effect.sync(() => { + requests.push({ + url: url.toString(), + authorization: request.headers["authorization"], + }); + return HttpClientResponse.fromWeb( + request, + new Response( + JSON.stringify({ + permissions: { + ADMINISTER: { + havePermission: true, + }, + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + }), + ), + ); + + const permissions = yield* Effect.gen(function* () { + const jiraClient = yield* JiraClient; + return yield* jiraClient.getMyPermissions(["ADMINISTER"]); + }).pipe(Effect.provide(jiraClientLayer(httpLayer))); + + expect(permissions["ADMINISTER"]?.havePermission).toBe(true); + expect(requests).toEqual([ + { + url: "https://example.atlassian.net/rest/api/3/mypermissions?permissions=ADMINISTER", + authorization: "Basic YWRtaW5AZXhhbXBsZS5jb206c2VjcmV0", + }, + ]); + }), + ); + + it.effect("retries transient HTTP transport failures before decoding Jira responses", () => + Effect.gen(function* () { + let requestCount = 0; + const httpLayer = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.gen(function* () { + requestCount += 1; + if (requestCount === 1) { + return yield* new HttpClientError.HttpClientError({ + reason: new HttpClientError.TransportError({ + request, + cause: new Error("connection reset"), + }), + }); + } + return HttpClientResponse.fromWeb( + request, + new Response( + JSON.stringify({ + permissions: { + ADMINISTER: { + havePermission: true, + }, + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + }), + ), + ); + + const fiber = yield* Effect.gen(function* () { + const jiraClient = yield* JiraClient; + yield* jiraClient.getMyPermissions(["ADMINISTER"]); + }).pipe(Effect.provide(jiraClientLayer(httpLayer)), Effect.forkChild); + yield* Effect.yieldNow; + expect(requestCount).toBe(1); + + yield* TestClock.adjust("250 millis"); + yield* Fiber.join(fiber); + expect(requestCount).toBe(2); + }), + ); + + it.effect("retries retryable Jira status responses before decoding Jira responses", () => + Effect.gen(function* () { + let requestCount = 0; + const httpLayer = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.sync(() => { + requestCount += 1; + return HttpClientResponse.fromWeb( + request, + requestCount === 1 + ? new Response("Service unavailable", { status: 503 }) + : new Response( + JSON.stringify({ + permissions: { + ADMINISTER: { + havePermission: true, + }, + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + }), + ), + ); + + const fiber = yield* Effect.gen(function* () { + const jiraClient = yield* JiraClient; + yield* jiraClient.getMyPermissions(["ADMINISTER"]); + }).pipe(Effect.provide(jiraClientLayer(httpLayer)), Effect.forkChild); + yield* Effect.yieldNow; + expect(requestCount).toBe(1); + + yield* TestClock.adjust("250 millis"); + yield* Fiber.join(fiber); + expect(requestCount).toBe(2); + }), + ); + + it.effect("searches projects with query params and properties", () => + Effect.gen(function* () { + const requests: string[] = []; + const httpLayer = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request, url) => + Effect.sync(() => { + requests.push(url.toString()); + return HttpClientResponse.fromWeb( + request, + new Response( + JSON.stringify({ + values: [ + { + id: "10000", + key: "ENG", + name: "Engineering", + properties: { + "example.property": { enabled: true }, + }, + }, + ], + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + }), + ), + ); + + const spaces = yield* Effect.gen(function* () { + const jiraClient = yield* JiraClient; + return yield* jiraClient + .searchProjectSpaces({ + propertyQuery: "[intercomIntegrationStatus]=true", + properties: ["example.property"], + }) + .pipe(Stream.runCollect); + }).pipe(Effect.provide(jiraClientLayer(httpLayer))); + + expect(spaces.map((space) => space.key)).toEqual(["ENG"]); + expect(spaces[0]?.properties).toEqual({ "example.property": { enabled: true } }); + + const searchUrl = new URL(requests[0] ?? ""); + expect(searchUrl.pathname).toBe("/rest/api/3/project/search"); + expect(searchUrl.searchParams.get("properties")).toBe("example.property"); + expect(searchUrl.searchParams.get("propertyQuery")).toBe("[intercomIntegrationStatus]=true"); + expect(searchUrl.searchParams.getAll("keys")).toEqual([]); + }), + ); + + it.effect("passes project search keys without adding domain validation", () => + Effect.gen(function* () { + const requests: string[] = []; + const httpLayer = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request, url) => + Effect.sync(() => { + requests.push(url.toString()); + return HttpClientResponse.fromWeb( + request, + new Response( + JSON.stringify({ + values: [ + { + key: "ENG", + properties: { + "example.property": { enabled: true }, + }, + }, + { + key: "OPS", + }, + ], + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + }), + ), + ); + + const spaces = yield* Effect.gen(function* () { + const jiraClient = yield* JiraClient; + return yield* jiraClient + .searchProjectSpaces({ keys: ["eng", "OPS"], properties: ["example.property"] }) + .pipe(Stream.runCollect); + }).pipe(Effect.provide(jiraClientLayer(httpLayer))); + + expect(spaces.map((space) => space.key)).toEqual(["ENG", "OPS"]); + expect(spaces[0]?.properties).toEqual({ "example.property": { enabled: true } }); + expect(spaces[1]?.properties).toEqual({}); + + const searchUrl = new URL(requests[0] ?? ""); + expect(searchUrl.pathname).toBe("/rest/api/3/project/search"); + expect(searchUrl.searchParams.get("properties")).toBe("example.property"); + expect(searchUrl.searchParams.get("propertyQuery")).toBeNull(); + expect(searchUrl.searchParams.getAll("keys")).toEqual(["eng", "OPS"]); + }), + ); + + it.effect("counts work items with the approximate count endpoint and caller-provided JQL", () => + Effect.gen(function* () { + const requests: { + readonly method: string; + readonly url: string; + readonly contentType: string | undefined; + readonly body: unknown; + }[] = []; + const httpLayer = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request, url) => + Effect.sync(() => { + requests.push({ + method: request.method, + url: url.toString(), + contentType: request.headers["content-type"], + body: request.body.toJSON(), + }); + return HttpClientResponse.fromWeb( + request, + new Response(JSON.stringify({ count: 153 }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + }), + ), + ); + + const count = yield* Effect.gen(function* () { + const jiraClient = yield* JiraClient; + return yield* jiraClient.approximateSearchCount( + 'project in ("ENG", "OPS") AND linkedIntercomConversationCount > 0', + ); + }).pipe(Effect.provide(jiraClientLayer(httpLayer))); + + expect(count).toBe(153); + expect(requests).toHaveLength(1); + expect(requests[0]?.method).toBe("POST"); + expect(requests[0]?.contentType).toBe("application/json"); + expect(requests[0]?.body).toMatchObject({ + _id: "effect/HttpBody", + _tag: "Uint8Array", + body: JSON.stringify({ + jql: 'project in ("ENG", "OPS") AND linkedIntercomConversationCount > 0', + }), + contentType: "application/json", + }); + + const countUrl = new URL(requests[0]?.url ?? ""); + expect(countUrl.pathname).toBe("/rest/api/3/search/approximate-count"); + expect(countUrl.search).toBe(""); + }), + ); + + it.effect("searches work items with caller-provided properties", () => + Effect.gen(function* () { + const requests: { + readonly method: string; + readonly url: string; + readonly contentType: string | undefined; + readonly body: unknown; + }[] = []; + const httpLayer = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request, url) => + Effect.sync(() => { + requests.push({ + method: request.method, + url: url.toString(), + contentType: request.headers["content-type"], + body: request.body.toJSON(), + }); + return HttpClientResponse.fromWeb( + request, + new Response( + JSON.stringify({ + isLast: true, + issues: [ + { + key: "ENG-1", + properties: { + "example.property": { count: 1, conversationIds: ["abc"] }, + }, + }, + ], + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + }, + ), + ); + }), + ), + ); + + const hits: { readonly key: string; readonly properties: Record }[] = []; + yield* Effect.gen(function* () { + const jiraClient = yield* JiraClient; + yield* jiraClient + .searchWorkItems({ + jql: 'project in ("ENG") AND linkedIntercomConversationCount > 0 ORDER BY key ASC', + fields: ["key"], + properties: ["example.property"], + }) + .pipe( + Stream.runForEach((hit) => + Effect.sync(() => { + hits.push(hit); + }), + ), + ); + }).pipe(Effect.provide(jiraClientLayer(httpLayer))); + + expect(hits).toEqual([ + { + key: "ENG-1", + properties: { "example.property": { count: 1, conversationIds: ["abc"] } }, + }, + ]); + expect(requests).toHaveLength(1); + expect(requests[0]?.method).toBe("POST"); + expect(requests[0]?.contentType).toBe("application/json"); + expect(requests[0]?.body).toMatchObject({ + _id: "effect/HttpBody", + _tag: "Uint8Array", + body: JSON.stringify({ + jql: 'project in ("ENG") AND linkedIntercomConversationCount > 0 ORDER BY key ASC', + maxResults: 100, + fields: ["key"], + properties: ["example.property"], + }), + contentType: "application/json", + }); + + const searchUrl = new URL(requests[0]?.url ?? ""); + expect(searchUrl.pathname).toBe("/rest/api/3/search/jql"); + expect(searchUrl.search).toBe(""); + }), + ); + + it.effect("continues work item search when Jira returns a next page token", () => + Effect.gen(function* () { + const requests: { + readonly body: unknown; + }[] = []; + const httpLayer = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.sync(() => { + requests.push({ + body: request.body.toJSON(), + }); + return HttpClientResponse.fromWeb( + request, + new Response( + JSON.stringify( + requests.length === 1 + ? { + issues: [{ key: "ENG-1" }], + nextPageToken: "page-2", + } + : { + issues: [{ key: "ENG-2" }], + }, + ), + { + status: 200, + headers: { "Content-Type": "application/json" }, + }, + ), + ); + }), + ), + ); + + const hits = yield* Effect.gen(function* () { + const jiraClient = yield* JiraClient; + return yield* jiraClient + .searchWorkItems({ + jql: 'project in ("ENG") ORDER BY key ASC', + }) + .pipe(Stream.runCollect); + }).pipe(Effect.provide(jiraClientLayer(httpLayer))); + + expect(hits.map((hit) => hit.key)).toEqual(["ENG-1", "ENG-2"]); + expect(requests).toHaveLength(2); + expect(requests[0]?.body).toMatchObject({ + body: JSON.stringify({ + jql: 'project in ("ENG") ORDER BY key ASC', + maxResults: 100, + }), + }); + expect(requests[1]?.body).toMatchObject({ + body: JSON.stringify({ + jql: 'project in ("ENG") ORDER BY key ASC', + maxResults: 100, + nextPageToken: "page-2", + }), + }); + }), + ); +}); diff --git a/src/shared/jira/jira.client.ts b/src/shared/jira/jira.client.ts new file mode 100644 index 0000000..1b5dd21 --- /dev/null +++ b/src/shared/jira/jira.client.ts @@ -0,0 +1,238 @@ +import { Buffer } from "node:buffer"; +import { NodeHttpClient } from "@effect/platform-node"; +import { Context, Effect, Layer, Option, Redacted, Stream } from "effect"; +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; + +import { AppError } from "../../errors.js"; +import { + ApproximateSearchCountRequest, + CountResponse, + type JiraCredentials, + type JiraPermissions, + type JiraProjectSpace, + type JiraSearchProjectSpacesParams, + type JiraSearchWorkItemsParams, + type JiraWorkItem, + PermissionResponse, + ProjectSearch, + SearchResponse, + SearchWorkItemsRequest, +} from "./jira.model.js"; +import { jiraStatusFailure, sendWithRetry } from "./retry.util.js"; + +const pageSize = 100; + +export class JiraClient extends Context.Service< + JiraClient, + { + readonly getMyPermissions: ( + permissions: readonly string[], + ) => Effect.Effect; + readonly searchProjectSpaces: ( + params: JiraSearchProjectSpacesParams, + ) => Stream.Stream; + readonly approximateSearchCount: (jql: string) => Effect.Effect; + readonly searchWorkItems: ( + params: JiraSearchWorkItemsParams, + ) => Stream.Stream; + } +>()("ifj/JiraClient") { + static layerNoDeps( + credentials: JiraCredentials, + ): Layer.Layer { + return Layer.effect(JiraClient, makeJiraClient(credentials)); + } + + static layer(credentials: JiraCredentials): Layer.Layer { + return JiraClient.layerNoDeps(credentials).pipe(Layer.provide(NodeHttpClient.layerFetch)); + } +} + +const makeJiraClient = ( + credentials: JiraCredentials, +): Effect.Effect => + Effect.gen(function* () { + const httpClient = yield* HttpClient.HttpClient; + const authHeader = `Basic ${Buffer.from( + `${credentials.user}:${Redacted.value(credentials.apiToken)}`, + "utf8", + ).toString("base64")}`; + + const requestHeaders = { + Authorization: authHeader, + Accept: "application/json", + }; + const apiUrl = (path: string): URL => new URL(path, credentials.source); + + const getMyPermissions = Effect.fn("JiraClient.getMyPermissions")(function* ( + permissions: readonly string[], + ) { + const params = new URLSearchParams({ permissions: permissions.join(",") }); + const path = `/rest/api/3/mypermissions?${params.toString()}`; + const context = { method: "GET", path }; + const decoded = yield* sendWithRetry( + context, + httpClient.get(apiUrl(path), { headers: requestHeaders }), + (response) => + HttpClientResponse.matchStatus(response, { + "2xx": (response) => + HttpClientResponse.schemaBodyJson(PermissionResponse)(response).pipe( + Effect.mapError( + (cause) => + new AppError( + "jira.malformed", + "Jira returned a malformed permission response.", + { + context: { path }, + cause, + }, + ), + ), + ), + orElse: (response) => jiraStatusFailure(response, context), + }), + ); + return decoded.permissions; + }); + + const searchProjectSpaces = ( + params: JiraSearchProjectSpacesParams, + ): Stream.Stream => + Stream.paginate(0, (startAt) => { + const path = `/rest/api/3/project/search?${new URLSearchParams([ + ...(params.keys ?? []).map((key) => ["keys", key]), + ...(params.properties ?? []).map((property) => ["properties", property]), + ...(params.propertyQuery === undefined ? [] : [["propertyQuery", params.propertyQuery]]), + ["startAt", String(startAt)], + ["maxResults", String(pageSize)], + ]).toString()}`; + const context = { method: "GET", path }; + + return sendWithRetry( + context, + httpClient.get(apiUrl(path), { headers: requestHeaders }), + (response) => + HttpClientResponse.matchStatus(response, { + 404: () => + Effect.succeed([[] as readonly JiraProjectSpace[], Option.none()] as const), + "2xx": (response) => + HttpClientResponse.schemaBodyJson(ProjectSearch)(response).pipe( + Effect.mapError( + (cause) => + new AppError( + "jira.malformed", + "Jira returned a malformed space search response.", + { + context: { path: "/rest/api/3/project/search" }, + cause, + }, + ), + ), + Effect.map( + (decoded) => + [ + decoded.values, + decoded.values.length < pageSize + ? Option.none() + : Option.some(startAt + decoded.values.length), + ] as const, + ), + ), + orElse: (response) => jiraStatusFailure(response, context), + }), + ); + }); + + const approximateSearchCount = Effect.fn("JiraClient.approximateSearchCount")(function* ( + jql: string, + ) { + const path = "/rest/api/3/search/approximate-count"; + const context = { method: "POST", path, jql }; + const request = yield* HttpClientRequest.post(apiUrl(path), { headers: requestHeaders }).pipe( + HttpClientRequest.schemaBodyJson(ApproximateSearchCountRequest)({ jql }), + Effect.mapError( + (cause) => + new AppError("jira.malformed", "Could not encode Jira request body.", { + context, + cause, + }), + ), + ); + const decoded = yield* sendWithRetry(context, httpClient.execute(request), (response) => + HttpClientResponse.matchStatus(response, { + "2xx": (response) => + HttpClientResponse.schemaBodyJson(CountResponse)(response).pipe( + Effect.mapError( + (cause) => + new AppError( + "jira.malformed", + "Jira returned a malformed approximate count response.", + { + context: { path, jql }, + cause, + }, + ), + ), + ), + orElse: (response) => jiraStatusFailure(response, context), + }), + ); + return decoded.count; + }); + + const searchWorkItems = ( + params: JiraSearchWorkItemsParams, + ): Stream.Stream => + Stream.paginate(Option.none(), (nextPageToken) => + Effect.gen(function* () { + const path = "/rest/api/3/search/jql"; + const context = { method: "POST", path, jql: params.jql }; + const request = yield* HttpClientRequest.post(apiUrl(path), { + headers: requestHeaders, + }).pipe( + HttpClientRequest.schemaBodyJson(SearchWorkItemsRequest)({ + jql: params.jql, + maxResults: pageSize, + ...(params.fields === undefined ? {} : { fields: params.fields }), + ...(params.properties === undefined ? {} : { properties: params.properties }), + ...(Option.isNone(nextPageToken) ? {} : { nextPageToken: nextPageToken.value }), + }), + Effect.mapError( + (cause) => + new AppError("jira.malformed", "Could not encode Jira request body.", { + context, + cause, + }), + ), + ); + const response = yield* sendWithRetry(context, httpClient.execute(request), (response) => + HttpClientResponse.matchStatus(response, { + "2xx": (response) => + HttpClientResponse.schemaBodyJson(SearchResponse)(response).pipe( + Effect.mapError( + (cause) => + new AppError("jira.malformed", "Jira returned a malformed search response.", { + context: { path, jql: params.jql }, + cause, + }), + ), + ), + orElse: (response) => jiraStatusFailure(response, context), + }), + ); + return [ + response.issues, + response.nextPageToken === undefined + ? Option.none>() + : Option.some(Option.some(response.nextPageToken)), + ] as const; + }), + ); + + return JiraClient.of({ + getMyPermissions, + searchProjectSpaces, + approximateSearchCount, + searchWorkItems, + }); + }); diff --git a/src/shared/jira/jira.model.ts b/src/shared/jira/jira.model.ts new file mode 100644 index 0000000..6ac6bc7 --- /dev/null +++ b/src/shared/jira/jira.model.ts @@ -0,0 +1,79 @@ +import { Effect, Schema, SchemaGetter } from "effect"; + +export const JiraProperties = Schema.Record(Schema.String, Schema.Unknown); + +export const JiraCredentials = Schema.Struct({ + source: Schema.String, + user: Schema.String, + apiToken: Schema.Redacted(Schema.String), +}); +export type JiraCredentials = Schema.Schema.Type; + +export const ApproximateSearchCountRequest = Schema.Struct({ + jql: Schema.String, +}); + +export const JiraSearchProjectSpacesParams = Schema.Struct({ + keys: Schema.optionalKey(Schema.Array(Schema.String)), + propertyQuery: Schema.optionalKey(Schema.String), + properties: Schema.optionalKey(Schema.Array(Schema.String)), +}); +export type JiraSearchProjectSpacesParams = Schema.Schema.Type< + typeof JiraSearchProjectSpacesParams +>; + +export const SearchWorkItemsRequest = Schema.Struct({ + jql: Schema.String, + maxResults: Schema.Number, + fields: Schema.optionalKey(Schema.Array(Schema.String)), + properties: Schema.optionalKey(Schema.Array(Schema.String)), + nextPageToken: Schema.optionalKey(Schema.String), +}); +export type JiraSearchWorkItemsParams = Omit< + Schema.Schema.Type, + "maxResults" | "nextPageToken" +>; + +const JiraPropertiesField = Schema.optionalKey(JiraProperties).pipe( + Schema.decodeTo(JiraProperties, { + decode: SchemaGetter.withDefault(Effect.succeed({})), + encode: SchemaGetter.passthrough(), + }), +); + +export const PermissionResponse = Schema.Struct({ + permissions: Schema.Record( + Schema.String, + Schema.Struct({ + havePermission: Schema.Boolean, + }), + ), +}); +export type JiraPermissions = Schema.Schema.Type["permissions"]; + +export const ProjectSearch = Schema.Struct({ + values: Schema.Array( + Schema.Struct({ + key: Schema.String, + properties: JiraPropertiesField, + }), + ), +}); +export type JiraProjectSpace = Schema.Schema.Type["values"][number]; + +export const SearchResponse = Schema.Struct({ + issues: Schema.Array( + Schema.Struct({ + key: Schema.String, + properties: JiraPropertiesField, + }), + ), + isLast: Schema.optionalKey(Schema.Boolean), + nextPageToken: Schema.optionalKey(Schema.String), + total: Schema.optionalKey(Schema.Number), +}); +export type JiraWorkItem = Schema.Schema.Type["issues"][number]; + +export const CountResponse = Schema.Struct({ + count: Schema.Number, +}); diff --git a/src/shared/jira/retry.util.ts b/src/shared/jira/retry.util.ts new file mode 100644 index 0000000..1307e63 --- /dev/null +++ b/src/shared/jira/retry.util.ts @@ -0,0 +1,169 @@ +import { Cause, Duration, Effect, Option, Schedule, Schema } from "effect"; +import { Headers } from "effect/unstable/http"; +import type { HttpClientResponse } from "effect/unstable/http"; + +import { AppError } from "../../errors.js"; + +type ResponseHeaders = Headers.Headers; + +interface RetryPolicy { + readonly maxAttempts: number; + readonly requestTimeoutMillis: number; + readonly baseDelayMillis: number; + readonly retryableStatuses: ReadonlySet; +} + +export const defaultRetryPolicy: RetryPolicy = { + maxAttempts: 4, + requestTimeoutMillis: 60_000, + baseDelayMillis: 250, + retryableStatuses: new Set([429, 500, 502, 503, 504]), +}; + +const ResponseHeadersSchema = Schema.Record(Schema.String, Schema.String); + +const rateLimitHeaders = (headers: ResponseHeaders): Record => { + const allowed: Record = {}; + for (const [key, value] of Object.entries(headers)) { + const normalized = key.toLowerCase(); + if ( + normalized === "retry-after" || + normalized === "ratelimit-reason" || + normalized.startsWith("x-ratelimit") || + normalized.startsWith("x-beta-ratelimit") || + normalized.startsWith("beta-ratelimit") + ) { + allowed[normalized] = value; + } + } + return allowed; +}; + +const retryAfterMs = (headers: ResponseHeaders, nowMillis: number): number | undefined => { + const retryAfter = Option.getOrUndefined(Headers.get(headers, "retry-after")); + if (retryAfter === undefined) { + return undefined; + } + const seconds = Number(retryAfter); + if (Number.isFinite(seconds)) { + return Math.max(0, seconds * 1000); + } + const dateMs = Date.parse(retryAfter); + return Number.isFinite(dateMs) ? Math.max(0, dateMs - nowMillis) : undefined; +}; + +const retryDelay = ( + policy: RetryPolicy, + headers: ResponseHeaders, + attempt: number, + nowMillis: number, +): number => retryAfterMs(headers, nowMillis) ?? policy.baseDelayMillis * 2 ** (attempt - 1); + +const transientRequestError = (cause: unknown, context: Record): AppError => + new AppError("jira.transient", "Jira request failed.", { + cause, + context: { + ...context, + headers: rateLimitHeaders(Headers.empty), + retryable: true, + }, + }); + +const retryableErrorHeaders = (error: AppError): ResponseHeaders => { + const headers = Schema.decodeUnknownOption(ResponseHeadersSchema)(error.context["headers"]); + return Option.match(headers, { + onNone: () => Headers.empty, + onSome: Headers.fromInput, + }); +}; + +const isRetryableError = (error: AppError): boolean => + error.code === "jira.transient" && error.context["retryable"] === true; + +export const jiraStatusFailure = ( + response: HttpClientResponse.HttpClientResponse, + context: Record, + policy: RetryPolicy = defaultRetryPolicy, +): Effect.Effect => + response.text.pipe( + Effect.option, + Effect.flatMap((body) => { + const failureContext = { + ...context, + status: response.status, + ...body.pipe( + Option.map((b) => ({ body: b })), + Option.getOrElse(() => ({})), + ), + }; + if (response.status === 401) { + return Effect.fail( + new AppError("jira.auth", "Jira authentication failed.", { + context: failureContext, + }), + ); + } + if (response.status === 403) { + return Effect.fail( + new AppError("jira.permission", "Jira authorization failed.", { + context: failureContext, + }), + ); + } + if (policy.retryableStatuses.has(response.status)) { + return Effect.fail( + new AppError( + "jira.transient", + `Jira request failed with status ${String(response.status)}.`, + { + context: { + ...failureContext, + headers: rateLimitHeaders(response.headers), + retryable: true, + }, + }, + ), + ); + } + return Effect.fail( + new AppError( + "jira.request", + `Jira request failed with status ${String(response.status)}.`, + { + context: failureContext, + }, + ), + ); + }), + ); + +export const sendWithRetry = ( + context: Record, + request: Effect.Effect, + handleResponse: (response: HttpClientResponse.HttpClientResponse) => Effect.Effect, + policy: RetryPolicy = defaultRetryPolicy, +): Effect.Effect => + request.pipe( + Effect.timeout(policy.requestTimeoutMillis), + Effect.mapError((cause) => transientRequestError(cause, context)), + Effect.flatMap(handleResponse), + Effect.retry( + Schedule.fromStepWithMetadata, never, never>( + Effect.succeed((metadata) => + isRetryableError(metadata.input) && metadata.attempt < policy.maxAttempts + ? Effect.succeed([ + metadata.attempt, + Duration.millis( + retryDelay( + policy, + retryableErrorHeaders(metadata.input), + metadata.attempt, + metadata.now, + ), + ), + ]) + : Cause.done(metadata.attempt), + ), + ), + ), + ); diff --git a/src/warnings.ts b/src/warnings.ts new file mode 100644 index 0000000..39ebf15 --- /dev/null +++ b/src/warnings.ts @@ -0,0 +1,45 @@ +export type WarningCode = + | "APPROXIMATE_COUNT_FAILED" + | "CONFIGURATION_MALFORMED" + | "DEFAULT_SCOPE_DISCOVERY_FAILED" + | "EMPTY_EXPLICIT_SPACE" + | "EMPTY_LINK_PROPERTY" + | "LINK_PROPERTY_MALFORMED"; + +interface ExportWarning { + readonly code: WarningCode; + readonly context: Record; +} + +interface WarningSummary { + readonly count: number; + readonly truncated: boolean; + readonly warnings: readonly ExportWarning[]; +} + +export class WarningCollector { + readonly #captured: ExportWarning[] = []; + #count = 0; + + constructor(readonly limit = 100) {} + + add(code: WarningCode, context: ExportWarning["context"] = {}): ExportWarning { + const warning = { code, context }; + this.#count += 1; + if (this.#captured.length < this.limit) { + this.#captured.push(warning); + } + return warning; + } + + summary(): WarningSummary { + return { + count: this.#count, + truncated: this.#count > this.#captured.length, + warnings: [...this.#captured], + }; + } +} + +export const formatWarning = (warning: ExportWarning): string => + `[warning:${warning.code}] ${JSON.stringify(warning.context)}`; diff --git a/test/index.test.ts b/test/index.test.ts deleted file mode 100644 index 35542c0..0000000 --- a/test/index.test.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Effect } from "effect"; -import { describe, expect, it } from "vitest"; - -import { greet } from "../src/index.js"; - -describe("greet", () => { - it("returns a greeting effect", async () => { - await expect(Effect.runPromise(greet("Effect"))).resolves.toBe("Hello, Effect!"); - }); -}); diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..2784c48 --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["src/**/*.test.ts", "dist/**", "node_modules/**"] +} diff --git a/tsconfig.json b/tsconfig.json index d43fa96..3919112 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,7 +17,7 @@ "noUnusedParameters": true, "forceConsistentCasingInFileNames": true, "skipLibCheck": true, - "noEmit": true, + "outDir": "dist", "types": ["node"], "plugins": [ { @@ -25,5 +25,5 @@ } ] }, - "include": ["src/**/*.ts", "test/**/*.ts", "*.config.ts"] + "include": ["src/**/*.ts", "*.config.ts"] } diff --git a/vitest.config.ts b/vitest.config.ts index f624398..ad7d958 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -3,5 +3,6 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { environment: "node", + exclude: ["dist/**", "node_modules/**"], }, });