diff --git a/.github/workflows/sonarqube-main.yml b/.github/workflows/sonarqube-main.yml new file mode 100644 index 0000000..ef8c1cc --- /dev/null +++ b/.github/workflows/sonarqube-main.yml @@ -0,0 +1,333 @@ +name: SonarCloud Full Metrics (main branch) + +on: + push: + branches: [main, master] + workflow_dispatch: + +permissions: + contents: read + +jobs: + full-metrics: + name: Full Project Report + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Python-Version aus Dockerfile lesen + id: pyver + run: | + VERSION=$(grep -oP '(?<=python:)\d+\.\d+' project/Dockerfile-backend | head -1) + echo "version=$VERSION" >> $GITHUB_OUTPUT + + # ===================================================== + # Backend-Tests + Coverage + # ===================================================== + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ steps.pyver.outputs.version }} + + - name: Install backend deps + working-directory: project/backend + run: | + python -m pip install --upgrade pip + python -m pip install -r requirements.txt + python -m pip install "pytest-cov>=5,<7" "coverage>=7" + + - name: Run backend tests with coverage + working-directory: project + run: | + python -m pytest \ + --cov=backend \ + --cov-report=xml:backend/coverage.xml \ + --cov-report=term-missing \ + backend/tests/ + continue-on-error: true + + # ===================================================== + # SonarCloud Scan + # ===================================================== + - name: SonarQube Scan + uses: SonarSource/sonarqube-scan-action@v3 + with: + projectBaseDir: project + args: > + -Dsonar.projectKey=GalacticCodeGambit_LazyCook + -Dsonar.organization=galacticcodegambit + -Dproject.settings=sonar-project.properties + -Dsonar.python.coverage.reportPaths=backend/coverage.xml + -Dsonar.python.version=${{ steps.pyver.outputs.version }} + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + + - name: Wait for analysis + id: qg + uses: SonarSource/sonarqube-quality-gate-action@v1.2.0 + timeout-minutes: 5 + with: + scanMetadataReportFile: project/.scannerwork/report-task.txt + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + + # ===================================================== + # Voller Projekt-Report ins Job-Summary + # ===================================================== + - name: Render Full Metrics + if: always() + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + SONAR_HOST: https://sonarcloud.io + QG_STATUS: ${{ steps.qg.outputs.quality-gate-status }} + run: | + set +e + KEY="GalacticCodeGambit_LazyCook" + OUT="$GITHUB_STEP_SUMMARY" + + # Alle relevanten Branch-Metriken auf einen Schwung holen + METRICS="alert_status,bugs,vulnerabilities,code_smells,security_hotspots,security_hotspots_reviewed,coverage,line_coverage,branch_coverage,lines_to_cover,uncovered_lines,duplicated_lines_density,duplicated_blocks,duplicated_files,duplicated_lines,complexity,cognitive_complexity,classes,functions,statements,files,ncloc,comment_lines,comment_lines_density,sqale_rating,reliability_rating,security_rating,security_review_rating,sqale_index,sqale_debt_ratio,reliability_remediation_effort,security_remediation_effort" + + URL="${SONAR_HOST}/api/measures/component?component=${KEY}&metricKeys=${METRICS}" + RESP=$(curl -s -u "${SONAR_TOKEN}:" "${URL}") + + # Mini-Diagnose im Log + echo "Anfrage: ${URL}" + echo "Response (erste 300 Zeichen):" + echo "${RESP}" | head -c 300 + echo "" + + # Hilfsfunktion: Metrik-Wert holen, "-" als Fallback + get() { + echo "${RESP}" | jq -r --arg k "$1" \ + '(.component.measures[]? | select(.metric==$k) | .value) // "-"' 2>/dev/null + } + + # Rating-Buchstabe (1.0..5.0 → A..E) + rating() { + case "$(get "$1")" in + 1.0) echo "A" ;; 2.0) echo "B" ;; 3.0) echo "C" ;; + 4.0) echo "D" ;; 5.0) echo "E" ;; *) echo "-" ;; + esac + } + + # Minuten → Stunden + Minuten + fmt_debt() { + local m=$1 + if [[ "$m" =~ ^[0-9]+$ ]]; then + local h=$((m / 60)); local r=$((m % 60)) + echo "${h}h ${r}min" + else + echo "-" + fi + } + + # Quality-Gate-Icon + if [ "${QG_STATUS}" = "PASSED" ] || [ "$(get alert_status)" = "OK" ]; then + QG_ICON=":white_check_mark:"; QG_TXT="PASSED" + else + QG_ICON=":x:"; QG_TXT="FAILED" + fi + + DEBT_FMT=$(fmt_debt "$(get sqale_index)") + REL_EFF_FMT=$(fmt_debt "$(get reliability_remediation_effort)") + SEC_EFF_FMT=$(fmt_debt "$(get security_remediation_effort)") + + # ===== Backend-Coverage direkt aus coverage.xml ===== + COV_FILE="project/backend/coverage.xml" + if [ -f "${COV_FILE}" ]; then + COV_DATA=$(python3 - "${COV_FILE}" <<'PYEOF' + import sys, xml.etree.ElementTree as ET + root = ET.parse(sys.argv[1]).getroot() + lr = float(root.get('line-rate', '0')) + br = float(root.get('branch-rate', '0')) + lv = int(root.get('lines-valid', '0')) + lc = int(root.get('lines-covered', '0')) + bv = int(root.get('branches-valid', '0')) + bc = int(root.get('branches-covered', '0')) + total = lv + bv + covered = lc + bc + overall = (covered / total * 100) if total > 0 else 0.0 + uncov_lines = lv - lc + uncov_branches = bv - bc + print(f"{overall:.1f} {lr*100:.1f} {br*100:.1f} {lv} {lc} {bv} {bc} {uncov_lines} {uncov_branches}") + PYEOF + ) + read -r COV_OVERALL COV_LINE COV_BRANCH LINES_VALID LINES_COVERED BRANCHES_VALID BRANCHES_COVERED UNCOVERED_LINES UNCOVERED_BRANCHES <<< "${COV_DATA}" + echo "Backend-Coverage aus coverage.xml: ${COV_OVERALL}% (Line ${COV_LINE}%, Branch ${COV_BRANCH}%)" + else + echo "::warning::coverage.xml nicht gefunden bei ${COV_FILE}" + COV_OVERALL="-"; COV_LINE="-"; COV_BRANCH="-" + LINES_VALID="-"; LINES_COVERED="-"; BRANCHES_VALID="-"; BRANCHES_COVERED="-" + UNCOVERED_LINES="-"; UNCOVERED_BRANCHES="-" + fi + + # ===== Summary schreiben ===== + { + echo "# SonarCloud – Voller Projekt-Report" + echo "" + echo "**Quality Gate:** ${QG_ICON} **${QG_TXT}** " + echo "**Branch:** ${GITHUB_REF_NAME} " + echo "**Commit:** ${GITHUB_SHA:0:7} " + echo "**Dashboard:** [${SONAR_HOST}/project/overview?id=${KEY}](${SONAR_HOST}/project/overview?id=${KEY})" + echo "" + + # --- Übersicht --- + echo "## Projekt-Übersicht" + echo "" + echo "| Kennzahl | Wert |" + echo "|---|---:|" + echo "| Lines of Code | $(get ncloc) |" + echo "| Kommentar-Zeilen | $(get comment_lines) |" + echo "| Kommentar-Anteil | $(get comment_lines_density)% |" + echo "| Dateien | $(get files) |" + echo "| Klassen | $(get classes) |" + echo "| Funktionen | $(get functions) |" + echo "| Statements | $(get statements) |" + echo "" + + # --- Coverage (aus pytest-cov coverage.xml, nicht aus Sonar-API) --- + echo "## Test-Coverage (Backend / Python)" + echo "" + echo "_Werte direkt aus \`backend/coverage.xml\` von pytest-cov gelesen._" + echo "" + echo "| Metrik | Wert |" + echo "|---|---:|" + echo "| Coverage (gesamt) | ${COV_OVERALL}% |" + echo "| Line Coverage | ${COV_LINE}% |" + echo "| Branch Coverage | ${COV_BRANCH}% |" + echo "| Deckbare Zeilen | ${LINES_VALID} |" + echo "| Gedeckte Zeilen | ${LINES_COVERED} |" + echo "| Ungedeckte Zeilen | ${UNCOVERED_LINES} |" + echo "| Deckbare Branches | ${BRANCHES_VALID} |" + echo "| Gedeckte Branches | ${BRANCHES_COVERED} |" + echo "| Ungedeckte Branches | ${UNCOVERED_BRANCHES} |" + echo "" + + # --- Duplikate --- + echo "## Duplikate" + echo "" + echo "| Metrik | Wert |" + echo "|---|---:|" + echo "| Duplizierte Zeilen (Rate) | $(get duplicated_lines_density)% |" + echo "| Duplizierte Zeilen (absolut) | $(get duplicated_lines) |" + echo "| Duplikat-Blöcke | $(get duplicated_blocks) |" + echo "| Betroffene Dateien | $(get duplicated_files) |" + echo "" + + # --- Komplexität --- + echo "## Komplexität" + echo "" + echo "| Metrik | Wert |" + echo "|---|---:|" + echo "| Zyklomatische Komplexität | $(get complexity) |" + echo "| Cognitive Complexity | $(get cognitive_complexity) |" + echo "" + + # --- Issues & Ratings --- + echo "## Issues & Bewertungen" + echo "" + echo "| Kategorie | Rating | Anzahl | Behebungs-Aufwand |" + echo "|---|:---:|---:|---:|" + echo "| Reliability (Bugs) | $(rating reliability_rating) | $(get bugs) | ${REL_EFF_FMT} |" + echo "| Security (Vulnerabilities) | $(rating security_rating) | $(get vulnerabilities) | ${SEC_EFF_FMT} |" + echo "| Maintainability (Code Smells) | $(rating sqale_rating) | $(get code_smells) | ${DEBT_FMT} |" + echo "| Security Hotspots | $(rating security_review_rating) | $(get security_hotspots) | $(get security_hotspots_reviewed)% reviewed |" + echo "" + + # --- Technische Schuld --- + echo "## Technische Schuld" + echo "" + echo "| Metrik | Wert |" + echo "|---|---:|" + echo "| Gesamte Schuld | ${DEBT_FMT} |" + echo "| Debt Ratio | $(get sqale_debt_ratio)% |" + echo "" + } >> "$OUT" + + # ===================================================== + # Top-10 Files: höchste Komplexität, schlechteste Coverage + # ===================================================== + { + echo "## Top-10 komplexeste Dateien" + echo "" + echo "| Datei | Komplexität | Cognitive | LoC |" + echo "|---|---:|---:|---:|" + } >> "$OUT" + + curl -s -u "${SONAR_TOKEN}:" \ + "${SONAR_HOST}/api/measures/component_tree?component=${KEY}&metricKeys=complexity,cognitive_complexity,ncloc&qualifiers=FIL&ps=10&s=metric&metricSort=complexity&asc=false" \ + | jq -r '.components[]? + | (.path // .key) as $p + | ((.measures[]? | select(.metric=="complexity") | .value) // "-") as $c + | ((.measures[]? | select(.metric=="cognitive_complexity")| .value) // "-") as $cog + | ((.measures[]? | select(.metric=="ncloc") | .value) // "-") as $n + | "| " + $p + " | " + $c + " | " + $cog + " | " + $n + " |"' \ + >> "$OUT" + + { + echo "" + echo "## Coverage-Übersicht aller Backend-Dateien" + echo "" + echo "_Aus \`backend/coverage.xml\`, sortiert aufsteigend nach Coverage (schlechteste zuerst)._" + echo "" + echo "| Datei | Coverage | Gedeckte / Deckbare | Ungedeckte Zeilen |" + echo "|---|---:|---:|---:|" + } >> "$OUT" + + if [ -f "${COV_FILE}" ]; then + python3 - "${COV_FILE}" >> "$OUT" <<'PYEOF' + import sys, xml.etree.ElementTree as ET + root = ET.parse(sys.argv[1]).getroot() + rows = [] + for cls in root.iter('class'): + fname = cls.get('filename', '') + lines = cls.find('lines') + if lines is None: + continue + total = len(lines.findall('line')) + if total == 0: + continue + covered = sum(1 for l in lines.findall('line') if int(l.get('hits', '0')) > 0) + uncov = total - covered + pct = covered / total * 100 + rows.append((pct, fname, covered, total, uncov)) + # Aufsteigend nach Coverage, bei Gleichstand größere Dateien zuerst + rows.sort(key=lambda r: (r[0], -r[3])) + for pct, fname, cov, total, uncov in rows: + print(f"| {fname} | {pct:.1f}% | {cov} / {total} | {uncov} |") + print() + print(f"_Insgesamt {len(rows)} Datei(en) mit Coverage-Daten._") + PYEOF + else + echo "_coverage.xml nicht gefunden._" >> "$OUT" + fi + + { + echo "" + echo "## Top-10 Dateien mit den meisten Duplikat-Blöcken" + echo "" + echo "| Datei | Blöcke | Dupl. Zeilen | LoC |" + echo "|---|---:|---:|---:|" + } >> "$OUT" + + curl -s -u "${SONAR_TOKEN}:" \ + "${SONAR_HOST}/api/measures/component_tree?component=${KEY}&metricKeys=duplicated_blocks,duplicated_lines,ncloc&qualifiers=FIL&ps=10&s=metric&metricSort=duplicated_blocks&asc=false" \ + | jq -r '.components[]? + | (.path // .key) as $p + | ((.measures[]? | select(.metric=="duplicated_blocks") | .value) // "0") as $b + | ((.measures[]? | select(.metric=="duplicated_lines") | .value) // "0") as $l + | ((.measures[]? | select(.metric=="ncloc") | .value) // "-") as $n + | select(($b | tonumber) > 0) + | "| " + $p + " | " + $b + " | " + $l + " | " + $n + " |"' \ + >> "$OUT" + + { + echo "" + echo "---" + echo "_Generiert von SonarCloud-Analyse aus Push auf \`${GITHUB_REF_NAME}\`._" + } >> "$OUT" diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml new file mode 100644 index 0000000..e2bc9c3 --- /dev/null +++ b/.github/workflows/sonarqube.yml @@ -0,0 +1,273 @@ + name: SonarCloud Analysis + + on: + pull_request: + branches: [main, master] + workflow_dispatch: + + permissions: + contents: read + + jobs: + sonarcloud: + name: Build, Test & Sonar Scan + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Python-Version aus Dockerfile lesen + id: pyver + run: | + VERSION=$(grep -oP '(?<=python:)\d+\.\d+' project/Dockerfile-backend | head -1) + echo "version=$VERSION" >> $GITHUB_OUTPUT + + # ===================================================== + # BACKEND (Python / FastAPI) – Tests + Coverage + # ===================================================== + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ steps.pyver.outputs.version }} + + - name: Install backend dependencies + working-directory: project/backend + run: | + python -m pip install --upgrade pip + python -m pip install -r requirements.txt + python -m pip install "pytest-cov>=5,<7" "coverage>=7" + + - name: Run backend tests with coverage + working-directory: project + run: | + python -m pytest \ + --cov=backend \ + --cov-report=xml:backend/coverage.xml \ + --cov-report=term-missing \ + backend/tests/ + continue-on-error: true + + # ===================================================== + # SONARCLOUD SCAN + # ===================================================== + - name: SonarQube Scan + uses: SonarSource/sonarqube-scan-action@v3 + with: + projectBaseDir: project + args: > + -Dsonar.projectKey=GalacticCodeGambit_LazyCook + -Dsonar.organization=galacticcodegambit + -Dproject.settings=sonar-project.properties + -Dsonar.python.coverage.reportPaths=backend/coverage.xml + -Dsonar.python.version=${{ steps.pyver.outputs.version }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + + - name: SonarCloud Quality Gate check + id: sonar-qg + uses: SonarSource/sonarqube-quality-gate-action@v1.2.0 + timeout-minutes: 5 + with: + scanMetadataReportFile: project/.scannerwork/report-task.txt + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + + # ===================================================== + # METRIKEN ALS MARKDOWN-SUMMARY IN GITHUB AUSGEBEN + # ===================================================== + - name: Render SonarCloud metrics to Job Summary + if: always() + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + SONAR_HOST: https://sonarcloud.io + QG_STATUS: ${{ steps.sonar-qg.outputs.quality-gate-status }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + set +e + + PROJECT_KEY=$(grep -E '^\s*sonar.projectKey\s*=' project/sonar-project.properties \ + | head -1 | cut -d'=' -f2 | tr -d ' \r\n') + + # PR-Kontext erkennen: dann Daten der PR-Analyse holen, nicht der Main-Branch + PR_PARAM="" + if [ -n "${PR_NUMBER}" ]; then + PR_PARAM="&pullRequest=${PR_NUMBER}" + echo "PR-Modus aktiv – Daten werden für PR #${PR_NUMBER} geholt" + else + echo "Branch-Modus aktiv – Daten werden für den Default-Branch geholt" + fi + + METRICS="alert_status,bugs,vulnerabilities,code_smells,coverage,line_coverage,branch_coverage,duplicated_lines_density,duplicated_blocks,duplicated_files,complexity,cognitive_complexity,ncloc,sqale_rating,reliability_rating,security_rating,sqale_index" + + API_URL="${SONAR_HOST}/api/measures/component?component=${PROJECT_KEY}&metricKeys=${METRICS}${PR_PARAM}" + HTTP_CODE=$(curl -s -o /tmp/sonar.json -w "%{http_code}" \ + -u "${SONAR_TOKEN}:" "${API_URL}") + RESP=$(cat /tmp/sonar.json) + + HAS_MEASURES=$(echo "${RESP}" | jq -r 'if (.component? and .component.measures?) then "yes" else "no" end' 2>/dev/null) + + # Zusätzlich Branch-Daten holen für Metriken, die im PR-Modus leer sind + # (z.B. ncloc, complexity, cognitive_complexity – Projekt-strukturelle Werte) + BRANCH_URL="${SONAR_HOST}/api/measures/component?component=${PROJECT_KEY}&metricKeys=${METRICS}" + curl -s -o /tmp/sonar_branch.json -u "${SONAR_TOKEN}:" "${BRANCH_URL}" >/dev/null + BRANCH_RESP=$(cat /tmp/sonar_branch.json) + + SUMMARY_FILE="${GITHUB_STEP_SUMMARY}" + + if [ "${HTTP_CODE}" != "200" ] || [ "${HAS_MEASURES}" != "yes" ]; then + { + echo "## SonarCloud Analyse" + echo "" + echo ":warning: Konnte die Metriken nicht abrufen." + echo "" + echo "- **HTTP Status:** ${HTTP_CODE}" + echo "- **Project Key:** ${PROJECT_KEY}" + echo "- **Dashboard:** [${SONAR_HOST}/project/overview?id=${PROJECT_KEY}](${SONAR_HOST}/project/overview?id=${PROJECT_KEY})" + } >> "$SUMMARY_FILE" + exit 0 + fi + + # Holt Metrik primär aus PR-Response, fällt auf Branch-Response zurück + get() { + local v + v=$(echo "${RESP}" | jq -r --arg k "$1" \ + 'if (.component? and .component.measures?) then + ((.component.measures[] | select(.metric==$k) | .value) // "-") + else "-" end') + if [ "$v" = "-" ] || [ -z "$v" ]; then + v=$(echo "${BRANCH_RESP}" | jq -r --arg k "$1" \ + 'if (.component? and .component.measures?) then + ((.component.measures[] | select(.metric==$k) | .value) // "-") + else "-" end') + fi + echo "$v" + } + + # Holt Metrik für Sub-Komponente. + # SonarCloud-Sub-Komponente kann ':' nicht immer per + # measures/component-Endpoint adressieren – wir nutzen component_tree + # mit dem 'q'-Filter, der nach Pfad-Suffix sucht. + get_sub() { + local path="$1" + local metric="$2" + local tree_url tree_resp v + + # Branch-Tree erstmal, danach PR falls vorhanden – beide pruefen + for try_pr in branch pr; do + tree_url="${SONAR_HOST}/api/measures/component_tree?component=${PROJECT_KEY}&metricKeys=${metric}&qualifiers=DIR&q=${path}&ps=20" + if [ "$try_pr" = "pr" ] && [ -n "${PR_NUMBER}" ]; then + tree_url="${tree_url}&pullRequest=${PR_NUMBER}" + elif [ "$try_pr" = "pr" ]; then + continue + fi + tree_resp=$(curl -s -u "${SONAR_TOKEN}:" "${tree_url}") + # Sucht in den Components die mit path enden ODER deren path == argument + v=$(echo "${tree_resp}" | jq -r --arg p "$path" --arg k "$metric" \ + '[.components[]? | select(.path == $p)] + | .[0].measures[]? | select(.metric==$k) | .value' 2>/dev/null) + if [ -n "$v" ] && [ "$v" != "null" ] && [ "$v" != "-" ]; then + echo "$v" + return + fi + done + echo "-" + } + + rating() { + case "$(get "$1")" in + 1.0) echo "A" ;; 2.0) echo "B" ;; 3.0) echo "C" ;; + 4.0) echo "D" ;; 5.0) echo "E" ;; *) echo "-" ;; + esac + } + + DEBT_MIN=$(get sqale_index) + if [[ "${DEBT_MIN}" =~ ^[0-9]+$ ]]; then + DEBT_H=$((DEBT_MIN / 60)); DEBT_REST=$((DEBT_MIN % 60)) + DEBT_FMT="${DEBT_H}h ${DEBT_REST}min" + else + DEBT_FMT="-" + fi + + if [ "${QG_STATUS}" = "PASSED" ] || [ "$(get alert_status)" = "OK" ]; then + QG_ICON="PASSED"; QG_EMOJI=":white_check_mark:" + else + QG_ICON="FAILED"; QG_EMOJI=":x:" + fi + + { + echo "## SonarCloud Analyse – LazyCook" + echo "" + echo "**Quality Gate:** ${QG_EMOJI} **${QG_ICON}**" + echo "**Dashboard:** [${SONAR_HOST}/project/overview?id=${PROJECT_KEY}](${SONAR_HOST}/project/overview?id=${PROJECT_KEY})" + echo "" + echo "### Coverage" + echo "" + echo "| Komponente | Coverage | Line Coverage | Branch Coverage | LoC |" + echo "|---|---|---|---|---|" + echo "| **Gesamt** | $(get coverage)% | $(get line_coverage)% | $(get branch_coverage)% | $(get ncloc) |" + echo "| Backend (Python) | $(get_sub backend coverage)% | $(get_sub backend line_coverage)% | $(get_sub backend branch_coverage)% | $(get_sub backend ncloc) |" + echo "" + + echo "### Duplikate & Komplexität" + echo "" + echo "| Bereich | Metrik | Wert |" + echo "|---|---|---|" + echo "| Duplikate | Duplizierte Zeilen | $(get duplicated_lines_density)% |" + echo "| Duplikate | Duplizierte Blöcke | $(get duplicated_blocks) |" + echo "| Duplikate | Betroffene Dateien | $(get duplicated_files) |" + echo "| Komplexität | Zyklomatische Komplexität | $(get complexity) |" + echo "| Komplexität | Cognitive Complexity | $(get cognitive_complexity) |" + echo "" + + echo "### Alle Dateien mit Duplikaten" + echo "" + } >> "$SUMMARY_FILE" + + TREE_URL="${SONAR_HOST}/api/measures/component_tree?component=${PROJECT_KEY}&metricKeys=duplicated_blocks,duplicated_lines,duplicated_lines_density,ncloc&qualifiers=FIL&ps=500&s=metric&metricSort=duplicated_blocks&asc=false${PR_PARAM}" + TREE_RESP=$(curl -s -u "${SONAR_TOKEN}:" "${TREE_URL}") + + FILES_WITH_DUPS=$(echo "${TREE_RESP}" | jq '[.components[]? | select((.measures[]? | select(.metric=="duplicated_blocks") | .value | tonumber) > 0)] | length' 2>/dev/null || echo "0") + + { + if [ "${FILES_WITH_DUPS}" = "0" ] || [ -z "${FILES_WITH_DUPS}" ]; then + echo "_Keine Datei hat aktuell Duplikate._" + echo "" + else + echo "_Insgesamt **${FILES_WITH_DUPS}** Dateien mit mindestens einem Duplikat-Block._" + echo "" + echo "| Datei | Blöcke | Dupl. Zeilen | Anteil | LoC |" + echo "|---|---:|---:|---:|---:|" + echo "${TREE_RESP}" | jq -r ' + .components[]? + | (.path // .key) as $p + | ((.measures[]? | select(.metric=="duplicated_blocks") | .value) // "0") as $b + | ((.measures[]? | select(.metric=="duplicated_lines") | .value) // "0") as $l + | ((.measures[]? | select(.metric=="duplicated_lines_density") | .value) // "0") as $pct + | ((.measures[]? | select(.metric=="ncloc") | .value) // "-") as $n + | select(($b | tonumber) > 0) + | "| " + $p + " | " + $b + " | " + $l + " | " + $pct + "% | " + $n + " |" + ' + echo "" + fi + echo "[Volle Duplikat-Ansicht in SonarCloud öffnen](${SONAR_HOST}/component_measures?id=${PROJECT_KEY}&metric=duplicated_lines_density&view=list)" + echo "" + + echo "### Ratings & Issues" + echo "" + echo "| Bewertung | Wert | Anzahl |" + echo "|---|---|---|" + echo "| Reliability Rating | $(rating reliability_rating) | $(get bugs) Bugs |" + echo "| Security Rating | $(rating security_rating) | $(get vulnerabilities) Vulnerabilities |" + echo "| Maintainability Rating | $(rating sqale_rating) | $(get code_smells) Code Smells |" + echo "| Technische Schuld | ${DEBT_FMT} | – |" + } >> "$SUMMARY_FILE" + + - name: Fail job on Quality Gate failure + if: steps.sonar-qg.outputs.quality-gate-status == 'FAILED' + run: | + echo "::error::SonarCloud Quality Gate ist FAILED – siehe Job Summary." + exit 1 \ No newline at end of file diff --git a/README.md b/README.md index 66a742c..e0db6b5 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ LazyCook soll eine Webanwendung sein. Die es ermöglichen seine vorhanden Zutate 1. Klonen das Repository: `git clone https://github.com/GalacticCodeGambit/LazyCook.git` 2. Navigieren zum Projektverzeichnis: - `cd LazyCook/Project` + `cd LazyCook/project` 3. Starten der Anwendung mit: Docker Compose: `docker compose up --build -d` @@ -34,7 +34,7 @@ Die Anwendung sollte jetzt unter `http://localhost:8000` erreichbar sein. Für die Funktion [#115](https://github.com/GalacticCodeGambit/LazyCook/issues/115) von Email Versenden/Empfangen muss im Ordner `project/` eine `.env` Datei mit den folgenden Variablen angelegt werden: ``` -EMAIL_HOST= +GMAIL_USER= GMAIL_PASSWORD= ``` diff --git a/docs/Review-Protokoll-01.md b/docs/Review-Protokoll-01.md index 5ce09da..d49bd59 100644 --- a/docs/Review-Protokoll-01.md +++ b/docs/Review-Protokoll-01.md @@ -1,6 +1,6 @@ # Review-Protokoll-01 ### **Datum** (Startzeit und Endzeit): -12.05.2026 16.:00 bis 16:20 +12.05.2026 16:00 bis 16:20 ### **Teilnehmende**: - Moderator: PrussianBaron diff --git a/project/.coveragerc b/project/.coveragerc new file mode 100644 index 0000000..033ec32 --- /dev/null +++ b/project/.coveragerc @@ -0,0 +1,12 @@ +[run] +# Pfade in coverage.xml relativ zum Working-Dir (= project/) +# damit Sonar "backend/Auth.py" matcht und nicht nur "Auth.py" +relative_files = True +branch = True +omit = + backend/tests/* + backend/__pycache__/* + +[report] +show_missing = True +skip_empty = True diff --git a/project/backend/Auth.py b/project/backend/Auth.py index 3c8dc46..2447716 100644 --- a/project/backend/Auth.py +++ b/project/backend/Auth.py @@ -16,10 +16,6 @@ PASSWORD_RESET_EXPIRE_MINUTES = 30 -import hashlib - -PASSWORD_RESET_EXPIRE_MINUTES = 30 - from Models import Token, User from Database import getAccountByEmail, saveRefreshToken, getRefreshToken diff --git a/project/backend/Database.py b/project/backend/Database.py index 580ad41..6fc2b65 100644 --- a/project/backend/Database.py +++ b/project/backend/Database.py @@ -247,7 +247,7 @@ def addRecipe(name: str, description: str, vid: int) -> int: return cur.lastrowid -def addIngredientToRecipe(zid: int, rid: int, amount: float) -> int: +def addIngredientToRecipe(zid: int, rid: int, amount: float) -> None: with getDB() as con: cur = con.cursor() cur.execute( @@ -272,10 +272,10 @@ def getIngridientByName(name: str): cur.execute( """ SELECT id, amountType - FROM Recipe - WHERE id = ? + FROM Ingredient + WHERE name = ? """, - (name), + (name,), ) row = cur.fetchone() return dict(row) if row else None @@ -336,8 +336,8 @@ def getAllocatedRecipes(name: str) -> list[dict]: """ SELECT rid FROM Exists_from - Inner Join Ingredient on rid=id, - Where Exists_from.id = ? + Inner Join Ingredient on rid=id + Where Ingrediant.name = ? """, (name,), ) diff --git a/project/backend/EmailService.py b/project/backend/EmailService.py index d978e0d..324b2b8 100644 --- a/project/backend/EmailService.py +++ b/project/backend/EmailService.py @@ -73,42 +73,3 @@ def sendPasswordResetEmail(to_email: str, name: str, resetLink: str) -> None: print(f"E-Mail Fehler: {e}") raise - -def sendPasswordResetEmail(to_email: str, name: str, resetLink: str) -> None: - try: - msg = MIMEMultipart("alternative") - msg["Subject"] = "Passwort zurücksetzen – Lazy Cook" - msg["From"] = gmailUser - msg["To"] = to_email - - html = f""" -
-

Hallo {name},

-

du hast angefordert, dein Passwort bei Lazy Cook zurückzusetzen.

-

Klicke auf den Button, um ein neues Passwort festzulegen:

-

- - Passwort zurücksetzen - -

-

- Oder kopiere diesen Link in deinen Browser:
- {resetLink} -

-

Der Link ist 30 Minuten gültig.

-

Falls du das nicht angefordert hast, ignoriere diese E-Mail einfach – dein Passwort bleibt unverändert.

-
-

– Das Lazy Cook Team

-
- """ - msg.attach(MIMEText(html, "html")) - - with smtplib.SMTP_SSL("smtp.gmail.com", 465) as server: - server.login(gmailUser, gmailPassword) - server.sendmail(gmailUser, to_email, msg.as_string()) - - except Exception as e: - print(f"E-Mail Fehler: {e}") - raise diff --git a/project/backend/Ingredient.py b/project/backend/Ingredient.py index 154caba..7014e22 100644 --- a/project/backend/Ingredient.py +++ b/project/backend/Ingredient.py @@ -6,6 +6,7 @@ class Ingredient: def __init__(self, name: str, amount: float): self.__name = name self.__amount = amount + self.__amountType = None def getName(self) -> str: return self.__name diff --git a/project/backend/Recipe.py b/project/backend/Recipe.py index b9ce137..356012a 100644 --- a/project/backend/Recipe.py +++ b/project/backend/Recipe.py @@ -16,11 +16,11 @@ def __init__(self, name: str, ingredients: list[Ingredient], description: str): def saveInDB(self) -> bool: rid = addRecipe(self.__name, self.__description, None) for ingridient in self.__ingredients: - zid = getIngridientByName(ingridient.getName()) - if not zid: + result = getIngridientByName(ingridient.getName()) + if not result: return False - else: - addIngredientToRecipe(zid, rid, ingridient.getAmount()) + zid = result['id'] + addIngredientToRecipe(zid, rid, ingridient.getAmount()) return True def getName(self) -> str: @@ -59,7 +59,7 @@ def getDuration(self) -> str: def setDuration(self, duration: str): self.__duration = duration - def getingredients(self) -> list[Ingredient]: + def getIngredients(self) -> list[Ingredient]: return self.__ingredients def setIngredient(self, ingredients: list[Ingredient]): diff --git a/project/backend/RecipeSUCUK.py b/project/backend/RecipeSUCUK.py index b41c3e6..d3de8f0 100644 --- a/project/backend/RecipeSUCUK.py +++ b/project/backend/RecipeSUCUK.py @@ -49,14 +49,15 @@ def __formatIngredients(id: int) -> list[Ingredient]: return Ingredients -ini = [] -ini.append(Ingredient("Linguine", 10)) -ini.append(Ingredient("Fresh Basil", 10)) -ini.append(Ingredient("Pine Nuts", 10)) -ini.append(Ingredient("Parmesan", 10)) -ini.append(Ingredient("Ground Beef", 10)) -ini.append(Ingredient("Cumin", 10)) -ini.append(Ingredient("Cucumber", 10)) -arr = findRecipes(ini) -for i in arr: - print(i.getName() + " " + str(i.getRating())) +if __name__ == "__main__": + ini = [] + ini.append(Ingredient("Linguine", 10)) + ini.append(Ingredient("Fresh Basil", 10)) + ini.append(Ingredient("Pine Nuts", 10)) + ini.append(Ingredient("Parmesan", 10)) + ini.append(Ingredient("Ground Beef", 10)) + ini.append(Ingredient("Cumin", 10)) + ini.append(Ingredient("Cucumber", 10)) + arr = findRecipes(ini) + for i in arr: + print(i.getName() + " " + str(i.getRating())) diff --git a/project/backend/requirements.txt b/project/backend/requirements.txt index 60137ce..3b3b797 100644 --- a/project/backend/requirements.txt +++ b/project/backend/requirements.txt @@ -5,4 +5,5 @@ gunicorn==23.0.0 bcrypt python-jose[cryptography] python-multipart -pytest==9.0.3 \ No newline at end of file +pytest==9.0.3 +pytest-cov==6.0.0 \ No newline at end of file diff --git a/project/frontend/app/homepage/homepage.tsx b/project/frontend/app/homepage/homepage.tsx index cb21ae5..52ff7bf 100644 --- a/project/frontend/app/homepage/homepage.tsx +++ b/project/frontend/app/homepage/homepage.tsx @@ -28,7 +28,6 @@ export default function Homepage() { diff --git a/project/frontend/app/profile/changeEmailPopup.tsx b/project/frontend/app/profile/changeEmailPopup.tsx index 1d1dd3b..b473678 100644 --- a/project/frontend/app/profile/changeEmailPopup.tsx +++ b/project/frontend/app/profile/changeEmailPopup.tsx @@ -3,7 +3,6 @@ import {fetchWithAuth} from "@/lib/auth"; import {useState} from "react"; import "../recipeFinder/style.css" -const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3000"; export default function ChangeEmail (){ @@ -13,7 +12,7 @@ export default function ChangeEmail (){ async function handleEmailChange() { setEmailMsg(""); try { - const res = await fetchWithAuth(`${API_URL}/users/me`, { + const res = await fetchWithAuth(`/users/me`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email: newEmail }), diff --git a/project/frontend/app/profile/changePasswordPopup.tsx b/project/frontend/app/profile/changePasswordPopup.tsx index 0eccf7f..12423c8 100644 --- a/project/frontend/app/profile/changePasswordPopup.tsx +++ b/project/frontend/app/profile/changePasswordPopup.tsx @@ -5,14 +5,12 @@ import {Eye, EyeOff} from "lucide-react"; import "../recipeFinder/style.css" import Field from "@/app/components/fields"; -const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3000"; - function PasswordInput({ value, onChange, placeholder, onKeyDown, ariaLabel }: { - value: string; - onChange: (v: string) => void; - placeholder: string; - onKeyDown?: (e: React.KeyboardEvent) => void; - ariaLabel?: string; + readonly value: string; + readonly onChange: (v: string) => void; + readonly placeholder: string; + readonly onKeyDown?: (e: React.KeyboardEvent) => void; + readonly ariaLabel?: string; }) { const [show, setShow] = useState(false); return ( @@ -52,14 +50,11 @@ function PasswordInput({ value, onChange, placeholder, onKeyDown, ariaLabel }: { ); } -type Modus = "change" | "forgot"; - interface ChangePasswordProps { - modus: Modus; onSuccess?: () => void; } -export default function ChangePassword({ modus, onSuccess }: ChangePasswordProps) { +export default function ChangePassword({ onSuccess }: Readonly) { const [currentPassword, setCurrentPassword] = useState(""); const [newPassword, setNewPassword] = useState(""); @@ -67,13 +62,10 @@ export default function ChangePassword({ modus, onSuccess }: ChangePasswordProps const [passwordMsg, setPasswordMsg] = useState(""); const [pwBlurred, setPwBlurred] = useState(false); - const isForgot = modus === "forgot"; - async function handlePasswordChange() { setPasswordMsg(""); - // Pflichtfelder im "change"-Modus - if (!isForgot && !currentPassword) { + if (!currentPassword) { setPasswordMsg("Bitte das aktuelle Passwort eingeben."); return; } @@ -86,27 +78,16 @@ export default function ChangePassword({ modus, onSuccess }: ChangePasswordProps return; } - // Bestätigung prüfen if (newPassword !== confirmPassword) { setPasswordMsg("Die Passwörter stimmen nicht überein."); return; } try { - const endpoint = isForgot - ? `${API_URL}/users/forgot-password` - : `${API_URL}/users/me`; - - const body = isForgot - ? { newPassword } - : { currentPassword, newPassword }; - - const fetcher = isForgot ? fetch : fetchWithAuth; - - const res = await fetcher(endpoint, { + const res = await fetchWithAuth("/users/me", { method: "PATCH", headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), + body: JSON.stringify({ currentPassword, newPassword }), }); if (!res.ok) { @@ -115,11 +96,7 @@ export default function ChangePassword({ modus, onSuccess }: ChangePasswordProps return; } - setPasswordMsg( - isForgot - ? "Passwort erfolgreich zurückgesetzt." - : "Passwort erfolgreich geändert." - ); + setPasswordMsg("Passwort erfolgreich geändert."); setCurrentPassword(""); setNewPassword(""); setConfirmPassword(""); @@ -131,13 +108,10 @@ export default function ChangePassword({ modus, onSuccess }: ChangePasswordProps return (
-

- {isForgot ? "Passwort zurücksetzen" : "Passwort ändern"} -

+

Passwort ändern

- {!isForgot && ( - setPwBlurred(true)} onKeyDown={(e) => e.key === "Enter" && handlePasswordChange()} state={pwBlurred && !currentPassword? "error" : "default"} />)} + setPwBlurred(true)} onKeyDown={(e) => e.key === "Enter" && handlePasswordChange()} state={pwBlurred && !currentPassword? "error" : "default"} /> - {isForgot ? "Zurücksetzen" : "Speichern"} + Speichern
diff --git a/project/frontend/app/profile/page.tsx b/project/frontend/app/profile/page.tsx index a6be68f..98df4a6 100644 --- a/project/frontend/app/profile/page.tsx +++ b/project/frontend/app/profile/page.tsx @@ -12,7 +12,6 @@ import ChangePassword from "@/app/profile/changePasswordPopup"; import "../recipeFinder/style.css" -const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3000"; export default function Profile() { const { user, loading, logout } = useAuth(); @@ -40,7 +39,7 @@ export default function Profile() { if (!user) return null; async function handleAccountDeletion() { - const res = await fetchWithAuth(`${API_URL}/users/me`, { method: "DELETE" }); + const res = await fetchWithAuth(`/users/me`, { method: "DELETE" }); if (!res.ok) throw new Error("Konto löschen fehlgeschlagen"); logout(); router.push("/"); @@ -124,7 +123,7 @@ export default function Profile() { setShowPasswordModal(false)}> - + ); diff --git a/project/frontend/lib/auth.tsx b/project/frontend/lib/auth.tsx index 64c79d9..470fb1b 100644 --- a/project/frontend/lib/auth.tsx +++ b/project/frontend/lib/auth.tsx @@ -108,7 +108,8 @@ async function fetchWithAuth(url: string, options: RequestInit = {}): Promise(null); -export function AuthProvider({ children }: { children: ReactNode }) { +export function AuthProvider({ children }: Readonly<{ children: ReactNode }>) { const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); @@ -195,8 +196,8 @@ export function AuthProvider({ children }: { children: ReactNode }) { setUser(null); } }; - window.addEventListener("storage", handleStorageChange); - return () => window.removeEventListener("storage", handleStorageChange); + globalThis.addEventListener("storage", handleStorageChange); + return () => globalThis.removeEventListener("storage", handleStorageChange); }, []); const login = useCallback(async (email: string, password: string) => { diff --git a/project/sonar-project.properties b/project/sonar-project.properties new file mode 100644 index 0000000..1252ae3 --- /dev/null +++ b/project/sonar-project.properties @@ -0,0 +1,28 @@ +# SonarCloud / SonarQube Projektkonfiguration +sonar.projectKey=GalacticCodeGambit_LazyCook +sonar.organization=galacticcodegambit +sonar.projectName=LazyCook +sonar.projectVersion=1.0 + +# Quellen + Tests +sonar.sources=backend,frontend/app +sonar.tests=backend/tests +sonar.exclusions=**/node_modules/**,**/__pycache__/**,**/.next/**,frontend/app/components/ui/**,**/*.config.*,**/coverage/** + +# Encoding +sonar.sourceEncoding=UTF-8 + +# Python +sonar.python.version=3.11 +sonar.python.coverage.reportPaths=backend/coverage.xml + +# CPD-Schwellwerte auf das absolute Minimum gestellt: +# auch kleinste Duplikate (>= 3 Zeilen / >= 10 Tokens) sollen gefunden werden +sonar.cpd.python.minimumTokens=10 +sonar.cpd.python.minimumLines=3 +sonar.cpd.css.minimumTokens=10 +sonar.cpd.css.minimumLines=3 +sonar.cpd.javascript.minimumTokens=10 +sonar.cpd.javascript.minimumLines=3 +sonar.cpd.typescript.minimumTokens=10 +sonar.cpd.typescript.minimumLines=3