diff --git a/bun.lock b/bun.lock index 2a1ba17b..634f3532 100644 --- a/bun.lock +++ b/bun.lock @@ -22,7 +22,7 @@ }, "apps/www": { "name": "@locusai/www", - "version": "0.25.6", + "version": "0.26.1", "dependencies": { "@next/third-parties": "^16.1.4", "@radix-ui/react-slot": "^1.2.4", @@ -48,25 +48,25 @@ }, "packages/cli": { "name": "@locusai/cli", - "version": "0.25.6", + "version": "0.26.1", "bin": { "locus": "./bin/locus.js", }, "devDependencies": { - "@locusai/sdk": "^0.25.6", + "@locusai/sdk": "^0.26.1", "@types/bun": "latest", "typescript": "^5.8.3", }, }, "packages/cron": { "name": "@locusai/locus-cron", - "version": "0.25.6", + "version": "0.26.1", "bin": { "locus-cron": "./bin/locus-cron.js", }, "dependencies": { - "@locusai/locus-pm2": "^0.25.6", - "@locusai/sdk": "^0.25.6", + "@locusai/locus-pm2": "^0.26.1", + "@locusai/sdk": "^0.26.1", }, "devDependencies": { "typescript": "^5.8.3", @@ -74,23 +74,39 @@ }, "packages/gateway": { "name": "@locusai/locus-gateway", - "version": "0.25.6", + "version": "0.26.1", "dependencies": { - "@locusai/sdk": "^0.25.6", + "@locusai/sdk": "^0.26.1", }, "devDependencies": { "typescript": "^5.8.3", }, }, + "packages/jira": { + "name": "@locusai/locus-jira", + "version": "0.1.0", + "bin": { + "locus-jira": "./bin/locus-jira.js", + }, + "dependencies": { + "@locusai/sdk": "^0.26.1", + "axios": "^1.7.0", + "open": "^10.0.0", + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.7.0", + }, + }, "packages/linear": { "name": "@locusai/locus-linear", - "version": "0.25.6", + "version": "0.26.1", "bin": { "locus-linear": "./bin/locus-linear.js", }, "dependencies": { "@linear/sdk": "^29.0.0", - "@locusai/sdk": "^0.25.6", + "@locusai/sdk": "^0.26.1", "open": "^10.0.0", }, "devDependencies": { @@ -99,7 +115,7 @@ }, "packages/pm2": { "name": "@locusai/locus-pm2", - "version": "0.25.6", + "version": "0.26.1", "dependencies": { "pm2": "^6.0.5", }, @@ -109,19 +125,19 @@ }, "packages/sdk": { "name": "@locusai/sdk", - "version": "0.25.6", + "version": "0.26.1", }, "packages/telegram": { "name": "@locusai/locus-telegram", - "version": "0.25.6", + "version": "0.26.1", "bin": { "locus-telegram": "./bin/locus-telegram.js", }, "dependencies": { "@grammyjs/runner": "^2.0.3", - "@locusai/locus-gateway": "^0.25.6", - "@locusai/locus-pm2": "^0.25.6", - "@locusai/sdk": "^0.25.6", + "@locusai/locus-gateway": "^0.26.1", + "@locusai/locus-pm2": "^0.26.1", + "@locusai/sdk": "^0.26.1", "grammy": "^1.35.0", }, "devDependencies": { @@ -375,6 +391,8 @@ "@locusai/locus-gateway": ["@locusai/locus-gateway@workspace:packages/gateway"], + "@locusai/locus-jira": ["@locusai/locus-jira@workspace:packages/jira"], + "@locusai/locus-linear": ["@locusai/locus-linear@workspace:packages/linear"], "@locusai/locus-pm2": ["@locusai/locus-pm2@workspace:packages/pm2"], @@ -573,6 +591,10 @@ "async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="], + "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], + + "axios": ["axios@1.13.6", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ=="], + "babel-jest": ["babel-jest@29.7.0", "", { "dependencies": { "@jest/transform": "^29.7.0", "@types/babel__core": "^7.1.14", "babel-plugin-istanbul": "^6.1.1", "babel-preset-jest": "^29.6.3", "chalk": "^4.0.0", "graceful-fs": "^4.2.9", "slash": "^3.0.0" }, "peerDependencies": { "@babel/core": "^7.8.0" } }, "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg=="], "babel-plugin-istanbul": ["babel-plugin-istanbul@6.1.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.0.0", "@istanbuljs/load-nyc-config": "^1.0.0", "@istanbuljs/schema": "^0.1.2", "istanbul-lib-instrument": "^5.0.4", "test-exclude": "^6.0.0" } }, "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA=="], @@ -611,6 +633,8 @@ "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], "camelcase": ["camelcase@6.3.0", "", {}, "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA=="], @@ -649,6 +673,8 @@ "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], + "commander": ["commander@2.15.1", "", {}, "sha512-VlfT9F3V0v+jr4yxPc5gg9s62/fIVWsd2Bk2iD435um1NlGMYdVCq+MjcXnhYq2icNOizHr1kK+5TI6H0Hy0ag=="], "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], @@ -687,6 +713,8 @@ "degenerator": ["degenerator@5.0.1", "", { "dependencies": { "ast-types": "^0.13.4", "escodegen": "^2.1.0", "esprima": "^4.0.1" } }, "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ=="], + "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], + "detect-indent": ["detect-indent@6.1.0", "", {}, "sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA=="], "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], @@ -699,6 +727,8 @@ "dir-glob": ["dir-glob@3.0.1", "", { "dependencies": { "path-type": "^4.0.0" } }, "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA=="], + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + "electron-to-chromium": ["electron-to-chromium@1.5.286", "", {}, "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A=="], "emittery": ["emittery@0.13.1", "", {}, "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ=="], @@ -711,6 +741,14 @@ "error-ex": ["error-ex@1.3.4", "", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ=="], + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], + + "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], @@ -755,6 +793,8 @@ "follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="], + "form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="], + "framer-motion": ["framer-motion@12.34.1", "", { "dependencies": { "motion-dom": "^12.34.1", "motion-utils": "^12.29.2", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-kcZyNaYQfvE2LlH6+AyOaJAQV4rGp5XbzfhsZpiSZcwDMfZUHhuxLWeyRzf5I7jip3qKRpuimPA9pXXfr111kQ=="], "fs-extra": ["fs-extra@7.0.1", "", { "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw=="], @@ -769,8 +809,12 @@ "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + "get-package-type": ["get-package-type@0.1.0", "", {}, "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q=="], + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + "get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="], "get-uri": ["get-uri@6.0.5", "", { "dependencies": { "basic-ftp": "^5.0.2", "data-uri-to-buffer": "^6.0.2", "debug": "^4.3.4" } }, "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg=="], @@ -785,6 +829,8 @@ "globby": ["globby@11.1.0", "", { "dependencies": { "array-union": "^2.1.0", "dir-glob": "^3.0.1", "fast-glob": "^3.2.9", "ignore": "^5.2.0", "merge2": "^1.4.1", "slash": "^3.0.0" } }, "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g=="], + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], "grammy": ["grammy@1.41.1", "", { "dependencies": { "@grammyjs/types": "3.25.0", "abort-controller": "^3.0.0", "debug": "^4.4.3", "node-fetch": "^2.7.0" } }, "sha512-wcHAQ1e7svL3fJMpDchcQVcWUmywhuepOOjHUHmMmWAwUJEIyK5ea5sbSjZd+Gy1aMpZeP8VYJa+4tP+j1YptQ=="], @@ -793,6 +839,10 @@ "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + + "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], + "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], "html-escaper": ["html-escaper@2.0.2", "", {}, "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="], @@ -985,12 +1035,18 @@ "makeerror": ["makeerror@1.0.12", "", { "dependencies": { "tmpl": "1.0.5" } }, "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg=="], + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + "merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="], "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], + "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + + "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + "mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], "minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], diff --git a/package.json b/package.json index 42955cf0..5a6393b7 100644 --- a/package.json +++ b/package.json @@ -23,8 +23,9 @@ "format": "biome check --write --formatter-enabled=true --assist-enabled=true ", "version": "changeset version", "release": "bun run build:packages && changeset publish", - "build:packages": "bun run build:sdk && bun run build:gateway && bun run build:pm2 && bun run build:cron && bun run build:linear && bun run build:telegram && bun run build:cli", + "build:packages": "bun run build:sdk && bun run build:gateway && bun run build:pm2 && bun run build:cron && bun run build:linear && bun run build:jira && bun run build:telegram && bun run build:cli", "build:linear": "cd packages/linear && bun run build", + "build:jira": "cd packages/jira && bun run build", "build:cron": "cd packages/cron && bun run build", "build:sdk": "cd packages/sdk && bun run build", "build:gateway": "cd packages/gateway && bun run build", diff --git a/packages/jira/package.json b/packages/jira/package.json new file mode 100644 index 00000000..0842da38 --- /dev/null +++ b/packages/jira/package.json @@ -0,0 +1,47 @@ +{ + "name": "@locusai/locus-jira", + "version": "0.1.0", + "description": "Fetch and execute Jira issues with Locus", + "type": "module", + "bin": { + "locus-jira": "./bin/locus-jira.js" + }, + "files": [ + "bin", + "package.json", + "README.md" + ], + "locus": { + "displayName": "Jira", + "description": "Fetch and execute Jira issues with Locus", + "commands": [ + "jira" + ], + "version": "0.1.0" + }, + "scripts": { + "build": "bun build src/cli.ts --outfile bin/locus-jira.js --target node", + "typecheck": "tsc --noEmit", + "lint": "biome lint .", + "format": "biome format --write .", + "clean": "rm -rf dist bin node_modules" + }, + "dependencies": { + "@locusai/sdk": "^0.26.1", + "axios": "^1.7.0", + "open": "^10.0.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.7.0" + }, + "keywords": [ + "locusai-package", + "locus", + "jira" + ], + "engines": { + "node": ">=18" + }, + "license": "MIT" +} diff --git a/packages/jira/src/auth/api-token.ts b/packages/jira/src/auth/api-token.ts new file mode 100644 index 00000000..1944efaa --- /dev/null +++ b/packages/jira/src/auth/api-token.ts @@ -0,0 +1,67 @@ +/** + * API Token authentication for Jira Cloud. + * + * Prompts the user for instance URL, email, and API token, + * then validates by calling GET /rest/api/3/myself with Basic auth. + */ + +import axios from "axios"; +import { handleJiraError } from "../errors.js"; +import type { JiraApiTokenCredentials } from "../types.js"; +import { normalizeUrl, prompt } from "./prompt.js"; + +/** + * Interactively prompt the user for Jira Cloud API token credentials + * and validate them by calling the /rest/api/3/myself endpoint. + */ +export async function promptForApiToken(): Promise { + process.stderr.write("\n Jira Cloud — API Token Authentication\n\n"); + + const rawUrl = await prompt( + " Jira instance URL (e.g. https://myteam.atlassian.net): " + ); + if (!rawUrl) { + throw new Error("Instance URL is required."); + } + const baseUrl = normalizeUrl(rawUrl); + + const email = await prompt(" Email: "); + if (!email) { + throw new Error("Email is required."); + } + + const apiToken = await prompt(" API token: ", true); + if (!apiToken) { + throw new Error("API token is required."); + } + + process.stderr.write(" Validating credentials...\n"); + + const encoded = Buffer.from(`${email}:${apiToken}`).toString("base64"); + + try { + const response = await axios.get(`${baseUrl}/rest/api/3/myself`, { + headers: { + Authorization: `Basic ${encoded}`, + Accept: "application/json", + }, + timeout: 15_000, + }); + + const displayName = + (response.data as Record)?.displayName ?? email; + process.stderr.write(` Authenticated as: ${displayName}\n\n`); + } catch (error) { + if (axios.isAxiosError(error)) { + handleJiraError(error); + } + throw error; + } + + return { + method: "api-token", + email, + apiToken, + baseUrl, + }; +} diff --git a/packages/jira/src/auth/oauth.ts b/packages/jira/src/auth/oauth.ts new file mode 100644 index 00000000..b4031204 --- /dev/null +++ b/packages/jira/src/auth/oauth.ts @@ -0,0 +1,340 @@ +/** + * OAuth 2.0 (3LO) authentication for Jira Cloud. + * + * Implements the full browser-based authorization code flow: + * 1. Generate CSRF state parameter + * 2. Open browser to Atlassian authorization URL + * 3. Start ephemeral HTTP server to receive callback + * 4. Exchange authorization code for tokens + * 5. Fetch Cloud ID from accessible-resources endpoint + * + * Also provides token refresh with rotating refresh token support. + */ + +import { + createServer, + type IncomingMessage, + type ServerResponse, +} from "node:http"; +import { randomBytes } from "node:crypto"; +import axios from "axios"; +import open from "open"; +import type { JiraOAuthCredentials } from "../types.js"; +import { prompt } from "./prompt.js"; + +// ─── Types ─────────────────────────────────────────────────────────────────── + +export interface JiraOAuthConfig { + clientId: string; + clientSecret: string; + callbackPort: number; + scopes: string[]; +} + +interface JiraTokenResponse { + access_token: string; + refresh_token: string; + expires_in: number; + scope: string; + token_type: string; +} + +interface JiraCloudResource { + id: string; + url: string; + name: string; + scopes: string[]; + avatarUrl: string; +} + +// ─── Constants ─────────────────────────────────────────────────────────────── + +const AUTH_URL = "https://auth.atlassian.com/authorize"; +const TOKEN_URL = "https://auth.atlassian.com/oauth/token"; +const RESOURCES_URL = + "https://api.atlassian.com/oauth/token/accessible-resources"; + +const DEFAULT_PORT = 8089; +const DEFAULT_SCOPES = [ + "read:jira-work", + "write:jira-work", + "read:jira-user", + "offline_access", +]; + +// ─── OAuth Flow ────────────────────────────────────────────────────────────── + +/** + * Interactively prompt the user for OAuth client credentials, + * then run the full OAuth 2.0 (3LO) authorization code flow. + */ +export async function promptForOAuth(): Promise { + process.stderr.write("\n Jira Cloud — OAuth 2.0 Authentication\n\n"); + process.stderr.write( + " You need an OAuth 2.0 (3LO) app registered at:\n" + + " https://developer.atlassian.com/console/myapps/\n\n" + + " Required callback URL: http://localhost:8089/callback\n\n" + ); + + const clientId = await prompt(" Client ID: "); + if (!clientId) { + throw new Error("Client ID is required."); + } + + const clientSecret = await prompt(" Client secret: ", true); + if (!clientSecret) { + throw new Error("Client secret is required."); + } + + const config: JiraOAuthConfig = { + clientId, + clientSecret, + callbackPort: DEFAULT_PORT, + scopes: DEFAULT_SCOPES, + }; + + return startOAuthFlow(config); +} + +/** + * Run the full OAuth 2.0 (3LO) authorization code flow: + * 1. Generate CSRF state + * 2. Open browser to authorization URL + * 3. Start callback server + * 4. Exchange code for tokens + * 5. Discover Cloud ID + */ +export async function startOAuthFlow( + config: JiraOAuthConfig +): Promise { + const state = randomBytes(16).toString("base64url"); + const redirectUri = `http://localhost:${config.callbackPort}/callback`; + + const authUrl = buildAuthorizationUrl(config, state, redirectUri); + + process.stderr.write(" Opening browser for authorization...\n"); + await open(authUrl); + process.stderr.write( + " If the browser did not open, visit:\n" + ` ${authUrl}\n\n` + ); + + const code = await waitForCallback(config.callbackPort, state); + + process.stderr.write(" Exchanging authorization code for tokens...\n"); + const tokens = await exchangeCodeForTokens(code, config, redirectUri); + + process.stderr.write(" Discovering Jira Cloud site...\n"); + const cloudId = await fetchCloudId(tokens.access_token); + + const expiresAt = new Date( + Date.now() + tokens.expires_in * 1000 + ).toISOString(); + + process.stderr.write(" OAuth authentication successful.\n\n"); + + return { + method: "oauth", + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresAt, + cloudId, + clientId: config.clientId, + clientSecret: config.clientSecret, + }; +} + +// ─── Authorization URL ─────────────────────────────────────────────────────── + +function buildAuthorizationUrl( + config: JiraOAuthConfig, + state: string, + redirectUri: string +): string { + const params = new URLSearchParams({ + audience: "api.atlassian.com", + client_id: config.clientId, + scope: config.scopes.join(" "), + redirect_uri: redirectUri, + state, + response_type: "code", + prompt: "consent", + }); + + return `${AUTH_URL}?${params.toString()}`; +} + +// ─── Callback Server ──────────────────────────────────────────────────────── + +/** + * Start an ephemeral HTTP server on the given port and wait for the + * OAuth callback with a valid authorization code. + * Validates the state parameter to prevent CSRF attacks. + */ +function waitForCallback(port: number, expectedState: string): Promise { + return new Promise((resolve, reject) => { + const server = createServer((req: IncomingMessage, res: ServerResponse) => { + const url = new URL(req.url ?? "/", `http://localhost:${port}`); + + if (url.pathname !== "/callback") { + res.writeHead(404); + res.end("Not found"); + return; + } + + const error = url.searchParams.get("error"); + if (error) { + const description = url.searchParams.get("error_description") ?? error; + res.writeHead(400, { "Content-Type": "text/html" }); + res.end( + "

