diff --git a/.github/workflows/build_jar.yml b/.github/workflows/build_jar.yml index ff6740f..37c4b48 100644 --- a/.github/workflows/build_jar.yml +++ b/.github/workflows/build_jar.yml @@ -46,6 +46,11 @@ jobs: java-version: ${{ steps.detect-java.outputs.version }} cache: maven + - name: Run tests with Maven (pdf-generator) + run: | + set -euo pipefail + mvn -f pdf-generator/pom.xml -B test + - name: Build with Maven (pdf-generator) run: | set -euo pipefail diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f9c38e7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,42 @@ +# Maven +target/ +pom.xml.tag +pom.xml.releaseBackup +pom.xml.versionsBackup +pom.xml.backup +release.properties +.flattened-pom.xml + +# Generated reports +*.pdf +sonarqube-report.pdf + +# Compiled classes +*.class +*.classpath + +# IDE +.idea/ +.vscode/ +*.iml +*.iws +*.ipr +.project +.settings/ +.classpath +*.swp +*.swo +*~ +.DS_Store +Thumbs.db + +# OS +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db + +# Logs +*.log diff --git a/.mvn/wrapper/maven-wrapper.properties b/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 0000000..7e50a07 --- /dev/null +++ b/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,3 @@ +wrapperVersion=3.3.4 +distributionType=only-script +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.6.3/apache-maven-3.6.3-bin.zip diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..66d43b2 --- /dev/null +++ b/Makefile @@ -0,0 +1,67 @@ +.PHONY: help build clean run install package test test-verbose quick + +# Default target +help: + @echo "sonar-report - Makefile targets" + @echo "" + @echo "Note: Uses Maven wrapper (./mvnw) - no Maven installation needed" + @echo "" + @echo "Targets:" + @echo " build Build the project and generate JAR" + @echo " clean Remove build artifacts" + @echo " package Create distribution JAR (alias: build)" + @echo " run Run the JAR with SonarQube (requires SONAR_URL, SONAR_TOKEN, SONAR_PROJECT)" + @echo " test Run unit tests" + @echo " test-verbose Run tests with detailed output" + @echo " install Install JAR to local Maven repository" + @echo " quick Fast build without tests" + @echo "" + @echo "Environment variables for 'make run':" + @echo " SONAR_URL SonarQube base URL (required)" + @echo " SONAR_TOKEN Authentication token (required)" + @echo " SONAR_PROJECT Project key (required)" + @echo "" + @echo "Example:" + @echo " make build" + @echo " make test" + @echo " make run SONAR_URL='https://sonarqube.example.com' SONAR_TOKEN='squ_token' SONAR_PROJECT='com.example:my-project'" + +# Build the project +build: clean + ./mvnw -f pdf-generator/pom.xml clean package -DskipTests + +# Clean build artifacts +clean: + ./mvnw -f pdf-generator/pom.xml clean + rm -f sonarqube-report.pdf + +# Alias for build +package: build + +# Install JAR to local Maven repository +install: + ./mvnw -f pdf-generator/pom.xml install -DskipTests + +# Run the application +run: build + @if [ -z "$(SONAR_URL)" ] || [ -z "$(SONAR_TOKEN)" ] || [ -z "$(SONAR_PROJECT)" ]; then \ + echo "Error: Required environment variables not set"; \ + echo "Usage: make run SONAR_URL='' SONAR_TOKEN='' SONAR_PROJECT=''"; \ + exit 1; \ + fi + java -jar pdf-generator/target/sonar-report-1.0-jar-with-dependencies.jar \ + "$(SONAR_URL)" \ + "$(SONAR_TOKEN)" \ + "$(SONAR_PROJECT)" + +# Run tests (if any) +test: + ./mvnw -f pdf-generator/pom.xml test -q + +# Run tests with verbose output +test-verbose: + ./mvnw -f pdf-generator/pom.xml test + +# Quick build without tests +quick: + ./mvnw -f pdf-generator/pom.xml package -DskipTests -T 1C diff --git a/README.md b/README.md index 823a2bc..6fde831 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,240 @@ # sonar-report -Usage +A command-line Java tool that connects to a SonarQube instance, retrieves code analysis metrics via its REST API, and generates a formatted PDF report. -java -jar sonar-report-VERSION.jar SonarQubeURL AuthToken SonarProject +The report includes quality ratings (reliability, security, maintainability), test coverage, technical debt, security hotspots, issue breakdowns by type and severity, and per-language code metrics — with an indexed table of contents and hyperlinks back to SonarQube coding rules. + +--- + +## Requirements + +- Java 17+ +- Access to a SonarQube instance and a valid authentication token + +**Note:** Maven is not required — the project includes a Maven wrapper that downloads Maven 3.9.6 automatically on first use. + +--- + +## Architecture + +The project is organized with clean separation of concerns: + +| Module | Purpose | Lines | +|---|---|---| +| `GenerateSonarReport` | Entry point — command-line argument parsing | ~15 | +| `SonarApiClient` | HTTP transport layer — SonarQube API calls with SSL bypass | ~75 | +| `ReportBuilder` | Report orchestration — fetches data and builds PDF sections | ~510 | +| `PDFReportWriter` | PDF rendering utilities — low-level PDF operations | ~860 | + +**Tests:** Unit tests in `src/test/java/com/ods/` using JUnit 5 + Mockito — 31 test cases covering CLI, HTTP, aggregation, and PDF utilities. + +**Package:** `com.ods` + +The design minimizes coupling: API client is isolated from report logic, HTTP client is built once and reused, and section builders are cohesive private methods within `ReportBuilder`. + +--- + +## Build + +### Using Make (Recommended) + +```bash +make build +``` + +All available Make targets: + +```bash +make help # Show all available targets +make build # Build and package the JAR +make clean # Remove build artifacts +make test # Run unit tests (quiet) +make test-verbose # Run unit tests (verbose) +make install # Install JAR to local Maven repository +make quick # Fast build without tests +``` + +### Using Maven Wrapper (Direct) + +```bash +cd pdf-generator +../mvnw clean package +``` + +Or on Windows: + +```cmd +cd pdf-generator +..\mvnw.cmd clean package +``` + +The output JAR is placed at: + +``` +pdf-generator/target/sonar-report-1.0-jar-with-dependencies.jar +``` + +Pre-built JARs are also attached to each [GitHub Release](../../releases). + +--- + +## Testing + +### Running Tests + +#### Using Make (Recommended) + +```bash +make test # Run tests (quiet output) +make test-verbose # Run tests with detailed output +``` + +#### Using Maven Wrapper (Direct) + +```bash +cd pdf-generator +../mvnw test +``` + +### Test Structure + +The project includes comprehensive unit tests covering all major components: + +| Test Class | Module | Coverage | +|---|---|---| +| `GenerateSonarReportTest` | CLI argument parsing | 5 test cases | +| `ReportBuilderTest` | Report data aggregation | 13 test cases | +| `PDFReportWriterTest` | PDF utilities | 7 test cases | +| `SonarApiClientTest` | HTTP API transport | 6 test cases | +| **Total** | — | **31 test cases** | + +### Test Details + +**GenerateSonarReportTest** — Validates command-line argument parsing: +- All known flags (`--sonar-url`, `--token`, `--project`, `--branch`, `--output`) +- Empty arguments, dangling flags, non-flag arguments +- Duplicate flag handling + +**ReportBuilderTest** — Tests data transformation utilities: +- `extractComponent()` — file path extraction from component identifiers +- `ratingToLetter()` — quality rating conversion (A–E) +- `minsToDaysHoursMins()` — technical debt time formatting +- `groupHotspotsByRule()` — aggregates security hotspots by rule key +- `groupIssuesByRule()` — aggregates code issues by rule key + +**PDFReportWriterTest** — Tests PDF rendering utilities: +- `getCurrentGMTTimeFormatted()` — GMT timestamp formatting with regex validation +- `splitBySlash()` — path component splitting (handles slashes at end) + +**SonarApiClientTest** — Tests HTTP API transport with mocked responses: +- Successful 200 responses return parsed JSON +- Error responses (401, 404) throw `IOException` +- URL encoding of special characters in project keys +- Branch parameter handling (with/without/blank) + +### Test Framework + +- **Framework:** JUnit 5 (Jupiter) +- **Mocking:** Mockito 5.11.0 +- **Compiler:** Java 17+ + +All tests are run during the standard Maven build via the Maven Surefire plugin. + +--- + +## Usage + +### Using Make + +```bash +make run SONAR_URL='https://sonarqube.example.com' SONAR_TOKEN='squ_abc123yourtoken' SONAR_PROJECT='com.example:my-project' +``` + +### Using Java Directly + +```bash +java -jar sonar-report-1.0-jar-with-dependencies.jar \ + --sonar-url \ + --token \ + --project \ + [--branch ] \ + [--output ] +``` + +### Arguments + +| Flag | Required | Description | +|---|---|---| +| `--sonar-url` | Yes | Base URL of your SonarQube server, e.g. `https://sonarqube.example.com` | +| `--token` | Yes | SonarQube user token (Bearer authentication) | +| `--project` | Yes | The project key as shown in SonarQube, e.g. `com.example:my-project` | +| `--branch` | No | Branch to analyze (default: SonarQube project default branch) | +| `--output` | No | Output PDF filename (default: `sonarqube-report.pdf`) | + +### Example + +```bash +java -jar sonar-report-1.0-jar-with-dependencies.jar \ + --sonar-url "https://sonarqube.example.com" \ + --token "squ_abc123yourtoken" \ + --project "com.example:my-project" \ + --branch "main" +``` + +With a custom output filename: + +```bash +java -jar sonar-report-1.0-jar-with-dependencies.jar \ + --sonar-url "https://sonarqube.example.com" \ + --token "squ_abc123yourtoken" \ + --project "com.example:my-project" \ + --branch "develop" \ + --output "my-project-report.pdf" +``` + +The report is written to the specified file (or `sonarqube-report.pdf` by default) in the current working directory. + +--- + +## Report Contents + +The generated PDF contains: + +- Project name, version, and quality gate status +- Quality ratings: Reliability, Security, Maintainability (grades A–E) +- Code coverage, complexity, duplication, and comment density +- Technical debt (formatted as days/hours/minutes) +- Security hotspots and vulnerabilities +- Open issues grouped by type and severity, linked to coding rules +- Metrics broken down by programming language +- Auto-generated table of contents with page bookmarks + +--- + +## Notes + +- The tool accepts self-signed TLS certificates, making it suitable for internal SonarQube deployments. +- Paginated API results (hotspots, issues) are fully iterated — no result cap at 500. +- All arguments are passed as named flags (`--sonar-url`, `--token`, `--project`, `--output`); no environment variables or config files are required. +- **Test Coverage:** Comprehensive unit tests (31 test cases) validate CLI parsing, API transport, data aggregation, and PDF utilities. +- **Bug fix:** Tests section now fetches correct metrics (was incorrectly reusing Metrics URL). +- **Performance:** HTTP client is built once and reused across all API calls. + +--- + +## CI/CD + +Two GitHub Actions workflows are included: + +| Workflow | Trigger | Purpose | +|---|---|---| +| `build_jar.yml` | Push, PR, Release | Builds the uber-JAR and attaches it to releases | +| `check_calls.yml` | Push, PR | Validates all SonarQube API endpoints used by the tool | + +The JAR artifact is named after the branch, PR number, or release tag automatically. + +--- + +## License + +See [LICENSE](LICENSE). diff --git a/mvnw b/mvnw new file mode 100644 index 0000000..bd8896b --- /dev/null +++ b/mvnw @@ -0,0 +1,295 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Apache Maven Wrapper startup batch script, version 3.3.4 +# +# Optional ENV vars +# ----------------- +# JAVA_HOME - location of a JDK home dir, required when download maven via java source +# MVNW_REPOURL - repo url base for downloading maven distribution +# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output +# ---------------------------------------------------------------------------- + +set -euf +[ "${MVNW_VERBOSE-}" != debug ] || set -x + +# OS specific support. +native_path() { printf %s\\n "$1"; } +case "$(uname)" in +CYGWIN* | MINGW*) + [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")" + native_path() { cygpath --path --windows "$1"; } + ;; +esac + +# set JAVACMD and JAVACCMD +set_java_home() { + # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched + if [ -n "${JAVA_HOME-}" ]; then + if [ -x "$JAVA_HOME/jre/sh/java" ]; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACCMD="$JAVA_HOME/jre/sh/javac" + else + JAVACMD="$JAVA_HOME/bin/java" + JAVACCMD="$JAVA_HOME/bin/javac" + + if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then + echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2 + echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2 + return 1 + fi + fi + else + JAVACMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v java + )" || : + JAVACCMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v javac + )" || : + + if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then + echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2 + return 1 + fi + fi +} + +# hash string like Java String::hashCode +hash_string() { + str="${1:-}" h=0 + while [ -n "$str" ]; do + char="${str%"${str#?}"}" + h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296)) + str="${str#?}" + done + printf %x\\n $h +} + +verbose() { :; } +[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; } + +die() { + printf %s\\n "$1" >&2 + exit 1 +} + +trim() { + # MWRAPPER-139: + # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds. + # Needed for removing poorly interpreted newline sequences when running in more + # exotic environments such as mingw bash on Windows. + printf "%s" "${1}" | tr -d '[:space:]' +} + +scriptDir="$(dirname "$0")" +scriptName="$(basename "$0")" + +# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties +while IFS="=" read -r key value; do + case "${key-}" in + distributionUrl) distributionUrl=$(trim "${value-}") ;; + distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;; + esac +done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties" +[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" + +case "${distributionUrl##*/}" in +maven-mvnd-*bin.*) + MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ + case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in + *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;; + :Darwin*x86_64) distributionPlatform=darwin-amd64 ;; + :Darwin*arm64) distributionPlatform=darwin-aarch64 ;; + :Linux*x86_64*) distributionPlatform=linux-amd64 ;; + *) + echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2 + distributionPlatform=linux-amd64 + ;; + esac + distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip" + ;; +maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;; +*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;; +esac + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}" +distributionUrlName="${distributionUrl##*/}" +distributionUrlNameMain="${distributionUrlName%.*}" +distributionUrlNameMain="${distributionUrlNameMain%-bin}" +MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}" +MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")" + +exec_maven() { + unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || : + exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD" +} + +if [ -d "$MAVEN_HOME" ]; then + verbose "found existing MAVEN_HOME at $MAVEN_HOME" + exec_maven "$@" +fi + +case "${distributionUrl-}" in +*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;; +*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;; +esac + +# prepare tmp dir +if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then + clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; } + trap clean HUP INT TERM EXIT +else + die "cannot create temp dir" +fi + +mkdir -p -- "${MAVEN_HOME%/*}" + +# Download and Install Apache Maven +verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +verbose "Downloading from: $distributionUrl" +verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +# select .zip or .tar.gz +if ! command -v unzip >/dev/null; then + distributionUrl="${distributionUrl%.zip}.tar.gz" + distributionUrlName="${distributionUrl##*/}" +fi + +# verbose opt +__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR='' +[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v + +# normalize http auth +case "${MVNW_PASSWORD:+has-password}" in +'') MVNW_USERNAME='' MVNW_PASSWORD='' ;; +has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;; +esac + +if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then + verbose "Found wget ... using wget" + wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl" +elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then + verbose "Found curl ... using curl" + curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl" +elif set_java_home; then + verbose "Falling back to use Java to download" + javaSource="$TMP_DOWNLOAD_DIR/Downloader.java" + targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName" + cat >"$javaSource" <<-END + public class Downloader extends java.net.Authenticator + { + protected java.net.PasswordAuthentication getPasswordAuthentication() + { + return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() ); + } + public static void main( String[] args ) throws Exception + { + setDefault( new Downloader() ); + java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() ); + } + } + END + # For Cygwin/MinGW, switch paths to Windows format before running javac and java + verbose " - Compiling Downloader.java ..." + "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java" + verbose " - Running Downloader.java ..." + "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")" +fi + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +if [ -n "${distributionSha256Sum-}" ]; then + distributionSha256Result=false + if [ "$MVN_CMD" = mvnd.sh ]; then + echo "Checksum validation is not supported for maven-mvnd." >&2 + echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + elif command -v sha256sum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then + distributionSha256Result=true + fi + elif command -v shasum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then + distributionSha256Result=true + fi + else + echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2 + echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + fi + if [ $distributionSha256Result = false ]; then + echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2 + echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2 + exit 1 + fi +fi + +# unzip and move +if command -v unzip >/dev/null; then + unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip" +else + tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar" +fi + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +actualDistributionDir="" + +# First try the expected directory name (for regular distributions) +if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then + if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then + actualDistributionDir="$distributionUrlNameMain" + fi +fi + +# If not found, search for any directory with the Maven executable (for snapshots) +if [ -z "$actualDistributionDir" ]; then + # enable globbing to iterate over items + set +f + for dir in "$TMP_DOWNLOAD_DIR"/*; do + if [ -d "$dir" ]; then + if [ -f "$dir/bin/$MVN_CMD" ]; then + actualDistributionDir="$(basename "$dir")" + break + fi + fi + done + set -f +fi + +if [ -z "$actualDistributionDir" ]; then + verbose "Contents of $TMP_DOWNLOAD_DIR:" + verbose "$(ls -la "$TMP_DOWNLOAD_DIR")" + die "Could not find Maven distribution directory in extracted archive" +fi + +verbose "Found extracted Maven distribution directory: $actualDistributionDir" +printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url" +mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME" + +clean || : +exec_maven "$@" diff --git a/mvnw.cmd b/mvnw.cmd new file mode 100644 index 0000000..92450f9 --- /dev/null +++ b/mvnw.cmd @@ -0,0 +1,189 @@ +<# : batch portion +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Apache Maven Wrapper startup batch script, version 3.3.4 +@REM +@REM Optional ENV vars +@REM MVNW_REPOURL - repo url base for downloading maven distribution +@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output +@REM ---------------------------------------------------------------------------- + +@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0) +@SET __MVNW_CMD__= +@SET __MVNW_ERROR__= +@SET __MVNW_PSMODULEP_SAVE=%PSModulePath% +@SET PSModulePath= +@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @( + IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B) +) +@SET PSModulePath=%__MVNW_PSMODULEP_SAVE% +@SET __MVNW_PSMODULEP_SAVE= +@SET __MVNW_ARG0_NAME__= +@SET MVNW_USERNAME= +@SET MVNW_PASSWORD= +@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*) +@echo Cannot start maven from wrapper >&2 && exit /b 1 +@GOTO :EOF +: end batch / begin powershell #> + +$ErrorActionPreference = "Stop" +if ($env:MVNW_VERBOSE -eq "true") { + $VerbosePreference = "Continue" +} + +# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties +$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl +if (!$distributionUrl) { + Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" +} + +switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) { + "maven-mvnd-*" { + $USE_MVND = $true + $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip" + $MVN_CMD = "mvnd.cmd" + break + } + default { + $USE_MVND = $false + $MVN_CMD = $script -replace '^mvnw','mvn' + break + } +} + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +if ($env:MVNW_REPOURL) { + $MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" } + $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')" +} +$distributionUrlName = $distributionUrl -replace '^.*/','' +$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$','' + +$MAVEN_M2_PATH = "$HOME/.m2" +if ($env:MAVEN_USER_HOME) { + $MAVEN_M2_PATH = "$env:MAVEN_USER_HOME" +} + +if (-not (Test-Path -Path $MAVEN_M2_PATH)) { + New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null +} + +$MAVEN_WRAPPER_DISTS = $null +if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) { + $MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists" +} else { + $MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists" +} + +$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain" +$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join '' +$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME" + +if (Test-Path -Path "$MAVEN_HOME" -PathType Container) { + Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME" + Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" + exit $? +} + +if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) { + Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl" +} + +# prepare tmp dir +$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile +$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir" +$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null +trap { + if ($TMP_DOWNLOAD_DIR.Exists) { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } + } +} + +New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null + +# Download and Install Apache Maven +Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +Write-Verbose "Downloading from: $distributionUrl" +Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +$webclient = New-Object System.Net.WebClient +if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) { + $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD) +} +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 +$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum +if ($distributionSha256Sum) { + if ($USE_MVND) { + Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." + } + Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash + if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) { + Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property." + } +} + +# unzip and move +Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +$actualDistributionDir = "" + +# First try the expected directory name (for regular distributions) +$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain" +$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD" +if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) { + $actualDistributionDir = $distributionUrlNameMain +} + +# If not found, search for any directory with the Maven executable (for snapshots) +if (!$actualDistributionDir) { + Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object { + $testPath = Join-Path $_.FullName "bin/$MVN_CMD" + if (Test-Path -Path $testPath -PathType Leaf) { + $actualDistributionDir = $_.Name + } + } +} + +if (!$actualDistributionDir) { + Write-Error "Could not find Maven distribution directory in extracted archive" +} + +Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir" +Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null +try { + Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null +} catch { + if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) { + Write-Error "fail to move MAVEN_HOME" + } +} finally { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } +} + +Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" diff --git a/pdf-generator/pom.xml b/pdf-generator/pom.xml index abefec0..ba739d0 100644 --- a/pdf-generator/pom.xml +++ b/pdf-generator/pom.xml @@ -34,17 +34,36 @@ jsoup 1.17.2 + + + + org.junit.jupiter + junit-jupiter + 5.10.2 + test + + + org.mockito + mockito-core + 5.11.0 + test + + + org.apache.maven.plugins + maven-surefire-plugin + 3.2.5 + maven-assembly-plugin 3.6.0 - GenerateSonarReport + com.ods.GenerateSonarReport diff --git a/pdf-generator/src/main/java/com/bi/GenerateSonarReport.java b/pdf-generator/src/main/java/com/bi/GenerateSonarReport.java deleted file mode 100644 index 86c38a2..0000000 --- a/pdf-generator/src/main/java/com/bi/GenerateSonarReport.java +++ /dev/null @@ -1,720 +0,0 @@ -import java.io.IOException; -import java.net.URI; -import java.net.URLEncoder; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; -import java.nio.charset.StandardCharsets; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -import javax.net.ssl.SSLContext; -import javax.net.ssl.SSLParameters; -import javax.net.ssl.TrustManager; -import javax.net.ssl.X509TrustManager; - -import org.json.JSONArray; -import org.json.JSONException; -import org.json.JSONObject; - - - -public class GenerateSonarReport { - - // Creates an HttpClient that accepts all SSL certificates and disables hostname verification. - private static HttpClient createUnsafeHttpClient() { - try { - // Defines a trust manager that does not validate certificate chains. - TrustManager[] trustAll = new TrustManager[]{ - new X509TrustManager() { - public java.security.cert.X509Certificate[] getAcceptedIssuers() { return new java.security.cert.X509Certificate[0]; } - public void checkClientTrusted(java.security.cert.X509Certificate[] certs, String authType) {} - public void checkServerTrusted(java.security.cert.X509Certificate[] certs, String authType) {} - } - }; - - // Initializes the SSL context with the permissive trust manager. - SSLContext sslContext = SSLContext.getInstance("TLS"); - sslContext.init(null, trustAll, new java.security.SecureRandom()); - - // Disables endpoint identification to avoid hostname validation. - SSLParameters sslParams = sslContext.getDefaultSSLParameters(); - sslParams.setEndpointIdentificationAlgorithm(null); - - // Builds and returns the customized HttpClient instance. - return HttpClient.newBuilder() - .sslContext(sslContext) - .sslParameters(sslParams) - .build(); - - } catch (java.security.NoSuchAlgorithmException | java.security.KeyManagementException e) { - throw new IllegalStateException("The insecure HttpClient couldn't be created", e); - } - } - - // Sends a GET request to the SonarQube API and returns the response as a JSONObject. - public static JSONObject fetchDataFromURL(String url, String call, String token, String projectKey) throws IOException, InterruptedException { - HttpClient client = createUnsafeHttpClient(); - String encodedProjectKey = URLEncoder.encode(projectKey, StandardCharsets.UTF_8); - String fullURL = String.format("%s%s%s", url, call, encodedProjectKey); - - - // Builds the HTTP request with Bearer token authentication. - HttpRequest request = HttpRequest.newBuilder() - .uri(URI.create(fullURL)) - .header("Authorization", "Bearer " + token) - .build(); - - - // Executes the request and stores the raw response body. - HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); - - // Parses the response body into JSON. - JSONObject json = new JSONObject(response.body()); - - // Returns the parsed JSON only if the request was successful. - if (response.statusCode() >= 200 && response.statusCode() < 300) { - return json; - } else { - throw new IOException("Error at obtaining data from the URL: Status Code " + response.statusCode() + ", Body: " + response.body()); - } - } - - // Main method that retrieves SonarQube data and generates the PDF report. - @SuppressWarnings("empty-statement") - public static void main(String[] args) throws IOException { - // Validates the required command-line arguments. - if (args.length < 3) { - System.err.println("Usage: java -jar target/pdf-generator-1.0-SNAPSHOT-jar-with-dependencies.jar "); - System.exit(1); - } - String apiUrl = args[0]; - String authToken = args[1]; - String project = args[2]; - - // Creates the PDF writer and initializes JSON containers. - PDFReportWriter pdf = new PDFReportWriter(); - JSONObject data = null; - JSONArray dataArray = null; - try { - // Retrieves basic project information used in the report header. - data = fetchDataFromURL(apiUrl, "/api/navigation/component?component=", authToken, project); - } catch (IOException | InterruptedException e) { - System.err.println("Error at doing the HTTP petition: " + e.getMessage()); - } - - // Extracts the project name from the response. - String name = data.getString("name"); - - // Writes the introduction section of the report. - pdf.tittle2Font(); - pdf.addLine("INTRODUCTION"); - pdf.bodyFont(); - pdf.addLine("• This document contains results of the code analysis of " + name + "."); - - // Extracts the analysis date and the analyzed branch. - String date = "• Date: " + data.getString("analysisDate"); - String branch = "• Branch: " + data.getString("branch"); - pdf.addLine(branch); - pdf.addLine(date.replace("T", " ")); - - // Starts the configuration section. - pdf.tittle2Font(); - pdf.addLine("CONFIGURATION"); - - // Builds the quality profiles description. - String qualityProfiles = "• Quality Profiles: "; - JSONArray qualityProfilesList = data.getJSONArray("qualityProfiles"); - - for (int i = 0; i < qualityProfilesList.length(); i++) { - JSONObject qualityProfile = qualityProfilesList.getJSONObject(i); - if (i+1 rows = new ArrayList<>(); - - data = null; - try { - - // Retrieves the rating metrics for reliability, security, security review, and maintainability. - data = fetchDataFromURL(apiUrl, "/api/measures/component?metricKeys=reliability_rating,software_quality_maintainability_rating,security_rating,security_review_rating&component=", authToken, project); - data = data.getJSONObject("component"); - - } catch (IOException | InterruptedException e) { - System.err.println("Error at doing the HTTP petition: " + e.getMessage()); - } - - // Reads the returned measures and converts Sonar numeric ratings into letter grades. - JSONArray measuresList = data.getJSONArray("measures"); - String[] measures = new String[4]; - for (int i = 0; i < measuresList.length(); i++) { - JSONObject measure = measuresList.getJSONObject(i); - String aux = ""; - switch (measure.getString("value")) { - case "1.0": aux = "A"; break; - case "2.0": aux = "B"; break; - case "3.0": aux = "C"; break; - case "4.0": aux = "D"; break; - case "5.0": aux = "E"; break; - default: - throw new AssertionError(); - } - measures[i] = aux; - } - - - // Adds the status row to the PDF table. - rows.add(measures); - pdf.drawTable(500, headers, rows); - - // Starts the quality gate status subsection. - pdf.tittle3Font(); - pdf.addLine("QUALITY GATE STATUS"); - - try { - - // Retrieves the global quality gate result for the project. - data = fetchDataFromURL(apiUrl, "/api/qualitygates/project_status?projectKey=", authToken, project); - data = data.getJSONObject("projectStatus"); - - } catch (IOException | InterruptedException e) { - System.err.println("Error at doing the HTTP petition: " + e.getMessage()); - } - - // Writes the quality gate result as a simple line. - pdf.bodyFont(); - pdf.addLine("| Quality Gate Status | " + data.getString("status") + " |"); - - - // Starts the metrics subsection. - pdf.tittle3Font(); - pdf.addLine("METRICS"); - - try { - - // Retrieves code metrics such as coverage, duplications, comments, and complexity. - data = fetchDataFromURL(apiUrl, "/api/measures/component?metricKeys=duplicated_lines_density,comment_lines_density,ncloc,complexity,cognitive_complexity,coverage&component=", authToken, project); - data = data.getJSONObject("component"); - - } catch (IOException | InterruptedException e) { - System.err.println("Error at doing the HTTP petition: " + e.getMessage()); - } - - headers = new String[] { "Coverage", "Duplications", "Comment Density", "Lines of Code", "Cyclomatic Complexity", "Cognitive Complexity" }; - rows = new ArrayList<>(); - - // Maps each metric key to its position in the output table. - Map metricIndex = new HashMap<>(); - metricIndex.put("coverage", 0); - metricIndex.put("duplicated_lines_density", 1); - metricIndex.put("comment_lines_density", 2); - metricIndex.put("ncloc", 3); - metricIndex.put("complexity", 4); - metricIndex.put("cognitive_complexity", 5); - measuresList = data.getJSONArray("measures"); - measures = new String[6]; - Arrays.fill(measures, "0"); - - // Fills the metrics array with the values returned by SonarQube. - for (int i = 0; i < measuresList.length(); i++) { - JSONObject measure = measuresList.getJSONObject(i); - String metric = measure.getString("metric"); - String value = measure.getString("value"); - - if (metricIndex.containsKey(metric)) { - int index = metricIndex.get(metric); - if (metric.contains("density") || metric.equals("coverage")) { - measures[index] = value + "%"; - } else { - measures[index] = value; - } - } - } - // Stores the total number of lines of code for later percentage calculations. - int totalLinesOfCode = Integer.parseInt(measures[3]); - rows.add(measures); - pdf.drawTable(500, headers, rows); - - - // Starts the tests subsection. - pdf.tittle3Font(); - pdf.addLine("TESTS"); - - try { - - // Retrieves the measure block used to populate the tests table. - data = fetchDataFromURL(apiUrl, "/api/measures/component?metricKeys=duplicated_lines_density,comment_lines_density,ncloc,complexity,cognitive_complexity,coverage&component=", authToken, project); - data = data.getJSONObject("component"); - - } catch (IOException | InterruptedException e) { - System.err.println("Error at doing the HTTP petition: " + e.getMessage()); - } - - headers = new String[] { "Total", "Success Rate", "Skipped", "Errors", "Failures" }; - rows = new ArrayList<>(); - - // Maps test-related metrics to their position in the table. - metricIndex = new HashMap<>(); - metricIndex.put("tests", 0); - metricIndex.put("test_success_density", 1); - metricIndex.put("skipped_tests", 2); - metricIndex.put("test_errors", 3); - metricIndex.put("test_failures", 4); - - measuresList = data.getJSONArray("measures"); - measures = new String[5]; - Arrays.fill(measures, "0"); - measures[1] = "0%"; - // Fills the tests array with available values. - for (int i = 0; i < measuresList.length(); i++) { - JSONObject measure = measuresList.getJSONObject(i); - String metric = measure.getString("metric"); - String value = measure.getString("value"); - - if (metricIndex.containsKey(metric)) { - int index = metricIndex.get(metric); - if (metric.equals("test_success_density")) { - measures[index] = value + "%"; - } else { - measures[index] = value; - } - } - } - - // Adds the test summary table to the PDF. - rows.add(measures); - pdf.drawTable(500, headers, rows); - - // Starts the technical debt subsection. - pdf.tittle3Font(); - pdf.addLine("DETAILED TECHNICAL DEBTS"); - - try { - - // Retrieves remediation effort metrics for reliability, security, and maintainability. - data = fetchDataFromURL(apiUrl, "/api/measures/component?metricKeys=reliability_remediation_effort,security_remediation_effort,sqale_index&component=", authToken, project); - data = data.getJSONObject("component"); - - } catch (IOException | InterruptedException e) { - System.err.println("Error at doing the HTTP petition: " + e.getMessage()); - } - - headers = new String[] { "Reliability", "Security", "Maintainability", "Total" }; - rows = new ArrayList<>(); - - // Maps remediation metrics to the table columns. - metricIndex = new HashMap<>(); - metricIndex.put("reliability_remediation_effort", 0); - metricIndex.put("security_remediation_effort", 1); - metricIndex.put("sqale_index", 2); - - measuresList = data.getJSONArray("measures"); - measures = new String[4]; - Arrays.fill(measures, "0d 0h 0m"); - int totalmins = 0; - // Converts remediation effort from minutes to a days-hours-minutes format. - for (int i = 0; i < measuresList.length(); i++) { - JSONObject measure = measuresList.getJSONObject(i); - String metric = measure.getString("metric"); - String value = measure.getString("value"); - if (metricIndex.containsKey(metric)) { - int index = metricIndex.get(metric); - int minutes = Integer.parseInt(value); - totalmins += minutes; - - measures[index] = minsToDaysHoursMins(minutes); - } - } - // Stores the total remediation effort. - measures[3] = minsToDaysHoursMins(totalmins); - - rows.add(measures); - pdf.drawTable(500, headers, rows); - - // Starts the language distribution subsection. - pdf.tittle3Font(); - pdf.addLine("LINES PER LANGUAGE"); - - try { - - // Retrieves the distribution of non-comment lines by programming language. - data = fetchDataFromURL(apiUrl, "/api/measures/component?metricKeys=ncloc_language_distribution&component=", authToken, project); - data = data.getJSONObject("component"); - - } catch (IOException | InterruptedException e) { - System.err.println("Error at doing the HTTP petition: " + e.getMessage()); - } - - headers = new String[] { "Language", "Number of Lines", "Total Percent" }; - rows = new ArrayList<>(); - - measuresList = data.getJSONArray("measures"); - - JSONObject measure = measuresList.getJSONObject(0); - - // Raw language distribution returned by SonarQube in key=value format. - String rawLanguages = measure.getString("value"); - - // Splits each language entry and calculates its percentage over total lines of code. - for (String pair : rawLanguages.split(";")) { - String[] parts = pair.split("="); - if (parts.length == 2) { - int lines = Integer.parseInt(parts[1]); - String percent = String.format("%.2f%%", (lines * 100.0) / totalLinesOfCode); - rows.add(new String[] { parts[0], parts[1], percent}); - } - } - - pdf.drawTable(500, headers, rows); - - // Starts the security hotspots section. - - pdf.tittle2Font(); - pdf.addLine("SECURITY HOTSPOTS"); - pdf.tittle3Font(); - pdf.addLine("SECURITY HOTSPOTS COUNT BY CATEGORY AND PRIORITY"); - - try { - - // Retrieves security hotspot categories and associated ratings. - data = fetchDataFromURL(apiUrl, "/api/security_reports/show?standard=sonarsourceSecurity&project=", authToken, project); - dataArray = data.getJSONArray("categories"); - - } catch (IOException | InterruptedException e) { - System.err.println("Error at doing the HTTP petition: " + e.getMessage()); - } - - headers = new String[] { "Categories", "Security", "Security Hotspots" }; - rows = new ArrayList<>(); - // Maps Sonar category identifiers to human-readable labels. - Map categories = new HashMap<>(); - categories.put("buffer-overflow", "Buffer Overflow"); - categories.put("sql-injection", "SQL Injection"); - categories.put("rce", "Code Injection (RCE)"); - categories.put("object-injection", "Object Injection"); - categories.put("command-injection", "Command Injection"); - categories.put("path-traversal-injection", "Path Traversal Injection"); - categories.put("ldap-injection", "LDAP Injection"); - categories.put("xpath-injection", "XPath Injection"); - categories.put("log-injection", "Log Injection"); - categories.put("xxe", "XML External Entity(XXE)"); - categories.put("xss", "Cross-Site Scripting (XSS)"); - categories.put("dos", "Denial of Service (DoS)"); - categories.put("ssrf", "Server-Side Request Forgery (SSRF)"); - categories.put("csrf", "Cross-Site Request Forgery (CSRF)"); - categories.put("http-response-splitting", "HTTP Responde Splitting"); - categories.put("open-redirect", "Open Redirect"); - categories.put("weak-cryptography", "Weak Cryptography"); - categories.put("auth", "Authentication"); - categories.put("insecure-conf", "Insecure Configuration"); - categories.put("file-manipulation", "File Manipulation"); - categories.put("encrypt-data", "Encryption of Sensitive Data"); - categories.put("traceability", "Traceability"); - categories.put("permission", "Permission"); - categories.put("others", "Others"); - - // Maps numeric ratings to letter grades. - Map rating = new HashMap<>(); - rating.put(1,"[A]"); - rating.put(2,"[B]"); - rating.put(3,"[C]"); - rating.put(4,"[D]"); - rating.put(5,"[E]"); - - // Builds the summary table for vulnerabilities and hotspots by category. - for (int i = 0; i < dataArray.length(); i++) { - JSONObject object = dataArray.getJSONObject(i); - String category = object.getString("category"); - Integer vuls = object.getInt("vulnerabilities"); - Integer vulsRate = 1; - if(object.has("vulnerabilityRating")) vulsRate = object.getInt("vulnerabilityRating"); - Integer hotSpots = object.getInt("toReviewSecurityHotspots"); - Integer hotSpotsRate = object.getInt("securityReviewRating"); - String vulsText = String.valueOf(vuls) + " " + rating.get(vulsRate); - String hotSpotsText = String.valueOf(hotSpots) + " " + rating.get(hotSpotsRate); - - rows.add(new String[] { categories.get(category), vulsText, hotSpotsText}); - } - - pdf.drawTable(500, headers, rows); - - // Starts the detailed hotspot list subsection. - - pdf.tittle3Font(); - pdf.addLine("SECURITY HOTSPOT LIST"); - - - try { - int pageIndex = 1; - int total = Integer.MAX_VALUE; - dataArray = new JSONArray(); - // Retrieves all hotspots page by page until the full result set is collected. - while ((pageIndex - 1) * 500 < total) { - data = fetchDataFromURL( - apiUrl, - String.format("/api/hotspots/search?status=TO_REVIEW&ps=500&pageIndex=%d&project=", pageIndex), - authToken, - project - ); - - JSONArray currentPageHotspots = data.getJSONArray("hotspots"); - for (int i = 0; i < currentPageHotspots.length(); i++) { - dataArray.put(currentPageHotspots.getJSONObject(i)); - } - - if (data.has("paging")) { - total = data.getJSONObject("paging").getInt("total"); - } - - pageIndex++; - } - - } catch (IOException | InterruptedException e) { - System.err.println("Error at doing the HTTP petition: " + e.getMessage()); - } - - // Groups hotspots by rule key to merge repeated entries. - Map hotspotMap = new HashMap<>(); - - for (int i = 0; i < dataArray.length(); i++) { - JSONObject originalObj = dataArray.getJSONObject(i); - String ruleKey = originalObj.getString("ruleKey"); - - if (hotspotMap.containsKey(ruleKey)) { - JSONObject existing = hotspotMap.get(ruleKey); - int currentCount = existing.getInt("count"); - existing.put("count", currentCount + 1); - String currentLocations = existing.getString("location"); - String file = originalObj.getString("component"); - file = file.contains(":") ? file.split(":", 2)[1].trim() : file; - JSONObject textLines = originalObj.getJSONObject("textRange"); - String textLine = Integer.toString(textLines.getInt("startLine")); - existing.put("location", currentLocations + " | " + file + ": " + textLine); - - } else { - // Creates a new grouped hotspot entry. - JSONObject newObj = new JSONObject(); - JSONObject textLines = originalObj.getJSONObject("textRange"); - String file = originalObj.getString("component"); - file = file.contains(":") ? file.split(":", 2)[1].trim() : file; - String textLine = Integer.toString(textLines.getInt("startLine")); - newObj.put("ruleKey", ruleKey); - newObj.put("count", 1); - newObj.put("vulnerabilityProbability", originalObj.getString("vulnerabilityProbability")); - newObj.put("message", originalObj.getString("message")); - newObj.put("location", file + ": " + textLine); - - - hotspotMap.put(ruleKey, newObj); - } - } - // Converts the grouped hotspot map into a JSONArray for iteration. - JSONArray hotspotArray = new JSONArray(hotspotMap.values()); - for (int i = 0; i < hotspotArray.length(); i++) { - JSONObject hotspotObject = hotspotArray.getJSONObject(i); - pdf.startBulletEntry(hotspotObject.getString("message")); - pdf.addIndentedLine("Vulnerability Probability", hotspotObject.getString("vulnerabilityProbability")); - pdf.addIndentedLine("Count", Integer.toString(hotspotObject.getInt("count"))); - pdf.addIndentedLine("Locations", hotspotObject.getString("location")); - pdf.addIndentedHyperlink("Root Cause/How to fix", apiUrl+"coding_rules?q="+hotspotObject.getString("ruleKey")+"&open="+hotspotObject.getString("ruleKey"),hotspotObject.getString("ruleKey")); - } - - // Starts the issues section. - - pdf.tittle2Font(); - pdf.addLine("ISSUES"); - pdf.tittle3Font(); - pdf.addLine("ISSUES COUNT BY SEVERITY AND TYPES"); - - headers = new String[] { "Type / Severity", "INFO", "MINOR", "MAJOR", "CRITICAL", "BLOCKER" }; - rows = new ArrayList<>(); - - try { - - // Retrieves bug counts grouped by severity. - data = fetchDataFromURL(apiUrl, "/api/issues/search?types=BUG&facets=severities&componentKeys=", authToken, project); - - } catch (IOException | InterruptedException e) { - System.err.println("Error at doing the HTTP petition: " + e.getMessage()); - } - - JSONArray bugArray = data.getJSONArray("facets"); - bugArray = bugArray.getJSONObject(0).getJSONArray("values"); - rows.add(new String[]{ "Bug", String.valueOf(bugArray.getJSONObject(4).getInt("count")), String.valueOf(bugArray.getJSONObject(0).getInt("count")), String.valueOf(bugArray.getJSONObject(1).getInt("count")), String.valueOf(bugArray.getJSONObject(2).getInt("count")), String.valueOf(bugArray.getJSONObject(3).getInt("count")) }); - - try { - - // Retrieves vulnerability counts grouped by severity. - data = fetchDataFromURL(apiUrl, "/api/issues/search?types=VULNERABILITY&facets=severities&componentKeys=", authToken, project); - - } catch (IOException | InterruptedException e) { - System.err.println("Error at doing the HTTP petition: " + e.getMessage()); - } - - JSONArray vulArray = data.getJSONArray("facets"); - vulArray = vulArray.getJSONObject(0).getJSONArray("values"); - rows.add(new String[]{ "Vulnerability", String.valueOf(vulArray.getJSONObject(4).getInt("count")), String.valueOf(vulArray.getJSONObject(0).getInt("count")), String.valueOf(vulArray.getJSONObject(1).getInt("count")), String.valueOf(vulArray.getJSONObject(2).getInt("count")), String.valueOf(vulArray.getJSONObject(3).getInt("count")) }); - - try { - - // Retrieves code smell counts grouped by severity. - data = fetchDataFromURL(apiUrl, "/api/issues/search?types=CODE_SMELL&facets=severities&componentKeys=", authToken, project); - - } catch (IOException | InterruptedException e) { - System.err.println("Error at doing the HTTP petition: " + e.getMessage()); - } - - JSONArray codeSmellArray = data.getJSONArray("facets"); - codeSmellArray = codeSmellArray.getJSONObject(0).getJSONArray("values"); - rows.add(new String[]{ "Code Smell", String.valueOf(codeSmellArray.getJSONObject(4).getInt("count")), String.valueOf(codeSmellArray.getJSONObject(0).getInt("count")), String.valueOf(codeSmellArray.getJSONObject(1).getInt("count")), String.valueOf(codeSmellArray.getJSONObject(2).getInt("count")), String.valueOf(codeSmellArray.getJSONObject(3).getInt("count")) }); - - pdf.drawTable(500, headers, rows); - - // Starts the detailed issues subsection. - pdf.tittle3Font(); - pdf.addLine("ISSUES LIST"); - - try { - int pageIndex = 1; - int total = Integer.MAX_VALUE; - dataArray = new JSONArray(); - // Retrieves all open issues page by page. - while ((pageIndex - 1) * 500 < total) { - data = fetchDataFromURL( - apiUrl, - String.format("/api/issues/search?issueStatuses=OPEN&ps=500&pageIndex=%d&componentKeys=", pageIndex), - authToken, - project - ); - - JSONArray currentPageHotspots = data.getJSONArray("issues"); - for (int i = 0; i < currentPageHotspots.length(); i++) { - dataArray.put(currentPageHotspots.getJSONObject(i)); - } - - if (data.has("paging")) { - total = data.getJSONObject("paging").getInt("total"); - } - - pageIndex++; - } - - } catch (IOException | InterruptedException e) { - System.err.println("Error at doing the HTTP petition: " + e.getMessage()); - } - - // Groups issues by rule key to avoid duplicated report entries. - Map issuesMap = new HashMap<>(); - - for (int i = 0; i < dataArray.length(); i++) { - JSONObject originalObj = dataArray.getJSONObject(i); - String ruleKey = originalObj.getString("rule"); - - if (issuesMap.containsKey(ruleKey)) { - JSONObject existing = issuesMap.get(ruleKey); - int currentCount = existing.getInt("count"); - existing.put("count", currentCount + 1); - String currentLocations = existing.getString("location"); - String file = originalObj.getString("component"); - file = file.contains(":") ? file.split(":", 2)[1].trim() : file; - // Check if the issue is in a line of text or is the file itself - if (originalObj.has("textRange")) { - JSONObject textLines = originalObj.getJSONObject("textRange"); - String textLine = Integer.toString(textLines.getInt("startLine")); - existing.put("location", currentLocations + " | " + file + ": " + textLine); - } - else existing.put("location", currentLocations + " | " + file); - } else { - // Creates a new grouped issue entry. - JSONObject newObj = new JSONObject(); - - - String file = originalObj.getString("component"); - file = file.contains(":") ? file.split(":", 2)[1].trim() : file; - - newObj.put("ruleKey", ruleKey); - newObj.put("count", 1); - newObj.put("severity", originalObj.getString("severity")); - newObj.put("message", originalObj.getString("message")); - newObj.put("type", originalObj.getString("type")); - // Check if the issue is in a line of text or is the file itself - if (originalObj.has("textRange")) { - JSONObject textLines = originalObj.getJSONObject("textRange"); - String textLine = Integer.toString(textLines.getInt("startLine")); - newObj.put("location", file + ": " + textLine); - } - else newObj.put("location", file); - issuesMap.put(ruleKey, newObj); - } - } - - // Converts the grouped issues map into a JSONArray. - JSONArray issuesArray = new JSONArray(issuesMap.values()); - - // Writes the detailed list of grouped issues into the PDF. - for (int i = 0; i < issuesArray.length(); i++) { - JSONObject issuesObject = issuesArray.getJSONObject(i); - pdf.startBulletEntry(issuesObject.getString("message")); - pdf.addIndentedLine("Type", issuesObject.getString("type")); - pdf.addIndentedLine("Severity", issuesObject.getString("severity")); - pdf.addIndentedLine("Count", Integer.toString(issuesObject.getInt("count"))); - pdf.addIndentedLine("Locations", issuesObject.getString("location")); - pdf.addIndentedHyperlink("Root Cause/How to fix", apiUrl+"coding_rules?q="+issuesObject.getString("ruleKey")+"&open="+issuesObject.getString("ruleKey"),issuesObject.getString("ruleKey")); - } - // Finalizes the report by inserting the index, adding the cover page, and saving the PDF. - pdf.insertIndexAtBeginning(); - pdf.addCoverPage("SonarQube Report", "Generated for "+ project); - pdf.save("sonarqube-report.pdf"); - } - - // Converts a number of minutes into a formatted string with days, hours, and minutes. - private static String minsToDaysHoursMins(int minutes) { - int days = minutes / (24 * 60); - int hours = (minutes % (24 * 60)) / 60; - int mins = minutes % 60; - - return String.format("%dd %02dh %02dm", days, hours, mins); - } - - // Safely returns the value of the "count" field from the given JSON array position. - private String getCountAsString(JSONArray jsonArray, int index) { - if (jsonArray != null && index >= 0 && index < jsonArray.length()) { - try { - return String.valueOf(jsonArray.getJSONObject(index).getString("count")); - } catch (JSONException e) { - System.err.println("Error at obtaning 'count' in the index " + index + ": " + e.getMessage()); - return ""; - } - } - return ""; -} - -} diff --git a/pdf-generator/src/main/java/com/ods/GenerateSonarReport.java b/pdf-generator/src/main/java/com/ods/GenerateSonarReport.java new file mode 100644 index 0000000..8667251 --- /dev/null +++ b/pdf-generator/src/main/java/com/ods/GenerateSonarReport.java @@ -0,0 +1,42 @@ +package com.ods; + +import java.util.HashMap; +import java.util.Map; + +public class GenerateSonarReport { + + public static void main(String[] args) { + Map params = parseArgs(args); + + String sonarUrl = params.get("--sonar-url"); + String token = params.get("--token"); + String project = params.get("--project"); + String branch = params.get("--branch"); + String output = params.getOrDefault("--output", "sonarqube-report.pdf"); + + if (sonarUrl == null || token == null || project == null) { + System.err.println("Usage: java -jar sonar-report-1.0-jar-with-dependencies.jar" + + " --sonar-url --token --project [--branch ] [--output ]"); + System.exit(1); + } + + try { + new ReportBuilder(sonarUrl, token, project, branch).build(output); + } catch (Exception e) { + System.err.println("ERROR: " + e.getMessage()); + e.printStackTrace(System.err); + System.exit(1); + } + } + + static Map parseArgs(String[] args) { + Map params = new HashMap<>(); + for (int i = 0; i + 1 < args.length; i++) { + if (args[i].startsWith("--")) { + params.put(args[i], args[i + 1]); + i++; + } + } + return params; + } +} diff --git a/pdf-generator/src/main/java/com/bi/PDFReportWriter.java b/pdf-generator/src/main/java/com/ods/PDFReportWriter.java similarity index 86% rename from pdf-generator/src/main/java/com/bi/PDFReportWriter.java rename to pdf-generator/src/main/java/com/ods/PDFReportWriter.java index 1e87f37..c9cf46c 100644 --- a/pdf-generator/src/main/java/com/bi/PDFReportWriter.java +++ b/pdf-generator/src/main/java/com/ods/PDFReportWriter.java @@ -1,3 +1,5 @@ +package com.ods; + import java.io.IOException; import java.io.InputStream; import java.time.LocalDateTime; @@ -136,7 +138,7 @@ public void addLine(String text) throws IOException { contentStream.endText(); } - + private List divideTextInLines(String text, PDFont font, float size, float maxWidth) throws IOException { List lines = new ArrayList<>(); if (text == null || text.isEmpty()) { @@ -176,7 +178,7 @@ private List divideTextInLines(String text, PDFont font, float size, flo return lines; } - private List splitBySlash(String word) { + static List splitBySlash(String word) { List result = new ArrayList<>(); StringBuilder current = new StringBuilder(); @@ -365,19 +367,19 @@ private void drawTableSection(String[] header, String[] row, float y, float rowH public void insertIndexAtBeginning() throws IOException { PDPage indexPage = new PDPage(PDRectangle.A4); - + List existingPages = new ArrayList<>(); for (PDPage page : document.getPages()) { existingPages.add(page); } - + int total = document.getNumberOfPages(); for (int i = total - 1; i >= 0; i--) { document.removePage(i); } - + document.addPage(indexPage); indexPages.add(indexPage); drawHeader(indexPage); @@ -388,7 +390,7 @@ public void insertIndexAtBeginning() throws IOException { float y = PDRectangle.A4.getHeight() - margin - 60; float lineHeight = 2.0f * bodySize; - + indexStream.setFont(tittle1Font, tittle1Size); indexStream.beginText(); indexStream.newLineAtOffset(margin, y); @@ -428,26 +430,26 @@ public void insertIndexAtBeginning() throws IOException { indexStream.setFont(thisFont, thisFontSize); - int pageNum = existingPages.indexOf(bm.page) + 2; + int pageNum = existingPages.indexOf(bm.page) + 2; String pageStr = String.valueOf(pageNum); float pageStrWidth = thisFont.getStringWidth(pageStr) / 1000 * thisFontSize; float titleWidth = thisFont.getStringWidth(title) / 1000 * thisFontSize; - + indexStream.beginText(); indexStream.newLineAtOffset(indent, y); indexStream.showText(title); indexStream.endText(); - + float pageNumX = PDRectangle.A4.getWidth() - margin - pageStrWidth; indexStream.beginText(); indexStream.newLineAtOffset(pageNumX, y); indexStream.showText(pageStr); indexStream.endText(); - + PDPageXYZDestination dest = new PDPageXYZDestination(); dest.setPage(bm.page); dest.setTop((int) bm.yPosition); @@ -487,7 +489,7 @@ public void insertIndexAtBeginning() throws IOException { indexStream.close(); - + for (PDPage page : existingPages) { document.addPage(page); } @@ -507,7 +509,7 @@ private List wrapText(String text, float maxWidth) throws IOException { lines.add(currentLine.toString()); } - + while (font.getStringWidth(word) / 1000 * fontSize > maxWidth) { int cutIndex = 1; while (cutIndex < word.length() && @@ -533,7 +535,7 @@ private List wrapText(String text, float maxWidth) throws IOException { } private void repositionIndexPages() { - int insertAfter = 0; + int insertAfter = 0; for (int i = indexPages.size() - 1; i >= 0; i--) { PDPage page = indexPages.get(i); @@ -551,7 +553,7 @@ public void save(String fileName) throws IOException { contentStream = null; } - repositionIndexPages(); + repositionIndexPages(); int totalPages = document.getNumberOfPages(); for (int i = 0; i < totalPages; i++) { @@ -688,7 +690,7 @@ public void startBulletEntry(String titulo) throws IOException { private void drawFooter(PDPage page, int pageNumber, int totalPages) throws IOException { try (PDPageContentStream footerStream = new PDPageContentStream(document, page, PDPageContentStream.AppendMode.APPEND, true, true)) { - float y = 20f; + float y = 20f; float pageWidth = PDRectangle.A4.getWidth(); String text = "Page " + pageNumber + " of " + totalPages; @@ -775,7 +777,7 @@ public void addIndentedLine(String label, String content) throws IOException { yPosition -= leading; margin = originalMargin; } - + public void addIndentedHyperlink(String label, String url, String visibleText) throws IOException { float originalMargin = margin; margin += 20; @@ -798,7 +800,7 @@ public void addIndentedHyperlink(String label, String url, String visibleText) t contentStream.newLineAtOffset(margin, yPosition); contentStream.showText("• " + label + ": "); contentStream.setFont(bodyFont, bodySize); - contentStream.setNonStrokingColor(0, 0, 1); + contentStream.setNonStrokingColor(0, 0, 1); contentStream.newLineAtOffset(labelWidth, 0); contentStream.showText(visibleText); contentStream.endText(); @@ -826,101 +828,6 @@ public void addIndentedHyperlink(String label, String url, String visibleText) t margin = originalMargin; } - private String expandTabs(String input, int tabSize) { - StringBuilder result = new StringBuilder(); - int position = 0; - for (char c : input.toCharArray()) { - if (c == '\t') { - int spaces = tabSize - (position % tabSize); - result.append(" ".repeat(spaces)); - position += spaces; - } else { - result.append(c); - position += (c == '\n') ? 0 : 1; - } - } - return result.toString(); - } - - public void addInlineFormattedBlock(String label, String content) throws IOException { - float originalMargin = margin; - margin += 20; - - if (contentStream != null) { - try { - contentStream.endText(); - } catch (IllegalStateException ignored) {} - } - - PDFont codeFont = bodyFont; - float fontSize = bodySize; - float maxWidth = PDRectangle.A4.getWidth() - 2 * margin; - - String[] lines = content.split("\n"); - if (lines.length == 0) return; - - String labelText = "• " + label + ": "; - float labelWidth = tittle3Font.getStringWidth(labelText) / 1000 * fontSize; - - if (yPosition <= margin + leading) { - contentStream.close(); - addNewPage(); - } - - contentStream.beginText(); - contentStream.setFont(tittle3Font, fontSize); - contentStream.newLineAtOffset(margin, yPosition); - contentStream.showText(labelText); - contentStream.setFont(codeFont, fontSize); - - String firstLine = lines[0]; - List wrappedFirst = divideTextInLines(firstLine, codeFont, fontSize, maxWidth - labelWidth); - - if (!wrappedFirst.isEmpty()) { - contentStream.newLineAtOffset(labelWidth, 0); - contentStream.showText(wrappedFirst.get(0)); - contentStream.endText(); - yPosition -= leading; - - for (int i = 1; i < wrappedFirst.size(); i++) { - if (yPosition <= margin + leading) { - contentStream.close(); - addNewPage(); - } - contentStream.beginText(); - contentStream.setFont(codeFont, fontSize); - contentStream.newLineAtOffset(margin, yPosition); - contentStream.showText(wrappedFirst.get(i)); - contentStream.endText(); - yPosition -= leading; - } - } else { - contentStream.endText(); - yPosition -= leading; - } - - for (int i = 1; i < lines.length; i++) { - String expanded = expandTabs(lines[i], 4); - List wrapped = divideTextInLines(expanded, codeFont, fontSize, maxWidth); - - for (String w : wrapped) { - if (yPosition <= margin + leading) { - contentStream.close(); - addNewPage(); - } - contentStream.beginText(); - contentStream.setFont(codeFont, fontSize); - contentStream.newLineAtOffset(margin, yPosition); - contentStream.showText(w); - contentStream.endText(); - yPosition -= leading; - } - } - - yPosition -= leading; - margin = originalMargin; - } - public void addCoverPage(String title, String subtitle) throws IOException { PDPage cover = new PDPage(PDRectangle.A4); @@ -950,49 +857,7 @@ public void addCoverPage(String title, String subtitle) throws IOException { document.getPages().insertBefore(cover, document.getPage(0)); } - private List buildHierarchicalBookmarks() { - List result = new ArrayList<>(); - int[] levels = new int[10]; - boolean hasLevel1 = bookmarks.stream().anyMatch(b -> b.level == 1); - - for (Bookmark bm : bookmarks) { - int realLevel = bm.level; - - if (!hasLevel1 && bm.level > 1) { - realLevel = bm.level - 1; - } - - levels[realLevel - 1]++; - - for (int i = realLevel; i < levels.length; i++) { - levels[i] = 0; - } - - StringBuilder num = new StringBuilder(); - for (int i = 0; i < realLevel; i++) { - if (levels[i] > 0) { - if (num.length() > 0) num.append("."); - num.append(levels[i]); - } - } - - result.add(new NumberedBookmark(num.toString(), bm)); - } - - return result; - } - - private static class NumberedBookmark { - String number; - Bookmark bookmark; - - NumberedBookmark(String number, Bookmark bookmark) { - this.number = number; - this.bookmark = bookmark; - } - } - public void addBookmark(String title, int level) { bookmarks.add(new Bookmark(title, currentPage, currentY, level)); } -} \ No newline at end of file +} diff --git a/pdf-generator/src/main/java/com/ods/ReportBuilder.java b/pdf-generator/src/main/java/com/ods/ReportBuilder.java new file mode 100644 index 0000000..6c2a7d1 --- /dev/null +++ b/pdf-generator/src/main/java/com/ods/ReportBuilder.java @@ -0,0 +1,464 @@ +package com.ods; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.json.JSONArray; +import org.json.JSONObject; + +public class ReportBuilder { + + private final SonarApiClient client; + private final PDFReportWriter pdf; + private final String apiUrl; + private final String project; + + private static final Map CATEGORY_NAMES = new HashMap<>(); + private static final Map RATING_LABELS = new HashMap<>(); + + static { + CATEGORY_NAMES.put("buffer-overflow", "Buffer Overflow"); + CATEGORY_NAMES.put("sql-injection", "SQL Injection"); + CATEGORY_NAMES.put("rce", "Code Injection (RCE)"); + CATEGORY_NAMES.put("object-injection", "Object Injection"); + CATEGORY_NAMES.put("command-injection", "Command Injection"); + CATEGORY_NAMES.put("path-traversal-injection", "Path Traversal Injection"); + CATEGORY_NAMES.put("ldap-injection", "LDAP Injection"); + CATEGORY_NAMES.put("xpath-injection", "XPath Injection"); + CATEGORY_NAMES.put("log-injection", "Log Injection"); + CATEGORY_NAMES.put("xxe", "XML External Entity(XXE)"); + CATEGORY_NAMES.put("xss", "Cross-Site Scripting (XSS)"); + CATEGORY_NAMES.put("dos", "Denial of Service (DoS)"); + CATEGORY_NAMES.put("ssrf", "Server-Side Request Forgery (SSRF)"); + CATEGORY_NAMES.put("csrf", "Cross-Site Request Forgery (CSRF)"); + CATEGORY_NAMES.put("http-response-splitting", "HTTP Response Splitting"); + CATEGORY_NAMES.put("open-redirect", "Open Redirect"); + CATEGORY_NAMES.put("weak-cryptography", "Weak Cryptography"); + CATEGORY_NAMES.put("auth", "Authentication"); + CATEGORY_NAMES.put("insecure-conf", "Insecure Configuration"); + CATEGORY_NAMES.put("file-manipulation", "File Manipulation"); + CATEGORY_NAMES.put("encrypt-data", "Encryption of Sensitive Data"); + CATEGORY_NAMES.put("traceability", "Traceability"); + CATEGORY_NAMES.put("permission", "Permission"); + CATEGORY_NAMES.put("others", "Others"); + + RATING_LABELS.put(1, "[A]"); + RATING_LABELS.put(2, "[B]"); + RATING_LABELS.put(3, "[C]"); + RATING_LABELS.put(4, "[D]"); + RATING_LABELS.put(5, "[E]"); + } + + public ReportBuilder(String apiUrl, String authToken, String project, String branch) throws IOException { + this.client = new SonarApiClient(apiUrl, authToken, branch); + this.pdf = new PDFReportWriter(); + this.apiUrl = apiUrl; + this.project = project; + } + + public void build(String outputFile) throws IOException { + buildIntroductionAndConfiguration(); + buildSynthesisSection(); + buildSecurityHotspotsSection(); + buildIssuesSection(); + pdf.insertIndexAtBeginning(); + pdf.addCoverPage("SonarQube Report", "Generated for " + project); + pdf.save(outputFile); + } + + private void buildIntroductionAndConfiguration() throws IOException { + JSONObject data = client.fetchDataFromURL("/api/navigation/component?component=", project); + + String name = data.getString("name"); + + pdf.tittle2Font(); + pdf.addLine("INTRODUCTION"); + pdf.bodyFont(); + pdf.addLine("• This document contains results of the code analysis of " + name + "."); + pdf.addLine("• Branch: " + data.getString("branch")); + pdf.addLine("• Date: " + data.getString("analysisDate").replace("T", " ")); + + pdf.tittle2Font(); + pdf.addLine("CONFIGURATION"); + + String qualityProfiles = "• Quality Profiles: "; + JSONArray qualityProfilesList = data.getJSONArray("qualityProfiles"); + for (int i = 0; i < qualityProfilesList.length(); i++) { + JSONObject qp = qualityProfilesList.getJSONObject(i); + String suffix = (i + 1 < qualityProfilesList.length()) ? ", " : "."; + qualityProfiles += qp.getString("name") + " [" + qp.getString("language") + "]" + suffix; + } + + pdf.bodyFont(); + pdf.addLine(qualityProfiles); + pdf.addLine("• Quality Gate: " + data.getJSONObject("qualityGate").getString("name") + "."); + } + + private void buildSynthesisSection() throws IOException { + pdf.tittle2Font(); + pdf.addLine("SYNTHESIS"); + + // Analysis Status + pdf.tittle3Font(); + pdf.addLine("ANALYSIS STATUS"); + + JSONObject data = client.fetchDataFromURL("/api/measures/component?metricKeys=reliability_rating,software_quality_maintainability_rating,security_rating,security_review_rating&component=", project); + data = data.getJSONObject("component"); + + String[] headers = {"Reliability", "Security", "Security Review", "Maintainability"}; + List rows = new ArrayList<>(); + JSONArray measuresList = data.getJSONArray("measures"); + String[] measures = new String[4]; + for (int i = 0; i < measuresList.length(); i++) { + measures[i] = ratingToLetter(measuresList.getJSONObject(i).getString("value")); + } + rows.add(measures); + pdf.drawTable(500, headers, rows); + + // Quality Gate Status + pdf.tittle3Font(); + pdf.addLine("QUALITY GATE STATUS"); + + data = client.fetchDataFromURL("/api/qualitygates/project_status?projectKey=", project); + data = data.getJSONObject("projectStatus"); + + pdf.bodyFont(); + pdf.addLine("| Quality Gate Status | " + data.getString("status") + " |"); + + // Metrics + pdf.tittle3Font(); + pdf.addLine("METRICS"); + + data = client.fetchDataFromURL("/api/measures/component?metricKeys=duplicated_lines_density,comment_lines_density,ncloc,complexity,cognitive_complexity,coverage&component=", project); + data = data.getJSONObject("component"); + + headers = new String[]{"Coverage", "Duplications", "Comment Density", "Lines of Code", "Cyclomatic Complexity", "Cognitive Complexity"}; + rows = new ArrayList<>(); + + Map metricIndex = new HashMap<>(); + metricIndex.put("coverage", 0); + metricIndex.put("duplicated_lines_density", 1); + metricIndex.put("comment_lines_density", 2); + metricIndex.put("ncloc", 3); + metricIndex.put("complexity", 4); + metricIndex.put("cognitive_complexity", 5); + + measuresList = data.getJSONArray("measures"); + measures = new String[6]; + Arrays.fill(measures, "0"); + + for (int i = 0; i < measuresList.length(); i++) { + JSONObject measure = measuresList.getJSONObject(i); + String metric = measure.getString("metric"); + String value = measure.getString("value"); + if (metricIndex.containsKey(metric)) { + int index = metricIndex.get(metric); + measures[index] = (metric.contains("density") || metric.equals("coverage")) ? value + "%" : value; + } + } + + int totalLinesOfCode = Integer.parseInt(measures[3]); + rows.add(measures); + pdf.drawTable(500, headers, rows); + + // Tests + pdf.tittle3Font(); + pdf.addLine("TESTS"); + + data = client.fetchDataFromURL("/api/measures/component?metricKeys=tests,test_success_density,skipped_tests,test_errors,test_failures&component=", project); + data = data.getJSONObject("component"); + + headers = new String[]{"Total", "Success Rate", "Skipped", "Errors", "Failures"}; + rows = new ArrayList<>(); + + metricIndex = new HashMap<>(); + metricIndex.put("tests", 0); + metricIndex.put("test_success_density", 1); + metricIndex.put("skipped_tests", 2); + metricIndex.put("test_errors", 3); + metricIndex.put("test_failures", 4); + + measuresList = data.getJSONArray("measures"); + measures = new String[5]; + Arrays.fill(measures, "0"); + measures[1] = "0%"; + + for (int i = 0; i < measuresList.length(); i++) { + JSONObject measure = measuresList.getJSONObject(i); + String metric = measure.getString("metric"); + String value = measure.getString("value"); + if (metricIndex.containsKey(metric)) { + int index = metricIndex.get(metric); + measures[index] = metric.equals("test_success_density") ? value + "%" : value; + } + } + + rows.add(measures); + pdf.drawTable(500, headers, rows); + + // Technical Debt + pdf.tittle3Font(); + pdf.addLine("DETAILED TECHNICAL DEBTS"); + + data = client.fetchDataFromURL("/api/measures/component?metricKeys=reliability_remediation_effort,security_remediation_effort,sqale_index&component=", project); + data = data.getJSONObject("component"); + + headers = new String[]{"Reliability", "Security", "Maintainability", "Total"}; + rows = new ArrayList<>(); + + metricIndex = new HashMap<>(); + metricIndex.put("reliability_remediation_effort", 0); + metricIndex.put("security_remediation_effort", 1); + metricIndex.put("sqale_index", 2); + + measuresList = data.getJSONArray("measures"); + measures = new String[4]; + Arrays.fill(measures, "0d 0h 0m"); + int totalMins = 0; + + for (int i = 0; i < measuresList.length(); i++) { + JSONObject measure = measuresList.getJSONObject(i); + String metric = measure.getString("metric"); + String value = measure.getString("value"); + if (metricIndex.containsKey(metric)) { + int index = metricIndex.get(metric); + int minutes = Integer.parseInt(value); + totalMins += minutes; + measures[index] = minsToDaysHoursMins(minutes); + } + } + + measures[3] = minsToDaysHoursMins(totalMins); + rows.add(measures); + pdf.drawTable(500, headers, rows); + + // Lines per Language + pdf.tittle3Font(); + pdf.addLine("LINES PER LANGUAGE"); + + data = client.fetchDataFromURL("/api/measures/component?metricKeys=ncloc_language_distribution&component=", project); + data = data.getJSONObject("component"); + + headers = new String[]{"Language", "Number of Lines", "Total Percent"}; + rows = new ArrayList<>(); + + String rawLanguages = data.getJSONArray("measures").getJSONObject(0).getString("value"); + for (String pair : rawLanguages.split(";")) { + String[] parts = pair.split("="); + if (parts.length == 2) { + int lines = Integer.parseInt(parts[1]); + String percent = String.format("%.2f%%", (lines * 100.0) / totalLinesOfCode); + rows.add(new String[]{parts[0], parts[1], percent}); + } + } + + pdf.drawTable(500, headers, rows); + } + + private void buildSecurityHotspotsSection() throws IOException { + pdf.tittle2Font(); + pdf.addLine("SECURITY HOTSPOTS"); + pdf.tittle3Font(); + pdf.addLine("SECURITY HOTSPOTS COUNT BY CATEGORY AND PRIORITY"); + + JSONObject data = client.fetchDataFromURL("/api/security_reports/show?standard=sonarsourceSecurity&project=", project); + JSONArray dataArray = data.getJSONArray("categories"); + + String[] headers = {"Categories", "Security", "Security Hotspots"}; + List rows = new ArrayList<>(); + + for (int i = 0; i < dataArray.length(); i++) { + JSONObject object = dataArray.getJSONObject(i); + String category = object.getString("category"); + int vuls = object.getInt("vulnerabilities"); + int vulsRate = object.has("vulnerabilityRating") ? object.getInt("vulnerabilityRating") : 1; + int hotSpots = object.getInt("toReviewSecurityHotspots"); + int hotSpotsRate = object.getInt("securityReviewRating"); + rows.add(new String[]{ + CATEGORY_NAMES.get(category), + vuls + " " + RATING_LABELS.get(vulsRate), + hotSpots + " " + RATING_LABELS.get(hotSpotsRate) + }); + } + + pdf.drawTable(500, headers, rows); + + pdf.tittle3Font(); + pdf.addLine("SECURITY HOTSPOT LIST"); + + int pageIndex = 1; + int total = Integer.MAX_VALUE; + dataArray = new JSONArray(); + while ((pageIndex - 1) * 500 < total) { + data = client.fetchDataFromURL( + String.format("/api/hotspots/search?status=TO_REVIEW&ps=500&pageIndex=%d&project=", pageIndex), + project + ); + JSONArray currentPage = data.getJSONArray("hotspots"); + for (int i = 0; i < currentPage.length(); i++) { + dataArray.put(currentPage.getJSONObject(i)); + } + if (data.has("paging")) { + total = data.getJSONObject("paging").getInt("total"); + } + pageIndex++; + } + + JSONArray hotspotArray = new JSONArray(groupHotspotsByRule(dataArray).values()); + for (int i = 0; i < hotspotArray.length(); i++) { + JSONObject hotspot = hotspotArray.getJSONObject(i); + pdf.startBulletEntry(hotspot.getString("message")); + pdf.addIndentedLine("Vulnerability Probability", hotspot.getString("vulnerabilityProbability")); + pdf.addIndentedLine("Count", Integer.toString(hotspot.getInt("count"))); + pdf.addIndentedLine("Locations", hotspot.getString("location")); + pdf.addIndentedHyperlink("Root Cause/How to fix", + apiUrl + "coding_rules?q=" + hotspot.getString("ruleKey") + "&open=" + hotspot.getString("ruleKey"), + hotspot.getString("ruleKey")); + } + } + + private void buildIssuesSection() throws IOException { + pdf.tittle2Font(); + pdf.addLine("ISSUES"); + pdf.tittle3Font(); + pdf.addLine("ISSUES COUNT BY SEVERITY AND TYPES"); + + String[] headers = {"Type / Severity", "INFO", "MINOR", "MAJOR", "CRITICAL", "BLOCKER"}; + List rows = new ArrayList<>(); + + String[][] typeLabels = {{"BUG", "Bug"}, {"VULNERABILITY", "Vulnerability"}, {"CODE_SMELL", "Code Smell"}}; + for (String[] typeLabel : typeLabels) { + JSONObject data = client.fetchDataFromURL("/api/issues/search?types=" + typeLabel[0] + "&facets=severities&componentKeys=", project); + JSONArray facetValues = data.getJSONArray("facets").getJSONObject(0).getJSONArray("values"); + rows.add(new String[]{ + typeLabel[1], + String.valueOf(facetValues.getJSONObject(4).getInt("count")), + String.valueOf(facetValues.getJSONObject(0).getInt("count")), + String.valueOf(facetValues.getJSONObject(1).getInt("count")), + String.valueOf(facetValues.getJSONObject(2).getInt("count")), + String.valueOf(facetValues.getJSONObject(3).getInt("count")) + }); + } + + pdf.drawTable(500, headers, rows); + + pdf.tittle3Font(); + pdf.addLine("ISSUES LIST"); + + JSONArray dataArray = new JSONArray(); + int pageIndex = 1; + int total = Integer.MAX_VALUE; + while ((pageIndex - 1) * 500 < total) { + JSONObject data = client.fetchDataFromURL( + String.format("/api/issues/search?issueStatuses=OPEN&ps=500&pageIndex=%d&componentKeys=", pageIndex), + project + ); + JSONArray currentPage = data.getJSONArray("issues"); + for (int i = 0; i < currentPage.length(); i++) { + dataArray.put(currentPage.getJSONObject(i)); + } + if (data.has("paging")) { + total = data.getJSONObject("paging").getInt("total"); + } + pageIndex++; + } + + JSONArray issuesArray = new JSONArray(groupIssuesByRule(dataArray).values()); + for (int i = 0; i < issuesArray.length(); i++) { + JSONObject issue = issuesArray.getJSONObject(i); + pdf.startBulletEntry(issue.getString("message")); + pdf.addIndentedLine("Type", issue.getString("type")); + pdf.addIndentedLine("Severity", issue.getString("severity")); + pdf.addIndentedLine("Count", Integer.toString(issue.getInt("count"))); + pdf.addIndentedLine("Locations", issue.getString("location")); + pdf.addIndentedHyperlink("Root Cause/How to fix", + apiUrl + "coding_rules?q=" + issue.getString("ruleKey") + "&open=" + issue.getString("ruleKey"), + issue.getString("ruleKey")); + } + } + + static Map groupHotspotsByRule(JSONArray hotspots) { + Map result = new HashMap<>(); + for (int i = 0; i < hotspots.length(); i++) { + JSONObject obj = hotspots.getJSONObject(i); + String ruleKey = obj.getString("ruleKey"); + String file = extractComponent(obj.getString("component")); + String textLine = Integer.toString(obj.getJSONObject("textRange").getInt("startLine")); + if (result.containsKey(ruleKey)) { + JSONObject existing = result.get(ruleKey); + existing.put("count", existing.getInt("count") + 1); + existing.put("location", existing.getString("location") + " | " + file + ": " + textLine); + } else { + JSONObject newObj = new JSONObject(); + newObj.put("ruleKey", ruleKey); + newObj.put("count", 1); + newObj.put("vulnerabilityProbability", obj.getString("vulnerabilityProbability")); + newObj.put("message", obj.getString("message")); + newObj.put("location", file + ": " + textLine); + result.put(ruleKey, newObj); + } + } + return result; + } + + static Map groupIssuesByRule(JSONArray issues) { + Map result = new HashMap<>(); + for (int i = 0; i < issues.length(); i++) { + JSONObject obj = issues.getJSONObject(i); + String ruleKey = obj.getString("rule"); + String file = extractComponent(obj.getString("component")); + if (result.containsKey(ruleKey)) { + JSONObject existing = result.get(ruleKey); + existing.put("count", existing.getInt("count") + 1); + String loc = existing.getString("location"); + if (obj.has("textRange")) { + String textLine = Integer.toString(obj.getJSONObject("textRange").getInt("startLine")); + existing.put("location", loc + " | " + file + ": " + textLine); + } else { + existing.put("location", loc + " | " + file); + } + } else { + JSONObject newObj = new JSONObject(); + newObj.put("ruleKey", ruleKey); + newObj.put("count", 1); + newObj.put("severity", obj.getString("severity")); + newObj.put("message", obj.getString("message")); + newObj.put("type", obj.getString("type")); + if (obj.has("textRange")) { + String textLine = Integer.toString(obj.getJSONObject("textRange").getInt("startLine")); + newObj.put("location", file + ": " + textLine); + } else { + newObj.put("location", file); + } + result.put(ruleKey, newObj); + } + } + return result; + } + + static String extractComponent(String component) { + return component.contains(":") ? component.split(":", 2)[1].trim() : component; + } + + static String ratingToLetter(String value) { + switch (value) { + case "1.0": return "A"; + case "2.0": return "B"; + case "3.0": return "C"; + case "4.0": return "D"; + case "5.0": return "E"; + default: throw new AssertionError("Unknown rating: " + value); + } + } + + static String minsToDaysHoursMins(int minutes) { + int days = minutes / (24 * 60); + int hours = (minutes % (24 * 60)) / 60; + int mins = minutes % 60; + return String.format("%dd %02dh %02dm", days, hours, mins); + } +} diff --git a/pdf-generator/src/main/java/com/ods/SonarApiClient.java b/pdf-generator/src/main/java/com/ods/SonarApiClient.java new file mode 100644 index 0000000..73ca149 --- /dev/null +++ b/pdf-generator/src/main/java/com/ods/SonarApiClient.java @@ -0,0 +1,106 @@ +package com.ods; + +import java.io.IOException; +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; + +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLParameters; +import javax.net.ssl.TrustManager; +import javax.net.ssl.X509TrustManager; + +import org.json.JSONObject; + +public class SonarApiClient { + + private final String apiUrl; + private final String authToken; + private final String branch; + private final HttpClient httpClient; + + public SonarApiClient(String apiUrl, String authToken, String branch) { + this.apiUrl = apiUrl; + this.authToken = authToken; + this.branch = branch; + this.httpClient = createUnsafeHttpClient(); + } + + SonarApiClient(String apiUrl, String authToken, String branch, HttpClient httpClient) { + this.apiUrl = apiUrl; + this.authToken = authToken; + this.branch = branch; + this.httpClient = httpClient; + } + + // Creates an HttpClient that accepts all SSL certificates and disables hostname verification. + private HttpClient createUnsafeHttpClient() { + try { + TrustManager[] trustAll = new TrustManager[]{ + new X509TrustManager() { + public java.security.cert.X509Certificate[] getAcceptedIssuers() { return new java.security.cert.X509Certificate[0]; } + public void checkClientTrusted(java.security.cert.X509Certificate[] certs, String authType) {} + public void checkServerTrusted(java.security.cert.X509Certificate[] certs, String authType) {} + } + }; + SSLContext sslContext = SSLContext.getInstance("TLS"); + sslContext.init(null, trustAll, new java.security.SecureRandom()); + SSLParameters sslParams = sslContext.getDefaultSSLParameters(); + sslParams.setEndpointIdentificationAlgorithm(null); + return HttpClient.newBuilder() + .sslContext(sslContext) + .sslParameters(sslParams) + .build(); + } catch (java.security.NoSuchAlgorithmException | java.security.KeyManagementException e) { + throw new IllegalStateException("The insecure HttpClient couldn't be created", e); + } + } + + public JSONObject fetchDataFromURL(String call, String projectKey) throws IOException { + String encodedProjectKey = URLEncoder.encode(projectKey, StandardCharsets.UTF_8); + String fullURL = String.format("%s%s%s", apiUrl, call, encodedProjectKey); + if (branch != null && !branch.isBlank()) { + fullURL += "&branch=" + URLEncoder.encode(branch, StandardCharsets.UTF_8); + } + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(fullURL)) + .header("Authorization", "Bearer " + authToken) + .build(); + + HttpResponse response; + try { + response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("HTTP request was interrupted: " + fullURL, e); + } + + if (response.statusCode() >= 200 && response.statusCode() < 300) { + return new JSONObject(response.body()); + } else { + String body = response.body(); + String detail = extractSonarError(body); + throw new IOException("SonarQube API error (HTTP " + response.statusCode() + ")" + + (detail != null ? ": " + detail : " — " + body)); + } + } + + private static String extractSonarError(String body) { + try { + org.json.JSONArray errors = new JSONObject(body).optJSONArray("errors"); + if (errors != null && errors.length() > 0) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < errors.length(); i++) { + if (i > 0) sb.append("; "); + sb.append(errors.getJSONObject(i).optString("msg", "unknown error")); + } + return sb.toString(); + } + } catch (Exception ignored) {} + return null; + } +} diff --git a/pdf-generator/src/test/java/com/ods/GenerateSonarReportTest.java b/pdf-generator/src/test/java/com/ods/GenerateSonarReportTest.java new file mode 100644 index 0000000..403b1fb --- /dev/null +++ b/pdf-generator/src/test/java/com/ods/GenerateSonarReportTest.java @@ -0,0 +1,56 @@ +package com.ods; + +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +class GenerateSonarReportTest { + + @Test + void parseArgs_allKnownFlags_returnsCorrectMap() { + String[] args = { + "--sonar-url", "http://sonar", + "--token", "abc123", + "--project", "myProj", + "--branch", "main", + "--output", "report.pdf" + }; + Map result = GenerateSonarReport.parseArgs(args); + assertEquals("http://sonar", result.get("--sonar-url")); + assertEquals("abc123", result.get("--token")); + assertEquals("myProj", result.get("--project")); + assertEquals("main", result.get("--branch")); + assertEquals("report.pdf", result.get("--output")); + } + + @Test + void parseArgs_emptyArgs_returnsEmptyMap() { + Map result = GenerateSonarReport.parseArgs(new String[]{}); + assertTrue(result.isEmpty()); + } + + @Test + void parseArgs_danglingFlag_isIgnored() { + String[] args = {"--sonar-url", "http://sonar", "--token"}; + Map result = GenerateSonarReport.parseArgs(args); + assertEquals(1, result.size()); + assertEquals("http://sonar", result.get("--sonar-url")); + } + + @Test + void parseArgs_nonFlagArgument_isIgnored() { + String[] args = {"notaflag", "value", "--token", "abc"}; + Map result = GenerateSonarReport.parseArgs(args); + assertEquals(1, result.size()); + assertEquals("abc", result.get("--token")); + } + + @Test + void parseArgs_duplicateFlag_lastValueWins() { + String[] args = {"--token", "first", "--token", "second"}; + Map result = GenerateSonarReport.parseArgs(args); + assertEquals("second", result.get("--token")); + } +} diff --git a/pdf-generator/src/test/java/com/ods/PDFReportWriterTest.java b/pdf-generator/src/test/java/com/ods/PDFReportWriterTest.java new file mode 100644 index 0000000..2b793d6 --- /dev/null +++ b/pdf-generator/src/test/java/com/ods/PDFReportWriterTest.java @@ -0,0 +1,60 @@ +package com.ods; + +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.regex.Pattern; + +import static org.junit.jupiter.api.Assertions.*; + +class PDFReportWriterTest { + + // --- getCurrentGMTTimeFormatted --- + + @Test + void getCurrentGMTTimeFormatted_matchesExpectedPattern() { + String result = PDFReportWriter.getCurrentGMTTimeFormatted(); + assertNotNull(result); + // Expected: "May 7, 2026, 09:30 AM GMT" + Pattern pattern = Pattern.compile("^[A-Z][a-z]+ \\d{1,2}, \\d{4}, \\d{2}:\\d{2} (AM|PM) GMT$"); + assertTrue(pattern.matcher(result).matches(), + "Date string '" + result + "' does not match expected format"); + } + + @Test + void getCurrentGMTTimeFormatted_containsGMT() { + assertTrue(PDFReportWriter.getCurrentGMTTimeFormatted().endsWith("GMT")); + } + + // --- splitBySlash --- + + @Test + void splitBySlash_noSlash_returnsSingleElement() { + List result = PDFReportWriter.splitBySlash("filename.java"); + assertEquals(1, result.size()); + assertEquals("filename.java", result.get(0)); + } + + @Test + void splitBySlash_singleSlash_splitsThere() { + List result = PDFReportWriter.splitBySlash("src/main"); + assertEquals(2, result.size()); + assertEquals("src/", result.get(0)); + assertEquals("main", result.get(1)); + } + + @Test + void splitBySlash_multipleSlashes_splitsAtEach() { + List result = PDFReportWriter.splitBySlash("src/main/java/"); + assertEquals(3, result.size()); + assertEquals("src/", result.get(0)); + assertEquals("main/", result.get(1)); + assertEquals("java/", result.get(2)); + } + + @Test + void splitBySlash_emptyString_returnsEmptyElement() { + List result = PDFReportWriter.splitBySlash(""); + assertEquals(0, result.size()); + } +} diff --git a/pdf-generator/src/test/java/com/ods/ReportBuilderTest.java b/pdf-generator/src/test/java/com/ods/ReportBuilderTest.java new file mode 100644 index 0000000..51bd8a8 --- /dev/null +++ b/pdf-generator/src/test/java/com/ods/ReportBuilderTest.java @@ -0,0 +1,191 @@ +package com.ods; + +import org.json.JSONArray; +import org.json.JSONObject; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +class ReportBuilderTest { + + // --- extractComponent --- + + @Test + void extractComponent_withColon_returnsAfterColon() { + assertEquals("src/main/java/Foo.java", ReportBuilder.extractComponent("myproject:src/main/java/Foo.java")); + } + + @Test + void extractComponent_noColon_returnsFull() { + assertEquals("myproject", ReportBuilder.extractComponent("myproject")); + } + + @Test + void extractComponent_multipleColons_splitsOnFirst() { + assertEquals("path:to/file", ReportBuilder.extractComponent("project:path:to/file")); + } + + // --- ratingToLetter --- + + @Test + void ratingToLetter_allRatings() { + assertEquals("A", ReportBuilder.ratingToLetter("1.0")); + assertEquals("B", ReportBuilder.ratingToLetter("2.0")); + assertEquals("C", ReportBuilder.ratingToLetter("3.0")); + assertEquals("D", ReportBuilder.ratingToLetter("4.0")); + assertEquals("E", ReportBuilder.ratingToLetter("5.0")); + } + + @Test + void ratingToLetter_unknownValue_throwsAssertionError() { + assertThrows(AssertionError.class, () -> ReportBuilder.ratingToLetter("6.0")); + } + + // --- minsToDaysHoursMins --- + + @Test + void minsToDaysHoursMins_zero() { + assertEquals("0d 00h 00m", ReportBuilder.minsToDaysHoursMins(0)); + } + + @Test + void minsToDaysHoursMins_exactHour() { + assertEquals("0d 01h 00m", ReportBuilder.minsToDaysHoursMins(60)); + } + + @Test + void minsToDaysHoursMins_mixed() { + assertEquals("0d 01h 30m", ReportBuilder.minsToDaysHoursMins(90)); + } + + @Test + void minsToDaysHoursMins_exactDay() { + assertEquals("1d 00h 00m", ReportBuilder.minsToDaysHoursMins(24 * 60)); + } + + @Test + void minsToDaysHoursMins_largeValue() { + assertEquals("2d 03h 45m", ReportBuilder.minsToDaysHoursMins(2 * 24 * 60 + 3 * 60 + 45)); + } + + // --- groupHotspotsByRule --- + + @Test + void groupHotspotsByRule_singleHotspot_correctEntry() { + JSONArray array = new JSONArray(); + array.put(buildHotspot("java:S1234", "Security issue", "HIGH", "project:src/Foo.java", 42)); + + Map result = ReportBuilder.groupHotspotsByRule(array); + + assertEquals(1, result.size()); + JSONObject entry = result.get("java:S1234"); + assertNotNull(entry); + assertEquals(1, entry.getInt("count")); + assertEquals("Security issue", entry.getString("message")); + assertEquals("src/Foo.java: 42", entry.getString("location")); + } + + @Test + void groupHotspotsByRule_duplicateRuleKey_aggregatesCountAndLocations() { + JSONArray array = new JSONArray(); + for (int i = 0; i < 3; i++) { + array.put(buildHotspot("java:S1234", "Security issue", "HIGH", "project:src/Foo.java", 10 + i)); + } + + Map result = ReportBuilder.groupHotspotsByRule(array); + + assertEquals(1, result.size()); + assertEquals(3, result.get("java:S1234").getInt("count")); + assertTrue(result.get("java:S1234").getString("location").contains(" | ")); + } + + @Test + void groupHotspotsByRule_differentRuleKeys_separateEntries() { + JSONArray array = new JSONArray(); + array.put(buildHotspot("java:S0001", "Issue A", "HIGH", "project:A.java", 1)); + array.put(buildHotspot("java:S0002", "Issue B", "LOW", "project:B.java", 2)); + + Map result = ReportBuilder.groupHotspotsByRule(array); + + assertEquals(2, result.size()); + assertTrue(result.containsKey("java:S0001")); + assertTrue(result.containsKey("java:S0002")); + } + + // --- groupIssuesByRule --- + + @Test + void groupIssuesByRule_singleIssueWithTextRange_correctEntry() { + JSONArray array = new JSONArray(); + array.put(buildIssue("java:S2095", "Close this resource", "MAJOR", "BUG", "project:src/Bar.java", 99)); + + Map result = ReportBuilder.groupIssuesByRule(array); + + assertEquals(1, result.size()); + JSONObject entry = result.get("java:S2095"); + assertNotNull(entry); + assertEquals(1, entry.getInt("count")); + assertEquals("MAJOR", entry.getString("severity")); + assertEquals("BUG", entry.getString("type")); + assertEquals("src/Bar.java: 99", entry.getString("location")); + } + + @Test + void groupIssuesByRule_issueWithoutTextRange_usesFileOnly() { + JSONObject issue = new JSONObject(); + issue.put("rule", "java:S0000"); + issue.put("message", "Some issue"); + issue.put("severity", "INFO"); + issue.put("type", "CODE_SMELL"); + issue.put("component", "project:src/Baz.java"); + + JSONArray array = new JSONArray(); + array.put(issue); + + Map result = ReportBuilder.groupIssuesByRule(array); + + assertEquals("src/Baz.java", result.get("java:S0000").getString("location")); + } + + @Test + void groupIssuesByRule_duplicateRuleKey_aggregatesCount() { + JSONArray array = new JSONArray(); + for (int i = 0; i < 4; i++) { + array.put(buildIssue("java:S9999", "Repeated", "MINOR", "CODE_SMELL", "project:X.java", 5 + i)); + } + + Map result = ReportBuilder.groupIssuesByRule(array); + + assertEquals(1, result.size()); + assertEquals(4, result.get("java:S9999").getInt("count")); + } + + // --- helpers --- + + private JSONObject buildHotspot(String ruleKey, String message, String probability, String component, int line) { + JSONObject hotspot = new JSONObject(); + hotspot.put("ruleKey", ruleKey); + hotspot.put("message", message); + hotspot.put("vulnerabilityProbability", probability); + hotspot.put("component", component); + JSONObject textRange = new JSONObject(); + textRange.put("startLine", line); + hotspot.put("textRange", textRange); + return hotspot; + } + + private JSONObject buildIssue(String rule, String message, String severity, String type, String component, int line) { + JSONObject issue = new JSONObject(); + issue.put("rule", rule); + issue.put("message", message); + issue.put("severity", severity); + issue.put("type", type); + issue.put("component", component); + JSONObject textRange = new JSONObject(); + textRange.put("startLine", line); + issue.put("textRange", textRange); + return issue; + } +} diff --git a/pdf-generator/src/test/java/com/ods/SonarApiClientTest.java b/pdf-generator/src/test/java/com/ods/SonarApiClientTest.java new file mode 100644 index 0000000..5d1b28c --- /dev/null +++ b/pdf-generator/src/test/java/com/ods/SonarApiClientTest.java @@ -0,0 +1,245 @@ +package com.ods; + +import org.json.JSONObject; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.io.IOException; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; + +class SonarApiClientTest { + + @SuppressWarnings("unchecked") + private HttpResponse mockResponse(int status, String body) { + HttpResponse response = Mockito.mock(HttpResponse.class); + Mockito.when(response.statusCode()).thenReturn(status); + Mockito.when(response.body()).thenReturn(body); + return response; + } + + @SuppressWarnings("unchecked") + private HttpClient mockClientReturning(HttpResponse response) throws IOException, InterruptedException { + HttpClient client = Mockito.mock(HttpClient.class); + Mockito.doReturn(response).when(client).send(any(HttpRequest.class), any()); + return client; + } + + // --- successful responses --- + + @Test + void fetchDataFromURL_successfulResponse_returnsJsonObject() throws Exception { + HttpClient mockClient = mockClientReturning(mockResponse(200, "{\"key\": \"value\"}")); + SonarApiClient client = new SonarApiClient("http://sonar", "token", null, mockClient); + + JSONObject result = client.fetchDataFromURL("/api/test?component=", "myproject"); + + assertEquals("value", result.getString("key")); + } + + @Test + void fetchDataFromURL_withBranch_doesNotThrow() throws Exception { + HttpClient mockClient = mockClientReturning(mockResponse(200, "{\"ok\": true}")); + SonarApiClient client = new SonarApiClient("http://sonar", "token", "main", mockClient); + + JSONObject result = client.fetchDataFromURL("/api/test?component=", "proj"); + + assertTrue(result.getBoolean("ok")); + } + + @Test + void fetchDataFromURL_withBlankBranch_treatedAsNoBranch() throws Exception { + HttpClient mockClient = mockClientReturning(mockResponse(200, "{\"ok\": true}")); + SonarApiClient client = new SonarApiClient("http://sonar", "token", " ", mockClient); + + JSONObject result = client.fetchDataFromURL("/api/test?component=", "proj"); + + assertTrue(result.getBoolean("ok")); + } + + // --- error status codes: message content --- + + @Test + void fetchDataFromURL_404WithSonarErrorMsg_messageIncludesDetail() throws Exception { + String body = "{\"errors\":[{\"msg\":\"Component 'potato' on branch 'main' not found\"}]}"; + HttpClient mockClient = mockClientReturning(mockResponse(404, body)); + SonarApiClient client = new SonarApiClient("http://sonar", "token", null, mockClient); + + IOException ex = assertThrows(IOException.class, () -> + client.fetchDataFromURL("/api/navigation/component?component=", "potato")); + + assertTrue(ex.getMessage().contains("HTTP 404"), "message should include status code"); + assertTrue(ex.getMessage().contains("Component 'potato' on branch 'main' not found"), + "message should include SonarQube error detail"); + } + + @Test + void fetchDataFromURL_401WithSonarErrorMsg_messageIncludesDetail() throws Exception { + String body = "{\"errors\":[{\"msg\":\"Authentication required\"}]}"; + HttpClient mockClient = mockClientReturning(mockResponse(401, body)); + SonarApiClient client = new SonarApiClient("http://sonar", "token", null, mockClient); + + IOException ex = assertThrows(IOException.class, () -> + client.fetchDataFromURL("/api/test?component=", "myproject")); + + assertTrue(ex.getMessage().contains("HTTP 401")); + assertTrue(ex.getMessage().contains("Authentication required")); + } + + @Test + void fetchDataFromURL_multipleErrors_allConcatenatedInMessage() throws Exception { + String body = "{\"errors\":[{\"msg\":\"First error\"},{\"msg\":\"Second error\"}]}"; + HttpClient mockClient = mockClientReturning(mockResponse(400, body)); + SonarApiClient client = new SonarApiClient("http://sonar", "token", null, mockClient); + + IOException ex = assertThrows(IOException.class, () -> + client.fetchDataFromURL("/api/test?component=", "proj")); + + assertTrue(ex.getMessage().contains("First error")); + assertTrue(ex.getMessage().contains("Second error")); + } + + @Test + void fetchDataFromURL_nonJsonErrorBody_rawBodyIncludedInMessage() throws Exception { + String body = "Service Unavailable"; + HttpClient mockClient = mockClientReturning(mockResponse(503, body)); + SonarApiClient client = new SonarApiClient("http://sonar", "token", null, mockClient); + + IOException ex = assertThrows(IOException.class, () -> + client.fetchDataFromURL("/api/test?component=", "proj")); + + assertTrue(ex.getMessage().contains("HTTP 503")); + assertTrue(ex.getMessage().contains("Service Unavailable")); + } + + @Test + void fetchDataFromURL_emptyErrorsArray_rawBodyIncludedInMessage() throws Exception { + String body = "{\"errors\":[]}"; + HttpClient mockClient = mockClientReturning(mockResponse(404, body)); + SonarApiClient client = new SonarApiClient("http://sonar", "token", null, mockClient); + + IOException ex = assertThrows(IOException.class, () -> + client.fetchDataFromURL("/api/test?component=", "myproject")); + + assertTrue(ex.getMessage().contains("HTTP 404")); + assertTrue(ex.getMessage().contains(body)); + } + + @Test + void fetchDataFromURL_500Response_throwsIOException() throws Exception { + HttpClient mockClient = mockClientReturning(mockResponse(500, "{\"errors\":[{\"msg\":\"Internal error\"}]}")); + SonarApiClient client = new SonarApiClient("http://sonar", "token", null, mockClient); + + IOException ex = assertThrows(IOException.class, () -> + client.fetchDataFromURL("/api/test?component=", "myproject")); + + assertTrue(ex.getMessage().contains("HTTP 500")); + assertTrue(ex.getMessage().contains("Internal error")); + } + + // --- InterruptedException handling --- + + @Test + @SuppressWarnings("unchecked") + void fetchDataFromURL_interruptedException_wrappedAsIOException() throws Exception { + HttpClient mockClient = Mockito.mock(HttpClient.class); + Mockito.doThrow(new InterruptedException("connection interrupted")) + .when(mockClient).send(any(HttpRequest.class), any()); + SonarApiClient client = new SonarApiClient("http://sonar", "token", null, mockClient); + + Thread.currentThread().interrupted(); // clear any prior flag + IOException ex = assertThrows(IOException.class, () -> + client.fetchDataFromURL("/api/test?component=", "proj")); + + assertTrue(ex.getMessage().contains("interrupted")); + assertInstanceOf(InterruptedException.class, ex.getCause()); + } + + @Test + @SuppressWarnings("unchecked") + void fetchDataFromURL_interruptedException_restoresThreadInterruptFlag() throws Exception { + HttpClient mockClient = Mockito.mock(HttpClient.class); + Mockito.doThrow(new InterruptedException()) + .when(mockClient).send(any(HttpRequest.class), any()); + SonarApiClient client = new SonarApiClient("http://sonar", "token", null, mockClient); + + Thread.currentThread().interrupted(); // clear any prior flag + assertThrows(IOException.class, () -> + client.fetchDataFromURL("/api/test?component=", "proj")); + + assertTrue(Thread.currentThread().isInterrupted(), "thread interrupt flag should be restored"); + Thread.currentThread().interrupted(); // clean up + } + + // --- URL encoding --- + + @Test + void fetchDataFromURL_projectKeyWithSpecialChars_encodedInUrl() throws Exception { + HttpClient mockClient = Mockito.mock(HttpClient.class); + HttpResponse response = mockResponse(200, "{\"ok\": true}"); + + @SuppressWarnings("unchecked") + java.util.concurrent.atomic.AtomicReference capturedRequest = + new java.util.concurrent.atomic.AtomicReference<>(); + + Mockito.doAnswer(inv -> { + capturedRequest.set(inv.getArgument(0)); + return response; + }).when(mockClient).send(any(HttpRequest.class), any()); + + SonarApiClient client = new SonarApiClient("http://sonar", "token", null, mockClient); + client.fetchDataFromURL("/api/test?component=", "my project/key"); + + String url = capturedRequest.get().uri().toString(); + assertFalse(url.contains(" "), "URL should not contain raw spaces"); + assertTrue(url.contains("my+project") || url.contains("my%20project"), + "URL should contain encoded project key"); + } + + @Test + void fetchDataFromURL_withBranch_includesInUrl() throws Exception { + HttpClient mockClient = Mockito.mock(HttpClient.class); + HttpResponse response = mockResponse(200, "{\"ok\": true}"); + + @SuppressWarnings("unchecked") + java.util.concurrent.atomic.AtomicReference capturedRequest = + new java.util.concurrent.atomic.AtomicReference<>(); + + Mockito.doAnswer(inv -> { + capturedRequest.set(inv.getArgument(0)); + return response; + }).when(mockClient).send(any(HttpRequest.class), any()); + + SonarApiClient client = new SonarApiClient("http://sonar", "token", "feature/test", mockClient); + client.fetchDataFromURL("/api/test?component=", "myproject"); + + String url = capturedRequest.get().uri().toString(); + assertTrue(url.contains("&branch="), "URL should contain branch parameter"); + assertTrue(url.contains("feature%2Ftest"), "branch value should be URL-encoded"); + } + + @Test + void fetchDataFromURL_withoutBranch_noBranchInUrl() throws Exception { + HttpClient mockClient = Mockito.mock(HttpClient.class); + HttpResponse response = mockResponse(200, "{\"ok\": true}"); + + @SuppressWarnings("unchecked") + java.util.concurrent.atomic.AtomicReference capturedRequest = + new java.util.concurrent.atomic.AtomicReference<>(); + + Mockito.doAnswer(inv -> { + capturedRequest.set(inv.getArgument(0)); + return response; + }).when(mockClient).send(any(HttpRequest.class), any()); + + SonarApiClient client = new SonarApiClient("http://sonar", "token", null, mockClient); + client.fetchDataFromURL("/api/test?component=", "myproject"); + + String url = capturedRequest.get().uri().toString(); + assertFalse(url.contains("&branch="), "URL should not contain branch parameter when branch is null"); + } +}