opencode-sonarqube 2.1.3 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +220 -2
  2. package/dist/index.js +365 -82
  3. package/package.json +1 -2
package/README.md CHANGED
@@ -212,7 +212,7 @@ Create `.sonarqube/config.json` in your project root:
212
212
  }
213
213
  ```
214
214
 
215
- ## Tool Actions (15 total)
215
+ ## Tool Actions (16 total)
216
216
 
217
217
  The plugin adds a `sonarqube` tool with these actions:
218
218
 
@@ -462,6 +462,152 @@ This will:
462
462
 
463
463
  **Note:** The `.sonarqube/` directory contains sensitive tokens - never commit it!
464
464
 
465
+ ## Troubleshooting
466
+
467
+ ### Common Issues
468
+
469
+ #### "Cannot connect to SonarQube server"
470
+
471
+ **Symptoms:** Plugin shows connection errors, health check fails.
472
+
473
+ **Solutions:**
474
+ 1. **Check URL format**: Must include protocol (`https://` or `http://`)
475
+ ```bash
476
+ # Wrong
477
+ export SONAR_HOST_URL="sonarqube.example.com"
478
+ # Correct
479
+ export SONAR_HOST_URL="https://sonarqube.example.com"
480
+ ```
481
+
482
+ 2. **Verify server is running**:
483
+ ```bash
484
+ curl -s "$SONAR_HOST_URL/api/system/status" | jq .status
485
+ # Should output: "UP"
486
+ ```
487
+
488
+ 3. **Check firewall/VPN**: Ensure your machine can reach the server.
489
+
490
+ 4. **Verify credentials**:
491
+ ```bash
492
+ curl -u "$SONAR_USER:$SONAR_PASSWORD" "$SONAR_HOST_URL/api/authentication/validate"
493
+ # Should output: {"valid":true}
494
+ ```
495
+
496
+ #### "Authentication failed" / 401 Errors
497
+
498
+ **Solutions:**
499
+ 1. **Reload environment variables**:
500
+ ```bash
501
+ source ~/.zshrc # or ~/.bashrc
502
+ ```
503
+
504
+ 2. **Check credentials are set**:
505
+ ```bash
506
+ echo $SONAR_HOST_URL # Should show your URL
507
+ echo $SONAR_USER # Should show username
508
+ ```
509
+
510
+ 3. **Verify password has no special characters** that need escaping:
511
+ ```bash
512
+ # If password contains special chars, quote it:
513
+ export SONAR_PASSWORD='my$pecial!pass'
514
+ ```
515
+
516
+ #### "Project not found" after setup
517
+
518
+ **Solutions:**
519
+ 1. **Re-run setup with force**:
520
+ ```typescript
521
+ sonarqube({ action: "setup", force: true })
522
+ ```
523
+
524
+ 2. **Check if project exists on server**: Visit `$SONAR_HOST_URL/projects` in browser.
525
+
526
+ 3. **Delete local state and re-initialize**:
527
+ ```bash
528
+ rm -rf .sonarqube/
529
+ # Then in OpenCode: sonarqube({ action: "setup" })
530
+ ```
531
+
532
+ #### Plugin doesn't load / "Plugin not found"
533
+
534
+ **Solutions:**
535
+ 1. **Verify installation**:
536
+ ```bash
537
+ cat package.json | grep opencode-sonarqube
538
+ # Should show: "opencode-sonarqube": "..."
539
+ ```
540
+
541
+ 2. **Check opencode.json**:
542
+ ```json
543
+ {
544
+ "plugin": ["opencode-sonarqube"]
545
+ }
546
+ ```
547
+
548
+ 3. **Reinstall**:
549
+ ```bash
550
+ bun remove opencode-sonarqube && bun add opencode-sonarqube
551
+ ```
552
+
553
+ 4. **Restart OpenCode** after installation.
554
+
555
+ #### Analysis shows no issues but Quality Gate fails
556
+
557
+ This usually means **security hotspots** need review.
558
+
559
+ **Solution:**
560
+ ```typescript
561
+ // List hotspots
562
+ sonarqube({ action: "hotspots" })
563
+
564
+ // Bulk-review as safe (if appropriate)
565
+ sonarqube({ action: "reviewhotspot", resolution: "SAFE" })
566
+ ```
567
+
568
+ #### Slow analysis / Timeout errors
569
+
570
+ **Solutions:**
571
+ 1. **Exclude unnecessary files** in `.sonarqube/config.json`:
572
+ ```json
573
+ {
574
+ "exclusions": "**/node_modules/**,**/dist/**,**/*.min.js"
575
+ }
576
+ ```
577
+
578
+ 2. **Increase timeout** (set environment variable):
579
+ ```bash
580
+ export SONARQUBE_TIMEOUT=120000 # 2 minutes
581
+ ```
582
+
583
+ 3. **Check server resources**: Analysis is CPU-intensive on the server side.
584
+
585
+ ### Debug Mode
586
+
587
+ Enable detailed logging:
588
+ ```bash
589
+ export SONARQUBE_DEBUG=true
590
+ ```
591
+
592
+ Logs are written to: `/tmp/sonarqube-plugin-debug.log`
593
+
594
+ View logs in real-time:
595
+ ```bash
596
+ tail -f /tmp/sonarqube-plugin-debug.log
597
+ ```
598
+
599
+ ### Getting Help
600
+
601
+ 1. **Check existing issues**: [GitHub Issues](https://github.com/mguttmann/opencode-sonarqube/issues)
602
+ 2. **Open new issue** with:
603
+ - OpenCode version
604
+ - Plugin version (`bun list opencode-sonarqube`)
605
+ - SonarQube server version
606
+ - Error message/logs
607
+ - Steps to reproduce
608
+
609
+ ---
610
+
465
611
  ## FAQ
466
612
 
467
613
  ### Where is the configuration stored?
@@ -505,7 +651,79 @@ sonarqube({ action: "analyze" })
505
651
 
506
652
  ### How do I use this with multiple SonarQube servers?
507
653
 
508
- Currently, the plugin uses global environment variables. For different servers per project, you'd need to set the environment variables differently per terminal session.
654
+ Currently, the plugin uses global environment variables. For different servers per project:
655
+
656
+ **Option 1: Use direnv (recommended)**
657
+ ```bash
658
+ # Install direnv
659
+ brew install direnv # macOS
660
+
661
+ # In project directory, create .envrc
662
+ echo 'export SONAR_HOST_URL="https://server1.example.com"' > .envrc
663
+ echo 'export SONAR_USER="admin"' >> .envrc
664
+ echo 'export SONAR_PASSWORD="password1"' >> .envrc
665
+ direnv allow
666
+ ```
667
+
668
+ **Option 2: Use project-specific shell scripts**
669
+ ```bash
670
+ # Create project setup script
671
+ cat > .sonarqube-env.sh << 'EOF'
672
+ export SONAR_HOST_URL="https://server1.example.com"
673
+ export SONAR_USER="admin"
674
+ export SONAR_PASSWORD="password1"
675
+ EOF
676
+
677
+ # Source before starting OpenCode
678
+ source .sonarqube-env.sh && opencode
679
+ ```
680
+
681
+ **Option 3: Different terminal profiles**
682
+ Create shell profiles for each server and switch between them.
683
+
684
+ ### What happens if SonarQube server is offline?
685
+
686
+ The plugin handles offline scenarios gracefully:
687
+
688
+ 1. **During startup**: System prompt injection is skipped, no errors shown
689
+ 2. **During analysis**: Error message explains the connection issue
690
+ 3. **Cached data**: Last analysis results are preserved locally in `.sonarqube/project.json`
691
+ 4. **Auto-retry**: The plugin automatically retries failed requests up to 3 times with exponential backoff
692
+
693
+ You can continue coding - the plugin will reconnect when the server is available again.
694
+
695
+ ### How do I reset everything and start fresh?
696
+
697
+ ```bash
698
+ # Remove local plugin state
699
+ rm -rf .sonarqube/
700
+
701
+ # Remove from package.json (optional)
702
+ bun remove opencode-sonarqube
703
+
704
+ # Reinstall
705
+ bun add opencode-sonarqube
706
+
707
+ # Restart OpenCode and run setup
708
+ # In OpenCode: sonarqube({ action: "setup" })
709
+ ```
710
+
711
+ ### Can I use a token instead of username/password?
712
+
713
+ Yes! SonarQube tokens are recommended for better security:
714
+
715
+ 1. **Generate token** in SonarQube: User > My Account > Security > Generate Token
716
+ 2. **Use as password** (leave user as your username):
717
+ ```bash
718
+ export SONAR_USER="your-username"
719
+ export SONAR_PASSWORD="sqp_your_token_here"
720
+ ```
721
+
722
+ Or use token directly (user becomes empty):
723
+ ```bash
724
+ export SONAR_USER=""
725
+ export SONAR_PASSWORD="sqp_your_token_here"
726
+ ```
509
727
 
510
728
  ### Can I use this without OpenCode?
511
729
 
package/dist/index.js CHANGED
@@ -4743,13 +4743,79 @@ function buildAuthHeader(auth) {
4743
4743
  const credentials = auth.user + ":" + auth.password;
4744
4744
  return `Basic ${btoa(credentials)}`;
4745
4745
  }
4746
+ function sleep(ms) {
4747
+ return new Promise((resolve) => setTimeout(resolve, ms));
4748
+ }
4749
+ function calculateBackoffDelay(attempt, baseDelay) {
4750
+ const exponentialDelay = baseDelay * Math.pow(2, attempt);
4751
+ const jitter = exponentialDelay * 0.25 * (Math.random() * 2 - 1);
4752
+ return Math.min(exponentialDelay + jitter, 30000);
4753
+ }
4754
+ function isRetryableError(error45, statusCode) {
4755
+ if (error45 instanceof TypeError && error45.message.includes("fetch")) {
4756
+ return true;
4757
+ }
4758
+ if (statusCode && RETRYABLE_STATUS_CODES.has(statusCode)) {
4759
+ return true;
4760
+ }
4761
+ if (error45 instanceof Error && "code" in error45) {
4762
+ const errorCode = error45.code;
4763
+ return errorCode === "CONNECTION_ERROR";
4764
+ }
4765
+ return false;
4766
+ }
4767
+ function getRateLimitWaitTime(response, attempt, baseDelay) {
4768
+ const retryAfter = response.headers.get("Retry-After");
4769
+ return retryAfter ? Number.parseInt(retryAfter, 10) * 1000 : calculateBackoffDelay(attempt, baseDelay);
4770
+ }
4746
4771
 
4747
4772
  class SonarQubeClient {
4748
4773
  baseUrl;
4749
4774
  auth;
4775
+ maxRetries;
4776
+ retryDelay;
4777
+ timeout;
4750
4778
  constructor(config3, _logger) {
4751
4779
  this.baseUrl = normalizeUrl(config3.url);
4752
4780
  this.auth = config3.auth;
4781
+ this.maxRetries = config3.maxRetries ?? DEFAULT_MAX_RETRIES;
4782
+ this.retryDelay = config3.retryDelay ?? DEFAULT_RETRY_DELAY;
4783
+ this.timeout = config3.timeout ?? DEFAULT_TIMEOUT;
4784
+ }
4785
+ async executeRequest(url2, method, headers, body, attempt) {
4786
+ const controller = new AbortController;
4787
+ const timeoutId = setTimeout(() => controller.abort(), this.timeout);
4788
+ try {
4789
+ const response = await fetch(url2, { method, headers, body, signal: controller.signal });
4790
+ clearTimeout(timeoutId);
4791
+ return this.processResponse(response, attempt);
4792
+ } catch (error45) {
4793
+ clearTimeout(timeoutId);
4794
+ return this.handleFetchException(error45);
4795
+ }
4796
+ }
4797
+ async processResponse(response, attempt) {
4798
+ if (response.ok) {
4799
+ const text = await response.text();
4800
+ return { success: true, data: parseResponseBody(text) };
4801
+ }
4802
+ const canRetry = attempt < this.maxRetries && RETRYABLE_STATUS_CODES.has(response.status);
4803
+ if (!canRetry) {
4804
+ await handleResponseError(response);
4805
+ throw new Error("Unreachable");
4806
+ }
4807
+ const waitTime = response.status === 429 ? getRateLimitWaitTime(response, attempt, this.retryDelay) : calculateBackoffDelay(attempt, this.retryDelay);
4808
+ return { success: false, shouldRetry: true, statusCode: response.status, waitTime };
4809
+ }
4810
+ handleFetchException(error45) {
4811
+ if (error45 instanceof Error && error45.name === "AbortError") {
4812
+ return { success: false, shouldRetry: false, error: error45 };
4813
+ }
4814
+ return {
4815
+ success: false,
4816
+ shouldRetry: isRetryableError(error45),
4817
+ error: error45
4818
+ };
4753
4819
  }
4754
4820
  async request(endpoint, options = {}) {
4755
4821
  const { method = "GET", params, body } = options;
@@ -4762,20 +4828,23 @@ class SonarQubeClient {
4762
4828
  if (requestBody) {
4763
4829
  headers["Content-Type"] = "application/x-www-form-urlencoded";
4764
4830
  }
4765
- try {
4766
- const response = await fetch(url2, {
4767
- method,
4768
- headers,
4769
- body: requestBody
4770
- });
4771
- if (!response.ok) {
4772
- await handleResponseError(response);
4831
+ let lastError;
4832
+ for (let attempt = 0;attempt <= this.maxRetries; attempt++) {
4833
+ const result = await this.executeRequest(url2, method, headers, requestBody, attempt);
4834
+ if (result.success) {
4835
+ return result.data;
4773
4836
  }
4774
- const text = await response.text();
4775
- return parseResponseBody(text);
4776
- } catch (error45) {
4777
- handleFetchError(error45, this.baseUrl);
4837
+ lastError = result.error;
4838
+ if (result.shouldRetry && attempt < this.maxRetries) {
4839
+ await sleep(result.waitTime ?? calculateBackoffDelay(attempt, this.retryDelay));
4840
+ continue;
4841
+ }
4842
+ if (result.error instanceof Error && result.error.name === "AbortError") {
4843
+ throw ConnectionError(`Request to ${endpoint} timed out after ${this.timeout}ms`);
4844
+ }
4845
+ handleFetchError(result.error, this.baseUrl);
4778
4846
  }
4847
+ handleFetchError(lastError, this.baseUrl);
4779
4848
  }
4780
4849
  getBaseUrl() {
4781
4850
  return this.baseUrl;
@@ -4834,14 +4903,16 @@ class SonarQubeClient {
4834
4903
  return this.request(endpoint, { method: "DELETE", params });
4835
4904
  }
4836
4905
  }
4837
- function createClientWithToken(url2, token, logger4) {
4838
- return new SonarQubeClient({ url: url2, auth: { token } }, logger4);
4906
+ function createClientWithToken(url2, token, logger4, options) {
4907
+ return new SonarQubeClient({ url: url2, auth: { token }, ...options }, logger4);
4839
4908
  }
4840
- function createClientWithCredentials(url2, user, password, logger4) {
4841
- return new SonarQubeClient({ url: url2, auth: { user, password } }, logger4);
4909
+ function createClientWithCredentials(url2, user, password, logger4, options) {
4910
+ return new SonarQubeClient({ url: url2, auth: { user, password }, ...options }, logger4);
4842
4911
  }
4912
+ var DEFAULT_MAX_RETRIES = 3, DEFAULT_RETRY_DELAY = 1000, DEFAULT_TIMEOUT = 30000, RETRYABLE_STATUS_CODES;
4843
4913
  var init_client = __esm(() => {
4844
4914
  init_types2();
4915
+ RETRYABLE_STATUS_CODES = new Set([408, 429, 500, 502, 503, 504]);
4845
4916
  });
4846
4917
 
4847
4918
  // src/api/projects.ts
@@ -4956,7 +5027,8 @@ __export(exports_state, {
4956
5027
  hasProjectState: () => hasProjectState,
4957
5028
  ensureGitignore: () => ensureGitignore,
4958
5029
  deleteProjectState: () => deleteProjectState,
4959
- createInitialState: () => createInitialState
5030
+ createInitialState: () => createInitialState,
5031
+ STATE_VERSION: () => STATE_VERSION
4960
5032
  });
4961
5033
  function getStatePath(directory) {
4962
5034
  return `${directory}/${STATE_DIR}/${STATE_FILE}`;
@@ -4976,9 +5048,44 @@ async function loadProjectState(directory) {
4976
5048
  }
4977
5049
  try {
4978
5050
  const content = await Bun.file(statePath).text();
4979
- const data = JSON.parse(content);
4980
- const state = ProjectStateSchema.parse(data);
4981
- return state;
5051
+ if (!content.trim()) {
5052
+ logger5.warn("Empty state file detected, removing");
5053
+ await deleteProjectState(directory);
5054
+ return null;
5055
+ }
5056
+ let data;
5057
+ try {
5058
+ data = JSON.parse(content);
5059
+ } catch (parseError) {
5060
+ const errorMessage = parseError instanceof Error ? parseError.message : String(parseError);
5061
+ logger5.error("Corrupt JSON in state file, attempting backup recovery", { error: errorMessage });
5062
+ const recovered2 = await recoverFromBackup(directory);
5063
+ if (recovered2) {
5064
+ return recovered2;
5065
+ }
5066
+ await deleteProjectState(directory);
5067
+ return null;
5068
+ }
5069
+ const validationResult = ProjectStateSchema.safeParse(data);
5070
+ if (validationResult.success) {
5071
+ const state = await migrateState(data, directory);
5072
+ return state;
5073
+ }
5074
+ logger5.warn("State validation failed, attempting repair", {
5075
+ errors: validationResult.error.format()
5076
+ });
5077
+ const repairedState = await repairState(data, directory);
5078
+ if (repairedState) {
5079
+ logger5.info("State successfully repaired");
5080
+ return repairedState;
5081
+ }
5082
+ logger5.error("State repair failed, attempting backup recovery");
5083
+ const recovered = await recoverFromBackup(directory);
5084
+ if (recovered) {
5085
+ return recovered;
5086
+ }
5087
+ logger5.error("Failed to load project state - no recovery possible");
5088
+ return null;
4982
5089
  } catch (error45) {
4983
5090
  logger5.error("Failed to load project state", {
4984
5091
  error: error45 instanceof Error ? error45.message : String(error45),
@@ -4987,14 +5094,132 @@ async function loadProjectState(directory) {
4987
5094
  return null;
4988
5095
  }
4989
5096
  }
5097
+ async function migrateState(data, directory) {
5098
+ const currentVersion = data._version ?? 1;
5099
+ if (currentVersion >= STATE_VERSION) {
5100
+ const { _version: _version2, ...state } = data;
5101
+ return state;
5102
+ }
5103
+ logger5.info("Migrating state", { from: currentVersion, to: STATE_VERSION });
5104
+ let migratedData = { ...data };
5105
+ if (currentVersion < 2) {
5106
+ migratedData = {
5107
+ ...migratedData,
5108
+ languages: migratedData.languages ?? [],
5109
+ setupComplete: migratedData.setupComplete ?? true
5110
+ };
5111
+ }
5112
+ migratedData._version = STATE_VERSION;
5113
+ const { _version, ...cleanState } = migratedData;
5114
+ const finalState = cleanState;
5115
+ await createBackup(directory);
5116
+ await saveProjectState(directory, finalState);
5117
+ logger5.info("State migration complete");
5118
+ return finalState;
5119
+ }
5120
+ function extractString(data, key, defaultValue) {
5121
+ const value = data[key];
5122
+ return typeof value === "string" && value.length > 0 ? value : defaultValue;
5123
+ }
5124
+ function extractBoolean(data, key, defaultValue) {
5125
+ const value = data[key];
5126
+ return typeof value === "boolean" ? value : defaultValue;
5127
+ }
5128
+ function extractStringArray(data, key) {
5129
+ const value = data[key];
5130
+ return Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
5131
+ }
5132
+ function extractProjectKey(data, directory) {
5133
+ const key = extractString(data, "projectKey");
5134
+ if (key)
5135
+ return key;
5136
+ const dirName = directory.split("/").pop();
5137
+ return dirName && dirName.length > 0 ? dirName : null;
5138
+ }
5139
+ async function repairState(data, directory) {
5140
+ await createBackup(directory);
5141
+ const projectKey = extractProjectKey(data, directory);
5142
+ const projectToken = extractString(data, "projectToken");
5143
+ if (!projectKey || !projectToken) {
5144
+ return null;
5145
+ }
5146
+ const repaired = {
5147
+ projectKey,
5148
+ projectToken,
5149
+ tokenName: extractString(data, "tokenName") ?? `opencode-${projectKey}-recovered`,
5150
+ initializedAt: extractString(data, "initializedAt") ?? new Date().toISOString(),
5151
+ languages: extractStringArray(data, "languages"),
5152
+ qualityGate: extractString(data, "qualityGate"),
5153
+ lastAnalysis: extractString(data, "lastAnalysis"),
5154
+ setupComplete: extractBoolean(data, "setupComplete", true)
5155
+ };
5156
+ const validationResult = ProjectStateSchema.safeParse(repaired);
5157
+ if (!validationResult.success) {
5158
+ logger5.error("Repaired state still invalid", { errors: validationResult.error.format() });
5159
+ return null;
5160
+ }
5161
+ await saveProjectState(directory, validationResult.data);
5162
+ return validationResult.data;
5163
+ }
5164
+ async function createBackup(directory) {
5165
+ const statePath = getStatePath(directory);
5166
+ const backupPath = `${directory}/${STATE_DIR}/${BACKUP_FILE}`;
5167
+ try {
5168
+ const exists = await Bun.file(statePath).exists();
5169
+ if (!exists) {
5170
+ return false;
5171
+ }
5172
+ const content = await Bun.file(statePath).text();
5173
+ await Bun.write(backupPath, content);
5174
+ logger5.info("Created state backup");
5175
+ return true;
5176
+ } catch {
5177
+ logger5.warn("Failed to create state backup");
5178
+ return false;
5179
+ }
5180
+ }
5181
+ async function recoverFromBackup(directory) {
5182
+ const backupPath = `${directory}/${STATE_DIR}/${BACKUP_FILE}`;
5183
+ try {
5184
+ const exists = await Bun.file(backupPath).exists();
5185
+ if (!exists) {
5186
+ return null;
5187
+ }
5188
+ const content = await Bun.file(backupPath).text();
5189
+ if (!content.trim()) {
5190
+ return null;
5191
+ }
5192
+ const data = JSON.parse(content);
5193
+ const validationResult = ProjectStateSchema.safeParse(data);
5194
+ if (!validationResult.success) {
5195
+ return null;
5196
+ }
5197
+ await saveProjectState(directory, validationResult.data);
5198
+ logger5.info("State recovered from backup");
5199
+ return validationResult.data;
5200
+ } catch {
5201
+ return null;
5202
+ }
5203
+ }
4990
5204
  async function saveProjectState(directory, state) {
4991
5205
  const stateDir = getStateDir(directory);
4992
5206
  const statePath = getStatePath(directory);
4993
5207
  const { mkdir } = await import("node:fs/promises");
4994
5208
  await mkdir(stateDir, { recursive: true });
4995
- const content = JSON.stringify(state, null, 2);
5209
+ const existingFile = Bun.file(statePath);
5210
+ if (await existingFile.exists()) {
5211
+ try {
5212
+ const existingContent = await existingFile.text();
5213
+ if (existingContent.trim()) {
5214
+ const backupPath = `${stateDir}/${BACKUP_FILE}`;
5215
+ await Bun.write(backupPath, existingContent);
5216
+ }
5217
+ } catch {}
5218
+ }
5219
+ const versionedState = { ...state, _version: STATE_VERSION };
5220
+ const content = JSON.stringify(versionedState, null, 2);
4996
5221
  await Bun.write(statePath, content);
4997
- logger5.info("Saved project state", { projectKey: state.projectKey });
5222
+ logger5.info("Saved project state", { projectKey: state.projectKey, version: STATE_VERSION });
4998
5223
  }
4999
5224
  async function updateProjectState(directory, updates) {
5000
5225
  const existing = await loadProjectState(directory);
@@ -5063,7 +5288,7 @@ ${entry}
5063
5288
  await Bun.write(gitignorePath, newContent);
5064
5289
  logger5.info("Added SonarQube exclusion to .gitignore");
5065
5290
  }
5066
- var logger5, STATE_DIR = ".sonarqube", STATE_FILE = "project.json";
5291
+ var logger5, STATE_DIR = ".sonarqube", STATE_FILE = "project.json", BACKUP_FILE = "project.json.backup", STATE_VERSION = 2;
5067
5292
  var init_state = __esm(() => {
5068
5293
  init_types2();
5069
5294
  init_logger();
@@ -19952,7 +20177,51 @@ Please check:
19952
20177
  return msg;
19953
20178
  },
19954
20179
  connectionError(serverUrl) {
19955
- return `Cannot connect to SonarQube server at ${serverUrl}`;
20180
+ return `Cannot connect to SonarQube server at ${serverUrl}
20181
+
20182
+ **The server appears to be offline or unreachable.**
20183
+
20184
+ Possible causes:
20185
+ 1. Server is down or restarting
20186
+ 2. Network connectivity issues (VPN, firewall)
20187
+ 3. Incorrect URL in configuration
20188
+
20189
+ What you can do:
20190
+ - Continue coding - the plugin will auto-retry when the server is available
20191
+ - Check server status manually: curl -s "${serverUrl}/api/system/status"
20192
+ - Verify your network connection
20193
+ - Try again later with: sonarqube({ action: "status" })
20194
+
20195
+ Your last analysis results are preserved in .sonarqube/project.json`;
20196
+ },
20197
+ timeoutError(action, timeoutMs) {
20198
+ return `Action \`${action}\` timed out after ${timeoutMs / 1000} seconds.
20199
+
20200
+ This usually means:
20201
+ 1. The SonarQube server is under heavy load
20202
+ 2. Network latency is very high
20203
+ 3. The analysis is taking longer than expected
20204
+
20205
+ Suggestions:
20206
+ - Try again in a few minutes
20207
+ - For large projects, analysis may take several minutes
20208
+ - Check server health: sonarqube({ action: "status" })`;
20209
+ },
20210
+ offlineMode(cachedData) {
20211
+ let msg = `**Operating in offline mode** - SonarQube server is not reachable.`;
20212
+ if (cachedData?.lastAnalysis) {
20213
+ msg += `
20214
+
20215
+ Last known status (from ${cachedData.lastAnalysis}):`;
20216
+ if (cachedData.qualityGate) {
20217
+ msg += `
20218
+ - Quality Gate: ${cachedData.qualityGate}`;
20219
+ }
20220
+ }
20221
+ msg += `
20222
+
20223
+ The plugin will automatically reconnect when the server is available.`;
20224
+ return msg;
19956
20225
  },
19957
20226
  authenticationError(reason) {
19958
20227
  return `Authentication failed: ${reason}`;
@@ -20713,80 +20982,94 @@ var SonarQubeToolArgsSchema = exports_external2.object({
20713
20982
  resolution: exports_external2.enum(["SAFE", "FIXED", "ACKNOWLEDGED"]).optional().describe("Hotspot review resolution: SAFE (no risk), FIXED (remediated), ACKNOWLEDGED (accepted risk)"),
20714
20983
  comment: exports_external2.string().optional().describe("Review comment explaining the decision")
20715
20984
  });
20716
- async function executeSonarQubeTool(args, context) {
20717
- const directory = context.directory ?? process.cwd();
20718
- const rawConfig = context.config?.["sonarqube"] ?? context.config;
20719
- const config3 = loadConfig(rawConfig);
20720
- if (!config3) {
20721
- return formatError2(ErrorMessages.configurationMissing("SonarQube configuration not found.", [
20722
- "Set environment variables: SONAR_HOST_URL, SONAR_USER, and SONAR_PASSWORD",
20723
- "Or create a .sonarqube/config.json file in your project",
20724
- "Or add plugin configuration in opencode.json"
20725
- ]) + `
20985
+ function getMissingConfigError() {
20986
+ return formatError2(ErrorMessages.configurationMissing("SonarQube configuration not found.", [
20987
+ "Set environment variables: SONAR_HOST_URL, SONAR_USER, and SONAR_PASSWORD",
20988
+ "Or create a .sonarqube/config.json file in your project",
20989
+ "Or add plugin configuration in opencode.json"
20990
+ ]) + `
20726
20991
 
20727
20992
  Required environment variables:
20728
20993
  - SONAR_HOST_URL (e.g., https://sonarqube.company.com)
20729
20994
  - SONAR_USER (e.g., admin)
20730
20995
  - SONAR_PASSWORD (password or token)`);
20996
+ }
20997
+ async function ensureBootstrapped(config3, directory) {
20998
+ if (!await needsBootstrap(directory)) {
20999
+ return null;
21000
+ }
21001
+ logger10.info("First run detected, running bootstrap");
21002
+ const { bootstrap: bootstrap2 } = await Promise.resolve().then(() => (init_bootstrap(), exports_bootstrap));
21003
+ const result = await bootstrap2({ config: config3, directory });
21004
+ if (!result.success) {
21005
+ return formatError2(`Setup Failed
21006
+
21007
+ ${result.message}`);
21008
+ }
21009
+ logger10.info("Bootstrap completed", { projectKey: result.projectKey });
21010
+ return null;
21011
+ }
21012
+ async function routeAction(ctx, args) {
21013
+ const handlers = {
21014
+ analyze: () => handleAnalyze(ctx, args),
21015
+ issues: () => handleIssues(ctx, args),
21016
+ newissues: () => handleNewIssues(ctx, args),
21017
+ status: () => handleStatus(ctx),
21018
+ validate: () => handleValidate(ctx),
21019
+ hotspots: () => handleHotspots(ctx),
21020
+ reviewhotspot: () => handleReviewHotspot(ctx, args.hotspotKey, args.resolution, args.comment),
21021
+ duplications: () => handleDuplications(ctx),
21022
+ rule: () => handleRule(ctx, args.ruleKey),
21023
+ history: () => handleHistory(ctx, args.branch),
21024
+ profile: () => handleProfile(ctx),
21025
+ branches: () => handleBranches(ctx),
21026
+ metrics: () => handleMetrics(ctx, args.branch),
21027
+ worstfiles: () => handleWorstFiles(ctx)
21028
+ };
21029
+ const handler = handlers[args.action];
21030
+ return handler ? handler() : formatError2(`Unknown action: ${args.action}`);
21031
+ }
21032
+ function handleExecutionError(error45, action, serverUrl) {
21033
+ const errorMessage = error45 instanceof Error ? error45.message : String(error45);
21034
+ logger10.error(`Tool execution failed: ${errorMessage}`);
21035
+ if (!(error45 instanceof Error) || !("code" in error45)) {
21036
+ return formatError2(ErrorMessages.apiError(action, errorMessage, serverUrl));
21037
+ }
21038
+ const errorCode = error45.code;
21039
+ if (errorCode === "CONNECTION_ERROR" || errorMessage.includes("Cannot connect")) {
21040
+ return formatError2(ErrorMessages.connectionError(serverUrl));
21041
+ }
21042
+ if (errorMessage.includes("timed out")) {
21043
+ return formatError2(ErrorMessages.timeoutError(action, 30000));
21044
+ }
21045
+ if (errorCode === "AUTH_ERROR") {
21046
+ return formatError2(ErrorMessages.authenticationError(errorMessage));
21047
+ }
21048
+ return formatError2(ErrorMessages.apiError(action, errorMessage, serverUrl));
21049
+ }
21050
+ async function executeSonarQubeTool(args, context) {
21051
+ const directory = context.directory ?? process.cwd();
21052
+ const rawConfig = context.config?.["sonarqube"] ?? context.config;
21053
+ const config3 = loadConfig(rawConfig);
21054
+ if (!config3) {
21055
+ return getMissingConfigError();
20731
21056
  }
20732
21057
  logger10.info(`Executing SonarQube tool: ${args.action}`, { directory });
20733
21058
  try {
20734
21059
  if (args.action === "init" || args.action === "setup") {
20735
21060
  return await handleSetup(config3, directory, args.force);
20736
21061
  }
20737
- if (await needsBootstrap(directory)) {
20738
- logger10.info("First run detected, running bootstrap");
20739
- const { bootstrap: bootstrap2 } = await Promise.resolve().then(() => (init_bootstrap(), exports_bootstrap));
20740
- const setupResult = await bootstrap2({ config: config3, directory });
20741
- if (!setupResult.success) {
20742
- return formatError2(`Setup Failed
20743
-
20744
- ${setupResult.message}`);
20745
- }
20746
- logger10.info("Bootstrap completed", { projectKey: setupResult.projectKey });
20747
- }
21062
+ const bootstrapError = await ensureBootstrapped(config3, directory);
21063
+ if (bootstrapError)
21064
+ return bootstrapError;
20748
21065
  const state = await getProjectState(directory);
20749
21066
  if (!state) {
20750
21067
  return formatError2(`Project not initialized. Run with action: "setup" first.`);
20751
21068
  }
20752
- const projectKey = args.projectKey ?? state.projectKey;
20753
- const ctx = createHandlerContext(config3, state, projectKey, directory);
20754
- switch (args.action) {
20755
- case "analyze":
20756
- return await handleAnalyze(ctx, args);
20757
- case "issues":
20758
- return await handleIssues(ctx, args);
20759
- case "newissues":
20760
- return await handleNewIssues(ctx, args);
20761
- case "status":
20762
- return await handleStatus(ctx);
20763
- case "validate":
20764
- return await handleValidate(ctx);
20765
- case "hotspots":
20766
- return await handleHotspots(ctx);
20767
- case "reviewhotspot":
20768
- return await handleReviewHotspot(ctx, args.hotspotKey, args.resolution, args.comment);
20769
- case "duplications":
20770
- return await handleDuplications(ctx);
20771
- case "rule":
20772
- return await handleRule(ctx, args.ruleKey);
20773
- case "history":
20774
- return await handleHistory(ctx, args.branch);
20775
- case "profile":
20776
- return await handleProfile(ctx);
20777
- case "branches":
20778
- return await handleBranches(ctx);
20779
- case "metrics":
20780
- return await handleMetrics(ctx, args.branch);
20781
- case "worstfiles":
20782
- return await handleWorstFiles(ctx);
20783
- default:
20784
- return formatError2(`Unknown action: ${args.action}`);
20785
- }
21069
+ const ctx = createHandlerContext(config3, state, args.projectKey ?? state.projectKey, directory);
21070
+ return await routeAction(ctx, args);
20786
21071
  } catch (error45) {
20787
- const errorMessage = error45 instanceof Error ? error45.message : String(error45);
20788
- logger10.error(`Tool execution failed: ${errorMessage}`);
20789
- return formatError2(ErrorMessages.apiError(args.action, errorMessage, config3.url));
21072
+ return handleExecutionError(error45, args.action, config3.url);
20790
21073
  }
20791
21074
  }
20792
21075
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-sonarqube",
3
- "version": "2.1.3",
3
+ "version": "2.2.0",
4
4
  "description": "OpenCode Plugin for SonarQube integration - Enterprise-level code quality from the start",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -38,7 +38,6 @@
38
38
  "homepage": "https://github.com/mguttmann/opencode-sonarqube#readme",
39
39
  "dependencies": {
40
40
  "@opencode-ai/plugin": "^1.1.34",
41
- "opencode-sonarqube": "latest",
42
41
  "zod": "^3.24.0"
43
42
  },
44
43
  "devDependencies": {