Authorization Failed

" + + `

${description}

` + + "

You can close this window.

" + ); + server.close(); + reject(new Error(`OAuth authorization denied: ${description}`)); + return; + } + + const code = url.searchParams.get("code"); + const state = url.searchParams.get("state"); + + if (!code) { + res.writeHead(400, { "Content-Type": "text/html" }); + res.end( + "

Missing Authorization Code

" + + "

You can close this window.

" + ); + server.close(); + reject(new Error("No authorization code received in callback.")); + return; + } + + if (state !== expectedState) { + res.writeHead(400, { "Content-Type": "text/html" }); + res.end( + "

Invalid State

" + + "

CSRF validation failed. Please try again.

" + + "

You can close this window.

" + ); + server.close(); + reject( + new Error("OAuth state mismatch — possible CSRF attack. Try again.") + ); + return; + } + + res.writeHead(200, { "Content-Type": "text/html" }); + res.end( + "

Authorization Successful

" + + "

You can close this window and return to the terminal.

" + ); + + server.close(); + resolve(code); + }); + + server.on("error", (err: Error) => { + reject( + new Error( + `Failed to start callback server on port ${port}: ${err.message}` + ) + ); + }); + + server.listen(port, "127.0.0.1", () => { + process.stderr.write( + ` Waiting for authorization callback on port ${port}...\n` + ); + }); + }); +} + +// ─── Token Exchange ────────────────────────────────────────────────────────── + +/** + * Exchange an authorization code for access and refresh tokens. + */ +async function exchangeCodeForTokens( + code: string, + config: JiraOAuthConfig, + redirectUri: string +): Promise { + const response = await axios.post( + TOKEN_URL, + { + grant_type: "authorization_code", + client_id: config.clientId, + client_secret: config.clientSecret, + code, + redirect_uri: redirectUri, + }, + { timeout: 15_000 } + ); + + return response.data as JiraTokenResponse; +} + +// ─── Cloud ID Discovery ───────────────────────────────────────────────────── + +/** + * Fetch the Cloud ID from Atlassian's accessible-resources endpoint. + * If multiple sites are accessible, uses the first one. + */ +async function fetchCloudId(accessToken: string): Promise { + const response = await axios.get(RESOURCES_URL, { + headers: { Authorization: `Bearer ${accessToken}` }, + timeout: 15_000, + }); + + const resources = response.data as JiraCloudResource[]; + + if (!resources.length) { + throw new Error( + "No Jira Cloud sites found for this account. " + + "Ensure your OAuth app has the correct scopes and your account has access to a Jira site." + ); + } + + if (resources.length > 1) { + process.stderr.write( + ` Found ${resources.length} Jira sites. Using: ${resources[0].name} (${resources[0].url})\n` + ); + } + + return resources[0].id; +} + +// ─── Token Refresh ─────────────────────────────────────────────────────────── + +/** + * Refresh an OAuth access token using a refresh token. + * Handles Atlassian's rotating refresh tokens — the new refresh token + * invalidates the previous one. + */ +export async function refreshAccessToken( + refreshToken: string, + config: Pick +): Promise<{ + accessToken: string; + refreshToken: string; + expiresAt: string; +}> { + const response = await axios.post( + TOKEN_URL, + { + grant_type: "refresh_token", + client_id: config.clientId, + client_secret: config.clientSecret, + refresh_token: refreshToken, + }, + { timeout: 15_000 } + ); + + const data = response.data as JiraTokenResponse; + + return { + accessToken: data.access_token, + refreshToken: data.refresh_token, + expiresAt: new Date(Date.now() + data.expires_in * 1000).toISOString(), + }; +} diff --git a/packages/jira/src/auth/pat.ts b/packages/jira/src/auth/pat.ts new file mode 100644 index 00000000..64cedadf --- /dev/null +++ b/packages/jira/src/auth/pat.ts @@ -0,0 +1,61 @@ +/** + * Personal Access Token (PAT) authentication for Jira Server / Data Center. + * + * Prompts the user for instance URL and PAT, + * then validates by calling GET /rest/api/2/myself with Bearer auth. + */ + +import axios from "axios"; +import { handleJiraError } from "../errors.js"; +import type { JiraPatCredentials } from "../types.js"; +import { normalizeUrl, prompt } from "./prompt.js"; + +/** + * Interactively prompt the user for Jira Server/DC PAT credentials + * and validate them by calling the /rest/api/2/myself endpoint. + */ +export async function promptForPAT(): Promise { + process.stderr.write( + "\n Jira Server / Data Center — PAT Authentication\n\n" + ); + + const rawUrl = await prompt( + " Jira instance URL (e.g. https://jira.mycompany.com): " + ); + if (!rawUrl) { + throw new Error("Instance URL is required."); + } + const baseUrl = normalizeUrl(rawUrl); + + const patToken = await prompt(" Personal access token: ", true); + if (!patToken) { + throw new Error("Personal access token is required."); + } + + process.stderr.write(" Validating credentials...\n"); + + try { + const response = await axios.get(`${baseUrl}/rest/api/2/myself`, { + headers: { + Authorization: `Bearer ${patToken}`, + Accept: "application/json", + }, + timeout: 15_000, + }); + + const displayName = + (response.data as Record)?.displayName ?? "user"; + process.stderr.write(` Authenticated as: ${displayName}\n\n`); + } catch (error) { + if (axios.isAxiosError(error)) { + handleJiraError(error); + } + throw error; + } + + return { + method: "pat", + patToken, + baseUrl, + }; +} diff --git a/packages/jira/src/auth/prompt.ts b/packages/jira/src/auth/prompt.ts new file mode 100644 index 00000000..a2134450 --- /dev/null +++ b/packages/jira/src/auth/prompt.ts @@ -0,0 +1,57 @@ +/** + * Shared stdin prompt utilities for auth flows. + */ + +import { createInterface } from "node:readline"; + +/** + * Prompt the user for input via stdin. When `hide` is true and stdin is a TTY, + * input characters are not echoed (for passwords/tokens). + */ +export function prompt(question: string, hide = false): Promise { + const rl = createInterface({ input: process.stdin, output: process.stderr }); + return new Promise((resolve) => { + if (hide && process.stdin.isTTY) { + process.stderr.write(question); + let input = ""; + process.stdin.setRawMode(true); + process.stdin.resume(); + process.stdin.setEncoding("utf-8"); + const onData = (ch: string) => { + if (ch === "\n" || ch === "\r" || ch === "\u0004") { + process.stdin.setRawMode(false); + process.stdin.pause(); + process.stdin.removeListener("data", onData); + rl.close(); + process.stderr.write("\n"); + resolve(input); + } else if (ch === "\u0003") { + process.stderr.write("\n"); + process.exit(1); + } else if (ch === "\u007f" || ch === "\b") { + if (input.length > 0) input = input.slice(0, -1); + } else { + input += ch; + } + }; + process.stdin.on("data", onData); + } else { + rl.question(question, (answer) => { + rl.close(); + resolve(answer.trim()); + }); + } + }); +} + +/** + * Normalize a URL: trim whitespace, strip trailing slashes, + * and prepend `https://` if no protocol is given. + */ +export function normalizeUrl(url: string): string { + let normalized = url.trim().replace(/\/+$/, ""); + if (!/^https?:\/\//i.test(normalized)) { + normalized = `https://${normalized}`; + } + return normalized; +} diff --git a/packages/jira/src/auth/store.ts b/packages/jira/src/auth/store.ts new file mode 100644 index 00000000..fdada75a --- /dev/null +++ b/packages/jira/src/auth/store.ts @@ -0,0 +1,14 @@ +/** + * Credential store for @locusai/locus-jira. + * + * Re-exports the credential persistence functions from config.ts. + * Credentials are stored in `.locus/config.json` under `packages.jira.auth`. + * Environment variables (JIRA_BASE_URL, JIRA_EMAIL, JIRA_API_TOKEN, JIRA_PAT) + * take precedence over stored config when present. + */ + +export { + clearCredentials, + loadCredentials, + saveCredentials, +} from "../config.js"; diff --git a/packages/jira/src/cli.ts b/packages/jira/src/cli.ts new file mode 100644 index 00000000..1b32f103 --- /dev/null +++ b/packages/jira/src/cli.ts @@ -0,0 +1,9 @@ +#!/usr/bin/env node +export {}; + +const { main } = await import("./index.js"); +const { handleCommandError } = await import("./errors.js"); + +main(process.argv.slice(2)).catch((error) => { + handleCommandError(error); +}); diff --git a/packages/jira/src/client/adf-to-md.ts b/packages/jira/src/client/adf-to-md.ts new file mode 100644 index 00000000..f2e25df4 --- /dev/null +++ b/packages/jira/src/client/adf-to-md.ts @@ -0,0 +1,275 @@ +/** + * ADF-to-Markdown converter for @locusai/locus-jira. + * + * Transforms Jira Cloud's Atlassian Document Format (ADF) JSON trees + * into clean Markdown suitable for AI agent consumption. + */ + +import type { ADFMark, ADFNode } from "./types.js"; + +/** + * Convert an ADF document tree to Markdown. + * Returns an empty string for null/undefined input. + */ +export function adfToMarkdown(adf: ADFNode | null | undefined): string { + if (!adf) return ""; + const raw = convertNode(adf, { depth: 0, ordered: false, indent: 0 }); + return cleanOutput(raw); +} + +interface Context { + depth: number; + ordered: boolean; + indent: number; +} + +// ─── Node Dispatch ────────────────────────────────────────────────────────── + +function convertNode(node: ADFNode, ctx: Context): string { + switch (node.type) { + case "doc": + return convertChildren(node, ctx); + case "paragraph": + return convertParagraph(node, ctx); + case "heading": + return convertHeading(node, ctx); + case "bulletList": + return convertList(node, { ...ctx, ordered: false }); + case "orderedList": + return convertList(node, { ...ctx, ordered: true }); + case "listItem": + return convertListItem(node, ctx); + case "codeBlock": + return convertCodeBlock(node); + case "blockquote": + return convertBlockquote(node, ctx); + case "rule": + return "---\n\n"; + case "table": + return convertTable(node, ctx); + case "panel": + return convertPanel(node, ctx); + case "mediaSingle": + case "mediaGroup": + return convertChildren(node, ctx); + case "media": + return convertMedia(node); + case "text": + return applyMarks(node.text ?? "", node.marks); + case "inlineCard": + return convertInlineCard(node); + case "mention": + return `@${(node.attrs?.text as string) ?? "unknown"}`; + case "emoji": + return convertEmoji(node); + case "hardBreak": + return "\n"; + default: + // Unknown node: try to process children, or return empty + return node.content ? convertChildren(node, ctx) : ""; + } +} + +// ─── Block Nodes ──────────────────────────────────────────────────────────── + +function convertParagraph(node: ADFNode, ctx: Context): string { + const text = convertChildren(node, ctx); + return `${text}\n\n`; +} + +function convertHeading(node: ADFNode, ctx: Context): string { + const level = Math.min(Math.max(Number(node.attrs?.level) || 1, 1), 6); + const text = convertChildren(node, ctx); + return `${"#".repeat(level)} ${text}\n\n`; +} + +function convertList(node: ADFNode, ctx: Context): string { + if (!node.content) return ""; + const items = node.content + .map((child, i) => + convertNode(child, { + ...ctx, + ordered: ctx.ordered, + depth: i, + }) + ) + .join(""); + // Only add trailing newline if not nested + return ctx.indent === 0 ? `${items}\n` : items; +} + +function convertListItem(node: ADFNode, ctx: Context): string { + const prefix = ctx.ordered ? `${ctx.depth + 1}. ` : "- "; + const indentStr = " ".repeat(ctx.indent); + + if (!node.content) return `${indentStr}${prefix}\n`; + + const parts: string[] = []; + for (const child of node.content) { + if (child.type === "paragraph") { + parts.push(convertChildren(child, ctx)); + } else if (child.type === "bulletList" || child.type === "orderedList") { + // Nested list — increase indent + const nested = child.content + ?.map((nestedItem, i) => + convertNode(nestedItem, { + ...ctx, + ordered: child.type === "orderedList", + depth: i, + indent: ctx.indent + 1, + }) + ) + .join(""); + if (nested) parts.push(`\n${nested}`); + } else { + parts.push(convertNode(child, ctx)); + } + } + + const firstLine = parts[0] ?? ""; + const rest = parts.slice(1).join(""); + return `${indentStr}${prefix}${firstLine}${rest}\n`; +} + +function convertCodeBlock(node: ADFNode): string { + const lang = (node.attrs?.language as string) ?? ""; + const code = node.content?.map((c) => c.text ?? "").join("") ?? ""; + return `\`\`\`${lang}\n${code}\n\`\`\`\n\n`; +} + +function convertBlockquote(node: ADFNode, ctx: Context): string { + const inner = convertChildren(node, ctx).trimEnd(); + const quoted = inner + .split("\n") + .map((line) => `> ${line}`) + .join("\n"); + return `${quoted}\n\n`; +} + +function convertTable(node: ADFNode, ctx: Context): string { + if (!node.content) return ""; + + const rows: string[][] = []; + const headerRowIndexes: number[] = []; + + for (const row of node.content) { + if (row.type !== "tableRow") continue; + const cells: string[] = []; + let isHeaderRow = false; + + for (const cell of row.content ?? []) { + if (cell.type === "tableHeader") isHeaderRow = true; + const text = convertChildren(cell, ctx).trim().replace(/\n/g, " "); + cells.push(text); + } + + if (isHeaderRow) headerRowIndexes.push(rows.length); + rows.push(cells); + } + + if (rows.length === 0) return ""; + + const colCount = Math.max(...rows.map((r) => r.length)); + const lines: string[] = []; + + for (let i = 0; i < rows.length; i++) { + const padded = rows[i]; + while (padded.length < colCount) padded.push(""); + lines.push(`| ${padded.join(" | ")} |`); + + // Add separator after header rows + if (headerRowIndexes.includes(i)) { + lines.push(`| ${Array(colCount).fill("---").join(" | ")} |`); + } + } + + // If no header row was found, add separator after first row + if (headerRowIndexes.length === 0 && rows.length > 0) { + lines.splice(1, 0, `| ${Array(colCount).fill("---").join(" | ")} |`); + } + + return `${lines.join("\n")}\n\n`; +} + +function convertPanel(node: ADFNode, ctx: Context): string { + const panelType = (node.attrs?.panelType as string) ?? "info"; + const label = panelType.charAt(0).toUpperCase() + panelType.slice(1); + const inner = convertChildren(node, ctx).trim(); + const quoted = inner + .split("\n") + .map((line, i) => (i === 0 ? `> **${label}:** ${line}` : `> ${line}`)) + .join("\n"); + return `${quoted}\n\n`; +} + +function convertMedia(node: ADFNode): string { + const url = node.attrs?.url as string | undefined; + if (url) return `[media](${url})`; + return "[media]"; +} + +// ─── Inline Nodes ─────────────────────────────────────────────────────────── + +function convertInlineCard(node: ADFNode): string { + const url = (node.attrs?.url as string) ?? ""; + const title = (node.attrs?.title as string) || url; + return url ? `[${title}](${url})` : title; +} + +function convertEmoji(node: ADFNode): string { + const text = node.attrs?.text as string | undefined; + if (text) return text; + const shortName = node.attrs?.shortName as string | undefined; + return shortName ?? ""; +} + +// ─── Marks ────────────────────────────────────────────────────────────────── + +function applyMarks(text: string, marks?: ADFMark[]): string { + if (!marks || marks.length === 0) return text; + + let result = text; + for (const mark of marks) { + switch (mark.type) { + case "strong": + result = `**${result}**`; + break; + case "em": + result = `*${result}*`; + break; + case "code": + result = `\`${result}\``; + break; + case "strike": + result = `~~${result}~~`; + break; + case "link": { + const href = (mark.attrs?.href as string) ?? ""; + result = `[${result}](${href})`; + break; + } + case "underline": + // No Markdown equivalent — pass through as plain text + break; + } + } + return result; +} + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +function convertChildren(node: ADFNode, ctx: Context): string { + if (!node.content) return ""; + return node.content.map((child) => convertNode(child, ctx)).join(""); +} + +function cleanOutput(text: string): string { + return ( + text + // Collapse 3+ newlines into 2 + .replace(/\n{3,}/g, "\n\n") + // Remove trailing whitespace on each line + .replace(/[ \t]+$/gm, "") + .trim() + ); +} diff --git a/packages/jira/src/client/client.ts b/packages/jira/src/client/client.ts new file mode 100644 index 00000000..f9e4e86c --- /dev/null +++ b/packages/jira/src/client/client.ts @@ -0,0 +1,380 @@ +/** + * JiraClient — core HTTP layer for @locusai/locus-jira. + * + * Wraps axios with request/response interceptors for authentication + * and error handling. Supports OAuth, API Token (Cloud), and PAT (Server/DC). + */ + +import axios, { type AxiosInstance, type AxiosError } from "axios"; +import { + loadJiraConfig, + saveCredentials, + validateJiraConfig, +} from "../config.js"; +import { handleJiraError, JiraTokenExpiredError } from "../errors.js"; +import type { JiraCredentials, JiraOAuthCredentials } from "../types.js"; +import type { + JiraBoard, + JiraIssue, + JiraProject, + JiraSearchResult, + JiraSprint, + JiraTransition, + JiraUser, +} from "./types.js"; + +const DEFAULT_TIMEOUT = 30_000; +const PAGE_SIZE = 50; +const TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1000; // 5 minutes + +// ─── Pagination ───────────────────────────────────────────────────────────── + +interface PaginatedResponse { + values?: T[]; + issues?: T[]; + total: number; + startAt: number; + maxResults: number; +} + +type PageFetcher = ( + startAt: number, + maxResults: number +) => Promise>; + +/** + * Generic offset-based pagination helper. + * Fetches all pages until `total` is reached using `startAt` + `maxResults`. + */ +async function fetchAllPages(fetcher: PageFetcher): Promise { + const all: T[] = []; + let startAt = 0; + let hasMore = true; + + while (hasMore) { + const page = await fetcher(startAt, PAGE_SIZE); + const items = page.values ?? page.issues ?? []; + all.push(...items); + + startAt += items.length; + hasMore = all.length < page.total && items.length > 0; + } + + return all; +} + +// ─── Base URL Resolution ──────────────────────────────────────────────────── + +function resolveBaseUrl(credentials: JiraCredentials): string { + switch (credentials.method) { + case "oauth": + return `https://api.atlassian.com/ex/jira/${credentials.cloudId}/rest/api/3`; + case "api-token": + return `${credentials.baseUrl}/rest/api/3`; + case "pat": + return `${credentials.baseUrl}/rest/api/2`; + } +} + +function resolveAgileBaseUrl(credentials: JiraCredentials): string { + switch (credentials.method) { + case "oauth": + return `https://api.atlassian.com/ex/jira/${credentials.cloudId}/rest/agile/1.0`; + case "api-token": + return `${credentials.baseUrl}/rest/agile/1.0`; + case "pat": + return `${credentials.baseUrl}/rest/agile/1.0`; + } +} + +// ─── JiraClient ───────────────────────────────────────────────────────────── + +export class JiraClient { + private readonly api: AxiosInstance; + private readonly agileApi: AxiosInstance; + private credentials: JiraCredentials; + + constructor(credentials: JiraCredentials) { + this.credentials = credentials; + + this.api = axios.create({ + baseURL: resolveBaseUrl(credentials), + timeout: DEFAULT_TIMEOUT, + headers: { Accept: "application/json" }, + }); + + this.agileApi = axios.create({ + baseURL: resolveAgileBaseUrl(credentials), + timeout: DEFAULT_TIMEOUT, + headers: { Accept: "application/json" }, + }); + + // Request interceptor — inject auth header + const authInterceptor = async (config: Record) => { + await this.ensureFreshToken(); + (config as { headers: Record }).headers = { + ...((config as { headers?: Record }).headers ?? {}), + Authorization: this.getAuthHeader(), + }; + return config; + }; + + this.api.interceptors.request.use(authInterceptor as never); + this.agileApi.interceptors.request.use(authInterceptor as never); + + // Response interceptor — map errors to typed classes + const errorInterceptor = (error: AxiosError) => { + if (error.response) { + handleJiraError(error); + } + return Promise.reject(error); + }; + + this.api.interceptors.response.use(undefined, errorInterceptor); + this.agileApi.interceptors.response.use(undefined, errorInterceptor); + } + + /** + * Create a JiraClient from the stored config in `.locus/config.json`. + * Throws if not authenticated. + */ + static fromConfig(cwd?: string): JiraClient { + const config = loadJiraConfig(cwd); + const error = validateJiraConfig(config); + if (error || !config.auth) { + throw new Error(error ?? "Not authenticated. Run: locus jira auth"); + } + return new JiraClient(config.auth); + } + + // ─── Auth Header ──────────────────────────────────────────────────────── + + private getAuthHeader(): string { + switch (this.credentials.method) { + case "oauth": + return `Bearer ${this.credentials.accessToken}`; + case "api-token": { + const encoded = Buffer.from( + `${this.credentials.email}:${this.credentials.apiToken}` + ).toString("base64"); + return `Basic ${encoded}`; + } + case "pat": + return `Bearer ${this.credentials.patToken}`; + } + } + + // ─── Token Freshness ────────────────────────────────────────────────── + + /** + * For OAuth credentials, check if the access token expires within + * 5 minutes and auto-refresh if needed. No-op for API Token and PAT. + */ + async ensureFreshToken(): Promise { + if (this.credentials.method !== "oauth") return; + + const oauth = this.credentials as JiraOAuthCredentials; + const expiresAt = new Date(oauth.expiresAt).getTime(); + const now = Date.now(); + + if (expiresAt - now > TOKEN_REFRESH_BUFFER_MS) return; + + try { + const response = await axios.post( + "https://auth.atlassian.com/oauth/token", + { + grant_type: "refresh_token", + client_id: oauth.clientId, + client_secret: oauth.clientSecret, + refresh_token: oauth.refreshToken, + }, + { timeout: 15_000 } + ); + + const data = response.data as { + access_token: string; + refresh_token?: string; + expires_in: number; + }; + + const updated: JiraOAuthCredentials = { + ...oauth, + accessToken: data.access_token, + refreshToken: data.refresh_token ?? oauth.refreshToken, + expiresAt: new Date(Date.now() + data.expires_in * 1000).toISOString(), + }; + + this.credentials = updated; + + // Update base URLs with fresh credentials + this.api.defaults.baseURL = resolveBaseUrl(updated); + this.agileApi.defaults.baseURL = resolveAgileBaseUrl(updated); + + // Persist updated tokens + saveCredentials(updated); + } catch { + throw new JiraTokenExpiredError(); + } + } + + // ─── Core API Methods ───────────────────────────────────────────────── + + /** + * GET /rest/api/3/issue/{key}?expand=renderedFields + */ + async getIssue(key: string): Promise { + const response = await this.api.get(`/issue/${encodeURIComponent(key)}`, { + params: { expand: "renderedFields" }, + }); + return response.data as JiraIssue; + } + + /** + * GET /rest/api/3/search?jql={jql}&startAt={}&maxResults={} + * When fetchAll is true, paginates through all results. + */ + async searchIssues( + jql: string, + opts?: { startAt?: number; maxResults?: number; fetchAll?: boolean } + ): Promise { + if (opts?.fetchAll) { + const issues = await fetchAllPages((startAt, maxResults) => + this.api + .get("/search", { params: { jql, startAt, maxResults } }) + .then((r) => r.data as PaginatedResponse) + ); + return { + issues, + total: issues.length, + startAt: 0, + maxResults: issues.length, + }; + } + + const response = await this.api.get("/search", { + params: { + jql, + startAt: opts?.startAt ?? 0, + maxResults: opts?.maxResults ?? PAGE_SIZE, + }, + }); + return response.data as JiraSearchResult; + } + + /** + * GET /rest/api/3/issue/{key}/transitions + */ + async getTransitions(key: string): Promise { + const response = await this.api.get( + `/issue/${encodeURIComponent(key)}/transitions` + ); + return (response.data as { transitions: JiraTransition[] }).transitions; + } + + /** + * POST /rest/api/3/issue/{key}/transitions + */ + async transitionIssue(key: string, transitionId: string): Promise { + await this.api.post(`/issue/${encodeURIComponent(key)}/transitions`, { + transition: { id: transitionId }, + }); + } + + /** + * POST /rest/api/3/issue/{key}/comment + */ + async addComment(key: string, body: string): Promise { + const isCloud = this.credentials.method !== "pat"; + + // Cloud API v3 expects ADF; Server API v2 accepts plain text + const commentBody = isCloud + ? { + type: "doc", + version: 1, + content: [ + { + type: "paragraph", + content: [{ type: "text", text: body }], + }, + ], + } + : body; + + await this.api.post(`/issue/${encodeURIComponent(key)}/comment`, { + body: commentBody, + }); + } + + /** + * GET /rest/api/3/project/search + */ + async getProjects(): Promise { + return fetchAllPages((startAt, maxResults) => + this.api + .get("/project/search", { params: { startAt, maxResults } }) + .then((r) => r.data as PaginatedResponse) + ); + } + + /** + * GET /rest/api/3/myself + */ + async getMyself(): Promise { + const response = await this.api.get("/myself"); + return response.data as JiraUser; + } + + /** + * POST /rest/api/3/issue/{key}/remotelink + */ + async addRemoteLink( + key: string, + title: string, + url: string + ): Promise { + await this.api.post(`/issue/${encodeURIComponent(key)}/remotelink`, { + object: { + url, + title, + }, + }); + } + + // ─── Agile API Methods ──────────────────────────────────────────────── + + /** + * GET /rest/agile/1.0/board + */ + async getBoards(): Promise { + return fetchAllPages((startAt, maxResults) => + this.agileApi + .get("/board", { params: { startAt, maxResults } }) + .then((r) => r.data as PaginatedResponse) + ); + } + + /** + * GET /rest/agile/1.0/board/{boardId}/sprint?state=active + */ + async getCurrentSprint(boardId: number): Promise { + const response = await this.agileApi.get(`/board/${boardId}/sprint`, { + params: { state: "active" }, + }); + const data = response.data as { values: JiraSprint[] }; + return data.values[0] ?? null; + } + + /** + * GET /rest/agile/1.0/sprint/{sprintId}/issue + */ + async getSprintIssues( + _boardId: number, + sprintId: number + ): Promise { + return fetchAllPages((startAt, maxResults) => + this.agileApi + .get(`/sprint/${sprintId}/issue`, { params: { startAt, maxResults } }) + .then((r) => r.data as PaginatedResponse) + ); + } +} diff --git a/packages/jira/src/client/types.ts b/packages/jira/src/client/types.ts new file mode 100644 index 00000000..d3260058 --- /dev/null +++ b/packages/jira/src/client/types.ts @@ -0,0 +1,111 @@ +/** + * Jira REST API response types for @locusai/locus-jira. + * + * These interfaces model the Jira Cloud REST API v3 responses + * used by the client module. + */ + +// ─── Atlassian Document Format ────────────────────────────────────────────── + +export interface ADFMark { + type: string; + attrs?: Record; +} + +export interface ADFNode { + type: string; + content?: ADFNode[]; + text?: string; + marks?: ADFMark[]; + attrs?: Record; +} + +// ─── Users ────────────────────────────────────────────────────────────────── + +export interface JiraUser { + accountId: string; + displayName: string; + emailAddress?: string; +} + +// ─── Comments ─────────────────────────────────────────────────────────────── + +export interface JiraComment { + id: string; + body: ADFNode | string; + author: JiraUser; + created: string; +} + +// ─── Issues ───────────────────────────────────────────────────────────────── + +export interface JiraIssueFields { + summary: string; + description: ADFNode | string | null; + status: { id: string; name: string }; + priority: { id: string; name: string } | null; + labels: string[]; + assignee: JiraUser | null; + reporter: JiraUser | null; + issuetype: { id: string; name: string }; + project: { id: string; key: string; name: string }; + created: string; + updated: string; + sprint?: JiraSprint | null; + parent?: { id: string; key: string } | null; + comment?: { comments: JiraComment[]; total: number }; +} + +export interface JiraIssue { + id: string; + key: string; + self: string; + fields: JiraIssueFields; + renderedFields?: Record; +} + +// ─── Search ───────────────────────────────────────────────────────────────── + +export interface JiraSearchResult { + issues: JiraIssue[]; + total: number; + startAt: number; + maxResults: number; +} + +// ─── Transitions ──────────────────────────────────────────────────────────── + +export interface JiraTransition { + id: string; + name: string; + to: { id: string; name: string }; +} + +// ─── Sprints ──────────────────────────────────────────────────────────────── + +export interface JiraSprint { + id: number; + name: string; + state: "active" | "closed" | "future"; + startDate?: string; + endDate?: string; + goal?: string; +} + +// ─── Boards ───────────────────────────────────────────────────────────────── + +export interface JiraBoard { + id: number; + name: string; + type: "scrum" | "kanban"; + location?: { projectId: number; projectKey: string; projectName: string }; +} + +// ─── Projects ─────────────────────────────────────────────────────────────── + +export interface JiraProject { + id: string; + key: string; + name: string; + style: string; +} diff --git a/packages/jira/src/commands/auth.ts b/packages/jira/src/commands/auth.ts new file mode 100644 index 00000000..481337ee --- /dev/null +++ b/packages/jira/src/commands/auth.ts @@ -0,0 +1,139 @@ +/** + * Auth command for locus-jira. + * + * Handles: + * - Default: prompt user to choose OAuth, API Token, or PAT, run flow, store credentials + * - --status: display current auth status + * - --revoke: clear stored credentials + */ + +import { promptForApiToken } from "../auth/api-token.js"; +import { promptForOAuth } from "../auth/oauth.js"; +import { promptForPAT } from "../auth/pat.js"; +import { prompt } from "../auth/prompt.js"; +import { + clearCredentials, + loadCredentials, + saveCredentials, +} from "../config.js"; + +export async function authCommand(args: string[]): Promise { + const flag = args[0]; + + if (flag === "--status") { + return showStatus(); + } + + if (flag === "--revoke") { + return handleRevoke(); + } + + // Check for --method flag + let method: string | undefined; + for (let i = 0; i < args.length; i++) { + if (args[i] === "--method" && args[i + 1]) { + method = args[i + 1]; + break; + } + } + + return handleAuthFlow(method); +} + +// ─── Auth Flow ────────────────────────────────────────────────────────────── + +async function handleAuthFlow(method?: string): Promise { + const existing = loadCredentials(); + if (existing) { + process.stderr.write( + " Already authenticated. Use --revoke to disconnect first, or --status to check.\n\n" + ); + return; + } + + let choice: string; + + if (method === "oauth") { + choice = "1"; + } else if (method === "api-token") { + choice = "2"; + } else if (method === "pat") { + choice = "3"; + } else if (method) { + process.stderr.write( + ` Unknown method: ${method}\n Valid methods: oauth, api-token, pat\n\n` + ); + process.exit(1); + } else { + process.stderr.write("\n Choose authentication method:\n"); + process.stderr.write(" 1) OAuth 2.0 (Jira Cloud — recommended)\n"); + process.stderr.write(" 2) API Token (Jira Cloud — email + token)\n"); + process.stderr.write(" 3) PAT (Jira Server / Data Center)\n\n"); + + choice = await prompt(" Enter 1, 2, or 3: "); + } + + let credentials: + | Awaited> + | Awaited> + | Awaited>; + + if (choice === "1") { + credentials = await promptForOAuth(); + } else if (choice === "2") { + credentials = await promptForApiToken(); + } else if (choice === "3") { + credentials = await promptForPAT(); + } else { + process.stderr.write(" Invalid choice. Please enter 1, 2, or 3.\n\n"); + process.exit(1); + } + + saveCredentials(credentials); + process.stderr.write(" Credentials saved to .locus/config.json\n"); + process.stderr.write(" Jira integration is ready.\n\n"); +} + +// ─── Status ───────────────────────────────────────────────────────────────── + +function showStatus(): void { + const creds = loadCredentials(); + + if (!creds) { + process.stderr.write("\n Not authenticated.\n Run: locus jira auth\n\n"); + return; + } + + process.stderr.write("\n Jira Auth Status\n"); + process.stderr.write(` ${"─".repeat(40)}\n`); + process.stderr.write(` Method: ${creds.method}\n`); + + if (creds.method === "api-token") { + process.stderr.write(` Instance: ${creds.baseUrl}\n`); + process.stderr.write(` Email: ${creds.email}\n`); + } else if (creds.method === "pat") { + process.stderr.write(` Instance: ${creds.baseUrl}\n`); + } else if (creds.method === "oauth") { + process.stderr.write(` Cloud ID: ${creds.cloudId}\n`); + const expiresAt = new Date(creds.expiresAt); + const isExpired = expiresAt.getTime() < Date.now(); + process.stderr.write( + ` Token: ${isExpired ? "expired" : `expires ${expiresAt.toLocaleString()}`}\n` + ); + } + + process.stderr.write("\n"); +} + +// ─── Revoke ───────────────────────────────────────────────────────────────── + +function handleRevoke(): void { + const creds = loadCredentials(); + if (!creds) { + process.stderr.write("\n Not authenticated — nothing to revoke.\n\n"); + return; + } + + clearCredentials(); + process.stderr.write("\n Credentials cleared from .locus/config.json\n\n"); +} diff --git a/packages/jira/src/commands/board.ts b/packages/jira/src/commands/board.ts new file mode 100644 index 00000000..10e9fddb --- /dev/null +++ b/packages/jira/src/commands/board.ts @@ -0,0 +1,82 @@ +/** + * Board command for locus-jira. + * + * - `locus jira board` → list available boards and prompt for selection + */ + +import { prompt } from "../auth/prompt.js"; +import { JiraClient } from "../client/client.js"; +import { loadJiraConfig, saveJiraConfig } from "../config.js"; + +export async function boardCommand(args: string[]): Promise { + const idArg = args[0]; + + if (idArg && !idArg.startsWith("--")) { + const boardId = Number.parseInt(idArg, 10); + if (Number.isNaN(boardId)) { + process.stderr.write(` Invalid board ID: ${idArg}\n\n`); + process.exit(1); + } + saveJiraConfig({ boardId }); + process.stderr.write(`\n Active board set to: ${boardId}\n\n`); + return; + } + + return selectBoard(); +} + +async function selectBoard(): Promise { + const config = loadJiraConfig(); + if (config.boardId) { + process.stderr.write(`\n Current board ID: ${config.boardId}\n`); + } + + const client = JiraClient.fromConfig(); + + process.stderr.write("\n Fetching boards...\n"); + const boards = await client.getBoards(); + + if (boards.length === 0) { + process.stderr.write(" No boards found.\n\n"); + return; + } + + process.stderr.write(`\n Available boards (${boards.length}):\n`); + process.stderr.write(` ${"─".repeat(70)}\n`); + process.stderr.write( + ` ${"#".padStart(3)} ${"Name".padEnd(30)} ${"Type".padEnd(10)} Project\n` + ); + process.stderr.write( + ` ${"─".repeat(3)} ${"─".repeat(30)} ${"─".repeat(10)} ${"─".repeat(20)}\n` + ); + + for (let i = 0; i < boards.length; i++) { + const b = boards[i]; + const name = b.name.length > 28 ? `${b.name.slice(0, 25)}...` : b.name; + const project = b.location?.projectKey ?? "-"; + + process.stderr.write( + ` ${String(i + 1).padStart(3)}) ${name.padEnd(30)} ${b.type.padEnd(10)} ${project}\n` + ); + } + + process.stderr.write("\n"); + const choice = await prompt(" Select board (number): "); + + if (!choice) { + process.stderr.write(" No selection made.\n\n"); + return; + } + + const num = Number.parseInt(choice, 10); + if (Number.isNaN(num) || num < 1 || num > boards.length) { + process.stderr.write(` Invalid selection: ${choice}\n\n`); + process.exit(1); + } + + const selected = boards[num - 1]; + saveJiraConfig({ boardId: selected.id }); + process.stderr.write( + `\n Active board set to: ${selected.name} (ID: ${selected.id}, ${selected.type})\n\n` + ); +} diff --git a/packages/jira/src/commands/issue.ts b/packages/jira/src/commands/issue.ts new file mode 100644 index 00000000..fc65ff21 --- /dev/null +++ b/packages/jira/src/commands/issue.ts @@ -0,0 +1,118 @@ +/** + * Single issue detail command for locus-jira. + * + * Displays full details for a single Jira issue. + * + * Usage: + * locus jira issue PROJ-123 → show full issue details + */ + +import { adfToMarkdown } from "../client/adf-to-md.js"; +import { JiraClient } from "../client/client.js"; +import type { ADFNode, JiraComment } from "../client/types.js"; +import { loadJiraConfig } from "../config.js"; + +export async function issueCommand(args: string[]): Promise { + const key = args[0]; + + if (!key || key.startsWith("--")) { + process.stderr.write( + "\n Usage: locus jira issue \n Example: locus jira issue PROJ-123\n\n" + ); + process.exit(1); + } + + const config = loadJiraConfig(); + const client = JiraClient.fromConfig(); + + process.stderr.write(`\n Fetching ${key}...\n`); + const issue = await client.getIssue(key.toUpperCase()); + + const f = issue.fields; + + // Header + process.stderr.write("\n"); + process.stderr.write(` ${issue.key}: ${f.summary}\n`); + process.stderr.write(` ${"═".repeat(70)}\n\n`); + + // Metadata + process.stderr.write(` Type: ${f.issuetype.name}\n`); + process.stderr.write(` Status: ${f.status.name}\n`); + process.stderr.write(` Priority: ${f.priority?.name ?? "-"}\n`); + process.stderr.write( + ` Assignee: ${f.assignee?.displayName ?? "Unassigned"}\n` + ); + process.stderr.write(` Reporter: ${f.reporter?.displayName ?? "-"}\n`); + + if (f.labels.length > 0) { + process.stderr.write(` Labels: ${f.labels.join(", ")}\n`); + } + + if (f.sprint) { + process.stderr.write(` Sprint: ${f.sprint.name}\n`); + } + + if (f.parent) { + process.stderr.write(` Parent: ${f.parent.key}\n`); + } + + process.stderr.write(` Project: ${f.project.key} — ${f.project.name}\n`); + process.stderr.write(` Created: ${f.created.split("T")[0]}\n`); + process.stderr.write(` Updated: ${f.updated.split("T")[0]}\n`); + + // URL + if (config.auth) { + const baseUrl = + config.auth.method === "oauth" + ? `https://api.atlassian.com/ex/jira/${config.auth.cloudId}` + : config.auth.baseUrl; + process.stderr.write(` URL: ${baseUrl}/browse/${issue.key}\n`); + } + + // Description + if (f.description) { + process.stderr.write(`\n ${"─".repeat(70)}\n`); + process.stderr.write(" Description:\n\n"); + + const markdown = + typeof f.description === "string" + ? f.description + : adfToMarkdown(f.description as ADFNode); + + const lines = markdown.split("\n"); + for (const line of lines) { + process.stderr.write(` ${line}\n`); + } + } + + // Comments + const commentData = f.comment; + if (commentData?.comments && commentData.comments.length > 0) { + process.stderr.write(`\n ${"─".repeat(70)}\n`); + process.stderr.write(` Comments (${commentData.total}):\n\n`); + + const recent = commentData.comments.slice(-5); + for (const comment of recent) { + formatComment(comment); + } + } + + process.stderr.write("\n"); +} + +function formatComment(comment: JiraComment): void { + const date = comment.created.split("T")[0] ?? comment.created; + const author = comment.author.displayName; + + const body = + typeof comment.body === "string" + ? comment.body + : adfToMarkdown(comment.body); + + process.stderr.write(` [${date}] ${author}:\n`); + const lines = body.split("\n"); + for (const line of lines) { + process.stderr.write(` ${line}\n`); + } + process.stderr.write("\n"); +} diff --git a/packages/jira/src/commands/issues.ts b/packages/jira/src/commands/issues.ts new file mode 100644 index 00000000..2367cdd2 --- /dev/null +++ b/packages/jira/src/commands/issues.ts @@ -0,0 +1,163 @@ +/** + * Issues list command for locus-jira. + * + * Lists issues with tabular output. + * + * Usage: + * locus jira issues → list project issues (default JQL) + * locus jira issues --jql "..." → custom JQL filter + * locus jira issues --sprint → show active sprint issues + * locus jira issues --limit 10 → limit results + */ + +import { JiraClient } from "../client/client.js"; +import type { JiraIssue } from "../client/types.js"; +import { loadJiraConfig } from "../config.js"; + +interface IssuesOptions { + jql?: string; + sprint?: boolean; + limit: number; +} + +export async function issuesCommand(args: string[]): Promise { + const options = parseIssuesArgs(args); + const config = loadJiraConfig(); + const client = JiraClient.fromConfig(); + + let issues: JiraIssue[]; + let header: string; + + if (options.sprint) { + // Sprint mode — requires boardId + if (!config.boardId) { + process.stderr.write( + "\n No board configured. Run: locus jira board\n\n" + ); + process.exit(1); + } + + process.stderr.write("\n Fetching active sprint...\n"); + const sprint = await client.getCurrentSprint(config.boardId); + if (!sprint) { + process.stderr.write(" No active sprint found.\n\n"); + return; + } + + header = `Sprint: ${sprint.name}`; + const sprintIssues = await client.getSprintIssues( + config.boardId, + sprint.id + ); + issues = sprintIssues.slice(0, options.limit); + } else { + // JQL mode + const jql = + options.jql ?? + config.defaultJql ?? + (config.projectKey + ? `project = ${config.projectKey} ORDER BY updated DESC` + : null); + + if (!jql) { + process.stderr.write( + "\n No project configured and no --jql provided.\n Run: locus jira project \n\n" + ); + process.exit(1); + } + + header = options.jql + ? `JQL: ${jql}` + : `Project: ${config.projectKey ?? "all"}`; + + process.stderr.write("\n Fetching issues...\n"); + const result = await client.searchIssues(jql, { + maxResults: options.limit, + }); + issues = result.issues; + } + + // Print header + process.stderr.write(`\n ${header}\n`); + process.stderr.write( + ` ${issues.length} issue${issues.length === 1 ? "" : "s"}\n` + ); + process.stderr.write(` ${"═".repeat(90)}\n`); + + if (issues.length === 0) { + process.stderr.write(" No issues found.\n\n"); + return; + } + + // Table header + process.stderr.write( + ` ${"Key".padEnd(14)} ${"Summary".padEnd(38)} ${"Status".padEnd(14)} ${"Priority".padEnd(10)} Assignee\n` + ); + process.stderr.write( + ` ${"─".repeat(14)} ${"─".repeat(38)} ${"─".repeat(14)} ${"─".repeat(10)} ${"─".repeat(12)}\n` + ); + + for (const issue of issues) { + const summary = + issue.fields.summary.length > 36 + ? `${issue.fields.summary.slice(0, 33)}...` + : issue.fields.summary; + const status = issue.fields.status.name; + const priority = issue.fields.priority?.name ?? "-"; + const assignee = issue.fields.assignee?.displayName ?? "-"; + const assigneeDisplay = + assignee.length > 12 ? `${assignee.slice(0, 9)}...` : assignee; + + process.stderr.write( + ` ${issue.key.padEnd(14)} ${summary.padEnd(38)} ${status.padEnd(14)} ${priority.padEnd(10)} ${assigneeDisplay}\n` + ); + } + + process.stderr.write("\n"); +} + +function parseIssuesArgs(args: string[]): IssuesOptions { + const options: IssuesOptions = { limit: 25 }; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + + switch (arg) { + case "--sprint": + options.sprint = true; + break; + case "--jql": { + const next = args[i + 1]; + if (!next || next.startsWith("--")) { + process.stderr.write(" --jql requires a query string.\n"); + process.exit(1); + } + options.jql = next; + i++; + break; + } + case "--limit": { + const next = args[i + 1]; + if (!next || next.startsWith("--")) { + process.stderr.write(" --limit requires a number.\n"); + process.exit(1); + } + const num = Number.parseInt(next, 10); + if (Number.isNaN(num) || num <= 0) { + process.stderr.write(" --limit must be a positive number.\n"); + process.exit(1); + } + options.limit = num; + i++; + break; + } + default: + if (arg.startsWith("--")) { + process.stderr.write(` Unknown flag: ${arg}\n`); + process.exit(1); + } + } + } + + return options; +} diff --git a/packages/jira/src/commands/project.ts b/packages/jira/src/commands/project.ts new file mode 100644 index 00000000..e01b99e1 --- /dev/null +++ b/packages/jira/src/commands/project.ts @@ -0,0 +1,84 @@ +/** + * Project command for locus-jira. + * + * - `locus jira project` → list available projects and prompt for selection + * - `locus jira project ENG` → set project key directly + */ + +import { prompt } from "../auth/prompt.js"; +import { JiraClient } from "../client/client.js"; +import { loadJiraConfig, saveJiraConfig } from "../config.js"; + +export async function projectCommand(args: string[]): Promise { + const key = args[0]; + + if (key && !key.startsWith("--")) { + setProject(key); + return; + } + + return selectProject(); +} + +function setProject(key: string): void { + const normalized = key.toUpperCase(); + saveJiraConfig({ projectKey: normalized }); + process.stderr.write(`\n Active project set to: ${normalized}\n\n`); +} + +async function selectProject(): Promise { + const config = loadJiraConfig(); + if (config.projectKey) { + process.stderr.write(`\n Current project: ${config.projectKey}\n`); + } + + const client = JiraClient.fromConfig(); + + process.stderr.write("\n Fetching projects...\n"); + const projects = await client.getProjects(); + + if (projects.length === 0) { + process.stderr.write(" No projects found.\n\n"); + return; + } + + process.stderr.write(`\n Available projects (${projects.length}):\n`); + process.stderr.write(` ${"─".repeat(60)}\n`); + + for (let i = 0; i < projects.length; i++) { + const p = projects[i]; + process.stderr.write( + ` ${String(i + 1).padStart(3)}) ${p.key.padEnd(12)} ${p.name}\n` + ); + } + + process.stderr.write("\n"); + const choice = await prompt(" Select project (number or key): "); + + if (!choice) { + process.stderr.write(" No selection made.\n\n"); + return; + } + + // Try as number first + const num = Number.parseInt(choice, 10); + let selected: (typeof projects)[0] | undefined; + + if (!Number.isNaN(num) && num >= 1 && num <= projects.length) { + selected = projects[num - 1]; + } else { + // Try as key + const upper = choice.toUpperCase(); + selected = projects.find((p) => p.key.toUpperCase() === upper); + } + + if (!selected) { + process.stderr.write(` Invalid selection: ${choice}\n\n`); + process.exit(1); + } + + saveJiraConfig({ projectKey: selected.key }); + process.stderr.write( + `\n Active project set to: ${selected.key} (${selected.name})\n\n` + ); +} diff --git a/packages/jira/src/commands/run.ts b/packages/jira/src/commands/run.ts new file mode 100644 index 00000000..34eb5c32 --- /dev/null +++ b/packages/jira/src/commands/run.ts @@ -0,0 +1,344 @@ +/** + * Run command for locus-jira. + * + * Fetches Jira issues and executes them via Locus's `invokeLocus` SDK function. + * + * Usage: + * locus jira run PROJ-101 PROJ-102 → run specific issues by key + * locus jira run --jql "..." → run issues matching JQL + * locus jira run --sprint → run active sprint issues + * locus jira run --sprint --status "To Do" → filter sprint issues by status + * locus jira run --dry-run → preview without executing + * locus jira run --sync → sync status back to Jira after execution + */ + +import { invokeLocus } from "@locusai/sdk"; +import { JiraClient } from "../client/client.js"; +import type { JiraIssue } from "../client/types.js"; +import { loadJiraConfig } from "../config.js"; +import type { LocusIssue } from "../mapper.js"; +import { mapJiraIssue } from "../mapper.js"; + +// ─── Types ────────────────────────────────────────────────────────────────── + +interface RunOptions { + keys: string[]; + jql?: string; + sprint?: boolean; + status?: string; + dryRun?: boolean; + sync?: boolean; +} + +interface RunResult { + issueKey: string; + title: string; + success: boolean; + error?: string; +} + +// ─── Arg Parsing ──────────────────────────────────────────────────────────── + +function parseRunArgs(args: string[]): RunOptions { + const options: RunOptions = { keys: [] }; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + + switch (arg) { + case "--jql": { + const next = args[i + 1]; + if (!next || next.startsWith("--")) { + process.stderr.write(" --jql requires a query string.\n"); + process.exit(1); + } + options.jql = next; + i++; + break; + } + case "--sprint": + options.sprint = true; + break; + case "--status": { + const next = args[i + 1]; + if (!next || next.startsWith("--")) { + process.stderr.write(" --status requires a status name.\n"); + process.exit(1); + } + options.status = next; + i++; + break; + } + case "--dry-run": + options.dryRun = true; + break; + case "--sync": + options.sync = true; + break; + default: + if (arg.startsWith("--")) { + process.stderr.write(` Unknown flag: ${arg}\n`); + process.exit(1); + } + // Positional args are issue keys + options.keys.push(arg.toUpperCase()); + } + } + + return options; +} + +// ─── Issue Fetching ───────────────────────────────────────────────────────── + +async function fetchIssuesByKeys( + client: JiraClient, + keys: string[] +): Promise { + const issues: JiraIssue[] = []; + for (const key of keys) { + process.stderr.write(` Fetching ${key}...\n`); + const issue = await client.getIssue(key); + issues.push(issue); + } + return issues; +} + +async function fetchIssuesByJql( + client: JiraClient, + jql: string +): Promise { + process.stderr.write(` Searching: ${jql}\n`); + const result = await client.searchIssues(jql, { fetchAll: true }); + return result.issues; +} + +async function fetchSprintIssues( + client: JiraClient, + boardId: number, + status?: string +): Promise { + process.stderr.write(" Fetching active sprint...\n"); + const sprint = await client.getCurrentSprint(boardId); + if (!sprint) { + process.stderr.write(" No active sprint found.\n\n"); + return []; + } + + process.stderr.write(` Sprint: ${sprint.name}\n`); + const issues = await client.getSprintIssues(boardId, sprint.id); + + if (status) { + const normalized = status.toLowerCase(); + return issues.filter( + (i) => i.fields.status.name.toLowerCase() === normalized + ); + } + + return issues; +} + +// ─── Issue Execution ──────────────────────────────────────────────────────── + +function buildExecPrompt(issue: LocusIssue): string { + const parts = [`# ${issue.id}: ${issue.title}`, ""]; + + if (issue.description) { + parts.push(issue.description, ""); + } + + if (issue.labels.length > 0) { + parts.push(`Labels: ${issue.labels.join(", ")}`, ""); + } + + if (issue.priority) { + parts.push(`Priority: ${issue.priority}`, ""); + } + + if (issue.comments && issue.comments.length > 0) { + parts.push("## Comments", ""); + for (const comment of issue.comments) { + parts.push(`- ${comment}`); + } + parts.push(""); + } + + parts.push(`Source: ${issue.url}`); + + return parts.join("\n"); +} + +async function executeIssue(issue: LocusIssue): Promise { + const prompt = buildExecPrompt(issue); + + process.stderr.write(` Executing ${issue.id}: ${issue.title}...\n`); + + const result = await invokeLocus(["exec", prompt]); + + if (result.exitCode !== 0) { + return { + issueKey: issue.id, + title: issue.title, + success: false, + error: result.stderr.trim() || `Exit code: ${result.exitCode}`, + }; + } + + return { + issueKey: issue.id, + title: issue.title, + success: true, + }; +} + +// ─── Dry Run ──────────────────────────────────────────────────────────────── + +function printDryRun(issues: LocusIssue[]): void { + process.stderr.write( + `\n Dry run — ${issues.length} issue(s) would execute:\n` + ); + process.stderr.write(` ${"═".repeat(70)}\n`); + + for (const issue of issues) { + const desc = + issue.description.length > 60 + ? `${issue.description.slice(0, 57)}...` + : issue.description; + process.stderr.write(` ${issue.id.padEnd(14)} ${issue.title}\n`); + if (desc) { + process.stderr.write(` ${"".padEnd(14)} ${desc}\n`); + } + } + + process.stderr.write("\n"); +} + +// ─── Results Summary ──────────────────────────────────────────────────────── + +function printResults(results: RunResult[]): void { + const succeeded = results.filter((r) => r.success); + const failed = results.filter((r) => !r.success); + + process.stderr.write(`\n ${"═".repeat(70)}\n`); + process.stderr.write(" Execution Summary\n"); + process.stderr.write(` ${"─".repeat(70)}\n`); + + for (const r of results) { + const icon = r.success ? "+" : "x"; + const status = r.success ? "OK" : "FAILED"; + process.stderr.write( + ` [${icon}] ${r.issueKey.padEnd(14)} ${status}${r.error ? ` — ${r.error}` : ""}\n` + ); + } + + process.stderr.write(` ${"─".repeat(70)}\n`); + process.stderr.write( + ` Total: ${results.length} | Succeeded: ${succeeded.length} | Failed: ${failed.length}\n\n` + ); +} + +// ─── Sync Back ────────────────────────────────────────────────────────────── + +async function syncResults( + client: JiraClient, + results: RunResult[] +): Promise { + const succeeded = results.filter((r) => r.success); + if (succeeded.length === 0) return; + + process.stderr.write(" Syncing results back to Jira...\n"); + + for (const r of succeeded) { + try { + await client.addComment( + r.issueKey, + `Locus executed this issue successfully.` + ); + } catch { + process.stderr.write( + ` Warning: Could not add comment to ${r.issueKey}\n` + ); + } + } + + process.stderr.write(" Sync complete.\n\n"); +} + +// ─── Command Entry ────────────────────────────────────────────────────────── + +export async function runCommand(args: string[]): Promise { + const options = parseRunArgs(args); + const config = loadJiraConfig(); + const client = JiraClient.fromConfig(); + + // Fetch issues based on mode + let jiraIssues: JiraIssue[]; + + if (options.keys.length > 0) { + // Explicit issue keys + jiraIssues = await fetchIssuesByKeys(client, options.keys); + } else if (options.jql) { + // JQL query + jiraIssues = await fetchIssuesByJql(client, options.jql); + } else if (options.sprint) { + // Sprint mode + if (!config.boardId) { + process.stderr.write( + "\n No board configured. Run: locus jira board\n\n" + ); + process.exit(1); + } + jiraIssues = await fetchSprintIssues( + client, + config.boardId, + options.status + ); + } else { + process.stderr.write( + "\n Usage: locus jira run | --jql | --sprint\n" + + " Run 'locus jira run --help' for details.\n\n" + ); + process.exit(1); + } + + if (jiraIssues.length === 0) { + process.stderr.write("\n No issues found.\n\n"); + return; + } + + // Enforce maxIssuesPerRun limit + const limit = config.maxIssuesPerRun; + if (jiraIssues.length > limit) { + process.stderr.write( + `\n Warning: Found ${jiraIssues.length} issues but maxIssuesPerRun is ${limit}. ` + + `Only the first ${limit} will be processed.\n` + ); + jiraIssues = jiraIssues.slice(0, limit); + } + + // Map to LocusIssue format + const locusIssues = jiraIssues.map((ji) => mapJiraIssue(ji, config)); + + process.stderr.write(`\n ${locusIssues.length} issue(s) to execute\n`); + + // Dry run mode + if (options.dryRun) { + printDryRun(locusIssues); + return; + } + + // Execute issues sequentially + process.stderr.write(` ${"─".repeat(70)}\n`); + const results: RunResult[] = []; + + for (const issue of locusIssues) { + const result = await executeIssue(issue); + results.push(result); + } + + printResults(results); + + // Sync back to Jira if requested + if (options.sync) { + await syncResults(client, results); + } +} diff --git a/packages/jira/src/commands/sprint.ts b/packages/jira/src/commands/sprint.ts new file mode 100644 index 00000000..3f29cd31 --- /dev/null +++ b/packages/jira/src/commands/sprint.ts @@ -0,0 +1,142 @@ +/** + * Sprint command for locus-jira. + * + * Shorthand for `run --sprint`. Fetches active sprint issues + * and executes them via Locus. + * + * Usage: + * locus jira sprint → run active sprint "To Do" issues + * locus jira sprint --status "In Progress" → filter by status + * locus jira sprint --info → show sprint details without running + * locus jira sprint --dry-run → preview without executing + */ + +import { JiraClient } from "../client/client.js"; +import { loadJiraConfig } from "../config.js"; +import { runCommand } from "./run.js"; + +// ─── Types ────────────────────────────────────────────────────────────────── + +interface SprintOptions { + status: string; + info?: boolean; + dryRun?: boolean; + sync?: boolean; +} + +// ─── Arg Parsing ──────────────────────────────────────────────────────────── + +function parseSprintArgs(args: string[]): SprintOptions { + const options: SprintOptions = { status: "To Do" }; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + + switch (arg) { + case "--status": { + const next = args[i + 1]; + if (!next || next.startsWith("--")) { + process.stderr.write(" --status requires a status name.\n"); + process.exit(1); + } + options.status = next; + i++; + break; + } + case "--info": + options.info = true; + break; + case "--dry-run": + options.dryRun = true; + break; + case "--sync": + options.sync = true; + break; + default: + if (arg.startsWith("--")) { + process.stderr.write(` Unknown flag: ${arg}\n`); + process.exit(1); + } + } + } + + return options; +} + +// ─── Sprint Info ──────────────────────────────────────────────────────────── + +async function showSprintInfo(boardId: number): Promise { + const client = JiraClient.fromConfig(); + + process.stderr.write("\n Fetching active sprint...\n"); + const sprint = await client.getCurrentSprint(boardId); + + if (!sprint) { + process.stderr.write(" No active sprint found.\n\n"); + return; + } + + process.stderr.write("\n Active Sprint\n"); + process.stderr.write(` ${"═".repeat(50)}\n`); + process.stderr.write(` Name: ${sprint.name}\n`); + process.stderr.write(` State: ${sprint.state}\n`); + + if (sprint.startDate) { + process.stderr.write(` Start: ${sprint.startDate.split("T")[0]}\n`); + } + if (sprint.endDate) { + process.stderr.write(` End: ${sprint.endDate.split("T")[0]}\n`); + } + if (sprint.goal) { + process.stderr.write(` Goal: ${sprint.goal}\n`); + } + + // Show issue count breakdown + const issues = await client.getSprintIssues(boardId, sprint.id); + const statusCounts = new Map(); + for (const issue of issues) { + const status = issue.fields.status.name; + statusCounts.set(status, (statusCounts.get(status) ?? 0) + 1); + } + + process.stderr.write(`\n Issues (${issues.length} total):\n`); + for (const [status, count] of statusCounts) { + process.stderr.write(` ${status.padEnd(20)} ${count}\n`); + } + + process.stderr.write("\n"); +} + +// ─── Command Entry ────────────────────────────────────────────────────────── + +export async function sprintCommand(args: string[]): Promise { + const config = loadJiraConfig(); + + if (!config.boardId) { + process.stderr.write( + "\n No board configured. Run: locus jira board\n" + + " A board is required to fetch sprint information.\n\n" + ); + process.exit(1); + } + + const options = parseSprintArgs(args); + + // Info mode — display sprint details and exit + if (options.info) { + return showSprintInfo(config.boardId); + } + + // Delegate to run command with --sprint flag + const runArgs = ["--sprint", "--status", options.status]; + + if (options.dryRun) { + runArgs.push("--dry-run"); + } + + if (options.sync) { + runArgs.push("--sync"); + } + + return runCommand(runArgs); +} diff --git a/packages/jira/src/commands/sync.ts b/packages/jira/src/commands/sync.ts new file mode 100644 index 00000000..d0044ba4 --- /dev/null +++ b/packages/jira/src/commands/sync.ts @@ -0,0 +1,480 @@ +/** + * Sync command for locus-jira. + * + * Pushes Locus execution results back to Jira: + * - Transitions issue statuses based on GitHub PR state + * - Posts execution summaries as Jira comments + * - Links GitHub PRs to Jira issues via remote links + * + * Usage: + * locus jira sync PROJ-101 PROJ-102 → sync specific issues + * locus jira sync --jql "..." → sync issues matching JQL + * locus jira sync --sprint → sync active sprint issues + * locus jira sync --comments → also post execution summary comments + * locus jira sync --dry-run → preview without executing + */ + +import { execSync } from "node:child_process"; +import { JiraClient } from "../client/client.js"; +import type { JiraIssue, JiraTransition } from "../client/types.js"; +import { loadJiraConfig } from "../config.js"; +import type { JiraConfig, TransitionOnPR } from "../types.js"; + +// ─── Types ────────────────────────────────────────────────────────────────── + +interface SyncOptions { + keys: string[]; + jql?: string; + sprint?: boolean; + comments?: boolean; + dryRun?: boolean; +} + +interface GitHubPR { + number: number; + title: string; + state: string; + url: string; +} + +interface SyncAction { + issueKey: string; + currentStatus: string; + targetStatus: string | null; + transitionId: string | null; + pr: GitHubPR | null; + comment: string | null; + skipped: boolean; + skipReason?: string; +} + +// ─── Arg Parsing ──────────────────────────────────────────────────────────── + +function parseSyncArgs(args: string[]): SyncOptions { + const options: SyncOptions = { keys: [] }; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + + switch (arg) { + case "--jql": { + const next = args[i + 1]; + if (!next || next.startsWith("--")) { + process.stderr.write(" --jql requires a query string.\n"); + process.exit(1); + } + options.jql = next; + i++; + break; + } + case "--sprint": + options.sprint = true; + break; + case "--comments": + options.comments = true; + break; + case "--dry-run": + options.dryRun = true; + break; + default: + if (arg.startsWith("--")) { + process.stderr.write(` Unknown flag: ${arg}\n`); + process.exit(1); + } + options.keys.push(arg.toUpperCase()); + } + } + + return options; +} + +// ─── GitHub PR Lookup ─────────────────────────────────────────────────────── + +/** + * Search for a GitHub PR that references the given Jira issue key. + * Uses `gh pr list --search` to find matching PRs by title or branch name. + */ +function findPRForIssue(issueKey: string): GitHubPR | null { + try { + const output = execSync( + `gh pr list --search "${issueKey}" --json number,title,state,url --limit 1`, + { encoding: "utf-8", timeout: 15_000, stdio: ["pipe", "pipe", "pipe"] } + ); + + const prs = JSON.parse(output) as Array<{ + number: number; + title: string; + state: string; + url: string; + }>; + + if (prs.length === 0) return null; + + return { + number: prs[0].number, + title: prs[0].title, + state: prs[0].state.toLowerCase(), + url: prs[0].url, + }; + } catch { + return null; + } +} + +// ─── Issue Fetching (reused patterns from run.ts) ─────────────────────────── + +async function fetchIssuesByKeys( + client: JiraClient, + keys: string[] +): Promise { + const issues: JiraIssue[] = []; + for (const key of keys) { + process.stderr.write(` Fetching ${key}...\n`); + const issue = await client.getIssue(key); + issues.push(issue); + } + return issues; +} + +async function fetchIssuesByJql( + client: JiraClient, + jql: string +): Promise { + process.stderr.write(` Searching: ${jql}\n`); + const result = await client.searchIssues(jql, { fetchAll: true }); + return result.issues; +} + +async function fetchSprintIssues( + client: JiraClient, + boardId: number +): Promise { + process.stderr.write(" Fetching active sprint...\n"); + const sprint = await client.getCurrentSprint(boardId); + if (!sprint) { + process.stderr.write(" No active sprint found.\n\n"); + return []; + } + + process.stderr.write(` Sprint: ${sprint.name}\n`); + return client.getSprintIssues(boardId, sprint.id); +} + +// ─── Transition Resolution ────────────────────────────────────────────────── + +/** + * Determine the target status and transition ID for an issue based on PR state. + * Returns null transition if no matching transition is available. + */ +function resolveTargetStatus( + prState: string, + transitionMap: TransitionOnPR +): string | null { + switch (prState) { + case "open": + return transitionMap.created ?? null; + case "merged": + return transitionMap.merged ?? null; + default: + return null; + } +} + +function findTransitionByTargetStatus( + transitions: JiraTransition[], + targetStatus: string +): JiraTransition | null { + const normalized = targetStatus.toLowerCase(); + return ( + transitions.find((t) => t.to.name.toLowerCase() === normalized) ?? null + ); +} + +// ─── Sync Plan ────────────────────────────────────────────────────────────── + +async function buildSyncPlan( + client: JiraClient, + issues: JiraIssue[], + config: JiraConfig, + postComments: boolean +): Promise { + const actions: SyncAction[] = []; + + for (const issue of issues) { + const key = issue.key; + const currentStatus = issue.fields.status.name; + + // Find associated GitHub PR + process.stderr.write(` Checking PR for ${key}...\n`); + const pr = findPRForIssue(key); + + if (!pr) { + actions.push({ + issueKey: key, + currentStatus, + targetStatus: null, + transitionId: null, + pr: null, + comment: null, + skipped: true, + skipReason: "No GitHub PR found", + }); + continue; + } + + // Determine target status from PR state + const targetStatus = resolveTargetStatus(pr.state, config.transitionOnPR); + + if (!targetStatus) { + actions.push({ + issueKey: key, + currentStatus, + targetStatus: null, + transitionId: null, + pr, + comment: postComments ? buildComment(pr) : null, + skipped: false, + }); + continue; + } + + // Check if already in target status (idempotent) + if (currentStatus.toLowerCase() === targetStatus.toLowerCase()) { + actions.push({ + issueKey: key, + currentStatus, + targetStatus, + transitionId: null, + pr, + comment: postComments ? buildComment(pr) : null, + skipped: true, + skipReason: `Already in "${currentStatus}"`, + }); + continue; + } + + // Find valid transition to target status + const transitions = await client.getTransitions(key); + const transition = findTransitionByTargetStatus(transitions, targetStatus); + + if (!transition) { + actions.push({ + issueKey: key, + currentStatus, + targetStatus, + transitionId: null, + pr, + comment: postComments ? buildComment(pr) : null, + skipped: true, + skipReason: `No transition to "${targetStatus}" from "${currentStatus}"`, + }); + continue; + } + + actions.push({ + issueKey: key, + currentStatus, + targetStatus, + transitionId: transition.id, + pr, + comment: postComments ? buildComment(pr) : null, + skipped: false, + }); + } + + return actions; +} + +// ─── Comment Builder ──────────────────────────────────────────────────────── + +function buildComment(pr: GitHubPR): string { + const status = pr.state === "merged" ? "Merged" : "Open"; + return ( + `[Locus] Sync update\n\n` + + `PR #${pr.number}: ${pr.title}\n` + + `Status: ${status}\n` + + `URL: ${pr.url}` + ); +} + +// ─── Dry Run ──────────────────────────────────────────────────────────────── + +function printDryRun(actions: SyncAction[]): void { + process.stderr.write( + `\n Dry run — ${actions.length} issue(s) analyzed:\n` + ); + process.stderr.write(` ${"═".repeat(70)}\n`); + + for (const action of actions) { + if (action.skipped) { + process.stderr.write( + ` [skip] ${action.issueKey.padEnd(14)} ${action.skipReason}\n` + ); + continue; + } + + const prInfo = action.pr + ? `PR #${action.pr.number} (${action.pr.state})` + : "no PR"; + + if (action.transitionId && action.targetStatus) { + process.stderr.write( + ` [move] ${action.issueKey.padEnd(14)} "${action.currentStatus}" → "${action.targetStatus}" (${prInfo})\n` + ); + } else { + process.stderr.write( + ` [link] ${action.issueKey.padEnd(14)} ${prInfo}\n` + ); + } + + if (action.comment) { + process.stderr.write( + ` [cmnt] ${action.issueKey.padEnd(14)} Will post execution summary\n` + ); + } + } + + process.stderr.write("\n"); +} + +// ─── Execute Sync ─────────────────────────────────────────────────────────── + +async function executeSyncPlan( + client: JiraClient, + actions: SyncAction[] +): Promise { + let transitioned = 0; + let commented = 0; + let linked = 0; + let skipped = 0; + + for (const action of actions) { + if (action.skipped) { + process.stderr.write( + ` [skip] ${action.issueKey.padEnd(14)} ${action.skipReason}\n` + ); + skipped++; + continue; + } + + // Transition issue status + if (action.transitionId && action.targetStatus) { + try { + await client.transitionIssue(action.issueKey, action.transitionId); + process.stderr.write( + ` [move] ${action.issueKey.padEnd(14)} "${action.currentStatus}" → "${action.targetStatus}"\n` + ); + transitioned++; + } catch { + process.stderr.write( + ` [warn] ${action.issueKey.padEnd(14)} Failed to transition to "${action.targetStatus}"\n` + ); + } + } + + // Link PR to issue + if (action.pr) { + try { + await client.addRemoteLink( + action.issueKey, + `PR #${action.pr.number}: ${action.pr.title}`, + action.pr.url + ); + process.stderr.write( + ` [link] ${action.issueKey.padEnd(14)} Linked PR #${action.pr.number}\n` + ); + linked++; + } catch { + process.stderr.write( + ` [warn] ${action.issueKey.padEnd(14)} Failed to add PR link (may already exist)\n` + ); + } + } + + // Post comment + if (action.comment) { + try { + await client.addComment(action.issueKey, action.comment); + process.stderr.write( + ` [cmnt] ${action.issueKey.padEnd(14)} Posted execution summary\n` + ); + commented++; + } catch { + process.stderr.write( + ` [warn] ${action.issueKey.padEnd(14)} Failed to post comment\n` + ); + } + } + } + + // Summary + process.stderr.write(`\n ${"─".repeat(70)}\n`); + process.stderr.write( + ` Sync complete: ${transitioned} transitioned, ${linked} linked, ${commented} commented, ${skipped} skipped\n\n` + ); +} + +// ─── Command Entry ────────────────────────────────────────────────────────── + +export async function syncCommand(args: string[]): Promise { + const options = parseSyncArgs(args); + const config = loadJiraConfig(); + + // Respect syncBack: false + if (!config.syncBack) { + process.stderr.write( + '\n Sync is disabled. Set "syncBack": true in .locus/config.json under packages.jira\n\n' + ); + process.exit(1); + } + + const client = JiraClient.fromConfig(); + + // Fetch issues based on mode + let jiraIssues: JiraIssue[]; + + if (options.keys.length > 0) { + jiraIssues = await fetchIssuesByKeys(client, options.keys); + } else if (options.jql) { + jiraIssues = await fetchIssuesByJql(client, options.jql); + } else if (options.sprint) { + if (!config.boardId) { + process.stderr.write( + "\n No board configured. Run: locus jira board\n\n" + ); + process.exit(1); + } + jiraIssues = await fetchSprintIssues(client, config.boardId); + } else { + process.stderr.write( + "\n Usage: locus jira sync | --jql | --sprint\n" + + " Run 'locus jira help' for details.\n\n" + ); + process.exit(1); + } + + if (jiraIssues.length === 0) { + process.stderr.write("\n No issues found.\n\n"); + return; + } + + process.stderr.write(`\n ${jiraIssues.length} issue(s) to sync\n`); + process.stderr.write(` ${"─".repeat(70)}\n`); + + // Build sync plan + const actions = await buildSyncPlan( + client, + jiraIssues, + config, + options.comments ?? false + ); + + // Dry run mode + if (options.dryRun) { + printDryRun(actions); + return; + } + + // Execute sync plan + await executeSyncPlan(client, actions); +} diff --git a/packages/jira/src/config.ts b/packages/jira/src/config.ts new file mode 100644 index 00000000..3637667e --- /dev/null +++ b/packages/jira/src/config.ts @@ -0,0 +1,215 @@ +/** + * Config module for @locusai/locus-jira. + * + * Loads the `packages.jira` section from `.locus/config.json`, + * validates required fields, and provides typed accessors. + * Also supports saving config updates and env-var overrides. + */ + +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { readLocusConfig } from "@locusai/sdk"; +import type { + JiraConfig, + JiraCredentials, + TransitionOnPR, +} from "./types.js"; + +const DEFAULT_JIRA_CONFIG: JiraConfig = { + auth: null, + projectKey: null, + boardId: null, + defaultJql: null, + syncBack: false, + transitionOnPR: {}, + userMapping: {}, + includeComments: true, + maxIssuesPerRun: 20, +}; + +/** + * Apply environment variable overrides to credentials. + * Supports OAuth (JIRA_OAUTH_*), API Token (JIRA_EMAIL + JIRA_API_TOKEN), + * and PAT (JIRA_PAT) overrides. OAuth takes highest precedence. + */ +function applyEnvOverrides(config: JiraConfig): JiraConfig { + // OAuth env vars (highest precedence — for CI/CD with pre-obtained tokens) + const oauthAccessToken = process.env.JIRA_OAUTH_ACCESS_TOKEN; + const oauthRefreshToken = process.env.JIRA_OAUTH_REFRESH_TOKEN; + const oauthClientId = process.env.JIRA_OAUTH_CLIENT_ID; + const oauthClientSecret = process.env.JIRA_OAUTH_CLIENT_SECRET; + const cloudId = process.env.JIRA_CLOUD_ID; + + if (oauthAccessToken && oauthClientId && oauthClientSecret && cloudId) { + return { + ...config, + auth: { + method: "oauth", + accessToken: oauthAccessToken, + refreshToken: oauthRefreshToken ?? "", + expiresAt: new Date(Date.now() + 3600 * 1000).toISOString(), + cloudId, + clientId: oauthClientId, + clientSecret: oauthClientSecret, + }, + }; + } + + const baseUrl = process.env.JIRA_BASE_URL; + const email = process.env.JIRA_EMAIL; + const apiToken = process.env.JIRA_API_TOKEN; + const pat = process.env.JIRA_PAT; + + if (pat && baseUrl) { + return { + ...config, + auth: { + method: "pat", + patToken: pat, + baseUrl, + }, + }; + } + + if (email && apiToken && baseUrl) { + return { + ...config, + auth: { + method: "api-token", + email, + apiToken, + baseUrl, + }, + }; + } + + return config; +} + +/** + * Load the Jira config section from `.locus/config.json`. + * Returns defaults for any missing fields. + * Environment variables override stored credentials. + */ +export function loadJiraConfig(cwd?: string): JiraConfig { + const locusConfig = readLocusConfig(cwd); + const pkg = locusConfig.packages?.jira as Partial | undefined; + + const config: JiraConfig = { + auth: (pkg?.auth as JiraCredentials) ?? DEFAULT_JIRA_CONFIG.auth, + projectKey: pkg?.projectKey ?? DEFAULT_JIRA_CONFIG.projectKey, + boardId: pkg?.boardId ?? DEFAULT_JIRA_CONFIG.boardId, + defaultJql: pkg?.defaultJql ?? DEFAULT_JIRA_CONFIG.defaultJql, + syncBack: pkg?.syncBack ?? DEFAULT_JIRA_CONFIG.syncBack, + transitionOnPR: + (pkg?.transitionOnPR as TransitionOnPR) ?? + DEFAULT_JIRA_CONFIG.transitionOnPR, + userMapping: pkg?.userMapping ?? DEFAULT_JIRA_CONFIG.userMapping, + includeComments: + pkg?.includeComments ?? DEFAULT_JIRA_CONFIG.includeComments, + maxIssuesPerRun: + pkg?.maxIssuesPerRun ?? DEFAULT_JIRA_CONFIG.maxIssuesPerRun, + }; + + return applyEnvOverrides(config); +} + +/** + * Validate that the Jira config has required fields for API operations. + * Returns an error message if invalid, or null if valid. + */ +export function validateJiraConfig(config: JiraConfig): string | null { + if (!config.auth) { + return "Not authenticated. Run: locus jira auth"; + } + return null; +} + +// ─── Raw Project Config I/O ───────────────────────────────────────────────── + +function readProjectConfig(cwd?: string): Record { + const configPath = join(cwd ?? process.cwd(), ".locus", "config.json"); + if (!existsSync(configPath)) return {}; + try { + return JSON.parse(readFileSync(configPath, "utf-8")) as Record< + string, + unknown + >; + } catch { + return {}; + } +} + +function writeProjectConfig( + config: Record, + cwd?: string +): void { + const configPath = join(cwd ?? process.cwd(), ".locus", "config.json"); + writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`); +} + +function getJiraSection( + config: Record +): Record { + if (!config.packages || typeof config.packages !== "object") { + config.packages = {}; + } + const packages = config.packages as Record; + if (!packages.jira || typeof packages.jira !== "object") { + packages.jira = {}; + } + return packages.jira as Record; +} + +// ─── Config Mutation ──────────────────────────────────────────────────────── + +/** + * Merge partial config into the stored Jira config section + * in `.locus/config.json`. + */ +export function saveJiraConfig( + update: Partial, + cwd?: string +): void { + const config = readProjectConfig(cwd); + const jira = getJiraSection(config); + + for (const [key, value] of Object.entries(update)) { + if (value !== undefined) { + jira[key] = value; + } + } + + writeProjectConfig(config, cwd); +} + +/** + * Update only the auth credentials in the Jira config section. + */ +export function saveCredentials( + credentials: JiraCredentials, + cwd?: string +): void { + const config = readProjectConfig(cwd); + const jira = getJiraSection(config); + jira.auth = credentials; + writeProjectConfig(config, cwd); +} + +/** + * Clear auth credentials from the Jira config section. + */ +export function clearCredentials(cwd?: string): void { + const config = readProjectConfig(cwd); + const jira = getJiraSection(config); + jira.auth = null; + writeProjectConfig(config, cwd); +} + +/** + * Read the stored auth credentials from the Jira config section. + */ +export function loadCredentials(cwd?: string): JiraCredentials | null { + const config = loadJiraConfig(cwd); + return config.auth; +} diff --git a/packages/jira/src/errors.ts b/packages/jira/src/errors.ts new file mode 100644 index 00000000..4e39e91e --- /dev/null +++ b/packages/jira/src/errors.ts @@ -0,0 +1,139 @@ +/** + * Structured error classes and centralized error handler for @locusai/locus-jira. + * + * Maps Jira REST API HTTP status codes to typed error classes, + * and provides `handleJiraError()` for use with axios error responses. + */ + +import type { AxiosError } from "axios"; + +// ─── Error Classes ────────────────────────────────────────────────────────── + +export class JiraAuthError extends Error { + constructor(message = "Jira authentication failed (401).") { + super(message); + this.name = "JiraAuthError"; + } +} + +export class JiraTokenExpiredError extends Error { + constructor(message = "Jira OAuth token refresh failed.") { + super(message); + this.name = "JiraTokenExpiredError"; + } +} + +export class JiraPermissionError extends Error { + constructor(message = "Jira permission denied (403).") { + super(message); + this.name = "JiraPermissionError"; + } +} + +export class JiraNotFoundError extends Error { + constructor(message = "Jira resource not found (404).") { + super(message); + this.name = "JiraNotFoundError"; + } +} + +export class JiraRateLimitError extends Error { + constructor(message = "Jira API rate limit exceeded (429).") { + super(message); + this.name = "JiraRateLimitError"; + } +} + +// ─── Error Handler ────────────────────────────────────────────────────────── + +/** + * Map an axios error to the appropriate Jira error class and throw. + * Call this in catch blocks when making Jira API requests. + */ +export function handleJiraError(error: AxiosError): never { + const status = error.response?.status; + const data = error.response?.data as Record | undefined; + const detail = + (data?.message as string) ?? + (data?.errorMessages as string[] | undefined)?.[0] ?? + error.message; + + switch (status) { + case 401: + throw new JiraAuthError(`Jira authentication failed (401): ${detail}`); + case 403: + throw new JiraPermissionError(`Jira permission denied (403): ${detail}`); + case 404: + throw new JiraNotFoundError(`Jira resource not found (404): ${detail}`); + case 429: + throw new JiraRateLimitError( + `Jira API rate limit exceeded (429): ${detail}` + ); + default: + throw new Error(`Jira API error (${status ?? "unknown"}): ${detail}`); + } +} + +// ─── Command Error Handler ────────────────────────────────────────────────── + +/** + * Shared error handler for locus-jira commands. + * Categorizes errors into actionable messages for the user. + */ +export function handleCommandError(err: unknown): never { + const msg = err instanceof Error ? err.message : String(err); + + if (err instanceof JiraAuthError || msg.includes("Not authenticated")) { + process.stderr.write( + "\n Not authenticated. Run:\n locus jira auth\n\n" + ); + process.exit(1); + } + + if (err instanceof JiraTokenExpiredError) { + process.stderr.write( + "\n Your Jira OAuth token has expired and could not be refreshed.\n" + + " Run:\n locus jira auth --revoke\n locus jira auth\n\n" + ); + process.exit(1); + } + + if (err instanceof JiraPermissionError) { + process.stderr.write( + "\n Jira permission denied (403).\n" + + " Your token may lack required scopes. Run:\n" + + " locus jira auth --revoke\n locus jira auth\n\n" + ); + process.exit(1); + } + + if (err instanceof JiraNotFoundError) { + process.stderr.write(`\n ${msg}\n\n`); + process.exit(1); + } + + if (err instanceof JiraRateLimitError) { + process.stderr.write( + "\n Jira API rate limit exceeded. Wait a moment and try again.\n\n" + ); + process.exit(1); + } + + if ( + msg.includes("ECONNREFUSED") || + msg.includes("ENOTFOUND") || + msg.includes("ETIMEDOUT") || + msg.includes("ECONNRESET") || + msg.includes("socket hang up") + ) { + process.stderr.write( + "\n Network error — could not reach the Jira API.\n" + + " Check your internet connection and try again.\n\n" + + ` Details: ${msg}\n\n` + ); + process.exit(1); + } + + process.stderr.write(`\n Error: ${msg}\n\n`); + process.exit(1); +} diff --git a/packages/jira/src/index.ts b/packages/jira/src/index.ts new file mode 100644 index 00000000..7c7a8527 --- /dev/null +++ b/packages/jira/src/index.ts @@ -0,0 +1,153 @@ +export { promptForApiToken } from "./auth/api-token.js"; +export { promptForPAT } from "./auth/pat.js"; +export { adfToMarkdown } from "./client/adf-to-md.js"; +export { JiraClient } from "./client/client.js"; +export type { + ADFMark, + ADFNode, + JiraBoard, + JiraComment, + JiraIssue, + JiraIssueFields, + JiraProject, + JiraSearchResult, + JiraSprint, + JiraTransition, + JiraUser, +} from "./client/types.js"; +export { + clearCredentials, + loadCredentials, + loadJiraConfig, + saveCredentials, + saveJiraConfig, + validateJiraConfig, +} from "./config.js"; +export { + handleCommandError, + handleJiraError, + JiraAuthError, + JiraNotFoundError, + JiraPermissionError, + JiraRateLimitError, + JiraTokenExpiredError, +} from "./errors.js"; +export type { LocusIssue } from "./mapper.js"; +export { mapJiraIssue, mapJiraIssueBatch } from "./mapper.js"; +export type { + JiraApiTokenCredentials, + JiraAuthMethod, + JiraConfig, + JiraCredentials, + JiraOAuthCredentials, + JiraPatCredentials, + TransitionOnPR, +} from "./types.js"; + +export async function main(args: string[]): Promise { + const command = args[0] ?? "help"; + + try { + switch (command) { + case "auth": { + const { authCommand } = await import("./commands/auth.js"); + return await authCommand(args.slice(1)); + } + case "project": { + const { projectCommand } = await import("./commands/project.js"); + return await projectCommand(args.slice(1)); + } + case "board": { + const { boardCommand } = await import("./commands/board.js"); + return await boardCommand(args.slice(1)); + } + case "issues": { + const { issuesCommand } = await import("./commands/issues.js"); + return await issuesCommand(args.slice(1)); + } + case "issue": { + const { issueCommand } = await import("./commands/issue.js"); + return await issueCommand(args.slice(1)); + } + case "run": { + const { runCommand } = await import("./commands/run.js"); + return await runCommand(args.slice(1)); + } + case "sprint": { + const { sprintCommand } = await import("./commands/sprint.js"); + return await sprintCommand(args.slice(1)); + } + case "sync": { + const { syncCommand } = await import("./commands/sync.js"); + return await syncCommand(args.slice(1)); + } + case "help": + case "--help": + case "-h": + printHelp(); + return; + default: + console.error( + `Unknown command: ${command}\nRun "locus jira help" for usage.` + ); + process.exit(1); + } + } catch (err) { + const { handleCommandError } = await import("./errors.js"); + handleCommandError(err); + } +} + +function printHelp(): void { + console.log( + ` +locus-jira — Fetch and execute Jira issues with Locus + +Usage: + locus jira [options] + +Commands: + auth Authenticate with Jira (API Token or PAT) + project Select active Jira project + board Select active Jira board + issues List issues (tabular view) + issue Show detailed view of a single issue + run Fetch and execute Jira issues via Locus + sprint Run active sprint issues (shorthand for run --sprint) + sync Sync execution results back to Jira + help Show this help message + +Auth Options: + --status Show current authentication status + --revoke Clear stored credentials + --method Skip interactive selection (api-token or pat) + +Issues Options: + --jql Custom JQL filter + --sprint Show issues from active sprint + --limit Limit results (default: 25) + +Run Options: + --jql Fetch issues by JQL query + --sprint Fetch issues from active sprint + --status Filter by Jira status (e.g., "To Do") + --dry-run Show issues without executing + --sync Sync status back to Jira after execution + +Sprint Options: + --status Filter sprint issues by status (default: "To Do") + --info Show sprint details without running + --dry-run Preview without executing + --sync Sync status back to Jira after execution + +Sync Options: + --jql Sync issues matching JQL query + --sprint Sync active sprint issues + --comments Post execution summary as Jira comment + --dry-run Show planned changes without executing + +Options: + -h, --help Show help +`.trim() + ); +} diff --git a/packages/jira/src/mapper.ts b/packages/jira/src/mapper.ts new file mode 100644 index 00000000..bf5f8889 --- /dev/null +++ b/packages/jira/src/mapper.ts @@ -0,0 +1,180 @@ +/** + * Issue mapper for @locusai/locus-jira. + * + * Converts Jira issues into Locus's internal LocusIssue format, + * consumed by `locus run`, `locus plan`, and `locus iterate`. + */ + +import { adfToMarkdown } from "./client/adf-to-md.js"; +import type { ADFNode, JiraComment, JiraIssue } from "./client/types.js"; +import type { JiraConfig } from "./types.js"; + +// ─── LocusIssue ───────────────────────────────────────────────────────────── + +export interface LocusIssue { + id: string; + title: string; + description: string; + labels: string[]; + priority: string; + assignee?: string; + url: string; + comments?: string[]; +} + +// ─── Priority Mapping ─────────────────────────────────────────────────────── + +const PRIORITY_MAP: Record = { + Highest: "p:critical", + High: "p:high", + Medium: "p:medium", + Low: "p:low", + Lowest: "p:lowest", +}; + +const DEFAULT_PRIORITY = "p:medium"; +const DEFAULT_COMMENT_COUNT = 5; + +/** + * Map a Jira priority name to a Locus priority label. + */ +function mapPriority(priorityName: string | null | undefined): string { + if (!priorityName) return DEFAULT_PRIORITY; + return PRIORITY_MAP[priorityName] ?? DEFAULT_PRIORITY; +} + +// ─── Description ──────────────────────────────────────────────────────────── + +/** + * Convert a Jira description field to Markdown. + * Handles ADF objects, raw strings, and null/undefined. + */ +function convertDescription( + description: ADFNode | string | null | undefined +): string { + if (!description) return ""; + if (typeof description === "string") return description; + return adfToMarkdown(description); +} + +// ─── Assignee ─────────────────────────────────────────────────────────────── + +/** + * Resolve a Jira assignee to a GitHub username. + * Looks up accountId in config.userMapping, falls back to displayName. + */ +function resolveAssignee( + assignee: JiraIssue["fields"]["assignee"], + userMapping: Record +): string | undefined { + if (!assignee) return undefined; + const mapped = userMapping[assignee.accountId]; + if (mapped) return mapped; + return assignee.displayName; +} + +// ─── Issue URL ────────────────────────────────────────────────────────────── + +/** + * Construct the browse URL for a Jira issue. + * For OAuth credentials (Cloud), uses the stored baseUrl from config. + * For API Token and PAT, uses auth.baseUrl directly. + */ +function buildIssueUrl(issueKey: string, config: JiraConfig): string { + const auth = config.auth; + if (!auth) return issueKey; + + if (auth.method === "oauth") { + // OAuth doesn't have baseUrl — use cloudId to construct URL + // The browse URL for Cloud is always https://**.atlassian.net/browse/KEY + // but we don't have the site URL. Fall back to the issue self link pattern. + return `https://api.atlassian.com/ex/jira/${auth.cloudId}/browse/${issueKey}`; + } + + return `${auth.baseUrl}/browse/${issueKey}`; +} + +// ─── Comments ─────────────────────────────────────────────────────────────── + +/** + * Format a Jira comment into a single-line string: `[date] author: body`. + */ +function formatComment(comment: JiraComment): string { + const date = comment.created.split("T")[0] ?? comment.created; + const author = comment.author.displayName; + const body = + typeof comment.body === "string" + ? comment.body + : adfToMarkdown(comment.body); + + // Collapse multiline body into a single line for compact display + const oneLine = body.replace(/\n+/g, " ").trim(); + return `[${date}] ${author}: ${oneLine}`; +} + +/** + * Extract and format the last N comments from a Jira issue. + */ +function extractComments( + issue: JiraIssue, + maxComments: number +): string[] | undefined { + const commentData = issue.fields.comment; + if (!commentData?.comments?.length) return undefined; + + const comments = commentData.comments; + const lastN = comments.slice(-maxComments); + return lastN.map(formatComment); +} + +// ─── Public API ───────────────────────────────────────────────────────────── + +/** + * Convert a Jira issue into Locus's internal LocusIssue format. + * + * Field mapping: + * - id ← issue.key (e.g., "PROJ-123") + * - title ← issue.fields.summary + * - description ← ADF→Markdown or raw string + * - labels ← issue.fields.labels + * - priority ← Jira priority name → Locus label + * - assignee ← config.userMapping lookup, fallback to displayName + * - url ← {baseUrl}/browse/{key} + * - comments ← last N comments formatted as "[date] author: body" + */ +export function mapJiraIssue(issue: JiraIssue, config: JiraConfig): LocusIssue { + const maxComments = config.includeComments ? DEFAULT_COMMENT_COUNT : 0; + + const result: LocusIssue = { + id: issue.key, + title: issue.fields.summary, + description: convertDescription(issue.fields.description), + labels: [...issue.fields.labels], + priority: mapPriority(issue.fields.priority?.name), + url: buildIssueUrl(issue.key, config), + }; + + const assignee = resolveAssignee(issue.fields.assignee, config.userMapping); + if (assignee) { + result.assignee = assignee; + } + + if (maxComments > 0) { + const comments = extractComments(issue, maxComments); + if (comments) { + result.comments = comments; + } + } + + return result; +} + +/** + * Convert a batch of Jira issues into LocusIssue format. + */ +export function mapJiraIssueBatch( + issues: JiraIssue[], + config: JiraConfig +): LocusIssue[] { + return issues.map((issue) => mapJiraIssue(issue, config)); +} diff --git a/packages/jira/src/types.ts b/packages/jira/src/types.ts new file mode 100644 index 00000000..2a22bd25 --- /dev/null +++ b/packages/jira/src/types.ts @@ -0,0 +1,61 @@ +/** + * Core TypeScript interfaces for @locusai/locus-jira. + * + * These types define the credential shapes (OAuth, API token, PAT), + * and the configuration stored in `.locus/config.json` under `packages.jira`. + */ + +// ─── Auth ─────────────────────────────────────────────────────────────────── + +export type JiraAuthMethod = "oauth" | "api-token" | "pat"; + +export interface JiraOAuthCredentials { + method: "oauth"; + accessToken: string; + refreshToken: string; + expiresAt: string; + cloudId: string; + clientId: string; + clientSecret: string; +} + +export interface JiraApiTokenCredentials { + method: "api-token"; + email: string; + apiToken: string; + baseUrl: string; +} + +export interface JiraPatCredentials { + method: "pat"; + patToken: string; + baseUrl: string; +} + +export type JiraCredentials = + | JiraOAuthCredentials + | JiraApiTokenCredentials + | JiraPatCredentials; + +// ─── Sync Configuration ───────────────────────────────────────────────────── + +export interface TransitionOnPR { + /** Status to transition to when a PR is created (e.g., "In Review"). */ + created?: string; + /** Status to transition to when a PR is merged (e.g., "Done"). */ + merged?: string; +} + +// ─── Configuration ────────────────────────────────────────────────────────── + +export interface JiraConfig { + auth: JiraCredentials | null; + projectKey: string | null; + boardId: number | null; + defaultJql: string | null; + syncBack: boolean; + transitionOnPR: TransitionOnPR; + userMapping: Record; + includeComments: boolean; + maxIssuesPerRun: number; +} diff --git a/packages/jira/tsconfig.json b/packages/jira/tsconfig.json new file mode 100644 index 00000000..6e4b4973 --- /dev/null +++ b/packages/jira/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "baseUrl": "." + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +}