Files
dotfiles/local/bin/x-sonarcloud
Ismo Vuorinen cff3d1dd8a feat(scripts): add x-sonarcloud script for LLM-driven issue analysis
Bridges LLM agents with SonarCloud's REST API to fetch and format
code quality issues as structured markdown with processing instructions.
2026-02-07 13:24:29 +02:00

624 lines
16 KiB
Bash
Executable File

#!/usr/bin/env bash
# x-sonarcloud - Fetch SonarCloud issues for LLM analysis
# Copyright (c) 2025 - Licensed under MIT
#
# Usage:
# x-sonarcloud # Auto-detect, all open issues
# x-sonarcloud --pr <number> # PR-specific issues
# x-sonarcloud --branch <name> # Branch-specific issues
# x-sonarcloud --org <org> --project-key <key> # Explicit project
# x-sonarcloud --severities BLOCKER,CRITICAL # Filter by severity
# x-sonarcloud --types BUG,VULNERABILITY # Filter by type
# x-sonarcloud --statuses OPEN,CONFIRMED # Filter by status
# x-sonarcloud --resolved # Include resolved issues
# x-sonarcloud -h|--help # Show this help
#
# Examples:
# x-sonarcloud # All open issues in project
# x-sonarcloud --pr 42 # Issues on PR #42
# x-sonarcloud --branch main # Issues on main branch
# x-sonarcloud --severities BLOCKER --types BUG # Only blocker bugs
#
# Requirements:
# - curl and jq installed
# - SONAR_TOKEN environment variable set
# - sonar-project.properties or .sonarlint/connectedMode.json for auto-detection
set -euo pipefail
# Colors for output (stderr only)
readonly RED='\033[0;31m'
readonly GREEN='\033[0;32m'
readonly YELLOW='\033[1;33m'
readonly BLUE='\033[0;34m'
readonly NC='\033[0m' # No Color
# API constants
readonly MAX_PAGE_SIZE=500
readonly MAX_TOTAL_ISSUES=10000
# Show usage information
show_usage()
{
sed -n '3,27p' "$0" | sed 's/^# //' | sed 's/^#//'
}
# Log functions
log_error()
{
echo -e "${RED}ERROR:${NC} $1" >&2
}
log_warn()
{
echo -e "${YELLOW}WARN:${NC} $1" >&2
}
log_info()
{
if [[ "${INFO:-0}" == "1" ]]; then
echo -e "${GREEN}INFO:${NC} $1" >&2
fi
}
log_debug()
{
if [[ "${DEBUG:-0}" == "1" ]]; then
echo -e "${BLUE}DEBUG:${NC} $1" >&2
fi
}
# Check required dependencies
check_dependencies()
{
local missing=0
if ! command -v curl &> /dev/null; then
log_error "curl is not installed. Install it with your package manager."
missing=1
fi
if ! command -v jq &> /dev/null; then
log_error "jq is not installed. Install it with your package manager:"
log_error " https://jqlang.github.io/jq/download/"
missing=1
fi
if [[ "$missing" -eq 1 ]]; then
exit 1
fi
}
# Check authentication
check_auth()
{
if [[ -z "${SONAR_TOKEN:-}" ]]; then
log_error "SONAR_TOKEN environment variable is not set."
log_error "Generate a token at: https://sonarcloud.io/account/security"
log_error "Then export it: export SONAR_TOKEN=your_token_here"
exit 1
fi
}
# Detect project from sonar-project.properties
detect_project_from_properties()
{
local props_file="sonar-project.properties"
if [[ ! -f "$props_file" ]]; then
return 1
fi
local org key
org=$(grep -E '^sonar\.organization=' "$props_file" 2> /dev/null | cut -d'=' -f2- || echo "")
key=$(grep -E '^sonar\.projectKey=' "$props_file" 2> /dev/null | cut -d'=' -f2- || echo "")
if [[ -n "$org" && -n "$key" ]]; then
log_debug "Detected from sonar-project.properties: org=$org key=$key"
echo "$org" "$key" ""
return 0
fi
return 1
}
# Detect project from .sonarlint/connectedMode.json
detect_project_from_sonarlint()
{
local sonarlint_file=".sonarlint/connectedMode.json"
if [[ ! -f "$sonarlint_file" ]]; then
return 1
fi
local org key region
org=$(jq -r '.sonarCloudOrganization // empty' "$sonarlint_file" 2> /dev/null || echo "")
key=$(jq -r '.projectKey // empty' "$sonarlint_file" 2> /dev/null || echo "")
region=$(jq -r '.region // empty' "$sonarlint_file" 2> /dev/null || echo "")
if [[ -n "$org" && -n "$key" ]]; then
log_debug "Detected from .sonarlint/connectedMode.json: org=$org key=$key region=$region"
echo "$org" "$key" "$region"
return 0
fi
return 1
}
# Orchestrate project detection in priority order
detect_project()
{
local result
# 1. sonar-project.properties
if result=$(detect_project_from_properties); then
echo "$result"
return 0
fi
# 2. .sonarlint/connectedMode.json
if result=$(detect_project_from_sonarlint); then
echo "$result"
return 0
fi
# No config found
log_error "Could not auto-detect SonarCloud project configuration."
log_error "Provide one of the following:"
log_error " 1. sonar-project.properties with sonar.organization and sonar.projectKey"
log_error " 2. .sonarlint/connectedMode.json with sonarCloudOrganization and projectKey"
log_error " 3. CLI flags: --org <org> --project-key <key>"
return 1
}
# Get API base URL (currently same for all regions)
get_base_url()
{
echo "https://sonarcloud.io"
}
# Make an authenticated SonarCloud API request
sonar_api_request()
{
local url="$1"
log_debug "API request: $url"
local http_code body response
response=$(curl -s -w "\n%{http_code}" \
-H "Authorization: Bearer $SONAR_TOKEN" \
"$url" 2> /dev/null) || {
log_error "curl request failed for: $url"
return 1
}
http_code=$(echo "$response" | tail -n1)
body=$(echo "$response" | sed '$d')
case "$http_code" in
200)
echo "$body"
return 0
;;
401)
log_error "Authentication failed (HTTP 401). Check your SONAR_TOKEN."
return 1
;;
403)
log_error "Access forbidden (HTTP 403). Token may lack required permissions."
return 1
;;
404)
log_error "Not found (HTTP 404). Check organization and project key."
return 1
;;
429)
log_error "Rate limited (HTTP 429). Wait before retrying."
return 1
;;
*)
log_error "API request failed with HTTP $http_code"
log_debug "Response body: $body"
return 1
;;
esac
}
# Fetch a single page of issues
fetch_issues_page()
{
local base_url="$1"
local project_key="$2"
local page="$3"
local pr_number="${4:-}"
local branch="${5:-}"
local severities="${6:-}"
local types="${7:-}"
local statuses="${8:-}"
local resolved="${9:-}"
local url="${base_url}/api/issues/search?componentKeys=${project_key}"
url="${url}&p=${page}&ps=${MAX_PAGE_SIZE}"
if [[ -n "$pr_number" ]]; then
url="${url}&pullRequest=${pr_number}"
fi
if [[ -n "$branch" ]]; then
url="${url}&branch=${branch}"
fi
if [[ -n "$severities" ]]; then
url="${url}&severities=${severities}"
fi
if [[ -n "$types" ]]; then
url="${url}&types=${types}"
fi
if [[ -n "$statuses" ]]; then
url="${url}&statuses=${statuses}"
fi
if [[ -n "$resolved" ]]; then
url="${url}&resolved=${resolved}"
fi
sonar_api_request "$url"
}
# Fetch all issues with pagination
fetch_all_issues()
{
local base_url="$1"
local project_key="$2"
local pr_number="${3:-}"
local branch="${4:-}"
local severities="${5:-}"
local types="${6:-}"
local statuses="${7:-}"
local resolved="${8:-}"
local page=1
local all_issues="[]"
local total=0
while true; do
log_info "Fetching issues page $page..."
local response
response=$(fetch_issues_page "$base_url" "$project_key" "$page" \
"$pr_number" "$branch" "$severities" "$types" "$statuses" "$resolved") || return 1
local page_issues page_total
page_issues=$(echo "$response" | jq '.issues // []' 2> /dev/null || echo "[]")
page_total=$(echo "$response" | jq '.total // 0' 2> /dev/null || echo "0")
local page_count
page_count=$(echo "$page_issues" | jq 'length' 2> /dev/null || echo "0")
log_debug "Page $page: $page_count issues (total available: $page_total)"
# Merge into accumulated results
all_issues=$(echo "$all_issues" "$page_issues" | jq -s '.[0] + .[1]' 2> /dev/null || echo "$all_issues")
total=$(echo "$all_issues" | jq 'length' 2> /dev/null || echo "0")
# Check if we have all issues or hit the cap
if [[ "$page_count" -lt "$MAX_PAGE_SIZE" ]]; then
break
fi
if [[ "$total" -ge "$MAX_TOTAL_ISSUES" ]]; then
log_warn "Reached maximum of $MAX_TOTAL_ISSUES issues. Results may be incomplete."
break
fi
page=$((page + 1))
done
log_info "Fetched $total issues total"
echo "$all_issues"
}
# Format issues grouped by severity then by file
format_issues_by_severity()
{
local issues="$1"
local base_url="$2"
local org="$3"
local project_key="$4"
echo "$issues" | jq -r --arg base_url "$base_url" --arg org "$org" --arg key "$project_key" '
group_by(.severity) | sort_by(-(
if .[0].severity == "BLOCKER" then 5
elif .[0].severity == "CRITICAL" then 4
elif .[0].severity == "MAJOR" then 3
elif .[0].severity == "MINOR" then 2
elif .[0].severity == "INFO" then 1
else 0 end
)) | .[] |
"### Severity: \(.[0].severity)\n" +
(
group_by(.component) | .[] |
"#### File: \(.[0].component | split(":") | if length > 1 then .[1:] | join(":") else .[0] end)\n" +
(
[.[] |
"##### Issue: \(.message)\n" +
"- **Rule:** \(.rule)\n" +
"- **Type:** \(.type)\n" +
"- **Severity:** \(.severity)\n" +
"- **Status:** \(.status)\n" +
"- **Line:** \(.line // "N/A")\n" +
"- **Effort:** \(.effort // "N/A")\n" +
"- **Created:** \(.creationDate // "N/A")\n" +
"- **URL:** \($base_url)/project/issues?open=\(.key)&id=\($key)\n"
] | join("\n")
)
)
' 2> /dev/null || echo "Error formatting issues."
}
# Format summary counts
format_summary()
{
local issues="$1"
echo "### By Severity"
echo ""
echo "$issues" | jq -r '
group_by(.severity) | .[] |
"- **\(.[0].severity):** \(length)"
' 2> /dev/null || echo "- Error computing severity counts"
echo ""
echo "### By Type"
echo ""
echo "$issues" | jq -r '
group_by(.type) | .[] |
"- **\(.[0].type):** \(length)"
' 2> /dev/null || echo "- Error computing type counts"
echo ""
echo "### Total"
echo ""
local count
count=$(echo "$issues" | jq 'length' 2> /dev/null || echo "0")
echo "- **Total issues:** $count"
}
# Format the full markdown output
format_output()
{
local org="$1"
local project_key="$2"
local mode="$3"
local mode_value="$4"
local base_url="$5"
local issues="$6"
local issue_count
issue_count=$(echo "$issues" | jq 'length' 2> /dev/null || echo "0")
# Header and LLM instructions
cat << 'EOF'
# SonarCloud Issues Analysis Report
## LLM Processing Instructions
You are analyzing code quality issues from SonarCloud for this project.
**Your tasks:**
1. **Triage**: Review each issue and assess its real impact on the codebase
2. **Priority Assessment**: Rank issues by severity and likelihood of causing problems
3. **Code Verification**: Check the actual source code to confirm each issue is valid
4. **Root Cause Analysis**: Identify why the issue exists and what pattern caused it
5. **Implementation Plan**: Create actionable fix tasks grouped by file for efficiency
6. **False Positive Detection**: Flag issues that appear to be false positives with reasoning
**Tools to use:**
- `find`, `cat`, `rg` commands and available tools to examine current codebase
- `git log` and `git blame` to understand code history and authorship
- File system tools to verify mentioned files exist and check current state
EOF
# Project information
cat << EOF
## Project Information
- **Organization:** $org
- **Project Key:** $project_key
EOF
case "$mode" in
pr)
echo "- **Mode:** Pull Request #$mode_value"
echo "- **URL:** ${base_url}/project/issues?pullRequest=${mode_value}&id=${project_key}"
;;
branch)
echo "- **Mode:** Branch \`$mode_value\`"
echo "- **URL:** ${base_url}/project/issues?branch=${mode_value}&id=${project_key}"
;;
*)
echo "- **Mode:** Project (all open issues)"
echo "- **URL:** ${base_url}/project/issues?id=${project_key}"
;;
esac
echo "- **Dashboard:** ${base_url}/project/overview?id=${project_key}"
# Issues section
echo ""
echo "## Issues ($issue_count total)"
echo ""
if [[ "$issue_count" -eq 0 ]]; then
echo "No issues found matching the specified filters."
else
format_issues_by_severity "$issues" "$base_url" "$org" "$project_key"
echo ""
echo "## Summary"
echo ""
format_summary "$issues"
fi
# Footer
cat << 'EOF'
## Next Steps for LLM Analysis
1. **Validate against current code:**
- Check if mentioned files and lines still match the reported issues
- Verify issues are not already fixed in the current branch
- Identify false positives and explain why they are false positives
2. **Prioritize fixes:**
- Address BLOCKER and CRITICAL severity issues first
- Group fixes by file to minimize context switching
- Consider effort estimates when planning the fix order
3. **Group by file for implementation:**
- Batch changes to the same file together
- Consider dependencies between fixes
- Plan atomic commits per logical change group
4. **Track progress:**
- Use todo lists and memory tools to track which issues are addressed
- Mark false positives with clear reasoning
- Verify fixes do not introduce new issues
EOF
}
# Main pipeline: fetch and display issues
fetch_and_display_issues()
{
local org="$1"
local project_key="$2"
local mode="$3"
local mode_value="$4"
local severities="${5:-}"
local types="${6:-}"
local statuses="${7:-}"
local resolved="${8:-}"
local base_url
base_url=$(get_base_url)
local pr_number=""
local branch=""
case "$mode" in
pr)
pr_number="$mode_value"
;;
branch)
branch="$mode_value"
;;
esac
log_info "Fetching SonarCloud issues for $project_key (mode: $mode)..."
local issues
issues=$(fetch_all_issues "$base_url" "$project_key" \
"$pr_number" "$branch" "$severities" "$types" "$statuses" "$resolved") || {
log_error "Failed to fetch issues"
return 1
}
format_output "$org" "$project_key" "$mode" "$mode_value" "$base_url" "$issues"
}
# Main function
main()
{
local org=""
local project_key=""
local mode="project"
local mode_value=""
local severities=""
local types=""
local statuses="OPEN,CONFIRMED,REOPENED"
local resolved="false"
# Parse arguments
while [[ $# -gt 0 ]]; do
case "$1" in
-h | --help)
show_usage
exit 0
;;
--pr)
mode="pr"
mode_value="${2:?Missing PR number after --pr}"
shift 2
;;
--branch)
mode="branch"
mode_value="${2:?Missing branch name after --branch}"
shift 2
;;
--org)
org="${2:?Missing organization after --org}"
shift 2
;;
--project-key)
project_key="${2:?Missing project key after --project-key}"
shift 2
;;
--severities)
severities="${2:?Missing severities after --severities}"
shift 2
;;
--types)
types="${2:?Missing types after --types}"
shift 2
;;
--statuses)
statuses="${2:?Missing statuses after --statuses}"
shift 2
;;
--resolved)
resolved="true"
statuses=""
shift
;;
*)
log_error "Unknown argument: $1"
show_usage
exit 1
;;
esac
done
check_dependencies
check_auth
# Auto-detect project if not specified via CLI
if [[ -z "$org" || -z "$project_key" ]]; then
local detected
detected=$(detect_project) || exit 1
# shellcheck disable=SC2034 # region reserved for future per-region base URLs
read -r detected_org detected_key detected_region <<< "$detected"
if [[ -z "$org" ]]; then
org="$detected_org"
fi
if [[ -z "$project_key" ]]; then
project_key="$detected_key"
fi
fi
log_debug "Organization: $org"
log_debug "Project Key: $project_key"
log_debug "Mode: $mode"
log_debug "Severities: ${severities:-all}"
log_debug "Types: ${types:-all}"
log_debug "Statuses: ${statuses:-all}"
log_debug "Resolved: $resolved"
fetch_and_display_issues "$org" "$project_key" "$mode" "$mode_value" \
"$severities" "$types" "$statuses" "$resolved"
}
# Run main function with all arguments
main "$@"