diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..699ed73 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["biomejs.biome"] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..2efb85a --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,34 @@ +{ + "editor.defaultFormatter": "biomejs.biome", + "editor.formatOnSave": true, + "editor.formatOnPaste": true, + "editor.formatOnType": false, + "editor.codeActionsOnSave": { + "source.removeUnusedImports": "always", + "source.fixAll.biome": "explicit", + "source.organizeImports": "none", + "source.organizeImports.biome": "explicit" + }, + "[javascript]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[typescript]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[javascriptreact]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[typescriptreact]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[json]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[jsonc]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "sonarlint.connectedMode.project": { + "connectionId": "https-eyecare-sonarqube-luxgroup-net-", + "projectKey": "bat" + } +} diff --git a/README.md b/README.md index a0c6804..8eaa958 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Sonar Autofixer -CLI utility for fetching SonarQube issues and integrating with Bitbucket/GitHub PR workflows. Automatically detects PR IDs from branches and fetches SonarQube issues for code quality analysis. +CLI utility for fetching SonarQube issues and running local SonarQube scans. Automatically detects PR IDs from branches and fetches SonarQube issues for code quality analysis. Includes AI editor integration for automated issue fixing. ## Installation @@ -40,65 +40,181 @@ Or install globally: npm install -g @davide97g/sonar-autofixer ``` +## Quick Start + +### 1. Initialize Configuration + +Run the interactive setup to configure your project: + +```bash +npx @davide97g/sonar-autofixer init +``` + +This will: + +- Create `.sonar/autofixer.config.json` with your project settings +- Add npm scripts to your `package.json` +- Create AI editor rules for automated issue fixing (Cursor, VSCode, Windsurf) + +### 2. Set Up Environment Variables + +Create a `.env` file in your project root: + +```env +# GitHub Configuration +GITHUB_TOKEN=your-github-token +GITHUB_OWNER=your-username +GITHUB_REPO=your-repo-name + +# SonarQube/SonarCloud Configuration +SONAR_TOKEN=your-sonar-token +SONAR_ORGANIZATION=your-organization +SONAR_COMPONENT_KEYS=your-project-key +SONAR_BASE_URL=https://sonarcloud.io/api/issues/search +``` + ## Usage -### As a CLI tool +### Commands -After installation, you can use the `sonar-fetch` command: +#### Fetch SonarQube Issues ```bash # Fetch issues for current branch (auto-detects PR) -sonar-fetch +npx @davide97g/sonar-autofixer fetch # Fetch issues for a specific branch -sonar-fetch my-branch +npx @davide97g/sonar-autofixer fetch my-branch # Fetch issues from a SonarQube PR link -sonar-fetch my-branch https://sonarqube.example.com/project/issues?id=project&pullRequest=PR_KEY +npx @davide97g/sonar-autofixer fetch my-branch https://sonarcloud.io/project/issues?id=project&pullRequest=PR_KEY ``` -### As an npm script +#### Run Local SonarQube Scan ```bash -npm run sonar:fetch [branch] [sonar-pr-link] +# Run local SonarQube scan +npx @davide97g/sonar-autofixer scan ``` -## Configuration +#### Initialize Configuration + +```bash +# Interactive setup +npx @davide97g/sonar-autofixer init +``` -Create a `.env` file in your project root with the following variables: +#### Check for Updates -```env -BITBUCKET_EMAIL=your-email@example.com -BITBUCKET_API_TOKEN=your-api-token -SONAR_BASE_URL=https://your-sonarqube.com/project/issues -BITBUCKET_BASE_URL=https://api.bitbucket.org/2.0/repositories/your-org/your-repo +```bash +# Check for updates and get latest version info +npx @davide97g/sonar-autofixer update +``` + +### As npm Scripts + +After initialization, you can use the added npm scripts: + +```bash +# Fetch issues +npm run sonar:fetch + +# Run local scan +npm run sonar:scan ``` ## Features -- **Automatic PR Detection**: Automatically detects PR IDs from your current git branch using Bitbucket/GitHub API +- **Automatic PR Detection**: Automatically detects PR IDs from your current git branch using GitHub API - **Fallback Support**: Falls back to branch-based fetching if PR detection fails - **PR Link Support**: Directly fetch issues using a SonarQube PR link +- **Local Scanning**: Run SonarQube scans locally and save results +- **AI Editor Integration**: Creates rules for Cursor, VSCode, Windsurf for automated issue fixing - **Issue Summary**: Displays a summary of issues by severity after fetching +- **Configuration Management**: Interactive setup for easy configuration +- **Update Checking**: Built-in command to check for updates and get latest version info + +## Updating the CLI + +Since this CLI is designed to be used with `npx`, updating is simple: + +### Always Get the Latest Version + +```bash +# Use @latest to always get the most recent version +npx @davide97g/sonar-autofixer@latest +``` + +### Check for Updates + +```bash +# Check current version and get update instructions +npx @davide97g/sonar-autofixer update +``` + +### Update Your npm Scripts + +If you've set up npm scripts in your `package.json`, update them to use `@latest`: + +```json +{ + "scripts": { + "sonar:fetch": "npx @davide97g/sonar-autofixer@latest fetch", + "sonar:scan": "npx @davide97g/sonar-autofixer@latest scan" + } +} +``` ## How It Works +### Fetch Command + 1. Detects the current git branch or uses provided branch name -2. Attempts to find associated PR using Bitbucket API or branch name pattern matching +2. Attempts to find associated PR using GitHub API or branch name pattern matching 3. Fetches SonarQube issues for the PR or branch 4. Saves issues to `.sonar/issues.json` 5. Displays a summary of fetched issues -## Output +### Scan Command + +1. Validates SonarQube token and configuration +2. Runs local SonarQube scanner +3. Saves results to `.sonar/scanner-report.json` +4. Provides detailed scan output + +### Init Command + +1. Prompts for project configuration (repo name, git provider, etc.) +2. Creates configuration file +3. Updates package.json with npm scripts +4. Creates AI editor rules based on your editor choice + +## Output Files + +- `.sonar/issues.json` - Fetched SonarQube issues in JSON format +- `.sonar/scanner-report.json` - Local scan results +- `.sonar/autofixer.config.json` - Project configuration +- `.cursor/rules/sonar-issue-fix.mdc` - Cursor AI rules (if selected) +- `.vscode/sonar-issue-fix.md` - VSCode rules (if selected) +- `.windsurf/rules/sonar-issue-fix.mdc` - Windsurf rules (if selected) + +## AI Editor Integration + +The tool creates specific rules for your chosen AI editor to help with automated SonarQube issue fixing: + +- **Cursor**: Creates `.cursor/rules/sonar-issue-fix.mdc` +- **VSCode with Copilot**: Creates `.vscode/sonar-issue-fix.md` +- **Windsurf**: Creates `.windsurf/rules/sonar-issue-fix.mdc` -The script saves fetched issues to `.sonar/issues.json` in your project root. This file contains all SonarQube issues in JSON format, ready for further processing or analysis. +These rules provide patterns and priorities for fixing common SonarQube issues. ## Requirements -- Node.js (v14 or higher) +- Node.js (v18 or higher) - Git repository -- Bitbucket/GitHub API token with appropriate permissions -- SonarQube access +- GitHub API token with appropriate permissions +- SonarQube/SonarCloud access +- SonarQube Scanner (for local scans): `npm install -g @sonar/scan` ## License diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..86d38e7 --- /dev/null +++ b/biome.json @@ -0,0 +1,55 @@ +{ + "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + "files": { + "ignoreUnknown": false, + "ignore": ["node_modules", "dist"] + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 100 + }, + "organizeImports": { + "enabled": true + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "correctness": { + "noUnusedVariables": "error" + }, + "style": { + "noParameterAssign": "error" + }, + "suspicious": { + "noExplicitAny": "warn" + } + } + }, + "javascript": { + "formatter": { + "quoteStyle": "double", + "trailingCommas": "es5", + "semicolons": "always" + } + }, + "overrides": [ + { + "include": ["*.ts", "*.tsx"], + "linter": { + "rules": { + "style": { + "useImportType": "error" + } + } + } + } + ] +} diff --git a/bun.lock b/bun.lock index 8bd3b96..d7adb86 100644 --- a/bun.lock +++ b/bun.lock @@ -7,13 +7,38 @@ "chalk": "^5.6.2", "commander": "^14.0.2", "dotenv": "^17.2.3", + "figlet": "^1.9.3", "fs-extra": "^11.3.2", + "gradient-string": "^3.0.0", "inquirer": "^12.10.0", "ora": "^9.0.0", }, + "devDependencies": { + "@biomejs/biome": "^1.9.4", + "@types/node": "^22.10.7", + "@typescript/native-preview": "^7.0.0-dev.20251029.1", + }, }, }, "packages": { + "@biomejs/biome": ["@biomejs/biome@1.9.4", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "1.9.4", "@biomejs/cli-darwin-x64": "1.9.4", "@biomejs/cli-linux-arm64": "1.9.4", "@biomejs/cli-linux-arm64-musl": "1.9.4", "@biomejs/cli-linux-x64": "1.9.4", "@biomejs/cli-linux-x64-musl": "1.9.4", "@biomejs/cli-win32-arm64": "1.9.4", "@biomejs/cli-win32-x64": "1.9.4" }, "bin": { "biome": "bin/biome" } }, "sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog=="], + + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@1.9.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bFBsPWrNvkdKrNCYeAp+xo2HecOGPAy9WyNyB/jKnnedgzl4W4Hb9ZMzYNbf8dMCGmUdSavlYHiR01QaYR58cw=="], + + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@1.9.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-ngYBh/+bEedqkSevPVhLP4QfVPCpb+4BBe2p7Xs32dBgs7rh9nY2AIYUL6BgLw1JVXV8GlpKmb/hNiuIxfPfZg=="], + + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@1.9.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-fJIW0+LYujdjUgJJuwesP4EjIBl/N/TcOX3IvIHJQNsAqvV2CHIogsmA94BPG6jZATS4Hi+xv4SkBBQSt1N4/g=="], + + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@1.9.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-v665Ct9WCRjGa8+kTr0CzApU0+XXtRgwmzIf1SeKSGAv+2scAlW6JR5PMFo6FzqqZ64Po79cKODKf3/AAmECqA=="], + + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@1.9.4", "", { "os": "linux", "cpu": "x64" }, "sha512-lRCJv/Vi3Vlwmbd6K+oQ0KhLHMAysN8lXoCI7XeHlxaajk06u7G+UsFSO01NAs5iYuWKmVZjmiOzJ0OJmGsMwg=="], + + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@1.9.4", "", { "os": "linux", "cpu": "x64" }, "sha512-gEhi/jSBhZ2m6wjV530Yy8+fNqG8PAinM3oV7CyO+6c3CEh16Eizm21uHVsyVBEB6RIM8JHIl6AGYCv6Q6Q9Tg=="], + + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@1.9.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-tlbhLk+WXZmgwoIKwHIHEBZUwxml7bRJgk0X2sPyNR3S93cdRq6XulAZRQJ17FYGGzWne0fgrXBKpl7l4M87Hg=="], + + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@1.9.4", "", { "os": "win32", "cpu": "x64" }, "sha512-8Y5wMhVIPaWe6jw2H+KlEm4wP/f7EW3810ZLmDlrEEy5KvBsb9ECEfu/kMWD484ijfQ8+nIi0giMgu9g1UAuuA=="], + "@inquirer/ansi": ["@inquirer/ansi@1.0.1", "", {}, "sha512-yqq0aJW/5XPhi5xOAL1xRCpe1eh8UFVgYFpFsjEqmIR8rKLyP+HINvFXwUaxYICflJrVlxnp7lLN6As735kVpw=="], "@inquirer/checkbox": ["@inquirer/checkbox@4.3.0", "", { "dependencies": { "@inquirer/ansi": "^1.0.1", "@inquirer/core": "^10.3.0", "@inquirer/figures": "^1.0.14", "@inquirer/type": "^3.0.9", "yoctocolors-cjs": "^2.1.2" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-5+Q3PKH35YsnoPTh75LucALdAxom6xh5D1oeY561x4cqBuH24ZFVyFREPe14xgnrtmGu3EEt1dIi60wRVSnGCw=="], @@ -46,6 +71,26 @@ "@inquirer/type": ["@inquirer/type@3.0.9", "", { "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-QPaNt/nmE2bLGQa9b7wwyRJoLZ7pN6rcyXvzU0YCmivmJyq1BVo94G98tStRWkoD1RgDX5C+dPlhhHzNdu/W/w=="], + "@types/node": ["@types/node@22.18.13", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-Bo45YKIjnmFtv6I1TuC8AaHBbqXtIo+Om5fE4QiU1Tj8QR/qt+8O3BAtOimG5IFmwaWiPmB3Mv3jtYzBA4Us2A=="], + + "@types/tinycolor2": ["@types/tinycolor2@1.4.6", "", {}, "sha512-iEN8J0BoMnsWBqjVbWH/c0G0Hh7O21lpR2/+PrvAVgWdzL7eexIFm4JN/Wn10PTcmNdtS6U67r499mlWMXOxNw=="], + + "@typescript/native-preview": ["@typescript/native-preview@7.0.0-dev.20251029.1", "", { "optionalDependencies": { "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20251029.1", "@typescript/native-preview-darwin-x64": "7.0.0-dev.20251029.1", "@typescript/native-preview-linux-arm": "7.0.0-dev.20251029.1", "@typescript/native-preview-linux-arm64": "7.0.0-dev.20251029.1", "@typescript/native-preview-linux-x64": "7.0.0-dev.20251029.1", "@typescript/native-preview-win32-arm64": "7.0.0-dev.20251029.1", "@typescript/native-preview-win32-x64": "7.0.0-dev.20251029.1" }, "bin": { "tsgo": "bin/tsgo.js" } }, "sha512-IRmYCDgwZQEfjy2GNJnQbqoRUrvdCbzLE0sLhwc6TP4I0Hx5TnHv3sJGKAgdmcbHmKHtwJeppXjgTRGtFTWRHQ=="], + + "@typescript/native-preview-darwin-arm64": ["@typescript/native-preview-darwin-arm64@7.0.0-dev.20251029.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-DBJ3jFP6/MaQj/43LN1TC7tjR4SXZUNDnREiVjtFzpOG4Q71D1LB6QryskkRZsNtxLaTuVV57l2ubCE8tNmz0w=="], + + "@typescript/native-preview-darwin-x64": ["@typescript/native-preview-darwin-x64@7.0.0-dev.20251029.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-fnxZZtlXeud6f3bev3q50QMR+FrnuTyVr5akp5G2/o4jfkqLV6cKzseGnY6so+ftwfwP/PX3GOkfL6Ag8NzR0Q=="], + + "@typescript/native-preview-linux-arm": ["@typescript/native-preview-linux-arm@7.0.0-dev.20251029.1", "", { "os": "linux", "cpu": "arm" }, "sha512-1ok8pxcIlwMTMggySPIVt926lymLWNhCgPTzO751zKFTDTJcmpzmpmSWbiFQQ3fcPzO8LocsLXRfBwYDd/uqQA=="], + + "@typescript/native-preview-linux-arm64": ["@typescript/native-preview-linux-arm64@7.0.0-dev.20251029.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-WK/N4Tk9nxI+k6AwJ7d80Gnd4+8kbBwmryIgOGPQNNvNJticYg6QiQsFGgC+HnCqvWDQ0fAyW+wdcPG6fwn/EA=="], + + "@typescript/native-preview-linux-x64": ["@typescript/native-preview-linux-x64@7.0.0-dev.20251029.1", "", { "os": "linux", "cpu": "x64" }, "sha512-GvTl9BeItX0Ox0wXiMIHkktl9sCTkTPBe6f6hEs4XfJlAKm+JHbYtB9UEs62QyPYBFMx2phCytVNejpaUZRJmQ=="], + + "@typescript/native-preview-win32-arm64": ["@typescript/native-preview-win32-arm64@7.0.0-dev.20251029.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-BUEC+M6gViaa/zDzOjAOEqpOZeUJxuwrjwOokqxXyUavX+mC6zb6ALqx4r7GAWrfY9sSvGUacW4ZbqDTXe8KAg=="], + + "@typescript/native-preview-win32-x64": ["@typescript/native-preview-win32-x64@7.0.0-dev.20251029.1", "", { "os": "win32", "cpu": "x64" }, "sha512-ODcXFgM62KpXxHqG5NMG+ipBqTbQ1pGkrzSByBwgRx0c/gTUhgML8UT7iK3nTrTtp9OBgPYPLLDNwiSLyzaIxA=="], + "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], @@ -70,12 +115,16 @@ "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + "figlet": ["figlet@1.9.3", "", { "dependencies": { "commander": "^14.0.0" }, "bin": { "figlet": "bin/index.js" } }, "sha512-majPgOpVtrZN1iyNGbsUP6bOtZ6eaJgg5HHh0vFvm5DJhh8dc+FJpOC4GABvMZ/A7XHAJUuJujhgUY/2jPWgMA=="], + "fs-extra": ["fs-extra@11.3.2", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-Xr9F6z6up6Ws+NjzMCZc6WXg2YFRlrLP9NQDO3VQrWrfiojdhS56TzueT88ze0uBdCTwEIhQ3ptnmKeWGFAe0A=="], "get-east-asian-width": ["get-east-asian-width@1.4.0", "", {}, "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q=="], "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + "gradient-string": ["gradient-string@3.0.0", "", { "dependencies": { "chalk": "^5.3.0", "tinygradient": "^1.1.5" } }, "sha512-frdKI4Qi8Ihp4C6wZNB565de/THpIaw3DjP5ku87M+N9rNSGmPTjfkq61SdRXB7eCaL8O1hkKDvf6CDMtOzIAg=="], + "iconv-lite": ["iconv-lite@0.7.0", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ=="], "inquirer": ["inquirer@12.10.0", "", { "dependencies": { "@inquirer/ansi": "^1.0.1", "@inquirer/core": "^10.3.0", "@inquirer/prompts": "^7.9.0", "@inquirer/type": "^3.0.9", "mute-stream": "^2.0.0", "run-async": "^4.0.5", "rxjs": "^7.8.2" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-K/epfEnDBZj2Q3NMDcgXWZye3nhSPeoJnOh8lcKWrldw54UEZfS4EmAMsAsmVbl7qKi+vjAsy39Sz4fbgRMewg=="], @@ -114,8 +163,14 @@ "strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], + "tinycolor2": ["tinycolor2@1.6.0", "", {}, "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw=="], + + "tinygradient": ["tinygradient@1.1.5", "", { "dependencies": { "@types/tinycolor2": "^1.4.0", "tinycolor2": "^1.0.0" } }, "sha512-8nIfc2vgQ4TeLnk2lFj4tRLvvJwEfQuabdsmvDdQPT0xlk9TaNtpGd6nNRxXoK6vQhN6RSzj+Cnp5tTQmpxmbw=="], + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], "wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], diff --git a/package.json b/package.json index 5e8b181..d86fd06 100644 --- a/package.json +++ b/package.json @@ -1,21 +1,24 @@ { "name": "@davide97g/sonar-autofixer", - "version": "1.1.0", + "version": "0.1.0", "description": "CLI utility for fetching SonarQube issues and integrating with Bitbucket/GitHub PR workflows", - "main": "src/cli.js", + "main": "dist/cli.js", "bin": { - "sonar-autofixer": "./src/cli.js" + "sonar-autofixer": "./dist/cli.js" }, "scripts": { - "prepublishOnly": "echo 'Publishing @davide97g/sonar-autofixer...'", + "build": "tsgo", + "lint": "biome lint src", + "lint:fix": "biome lint --write src", + "format": "biome format --write src", + "check": "biome check src", + "check:fix": "biome check --write src", + "prepublishOnly": "echo 'Publishing @davide97g/sonar-autofixer...' && npm run build", + "init": "node dist/cli.js init", "sonar:scan": "npx davide97g:sonar-autofixer scan", "sonar:fetch": "npx davide97g:sonar-autofixer fetch" }, - "files": [ - "src", - "README.md", - "package.json" - ], + "files": ["dist", "src/templates", "README.md", "package.json"], "repository": { "type": "git", "url": "git+https://github.com/davide97g/sonar-autofixer.git" @@ -39,17 +42,21 @@ "url": "https://github.com/davide97g/sonar-autofixer/issues" }, "homepage": "https://github.com/davide97g/sonar-autofixer#readme", - "publishConfig": { - "registry": "https://npm.pkg.github.com" - }, "dependencies": { "chalk": "^5.6.2", "commander": "^14.0.2", "dotenv": "^17.2.3", + "figlet": "^1.9.3", "fs-extra": "^11.3.2", + "gradient-string": "^3.0.0", "inquirer": "^12.10.0", "ora": "^9.0.0" }, + "devDependencies": { + "@biomejs/biome": "^1.9.4", + "@types/node": "^22.10.7", + "@typescript/native-preview": "^7.0.0-dev.20251029.1" + }, "type": "module", "engines": { "node": ">=18.0.0" diff --git a/src/cli.js b/src/cli.js deleted file mode 100755 index 3c515ca..0000000 --- a/src/cli.js +++ /dev/null @@ -1,55 +0,0 @@ -#!/usr/bin/env node - -import { Command } from "commander"; -import path from "path"; -import { fileURLToPath } from "url"; -import { spawnSync } from "node:child_process"; - -// ESM-compatible __dirname/__filename -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -const program = new Command(); - -program - .name("sonar-autofixer") - .description("CLI for sonar-autofixer") - .version("1.0.0"); - -const runNodeScript = (relativeScriptPath, args = []) => { - const scriptPath = path.join(__dirname, relativeScriptPath); - const result = spawnSync(process.execPath, [scriptPath, ...args], { - stdio: "inherit", - env: process.env, - cwd: process.cwd(), - }); - if (result.status !== 0) { - process.exit(result.status ?? 1); - } -}; - -program - .command("init") - .description("Initialize configuration for sonar-autofixer") - .allowExcessArguments(true) - .action(() => { - runNodeScript("./init.js", process.argv.slice(3)); - }); - -program - .command("fetch") - .description("Fetch Sonar issues and save to .sonar/issues.json") - .allowExcessArguments(true) - .action(() => { - runNodeScript("./fetch-sonar-issues-github.js", process.argv.slice(3)); - }); - -program - .command("scan") - .description("Run local Sonar scanner and save report") - .allowExcessArguments(true) - .action(() => { - runNodeScript("./scanner.js", process.argv.slice(3)); - }); - -program.parse(process.argv); diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..4db4b6f --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,102 @@ +#!/usr/bin/env node + +import { Command } from "commander"; +import { spawnSync } from "node:child_process"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { readFileSync } from "node:fs"; + +// ESM-compatible __dirname/__filename +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const program = new Command(); + +// Get current version from package.json +const packageJsonPath = path.join(__dirname, "..", "package.json"); +const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")); +const currentVersion = packageJson.version; + +program.name("sonar-autofixer").description("CLI for sonar-autofixer").version(currentVersion); + +const runNodeScript = (relativeScriptPath: string, args: string[] = []): void => { + const scriptPath = path.join(__dirname, relativeScriptPath); + const result = spawnSync(process.execPath, [scriptPath, ...args], { + stdio: "inherit", + env: process.env, + cwd: process.cwd(), + }); + if (result.status !== 0) { + process.exit(result.status ?? 1); + } +}; + +/** + * Check for available updates + */ +const checkForUpdates = async (): Promise => { + try { + console.log("πŸ” Checking for updates..."); + console.log(`Current version: ${currentVersion}`); + + const packageName = packageJson.name; + + console.log("\nπŸ”„ To get the latest version, use:"); + console.log(`npx ${packageName}@latest `); + + console.log("\nπŸ“ Current commands:"); + console.log(`npx ${packageName}@latest init`); + console.log(`npx ${packageName}@latest fetch`); + console.log(`npx ${packageName}@latest scan`); + console.log(`npx ${packageName}@latest update`); + } catch (error) { + console.error("❌ Error checking for updates:", error); + } +}; + +/** + * Show update reminder (non-blocking) + */ +const showUpdateReminder = (): void => { + const packageName = packageJson.name; + console.log( + `\nπŸ’‘ Tip: Use 'npx ${packageName}@latest ' to always get the latest version` + ); + console.log(` Run 'npx ${packageName} update' to check for updates\n`); +}; + +program + .command("init") + .description("Initialize configuration for sonar-autofixer") + .allowExcessArguments(true) + .action(() => { + runNodeScript("./init.js", process.argv.slice(3)); + showUpdateReminder(); + }); + +program + .command("fetch") + .description("Fetch Sonar issues and save to .sonar/issues.json") + .allowExcessArguments(true) + .action(() => { + runNodeScript("./versioning/index.js", process.argv.slice(3)); + showUpdateReminder(); + }); + +program + .command("scan") + .description("Run local Sonar scanner and save report") + .allowExcessArguments(true) + .action(() => { + runNodeScript("./sonar/scanner.js", process.argv.slice(3)); + showUpdateReminder(); + }); + +program + .command("update") + .description("Check for updates and show how to get the latest version") + .action(async () => { + await checkForUpdates(); + }); + +program.parse(process.argv); diff --git a/src/fetch-sonar-issues-github.js b/src/fetch-sonar-issues-github.js deleted file mode 100644 index 10d83dd..0000000 --- a/src/fetch-sonar-issues-github.js +++ /dev/null @@ -1,443 +0,0 @@ -#!/usr/bin/env node - -import dotenv from "dotenv"; -import { execSync } from "node:child_process"; -import fs from "node:fs"; -import path from "node:path"; -dotenv.config(); - -const githubToken = process.env.GITHUB_TOKEN; -const sonarToken = process.env.SONAR_TOKEN; -const sonarBaseUrlRaw = - process.env.SONAR_BASE_URL || "https://sonarcloud.io/api/issues/search"; -// Ensure we're using the API endpoint, not the web UI endpoint -const sonarBaseUrl = sonarBaseUrlRaw - .replace(/\/project\/issues\/search$/, "/api/issues/search") - .replace(/\/project\/issues$/, "/api/issues/search"); -const sonarComponentKeys = process.env.SONAR_COMPONENT_KEYS; -const sonarOrganization = process.env.SONAR_ORGANIZATION; -const githubOwner = process.env.GITHUB_OWNER; -const githubRepo = process.env.GITHUB_REPO; -const githubBaseUrl = process.env.GITHUB_API_URL || "https://api.github.com"; - -// SonarCloud/SonarQube API authentication -// According to SonarQube API docs: https://next.sonarqube.com/sonarqube/web_api/api/issues/search -// Authentication can be done via Basic Auth (token:empty) or token parameter -const getSonarAuthHeaders = (token) => { - if (!token) return {}; - - // SonarCloud/SonarQube typically uses Basic Auth: token as username, empty password - // Format: Authorization: Basic base64(token:) - const tokenString = `${token}:`; - const basicAuth = `Basic ${Buffer.from(tokenString).toString("base64")}`; - - return { - Authorization: basicAuth, - }; -}; - -/** - * Validates required configuration environment variables - * @throws {Error} If any required configuration is missing - */ -const validateConfiguration = () => { - if (!githubToken) { - throw new Error("GITHUB_TOKEN environment variable is required"); - } - if (!githubOwner || !githubRepo) { - throw new Error( - "GITHUB_OWNER and GITHUB_REPO environment variables are required" - ); - } - if (!sonarComponentKeys) { - throw new Error("SONAR_COMPONENT_KEYS environment variable is required"); - } - if (!sonarOrganization) { - throw new Error("SONAR_ORGANIZATION environment variable is required"); - } - if (!sonarToken) { - throw new Error( - "SONAR_TOKEN environment variable is required for SonarCloud API authentication" - ); - } -}; - -/** - * Automatically detects PR number from current branch using GitHub API - * @param {string} branch - Current git branch name - * @returns {Promise} PR number if found, null otherwise - */ -const detectPrId = async (branch) => { - try { - // Try to get PR number from GitHub API using the branch name - const githubApiUrl = `${githubBaseUrl}/repos/${githubOwner}/${githubRepo}/pulls?head=${githubOwner}:${branch}&state=open`; - console.log(`πŸ” Checking for PR associated with branch: ${branch}`); - const response = await fetch(githubApiUrl, { - headers: { - Authorization: `token ${githubToken}`, - Accept: "application/vnd.github.v3+json", - }, - }); - - if (response.ok) { - const data = await response.json(); - if (Array.isArray(data) && data.length > 0) { - const prNumber = data[0].number; - console.log(`βœ… Found PR #${prNumber} for branch: ${branch}`); - return prNumber.toString(); - } - } - - // Also check closed PRs in case the branch is merged - const closedPrUrl = `${githubBaseUrl}/repos/${githubOwner}/${githubRepo}/pulls?head=${githubOwner}:${branch}&state=all`; - const closedResponse = await fetch(closedPrUrl, { - headers: { - Authorization: `token ${githubToken}`, - Accept: "application/vnd.github.v3+json", - }, - }); - - if (closedResponse.ok) { - const closedData = await closedResponse.json(); - if (Array.isArray(closedData) && closedData.length > 0) { - const prNumber = closedData[0].number; - console.log( - `βœ… Found PR #${prNumber} for branch: ${branch} (closed/merged)` - ); - return prNumber.toString(); - } - } - - // Fallback: try to extract PR number from branch name if it follows a pattern like "feat/BAT-1234" or "pr/1234" - const prNumberRegex = - /(?:pr\/|PR\/|pull\/|PULL\/)(\d+)|(?:feat\/|feature\/|fix\/|bugfix\/).*?(\d+)/i; - const prNumberMatch = prNumberRegex.exec(branch); - if (prNumberMatch) { - const prNumber = prNumberMatch[1] || prNumberMatch[2]; - console.log(`βœ… Extracted PR #${prNumber} from branch name: ${branch}`); - return prNumber; - } - - console.log(`⚠️ No PR found for branch: ${branch}`); - return null; - } catch (error) { - console.log(`⚠️ Could not detect PR ID: ${error.message}`); - return null; - } -}; - -/** - * Ensures .sonar directory exists and saves issues to file - * @param {Object} issues - Issues data to save - * @param {string} usedSource - Source description for logging - */ -const saveIssuesToFile = (issues, usedSource) => { - const sonarDir = path.join(process.cwd(), ".sonar"); - if (!fs.existsSync(sonarDir)) { - fs.mkdirSync(sonarDir, { recursive: true }); - } - - const issuesPath = path.join(sonarDir, "issues.json"); - fs.writeFileSync(issuesPath, JSON.stringify(issues, null, 2)); - - console.log( - `βœ… Successfully fetched ${ - issues.issues?.length || 0 - } issues (source: ${usedSource})` - ); - console.log(`πŸ“ Saved to: ${issuesPath}`); -}; - -/** - * Displays summary of issues by severity - * @param {Object} issues - Issues data - */ -const displayIssuesSummary = (issues) => { - if (!issues.issues || issues.issues.length === 0) { - return; - } - - const severityCounts = {}; - for (const issue of issues.issues) { - const severity = issue.severity || "UNKNOWN"; - severityCounts[severity] = (severityCounts[severity] || 0) + 1; - } - - console.log("\nπŸ“Š Issues by severity:"); - const sortedEntries = Object.entries(severityCounts).sort( - ([, a], [, b]) => b - a - ); - for (const [severity, count] of sortedEntries) { - console.log(` ${severity}: ${count}`); - } -}; - -/** - * Builds URL for fetching issues by branch - * @param {string} branch - Branch name - * @returns {string} URL for fetching issues - */ -const buildUrlForBranch = (branch) => { - const params = new URLSearchParams({ - s: "FILE_LINE", - issueStatuses: "OPEN,CONFIRMED", - ps: "100", - facets: "impactSoftwareQualities,impactSeverities", - componentKeys: sonarComponentKeys, - organization: sonarOrganization, - branch: branch, - additionalFields: "_all", - }); - - let url = sonarBaseUrl; - if (!url.includes("/api/issues/search")) { - url = url.replace(/\/project\/issues[^/]*$/, "").replace(/\/$/, ""); - url = url.replace(/\/api\/issues$/, ""); - url = `${url}/api/issues/search`; - } - - return `${url}?${params.toString()}`; -}; - -/** - * Builds URL for fetching issues by PR link - * @param {string} prLink - SonarCloud PR link - * @returns {string} URL for fetching issues - */ -const buildUrlForPr = (prLink) => { - const prKeyMatch = /pullRequest=([^&]+)/.exec(prLink); - if (!prKeyMatch) { - throw new Error( - "Invalid SonarCloud/SonarQube PR link format. Expected format: https://sonarcloud.io/project/issues?id=project&pullRequest=PR_KEY" - ); - } - - const prKey = prKeyMatch[1]; - const params = new URLSearchParams({ - s: "FILE_LINE", - issueStatuses: "OPEN,CONFIRMED", - ps: "100", - facets: "impactSoftwareQualities,impactSeverities", - componentKeys: sonarComponentKeys, - organization: sonarOrganization, - pullRequest: prKey, - additionalFields: "_all", - }); - - let url = sonarBaseUrl; - if (!url.includes("/api/issues/search")) { - url = url.replace(/\/project\/issues[^/]*$/, "").replace(/\/$/, ""); - url = url.replace(/\/api\/issues$/, ""); - url = `${url}/api/issues/search`; - } - - return `${url}?${params.toString()}`; -}; - -/** - * Builds URL for fetching issues by PR ID - * @param {string} prId - PR ID - * @returns {string} URL for fetching issues - */ -const buildUrlForPrId = (prId) => { - const params = new URLSearchParams({ - s: "FILE_LINE", - issueStatuses: "OPEN,CONFIRMED", - ps: "100", - facets: "impactSoftwareQualities,impactSeverities", - componentKeys: sonarComponentKeys, - organization: sonarOrganization, - pullRequest: prId, - additionalFields: "_all", - }); - - let url = sonarBaseUrl; - if (!url.includes("/api/issues/search")) { - url = url.replace(/\/project\/issues[^/]*$/, "").replace(/\/$/, ""); - url = url.replace(/\/api\/issues$/, ""); - url = `${url}/api/issues/search`; - } - - return `${url}?${params.toString()}`; -}; - -/** - * Handles SonarQube API response - * @param {Response} response - Fetch response - * @returns {Promise} Parsed JSON response - */ -const handleSonarResponse = async (response) => { - const contentType = response.headers.get("content-type"); - if (!contentType?.includes("application/json")) { - const errorText = await response.text(); - console.error(`Unexpected content type: ${contentType}`); - console.error(`Response preview: ${errorText.substring(0, 500)}`); - throw new Error( - `Expected JSON but got ${contentType}. Check authentication and API endpoint. Status: ${response.status}` - ); - } - - if (!response.ok) { - const errorText = await response.text(); - throw new Error( - `HTTP error! status: ${response.status}, body: ${errorText.substring( - 0, - 200 - )}` - ); - } - - return await response.json(); -}; - -/** - * Fetches issues for a branch - * @param {string} branch - Branch name - * @returns {Promise} Issues data - */ -const fetchIssuesForBranch = async (branch) => { - const url = buildUrlForBranch(branch); - console.log( - `Fetching issues from: ${url.replace(/sonarToken=[^&]+/, "sonarToken=***")}` - ); - - const authHeaders = getSonarAuthHeaders(sonarToken); - const response = await fetch(url, { - headers: { - ...authHeaders, - "Content-Type": "application/json", - Accept: "application/json", - }, - }); - - return await handleSonarResponse(response); -}; - -/** - * Fetches issues for a PR link - * @param {string} prLink - SonarCloud PR link - * @returns {Promise} Issues data - */ -const fetchIssuesForPr = async (prLink) => { - const url = buildUrlForPr(prLink); - console.log( - `Fetching issues from PR: ${url.replace( - /sonarToken=[^&]+/, - "sonarToken=***" - )}` - ); - - const authHeaders = getSonarAuthHeaders(sonarToken); - const response = await fetch(url, { - headers: { - ...authHeaders, - "Content-Type": "application/json", - Accept: "application/json", - }, - }); - - return await handleSonarResponse(response); -}; - -/** - * Fetches issues for a PR ID - * @param {string} prId - PR ID - * @returns {Promise} Issues data - */ -const fetchIssuesForPrId = async (prId) => { - const url = buildUrlForPrId(prId); - console.log(`Fetching issues from PR ID: ${prId}`); - console.log(`URL: ${url.replace(/sonarToken=[^&]+/, "sonarToken=***")}`); - - const authHeaders = getSonarAuthHeaders(sonarToken); - const response = await fetch(url, { - headers: { - ...authHeaders, - "Content-Type": "application/json", - Accept: "application/json", - }, - }); - - return await handleSonarResponse(response); -}; - -/** - * Fetches issues based on the provided source (PR link, detected PR ID, or branch) - * @param {string|null} sonarPrLink - Optional SonarCloud PR link - * @param {string} currentBranch - Current git branch name - * @returns {Promise<{issues: Object, usedSource: string}>} Issues data and source description - */ -const fetchIssuesFromSource = async (sonarPrLink, currentBranch) => { - if (sonarPrLink) { - console.log(`Using provided SonarQube PR link: ${sonarPrLink}`); - const issues = await fetchIssuesForPr(sonarPrLink); - return { issues, usedSource: `PR: ${sonarPrLink}` }; - } - - const detectedPrId = await detectPrId(currentBranch); - if (detectedPrId) { - console.log(`πŸš€ Using automatically detected PR ID: ${detectedPrId}`); - const issues = await fetchIssuesForPrId(detectedPrId); - return { - issues, - usedSource: `PR #${detectedPrId} (auto-detected from branch: ${currentBranch})`, - }; - } - - console.log("πŸ“‹ No PR detected, falling back to branch-based approach"); - let issues = await fetchIssuesForBranch(currentBranch); - let usedSource = currentBranch; - - if (!issues.issues || issues.issues.length === 0) { - console.log( - "No issues found for current branch. Falling back to branch: develop" - ); - issues = await fetchIssuesForBranch("develop"); - usedSource = "develop"; - } - - return { issues, usedSource }; -}; - -/** - * Fetches SonarCloud issues for the current git branch and saves them to .sonar/issues.json. - * If the current branch has 0 issues, it falls back to fetching from "develop". - * Can automatically detect PR ID from current branch using GitHub API or use provided SonarCloud PR link. - * - * Usage: node fetch-sonar-issues-github.js [branch-name] [sonar-pr-link] - * - * @param {string} sonarPrLink - Optional SonarCloud PR link to fetch issues from - * @param {string} branchName - Optional branch name to use instead of current branch - */ -async function fetchSonarIssues(sonarPrLink = null, branchName = null) { - try { - // Get current git branch - const currentBranch = - branchName || - execSync("git branch --show-current", { - encoding: "utf8", - }).trim(); - console.log(`Current branch: ${currentBranch}`); - - validateConfiguration(); - - const { issues, usedSource } = await fetchIssuesFromSource( - sonarPrLink, - currentBranch - ); - - saveIssuesToFile(issues, usedSource); - displayIssuesSummary(issues); - } catch (error) { - console.error("❌ Error fetching SonarQube issues:", error.message); - process.exit(1); - } -} - -// Parse command line arguments -const args = process.argv.slice(2); -const branchName = args[0] || null; -const sonarPrLink = args[1] || null; - -// Use top-level await -await fetchSonarIssues(sonarPrLink, branchName); diff --git a/src/fetch-sonar-issues.mjs b/src/fetch-sonar-issues.mjs deleted file mode 100755 index f7663c6..0000000 --- a/src/fetch-sonar-issues.mjs +++ /dev/null @@ -1,241 +0,0 @@ -#!/usr/bin/env node - -import { execSync } from "node:child_process"; -import fs from "node:fs"; -import path from "node:path"; -import dotenv from "dotenv"; -dotenv.config(); - -const email = process.env.BITBUCKET_EMAIL; -const apiToken = process.env.BITBUCKET_API_TOKEN; -const sonarBaseUrl = process.env.SONAR_BASE_URL; -const bitbucketBaseUrl = process.env.BITBUCKET_BASE_URL; - -const auth = Buffer.from(`${email}:${apiToken}`).toString("base64"); - -/** - * Fetches SonarQube issues for the current git branch and saves them to .sonar/issues.json. - * If the current branch has 0 issues, it falls back to fetching from "develop". - * Can automatically detect PR ID from current branch using Bitbucket API or use provided SonarQube PR link. - * - * Usage: node fetch-sonar-issues.js [sonar-pr-link] - */ -try { - // Parse command line arguments - const args = process.argv.slice(2); - const branchName = args[0] || null; - const sonarPrLink = args[1] || null; - - // Get current git branch - const currentBranch = - branchName || - execSync("git branch --show-current", { - encoding: "utf8", - }).trim(); - console.log(`Current branch: ${currentBranch}`); - - /** - * Automatically detects PR ID from current branch using Bitbucket API - * @param {string} branch - Current git branch name - * @returns {Promise} PR ID if found, null otherwise - */ - const detectPrId = async (branch) => { - try { - // Try to get PR number from Bitbucket API using the branch name - const bitbucketApiUrl = `${bitbucketBaseUrl}/pullrequests?q=source.branch.name="${branch}"`; - console.log(`πŸ” Checking for PR associated with branch: ${branch}`); - const response = await fetch(bitbucketApiUrl, { - headers: { - Authorization: `Basic ${auth}`, - }, - }); - if (response.ok) { - const data = await response.json(); - if (data.values && data.values.length > 0) { - const prNumber = data.values[0].id; - console.log(`βœ… Found PR #${prNumber} for branch: ${branch}`); - return prNumber.toString(); - } - } - - // Fallback: try to extract PR number from branch name if it follows a pattern like "feat/BAT-1234" or "pr/1234" - const prNumberRegex = /(?:pr\/|PR\/|pull\/|PULL\/)(\d+)|(?:feat\/|feature\/|fix\/|bugfix\/).*?(\d+)/i; - const prNumberMatch = prNumberRegex.exec(branch); - if (prNumberMatch) { - const prNumber = prNumberMatch[1] || prNumberMatch[2]; - console.log( - `βœ… Extracted PR #${prNumber} from branch name: ${branch}` - ); - return prNumber; - } - - console.log(`⚠️ No PR found for branch: ${branch}`); - return null; - } catch (error) { - console.log(`⚠️ Could not detect PR ID: ${error.message}`); - return null; - } - }; - - const buildUrlForBranch = (branch) => { - const params = new URLSearchParams({ - branch, - components: "bat", - s: "FILE_LINE", - inNewCodePeriod: "true", - issueStatuses: "CONFIRMED,OPEN", - ps: "100", - facets: - "cleanCodeAttributeCategories,impactSoftwareQualities,severities,types,impactSeverities,codeVariants", - additionalFields: "_all", - timeZone: "Europe/Rome", - }); - return `${sonarBaseUrl}?${params.toString()}`; - }; - - const buildUrlForPr = (prLink) => { - // Extract PR key from the SonarQube PR link - // Expected format: https://eyecare-sonarqube.luxgroup.net/project/issues?id=bat&pullRequest=PR_KEY - const prKeyMatch = prLink.match(/pullRequest=([^&]+)/); - if (!prKeyMatch) { - throw new Error( - "Invalid SonarQube PR link format. Expected format: https://eyecare-sonarqube.luxgroup.net/project/issues?id=bat&pullRequest=PR_KEY" - ); - } - - const prKey = prKeyMatch[1]; - const params = new URLSearchParams({ - pullRequest: prKey, - components: "bat", - s: "FILE_LINE", - inNewCodePeriod: "true", - issueStatuses: "CONFIRMED,OPEN", - ps: "100", - facets: - "cleanCodeAttributeCategories,impactSoftwareQualities,severities,types,impactSeverities,codeVariants", - additionalFields: "_all", - timeZone: "Europe/Rome", - }); - return `${sonarBaseUrl}?${params.toString()}`; - }; - - const buildUrlForPrId = (prId) => { - const params = new URLSearchParams({ - pullRequest: prId, - components: "bat", - s: "FILE_LINE", - inNewCodePeriod: "true", - issueStatuses: "CONFIRMED,OPEN", - ps: "100", - facets: - "cleanCodeAttributeCategories,impactSoftwareQualities,severities,types,impactSeverities,codeVariants", - additionalFields: "_all", - timeZone: "Europe/Rome", - }); - return `${sonarBaseUrl}?${params.toString()}`; - }; - - const fetchIssuesForBranch = async (branch) => { - const url = buildUrlForBranch(branch); - console.log(`Fetching issues from: ${url}`); - const response = await fetch(url); - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - const json = await response.json(); - return json; - }; - - const fetchIssuesForPr = async (prLink) => { - const url = buildUrlForPr(prLink); - console.log(`Fetching issues from PR: ${url}`); - const response = await fetch(url); - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - const json = await response.json(); - return json; - }; - - const fetchIssuesForPrId = async (prId) => { - const url = buildUrlForPrId(prId); - console.log(`Fetching issues from PR ID: ${prId}`); - const response = await fetch(url); - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - const json = await response.json(); - return json; - }; - - let issues; - let usedSource; - - if (sonarPrLink) { - // If PR link is provided, fetch issues from that PR - console.log(`Using provided SonarQube PR link: ${sonarPrLink}`); - issues = await fetchIssuesForPr(sonarPrLink); - usedSource = `PR: ${sonarPrLink}`; - } else { - // Try to automatically detect PR ID from current branch - const detectedPrId = await detectPrId(currentBranch); - - if (detectedPrId) { - // Use detected PR ID - console.log(`πŸš€ Using automatically detected PR ID: ${detectedPrId}`); - issues = await fetchIssuesForPrId(detectedPrId); - usedSource = `PR #${detectedPrId} (auto-detected from branch: ${currentBranch})`; - } else { - // Fallback to branch-based approach - console.log("πŸ“‹ No PR detected, falling back to branch-based approach"); - issues = await fetchIssuesForBranch(currentBranch); - usedSource = currentBranch; - - // Fallback to develop if no issues found - if (!issues.issues || issues.issues.length === 0) { - console.log( - "No issues found for current branch. Falling back to branch: develop" - ); - issues = await fetchIssuesForBranch("develop"); - usedSource = "develop"; - } - } - } - - // Ensure .sonar directory exists - const sonarDir = path.join(process.cwd(), ".sonar"); - if (!fs.existsSync(sonarDir)) { - fs.mkdirSync(sonarDir, { recursive: true }); - } - - // Save issues to file - const issuesPath = path.join(sonarDir, "issues.json"); - fs.writeFileSync(issuesPath, JSON.stringify(issues, null, 2)); - - console.log( - `βœ… Successfully fetched ${ - issues.issues?.length || 0 - } issues (source: ${usedSource})` - ); - console.log(`πŸ“ Saved to: ${issuesPath}`); - - // Display summary - if (issues.issues && issues.issues.length > 0) { - const severityCounts = {}; - for (const issue of issues.issues) { - const severity = issue.severity || "UNKNOWN"; - severityCounts[severity] = (severityCounts[severity] || 0) + 1; - } - - console.log("\nπŸ“Š Issues by severity:"); - const sortedEntries = Object.entries(severityCounts).sort( - ([, a], [, b]) => b - a - ); - for (const [severity, count] of sortedEntries) { - console.log(` ${severity}: ${count}`); - } - } -} catch (error) { - console.error("❌ Error fetching SonarQube issues:", error.message); - process.exit(1); -} diff --git a/src/init.js b/src/init.js deleted file mode 100644 index 9b358a2..0000000 --- a/src/init.js +++ /dev/null @@ -1,180 +0,0 @@ -#!/usr/bin/env node - -import chalk from "chalk"; -import fs from "fs-extra"; -import inquirer from "inquirer"; -import ora from "ora"; -import path from "path"; -import { fileURLToPath } from "url"; - -// ESM-compatible __dirname/__filename -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -const runInit = async () => { - console.log(chalk.blue("Welcome to sonar-autofixer setup!")); - - // Load package.json to derive sensible defaults - const pkgPath = path.join(process.cwd(), "package.json"); - let pkg = {}; - try { - if (await fs.pathExists(pkgPath)) { - pkg = await fs.readJson(pkgPath); - } - } catch (error) { - console.warn( - chalk.yellow( - `Warning: could not read package.json for defaults: ${ - error instanceof Error ? error.message : String(error) - }` - ) - ); - } - - const defaultRepoName = - typeof pkg.name === "string" && pkg.name.trim() - ? pkg.name.trim() - : path.basename(process.cwd()); - const defaultVisibility = pkg.private === true ? "private" : "public"; - - const answers = await inquirer.prompt([ - { - type: "input", - name: "repoName", - message: "Repo name?", - default: defaultRepoName, - }, - { - type: "list", - name: "gitProvider", - message: "Git provider:", - choices: ["github", "bitbucket"], - default: "github", - }, - { - type: "list", - name: "repositoryVisibility", - message: "Repository visibility:", - choices: ["private", "public"], - default: defaultVisibility, - }, - { - type: "confirm", - name: "publicSonar", - message: "Public sonar?", - default: true, // Y/n - }, - { - type: "input", - name: "outputPath", - message: "Output path:", - default: ".sonar/", - }, - { - type: "list", - name: "aiEditor", - message: "AI editor:", - choices: ["cursor", "copilot (vscode)", "windsurf", "other"], - default: "cursor", - }, - ]); - - // 1) Write configuration file ./sonar/autofixer.config.json - const config = { - repoName: answers.repoName, - gitProvider: answers.gitProvider, - repositoryVisibility: answers.repositoryVisibility, - publicSonar: answers.publicSonar, - outputPath: answers.outputPath, - aiEditor: answers.aiEditor, - }; - - const configSpinner = ora({ - text: "Writing configuration…", - color: "yellow", - }).start(); - try { - const sonarDir = path.join(process.cwd(), ".sonar"); - await fs.ensureDir(sonarDir); - const configPath = path.join(sonarDir, "autofixer.config.json"); - await fs.writeJson(configPath, config, { spaces: 2 }); - configSpinner.succeed( - `Configuration saved to ${path.relative(process.cwd(), configPath)}` - ); - } catch (error) { - configSpinner.fail("Failed to write configuration"); - console.error( - chalk.red(error instanceof Error ? error.message : String(error)) - ); - process.exit(1); - } - - // 2) Update package.json scripts - const scriptsSpinner = ora({ - text: "Updating package.json scripts…", - color: "yellow", - }).start(); - try { - const existingPkg = (await fs.pathExists(pkgPath)) - ? await fs.readJson(pkgPath) - : {}; - if (!existingPkg.scripts) existingPkg.scripts = {}; - existingPkg.scripts["sonar:scan"] = "npx davide97g:sonar-autofixer scan"; - existingPkg.scripts["sonar:fetch"] = "npx davide97g:sonar-autofixer fetch"; - await fs.writeJson(pkgPath, existingPkg, { spaces: 2 }); - scriptsSpinner.succeed("package.json scripts updated"); - } catch (error) { - scriptsSpinner.fail("Failed to update package.json"); - console.error( - chalk.red(error instanceof Error ? error.message : String(error)) - ); - process.exit(1); - } - - // 3) Create rule file based on AI editor selection using src/templates/rule.md - const ruleSpinner = ora({ - text: "Creating AI editor rule…", - color: "yellow", - }).start(); - try { - const templateRulePath = path.join(__dirname, "./templates/rule.md"); - if (!(await fs.pathExists(templateRulePath))) { - throw new Error(`Template rule not found at ${templateRulePath}`); - } - const ruleContent = await fs.readFile(templateRulePath, "utf8"); - - const editor = answers.aiEditor; - let targetRulePath; - if (editor === "cursor") { - targetRulePath = path.join( - process.cwd(), - ".cursor/rules/sonar-issue-fix.mdc" - ); - } else if (editor === "copilot (vscode)") { - targetRulePath = path.join(process.cwd(), ".vscode/sonar-issue-fix.md"); - } else if (editor === "windsurf") { - targetRulePath = path.join( - process.cwd(), - ".windsurf/rules/sonar-issue-fix.mdc" - ); - } else { - targetRulePath = path.join(process.cwd(), "rules/sonar-issue-fix.md"); - } - - await fs.ensureDir(path.dirname(targetRulePath)); - await fs.writeFile(targetRulePath, ruleContent, "utf8"); - ruleSpinner.succeed( - `Rule created at ${path.relative(process.cwd(), targetRulePath)}` - ); - } catch (error) { - ruleSpinner.fail("Failed to create rule file"); - console.error( - chalk.red(error instanceof Error ? error.message : String(error)) - ); - process.exit(1); - } - - console.log(chalk.green("βœ… Setup complete.")); -}; - -await runInit(); diff --git a/src/init.ts b/src/init.ts new file mode 100644 index 0000000..bf8d4be --- /dev/null +++ b/src/init.ts @@ -0,0 +1,282 @@ +#!/usr/bin/env node + +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import chalk from "chalk"; +import fs from "fs-extra"; +import inquirer from "inquirer"; +import ora from "ora"; +import figlet from "figlet"; +import gradient from "gradient-string"; + +const colors = ["#A4A5A7", "#C74600", "#EB640A", "#F2A65D"]; +const dynamicGradient = gradient(colors); +// ESM-compatible __dirname/__filename +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +interface PackageJson { + name?: string; + private?: boolean; + scripts?: Record; + [key: string]: unknown; +} + +interface InitAnswers { + repoName: string; + gitProvider: "github" | "bitbucket"; + repositoryVisibility: "private" | "public"; + publicSonar: boolean; + outputPath: string; + aiEditor: "cursor" | "copilot (vscode)" | "windsurf" | "other"; +} + +interface Config { + repoName: string; + gitProvider: "github" | "bitbucket"; + repositoryVisibility: "private" | "public"; + publicSonar: boolean; + outputPath: string; + aiEditor: "cursor" | "copilot (vscode)" | "windsurf" | "other"; +} + +const runInit = async (): Promise => { + console.log(dynamicGradient("Welcome to sonar-autofixer setup!")); + + // Load package.json to derive sensible defaults + const pkgPath = path.join(process.cwd(), "package.json"); + let pkg: PackageJson = {}; + try { + if (await fs.pathExists(pkgPath)) { + pkg = (await fs.readJson(pkgPath)) as PackageJson; + } + } catch (error) { + console.warn( + chalk.yellow( + `Warning: could not read package.json for defaults: ${ + error instanceof Error ? error.message : String(error) + }` + ) + ); + } + + const defaultRepoName = + typeof pkg.name === "string" && pkg.name.trim() + ? pkg.name.trim() + : path.basename(process.cwd()); + const defaultVisibility = pkg.private === true ? "private" : "public"; + + let answers: InitAnswers; + try { + answers = await inquirer.prompt([ + { + type: "input", + name: "repoName", + message: "Repo name?", + default: defaultRepoName, + }, + { + type: "list", + name: "gitProvider", + message: "Git provider:", + choices: ["github", "bitbucket"], + default: "github", + }, + { + type: "list", + name: "repositoryVisibility", + message: "Repository visibility:", + choices: ["private", "public"], + default: defaultVisibility, + }, + { + type: "confirm", + name: "publicSonar", + message: "Public sonar?", + default: true, // Y/n + }, + { + type: "input", + name: "outputPath", + message: "Output path:", + default: ".sonar/", + }, + { + type: "list", + name: "aiEditor", + message: "AI editor:", + choices: ["cursor", "copilot (vscode)", "windsurf", "other"], + default: "cursor", + }, + ]); + } catch (error) { + // Handle graceful exit on SIGINT (Ctrl+C) + if ( + error && + typeof error === "object" && + (("name" in error && error.name === "ExitPromptError") || + ("message" in error && + typeof error.message === "string" && + error.message.includes("SIGINT"))) + ) { + console.log("\n"); + const exitGradient = dynamicGradient; + console.log( + exitGradient.multiline( + "πŸ‘‹ Setup cancelled\nThanks for trying sonar-autofixer!\nSee you next time ✨" + ) + ); + process.exit(0); + } + // Re-throw other errors + throw error; + } + + // 1) Write configuration file ./sonar/autofixer.config.json + const config: Config = { + repoName: answers.repoName, + gitProvider: answers.gitProvider, + repositoryVisibility: answers.repositoryVisibility, + publicSonar: answers.publicSonar, + outputPath: answers.outputPath, + aiEditor: answers.aiEditor, + }; + + const configSpinner = ora({ + text: "Writing configuration…", + color: "yellow", + }).start(); + try { + const sonarDir = path.join(process.cwd(), ".sonar"); + await fs.ensureDir(sonarDir); + const configPath = path.join(sonarDir, "autofixer.config.json"); + await fs.writeJson(configPath, config, { spaces: 2 }); + configSpinner.succeed(`Configuration saved to ${path.relative(process.cwd(), configPath)}`); + } catch (error) { + configSpinner.fail("Failed to write configuration"); + console.error(chalk.red(error instanceof Error ? error.message : String(error))); + process.exit(1); + } + + // 2) Update package.json scripts + const scriptsSpinner = ora({ + text: "Updating package.json scripts…", + color: "yellow", + }).start(); + try { + const existingPkg = (await fs.pathExists(pkgPath)) + ? ((await fs.readJson(pkgPath)) as PackageJson) + : {}; + if (!existingPkg.scripts) existingPkg.scripts = {}; + existingPkg.scripts["sonar:scan"] = "npx davide97g:sonar-autofixer scan"; + existingPkg.scripts["sonar:fetch"] = "npx davide97g:sonar-autofixer fetch"; + await fs.writeJson(pkgPath, existingPkg, { spaces: 2 }); + scriptsSpinner.succeed("package.json scripts updated"); + } catch (error) { + scriptsSpinner.fail("Failed to update package.json"); + console.error(chalk.red(error instanceof Error ? error.message : String(error))); + process.exit(1); + } + + // 3) Create rule file based on AI editor selection using src/templates/rule.md + const ruleSpinner = ora({ + text: "Creating AI editor rule…", + color: "yellow", + }).start(); + try { + // Go up one level from dist to src, then to templates + const templateRulePath = path.join(__dirname, "../src/templates/rule.md"); + if (!(await fs.pathExists(templateRulePath))) { + throw new Error(`Template rule not found at ${templateRulePath}`); + } + const ruleContent = await fs.readFile(templateRulePath, "utf8"); + + const editor = answers.aiEditor; + let targetRulePath: string; + if (editor === "cursor") { + targetRulePath = path.join(process.cwd(), ".cursor/rules/sonar-issue-fix.mdc"); + } else if (editor === "copilot (vscode)") { + targetRulePath = path.join(process.cwd(), ".vscode/sonar-issue-fix.md"); + } else if (editor === "windsurf") { + targetRulePath = path.join(process.cwd(), ".windsurf/rules/sonar-issue-fix.mdc"); + } else { + targetRulePath = path.join(process.cwd(), ".rules/sonar-issue-fix.md"); + } + + await fs.ensureDir(path.dirname(targetRulePath)); + await fs.writeFile(targetRulePath, ruleContent, "utf8"); + ruleSpinner.succeed(`Rule created at ${path.relative(process.cwd(), targetRulePath)}`); + } catch (error) { + ruleSpinner.fail("Failed to create rule file"); + console.error(chalk.red(error instanceof Error ? error.message : String(error))); + process.exit(1); + } + + console.log(dynamicGradient("βœ… Setup complete.")); +}; + +const runBanner = async (): Promise => { + return new Promise((resolve) => { + figlet.text( + "Bitrock", + { + font: "ANSI Shadow", + horizontalLayout: "default", + verticalLayout: "default", + }, + (err, data) => { + if (err) { + console.error("❌ Figlet error:", err); + return; + } + + const lines = data?.split("\n") ?? []; + + let i = 0; + const interval = setInterval(() => { + // Rotate gradient colors over time for smooth animation + const shifted = [...colors.slice(i), ...colors.slice(0, i)]; + const dynamicGradient = gradient(shifted); + + console.clear(); + console.log(chalk.bold(dynamicGradient.multiline(lines.join("\n")))); + console.log(dynamicGradient("⚑ Empowering modern engineering ⚑")); + + i = (i + 1) % colors.length; + }, 150); // Adjust speed here (lower = faster) + + setTimeout(() => { + clearInterval(interval); + console.log("\n"); + resolve(); + }, 4000); + } + ); + }); +}; + +await runBanner(); + +await runInit().catch((error) => { + // Handle any unexpected errors + if ( + error && + typeof error === "object" && + (("name" in error && error.name === "ExitPromptError") || + ("message" in error && typeof error.message === "string" && error.message.includes("SIGINT"))) + ) { + // Already handled in runInit, but just in case + console.log("\n"); + const exitGradient = dynamicGradient; + console.log( + exitGradient.multiline( + "πŸ‘‹ Setup cancelled\nThanks for trying sonar-autofixer!\nSee you next time ✨" + ) + ); + process.exit(0); + } + // For other errors, exit with error code + console.error(chalk.red("\n❌ An unexpected error occurred:")); + console.error(chalk.red(error instanceof Error ? error.message : String(error))); + process.exit(1); +}); diff --git a/src/scanner.js b/src/sonar/scanner.ts similarity index 80% rename from src/scanner.js rename to src/sonar/scanner.ts index 6850bda..ff4c056 100644 --- a/src/scanner.js +++ b/src/sonar/scanner.ts @@ -17,7 +17,7 @@ const __dirname = dirname(__filename); * Token is loaded from .env file via dotenv * Results are dumped to .sonar/scanner-report.json for local analysis */ -const runSonarScan = () => { +const runSonarScan = (): void => { try { // Get configuration from environment variables const sonarToken = process.env.SONAR_TOKEN; @@ -41,7 +41,11 @@ const runSonarScan = () => { execSync("which sonar", { encoding: "utf8", stdio: "ignore" }); } catch (error) { console.error("❌ Error: Sonar scanner (@sonar/scan) is not installed."); - console.error(`Scanner check failed: ${error.message}`); + console.error( + `Scanner check failed: ${ + error instanceof Error ? error.message : String(error) + }` + ); console.error("Please install it with: npm install -g @sonar/scan"); process.exit(1); } @@ -78,19 +82,27 @@ const runSonarScan = () => { "πŸ’‘ Note: This is a local scan. Results are saved to file for local analysis." ); } catch (error) { - console.error("❌ Error running Sonar scan:", error.message); + const errorMessage = error instanceof Error ? error.message : String(error); + console.error("❌ Error running Sonar scan:", errorMessage); // Provide helpful error messages - if (error.status === 127 || error.message.includes("command not found")) { + if ( + (error as { status?: number }).status === 127 || + errorMessage.includes("command not found") + ) { console.error( "\nπŸ’‘ Tip: Install Sonar Scanner with: npm install -g @sonar/scan" ); } - if (error.stdout || error.stderr) { + const execError = error as { + stdout?: Buffer | string; + stderr?: Buffer | string; + }; + if (execError.stdout || execError.stderr) { console.error("\nScanner output:"); - if (error.stdout) console.error(error.stdout.toString()); - if (error.stderr) console.error(error.stderr.toString()); + if (execError.stdout) console.error(execError.stdout.toString()); + if (execError.stderr) console.error(execError.stderr.toString()); } process.exit(1); diff --git a/src/sonar/sonar-issue-extractor.ts b/src/sonar/sonar-issue-extractor.ts new file mode 100644 index 0000000..17af105 --- /dev/null +++ b/src/sonar/sonar-issue-extractor.ts @@ -0,0 +1,497 @@ +import dotenv from "dotenv"; + +dotenv.config(); + +interface SonarIssue { + severity?: string; + [key: string]: unknown; +} + +interface SonarResponse { + issues?: SonarIssue[]; + [key: string]: unknown; +} + +interface Config { + sonarBaseUrl?: string; + publicSonar?: boolean; + gitProvider?: string; + [key: string]: unknown; +} + +/** + * SonarIssueExtractor - Handles all SonarQube API interactions and PR detection + */ +export class SonarIssueExtractor { + private readonly githubToken: string | undefined; + private readonly githubOwner: string | undefined; + private readonly githubRepo: string | undefined; + private readonly githubBaseUrl: string; + private readonly bitbucketEmail: string | undefined; + private readonly bitbucketApiToken: string | undefined; + private readonly bitbucketBaseUrl: string | undefined; + private readonly sonarToken: string | undefined; + private readonly sonarBaseUrlRaw: string; + private readonly sonarComponentKeys: string | undefined; + private readonly sonarOrganization: string | undefined; + + constructor() { + // GitHub configuration + this.githubToken = process.env.GITHUB_TOKEN; + this.githubOwner = process.env.GITHUB_OWNER; + this.githubRepo = process.env.GITHUB_REPO; + this.githubBaseUrl = process.env.GITHUB_API_URL || "https://api.github.com"; + + // Bitbucket configuration + this.bitbucketEmail = process.env.BITBUCKET_EMAIL; + this.bitbucketApiToken = process.env.BITBUCKET_API_TOKEN; + this.bitbucketBaseUrl = process.env.BITBUCKET_BASE_URL; + + // SonarQube configuration + this.sonarToken = process.env.SONAR_TOKEN; + this.sonarBaseUrlRaw = + process.env.SONAR_BASE_URL || "https://sonarcloud.io/api/issues/search"; + this.sonarComponentKeys = process.env.SONAR_COMPONENT_KEYS; + this.sonarOrganization = process.env.SONAR_ORGANIZATION; + } + + /** + * Gets SonarQube authentication headers + * @param token - SonarQube token + * @returns Authentication headers + */ + getSonarAuthHeaders(token?: string): Record { + if (!token) return {}; + + // SonarCloud/SonarQube typically uses Basic Auth: token as username, empty password + const tokenString = `${token}:`; + const basicAuth = `Basic ${Buffer.from(tokenString).toString("base64")}`; + + return { + Authorization: basicAuth, + }; + } + + /** + * Gets Bitbucket authentication headers + * @returns Authentication headers + */ + getBitbucketAuthHeaders(): Record { + if (!this.bitbucketEmail || !this.bitbucketApiToken) { + throw new Error("BITBUCKET_EMAIL and BITBUCKET_API_TOKEN are required"); + } + + const auth = Buffer.from( + `${this.bitbucketEmail}:${this.bitbucketApiToken}` + ).toString("base64"); + return { + Authorization: `Basic ${auth}`, + }; + } + + /** + * Normalizes SonarQube base URL to API endpoint + * @param baseUrl - Raw base URL + * @returns Normalized API URL + */ + normalizeSonarUrl(baseUrl: string): string { + let url = baseUrl; + if (!url.includes("/api/issues/search")) { + url = url.replace(/\/project\/issues[^/]*$/, "").replace(/\/$/, ""); + url = url.replace(/\/api\/issues$/, ""); + url = `${url}/api/issues/search`; + } + return url; + } + + /** + * Detects GitHub PR ID from branch name + * @param branch - Branch name + * @returns PR ID if found, null otherwise + */ + async detectGitHubPrId(branch: string): Promise { + try { + if (!this.githubToken || !this.githubOwner || !this.githubRepo) { + console.log("⚠️ GitHub configuration missing, skipping PR detection"); + return null; + } + + // Try to get PR number from GitHub API using the branch name + const githubApiUrl = `${this.githubBaseUrl}/repos/${this.githubOwner}/${this.githubRepo}/pulls?head=${this.githubOwner}:${branch}&state=open`; + console.log(`πŸ” Checking for PR associated with branch: ${branch}`); + + const response = await fetch(githubApiUrl, { + headers: { + Authorization: `token ${this.githubToken}`, + Accept: "application/vnd.github.v3+json", + }, + }); + + if (response.ok) { + const data = (await response.json()) as Array<{ number: number }>; + if (Array.isArray(data) && data.length > 0) { + const prNumber = data[0].number; + console.log(`βœ… Found PR #${prNumber} for branch: ${branch}`); + return prNumber.toString(); + } + } + + // Also check closed PRs in case the branch is merged + const closedPrUrl = `${this.githubBaseUrl}/repos/${this.githubOwner}/${this.githubRepo}/pulls?head=${this.githubOwner}:${branch}&state=all`; + const closedResponse = await fetch(closedPrUrl, { + headers: { + Authorization: `token ${this.githubToken}`, + Accept: "application/vnd.github.v3+json", + }, + }); + + if (closedResponse.ok) { + const closedData = (await closedResponse.json()) as Array<{ + number: number; + }>; + if (Array.isArray(closedData) && closedData.length > 0) { + const prNumber = closedData[0].number; + console.log( + `βœ… Found PR #${prNumber} for branch: ${branch} (closed/merged)` + ); + return prNumber.toString(); + } + } + + // Fallback: try to extract PR number from branch name + const prNumberRegex = + /(?:pr\/|PR\/|pull\/|PULL\/)(\d+)|(?:feat\/|feature\/|fix\/|bugfix\/).*?(\d+)/i; + const prNumberMatch = prNumberRegex.exec(branch); + if (prNumberMatch) { + const prNumber = prNumberMatch[1] || prNumberMatch[2]; + console.log(`βœ… Extracted PR #${prNumber} from branch name: ${branch}`); + return prNumber || null; + } + + console.log(`⚠️ No PR found for branch: ${branch}`); + return null; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + console.log(`⚠️ Could not detect GitHub PR ID: ${errorMessage}`); + return null; + } + } + + /** + * Detects Bitbucket PR ID from branch name + * @param branch - Branch name + * @returns PR ID if found, null otherwise + */ + async detectBitbucketPrId(branch: string): Promise { + try { + if ( + !this.bitbucketEmail || + !this.bitbucketApiToken || + !this.bitbucketBaseUrl + ) { + console.log( + "⚠️ Bitbucket configuration missing, skipping PR detection" + ); + return null; + } + + // Try to get PR number from Bitbucket API using the branch name + const bitbucketApiUrl = `${this.bitbucketBaseUrl}/pullrequests?q=source.branch.name="${branch}"`; + console.log(`πŸ” Checking for PR associated with branch: ${branch}`); + + const response = await fetch(bitbucketApiUrl, { + headers: this.getBitbucketAuthHeaders(), + }); + + if (response.ok) { + const data = (await response.json()) as { + values?: Array<{ id: number }>; + }; + if (data.values && data.values.length > 0) { + const prNumber = data.values[0].id; + console.log(`βœ… Found PR #${prNumber} for branch: ${branch}`); + return prNumber.toString(); + } + } + + // Fallback: try to extract PR number from branch name + const prNumberRegex = + /(?:pr\/|PR\/|pull\/|PULL\/)(\d+)|(?:feat\/|feature\/|fix\/|bugfix\/).*?(\d+)/i; + const prNumberMatch = prNumberRegex.exec(branch); + if (prNumberMatch) { + const prNumber = prNumberMatch[1] || prNumberMatch[2]; + console.log(`βœ… Extracted PR #${prNumber} from branch name: ${branch}`); + return prNumber || null; + } + + console.log(`⚠️ No PR found for branch: ${branch}`); + return null; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + console.log(`⚠️ Could not detect Bitbucket PR ID: ${errorMessage}`); + return null; + } + } + + /** + * Handles SonarQube API response + * @param response - Fetch response + * @returns Parsed JSON response + */ + async handleSonarResponse(response: Response): Promise { + const contentType = response.headers.get("content-type"); + if (!contentType?.includes("application/json")) { + const errorText = await response.text(); + console.error(`Unexpected content type: ${contentType}`); + console.error(`Response preview: ${errorText.substring(0, 500)}`); + throw new Error( + `Expected JSON but got ${contentType}. Check authentication and API endpoint. Status: ${response.status}` + ); + } + + if (!response.ok) { + const errorText = await response.text(); + throw new Error( + `HTTP error! status: ${response.status}, body: ${errorText.substring( + 0, + 200 + )}` + ); + } + + return (await response.json()) as SonarResponse; + } + + /** + * Builds URL for fetching issues by branch + * @param branch - Branch name + * @param config - Configuration object + * @returns URL for fetching issues + */ + buildUrlForBranch(branch: string, config: Config): string { + const sonarBaseUrl = this.normalizeSonarUrl( + config.sonarBaseUrl || this.sonarBaseUrlRaw + ); + + // Check if this is a SonarCloud setup (has organization and componentKeys) + if ( + config.publicSonar && + this.sonarComponentKeys && + this.sonarOrganization + ) { + const params = new URLSearchParams({ + s: "FILE_LINE", + issueStatuses: "OPEN,CONFIRMED", + ps: "100", + facets: "impactSoftwareQualities,impactSeverities", + componentKeys: this.sonarComponentKeys, + organization: this.sonarOrganization, + branch: branch, + additionalFields: "_all", + }); + return `${sonarBaseUrl}?${params.toString()}`; + } + // SonarQube setup (private instance) + const params = new URLSearchParams({ + branch, + components: "bat", + s: "FILE_LINE", + inNewCodePeriod: "true", + issueStatuses: "CONFIRMED,OPEN", + ps: "100", + facets: + "cleanCodeAttributeCategories,impactSoftwareQualities,severities,types,impactSeverities,codeVariants", + additionalFields: "_all", + timeZone: "Europe/Rome", + }); + return `${sonarBaseUrl}?${params.toString()}`; + } + + /** + * Builds URL for fetching issues by PR link + * @param prLink - SonarQube PR link + * @param config - Configuration object + * @returns URL for fetching issues + */ + buildUrlForPr(prLink: string, config: Config): string { + const sonarBaseUrl = this.normalizeSonarUrl( + config.sonarBaseUrl || this.sonarBaseUrlRaw + ); + + // Extract PR key from the SonarQube PR link + const prKeyMatch = prLink.match(/pullRequest=([^&]+)/); + if (!prKeyMatch) { + throw new Error( + "Invalid SonarQube PR link format. Expected format: https://sonarcloud.io/project/issues?id=project&pullRequest=PR_KEY" + ); + } + + const prKey = prKeyMatch[1]; + + // Check if this is a SonarCloud setup + if ( + config.publicSonar && + this.sonarComponentKeys && + this.sonarOrganization + ) { + const params = new URLSearchParams({ + s: "FILE_LINE", + issueStatuses: "OPEN,CONFIRMED", + ps: "100", + facets: "impactSoftwareQualities,impactSeverities", + componentKeys: this.sonarComponentKeys, + organization: this.sonarOrganization, + pullRequest: prKey, + additionalFields: "_all", + }); + return `${sonarBaseUrl}?${params.toString()}`; + } + // SonarQube setup (private instance) + const params = new URLSearchParams({ + pullRequest: prKey, + components: "bat", + s: "FILE_LINE", + inNewCodePeriod: "true", + issueStatuses: "CONFIRMED,OPEN", + ps: "100", + facets: + "cleanCodeAttributeCategories,impactSoftwareQualities,severities,types,impactSeverities,codeVariants", + additionalFields: "_all", + timeZone: "Europe/Rome", + }); + return `${sonarBaseUrl}?${params.toString()}`; + } + + /** + * Builds URL for fetching issues by PR ID + * @param prId - PR ID + * @param config - Configuration object + * @returns URL for fetching issues + */ + buildUrlForPrId(prId: string, config: Config): string { + const sonarBaseUrl = this.normalizeSonarUrl( + config.sonarBaseUrl || this.sonarBaseUrlRaw + ); + + // Check if this is a SonarCloud setup + if ( + config.publicSonar && + this.sonarComponentKeys && + this.sonarOrganization + ) { + const params = new URLSearchParams({ + s: "FILE_LINE", + issueStatuses: "OPEN,CONFIRMED", + ps: "100", + facets: "impactSoftwareQualities,impactSeverities", + componentKeys: this.sonarComponentKeys, + organization: this.sonarOrganization, + pullRequest: prId, + additionalFields: "_all", + }); + return `${sonarBaseUrl}?${params.toString()}`; + } + // SonarQube setup (private instance) + const params = new URLSearchParams({ + pullRequest: prId, + components: "bat", + s: "FILE_LINE", + inNewCodePeriod: "true", + issueStatuses: "CONFIRMED,OPEN", + ps: "100", + facets: + "cleanCodeAttributeCategories,impactSoftwareQualities,severities,types,impactSeverities,codeVariants", + additionalFields: "_all", + timeZone: "Europe/Rome", + }); + return `${sonarBaseUrl}?${params.toString()}`; + } + + /** + * Fetches issues for a branch + * @param branch - Branch name + * @param config - Configuration object + * @returns Issues data + */ + async fetchIssuesForBranch( + branch: string, + config: Config + ): Promise { + const url = this.buildUrlForBranch(branch, config); + console.log( + `Fetching issues from: ${url.replace( + /sonarToken=[^&]+/, + "sonarToken=***" + )}` + ); + + const authHeaders = this.getSonarAuthHeaders(this.sonarToken); + const response = await fetch(url, { + headers: { + ...authHeaders, + "Content-Type": "application/json", + Accept: "application/json", + }, + }); + + return await this.handleSonarResponse(response); + } + + /** + * Fetches issues for a PR link + * @param prLink - SonarQube PR link + * @param config - Configuration object + * @returns Issues data + */ + async fetchIssuesForPr( + prLink: string, + config: Config + ): Promise { + const url = this.buildUrlForPr(prLink, config); + console.log( + `Fetching issues from PR: ${url.replace( + /sonarToken=[^&]+/, + "sonarToken=***" + )}` + ); + + const authHeaders = this.getSonarAuthHeaders(this.sonarToken); + const response = await fetch(url, { + headers: { + ...authHeaders, + "Content-Type": "application/json", + Accept: "application/json", + }, + }); + + return await this.handleSonarResponse(response); + } + + /** + * Fetches issues for a PR ID + * @param prId - PR ID + * @param config - Configuration object + * @returns Issues data + */ + async fetchIssuesForPrId( + prId: string, + config: Config + ): Promise { + const url = this.buildUrlForPrId(prId, config); + console.log(`Fetching issues from PR ID: ${prId}`); + console.log(`URL: ${url.replace(/sonarToken=[^&]+/, "sonarToken=***")}`); + + const authHeaders = this.getSonarAuthHeaders(this.sonarToken); + const response = await fetch(url, { + headers: { + ...authHeaders, + "Content-Type": "application/json", + Accept: "application/json", + }, + }); + + return await this.handleSonarResponse(response); + } +} diff --git a/src/versioning/index.ts b/src/versioning/index.ts new file mode 100644 index 0000000..52279f1 --- /dev/null +++ b/src/versioning/index.ts @@ -0,0 +1,180 @@ +#!/usr/bin/env node + +import dotenv from "dotenv"; +import { execSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { SonarIssueExtractor } from "../sonar/sonar-issue-extractor.js"; + +dotenv.config(); + +interface Config { + gitProvider: "github" | "bitbucket"; + outputPath?: string; + sonarBaseUrl?: string; + publicSonar?: boolean; + [key: string]: unknown; +} + +interface SonarIssuesResponse { + issues?: Array<{ severity?: string; [key: string]: unknown }>; + [key: string]: unknown; +} + +/** + * Loads configuration from .sonar/autofixer.config.json + * @returns Configuration object + */ +const loadConfiguration = (): Config => { + const configPath = path.join( + process.cwd(), + ".sonar", + "autofixer.config.json" + ); + + if (!fs.existsSync(configPath)) { + throw new Error( + "Configuration file not found: .sonar/autofixer.config.json" + ); + } + + const config = JSON.parse(fs.readFileSync(configPath, "utf8")) as Config; + + // Validate required configuration + if (!config.gitProvider) { + throw new Error("gitProvider is required in configuration"); + } + + if (!["github", "bitbucket"].includes(config.gitProvider)) { + throw new Error("gitProvider must be either 'github' or 'bitbucket'"); + } + + return config; +}; + +/** + * Detects PR ID based on the configured git provider + * @param branch - Current git branch name + * @param gitProvider - Git provider (github or bitbucket) + * @returns PR ID if found, null otherwise + */ +const detectPrId = async ( + branch: string, + gitProvider: "github" | "bitbucket" +): Promise => { + const extractor = new SonarIssueExtractor(); + + if (gitProvider === "github") { + return await extractor.detectGitHubPrId(branch); + } + if (gitProvider === "bitbucket") { + return await extractor.detectBitbucketPrId(branch); + } + + return null; +}; + +/** + * Fetches SonarQube issues based on configuration and command line arguments + * @param branchName - Optional branch name + * @param sonarPrLink - Optional SonarQube PR link + */ +const fetchSonarIssues = async ( + branchName: string | null = null, + sonarPrLink: string | null = null +): Promise => { + try { + // Load configuration + const config = loadConfiguration(); + console.log(`πŸ”§ Using configuration: ${JSON.stringify(config, null, 2)}`); + + // Get current git branch + const currentBranch = + branchName || + execSync("git branch --show-current", { encoding: "utf8" }).trim(); + console.log(`Current branch: ${currentBranch}`); + + // Initialize SonarQube extractor + const extractor = new SonarIssueExtractor(); + + let issues: SonarIssuesResponse; + let usedSource: string; + + if (sonarPrLink) { + // If PR link is provided, fetch issues from that PR + console.log(`Using provided SonarQube PR link: ${sonarPrLink}`); + issues = await extractor.fetchIssuesForPr(sonarPrLink, config); + usedSource = `PR: ${sonarPrLink}`; + } else { + // Try to automatically detect PR ID from current branch + const detectedPrId = await detectPrId(currentBranch, config.gitProvider); + + if (detectedPrId) { + // Use detected PR ID + console.log(`πŸš€ Using automatically detected PR ID: ${detectedPrId}`); + issues = await extractor.fetchIssuesForPrId(detectedPrId, config); + usedSource = `PR #${detectedPrId} (auto-detected from branch: ${currentBranch})`; + } else { + // Fallback to branch-based approach + console.log("πŸ“‹ No PR detected, falling back to branch-based approach"); + issues = await extractor.fetchIssuesForBranch(currentBranch, config); + usedSource = currentBranch; + + // Fallback to develop if no issues found + if (!issues.issues || issues.issues.length === 0) { + console.log( + "No issues found for current branch. Falling back to branch: develop" + ); + issues = await extractor.fetchIssuesForBranch("develop", config); + usedSource = "develop"; + } + } + } + + // Save issues to file + const outputPath = config.outputPath || ".sonar/"; + const sonarDir = path.join(process.cwd(), outputPath); + if (!fs.existsSync(sonarDir)) { + fs.mkdirSync(sonarDir, { recursive: true }); + } + + const issuesPath = path.join(sonarDir, "issues.json"); + fs.writeFileSync(issuesPath, JSON.stringify(issues, null, 2)); + + console.log( + `βœ… Successfully fetched ${ + issues.issues?.length || 0 + } issues (source: ${usedSource})` + ); + console.log(`πŸ“ Saved to: ${issuesPath}`); + + // Display summary + if (issues.issues && issues.issues.length > 0) { + const severityCounts: Record = {}; + for (const issue of issues.issues) { + const severity = issue.severity || "UNKNOWN"; + severityCounts[severity] = (severityCounts[severity] || 0) + 1; + } + + console.log("\nπŸ“Š Issues by severity:"); + const sortedEntries = Object.entries(severityCounts).sort( + ([, a], [, b]) => b - a + ); + for (const [severity, count] of sortedEntries) { + console.log(` ${severity}: ${count}`); + } + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + console.error("❌ Error fetching SonarQube issues:", errorMessage); + process.exit(1); + } +}; + +// Parse command line arguments +const args = process.argv.slice(2); +const branchName = args[0] || null; +const sonarPrLink = args[1] || null; + +// Execute the main function +await fetchSonarIssues(branchName, sonarPrLink); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..869922c --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "lib": ["ES2022"], + "moduleResolution": "node", + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "allowSyntheticDefaultImports": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "noImplicitAny": true, + "strictNullChecks": true, + "strictFunctionTypes": true, + "strictBindCallApply": true, + "strictPropertyInitialization": true, + "noImplicitThis": true, + "alwaysStrict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "types": ["node"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} +