mirror of
https://github.com/ivuorinen/dotfiles.git
synced 2026-02-08 03:50:35 +00:00
Bridges LLM agents with SonarCloud's REST API to fetch and format code quality issues as structured markdown with processing instructions.
624 lines
16 KiB
Bash
Executable File
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 "$@"
|