diff --git a/.github/scripts/compose_telegram_message.py b/.github/scripts/compose_telegram_message.py new file mode 100644 index 0000000..48ee761 --- /dev/null +++ b/.github/scripts/compose_telegram_message.py @@ -0,0 +1,93 @@ +import html +import os + +FAILURE_STATES = {"failure", "cancelled", "timed_out", "action_required"} +SUCCESS_STATES = {"success", "skipped", "neutral"} + +JOB_RESULT_FIELDS = ( + ("backend", "BACKEND_RESULT"), + ("sonar-backend", "SONAR_BACKEND_RESULT"), +) + +SONAR_SKIP_FLAGS = { + "SONAR_BACKEND_RESULT": "SONAR_BACKEND_SKIPPED", +} + + +def normalize(value: str) -> str: + return (value or "unknown").lower() + + +def status_icon(status: str) -> str: + if status == "success": + return "✅" + if status in FAILURE_STATES: + return "❌" + if status in {"skipped", "neutral"}: + return "⏭️" + return "❓" + + +def get_actual_status(env_name: str, result: str) -> str: + skip_flag = SONAR_SKIP_FLAGS.get(env_name) + if skip_flag and normalize(os.environ.get(skip_flag, "")) == "true": + return "skipped" + return result + + +def overall_status() -> str: + statuses = [] + for _, env_name in JOB_RESULT_FIELDS: + result = normalize(os.environ.get(env_name, "unknown")) + actual = get_actual_status(env_name, result) + statuses.append(actual) + + if any(status in FAILURE_STATES for status in statuses): + return "failure" + if all(status in SUCCESS_STATES for status in statuses): + return "success" + return "unknown" + + +def esc(value: str) -> str: + return html.escape(str(value), quote=True) + + +def compose_message() -> str: + current_overall = overall_status() + lines = [ + "jwt-demo-reactive CI finished", + "", + f"{status_icon(current_overall)} Status: {esc(current_overall)}", + f"Branch: {esc(os.environ.get('GITHUB_REF_NAME', ''))}", + f"Commit: {esc(os.environ.get('GITHUB_SHA', ''))}", + f"Actor: {esc(os.environ.get('GITHUB_ACTOR', ''))}", + f"Workflow: {esc(os.environ.get('GITHUB_WORKFLOW', ''))}", + "", + "Job results", + ] + + for job_name, env_name in JOB_RESULT_FIELDS: + result = normalize(os.environ.get(env_name, "unknown")) + actual = get_actual_status(env_name, result) + lines.append(f"- {status_icon(actual)} {esc(job_name)}: {esc(actual)}") + + lines.append("") + lines.append( + f"Link: {esc(os.environ.get('GITHUB_SERVER_URL', ''))}/{esc(os.environ.get('GITHUB_REPOSITORY', ''))}/actions/runs/{esc(os.environ.get('GITHUB_RUN_ID', ''))}" + ) + + return "\n".join(lines) + + +def main() -> None: + message = compose_message() + output_path = os.environ["GITHUB_OUTPUT"] + with open(output_path, "a", encoding="utf-8") as output_file: + output_file.write("message< dict[str, str]: + data: dict[str, str] = {} + for raw_line in path.read_text(encoding="utf-8").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + data[key.strip()] = value.strip() + return data + + +def api_get_json(url: str, token: str) -> dict: + request = urllib.request.Request(url) + request.add_header("Authorization", f"Basic {build_basic_auth(token)}") + try: + with urllib.request.urlopen(request, timeout=30) as response: + return json.loads(response.read().decode("utf-8")) + except urllib.error.HTTPError as error: + response_body = error.read().decode("utf-8", errors="replace") + raise RuntimeError( + f"Sonar API request failed: {url} returned HTTP {error.code}. Response: {response_body}" + ) from error + except urllib.error.URLError as error: + raise RuntimeError(f"Sonar API request failed: {url}. Reason: {error.reason}") from error + + +def build_basic_auth(token: str) -> str: + import base64 + + raw = f"{token}:".encode("utf-8") + return base64.b64encode(raw).decode("ascii") + + +def wait_for_ce_task(ce_task_url: str, token: str, timeout_seconds: int, poll_seconds: int) -> dict: + started = time.monotonic() + while True: + payload = api_get_json(ce_task_url, token) + task = payload.get("task", {}) + status = task.get("status") + + if status in {"SUCCESS", "FAILED", "CANCELED"}: + return task + + if (time.monotonic() - started) > timeout_seconds: + raise TimeoutError(f"Timed out waiting for Sonar CE task at {ce_task_url}") + + time.sleep(poll_seconds) + + +def build_measures_url(host_url: str, project_key: str, pull_request: str | None, branch: str | None) -> str: + metric_keys = ",".join( + [ + "coverage", + "new_coverage", + "bugs", + "new_bugs", + "vulnerabilities", + "new_vulnerabilities", + "code_smells", + "new_code_smells", + "duplicated_lines_density", + "new_duplicated_lines_density", + ] + ) + query = { + "component": project_key, + "metricKeys": metric_keys, + } + if pull_request: + query["pullRequest"] = pull_request + elif branch: + query["branch"] = branch + return f"{host_url}/api/measures/component?{urllib.parse.urlencode(query)}" + + +def detect_analysis_scope() -> tuple[str | None, str | None]: + event_path = os.getenv("GITHUB_EVENT_PATH", "") + if event_path: + try: + event_payload = json.loads(Path(event_path).read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + event_payload = {} + pull_request_number = event_payload.get("pull_request", {}).get("number") + if pull_request_number is not None: + return str(pull_request_number), None + + branch = (os.getenv("GITHUB_HEAD_REF") or os.getenv("GITHUB_REF_NAME") or "").strip() + return None, branch or None + + +def to_measure_map(payload: dict) -> dict[str, str]: + component = payload.get("component", {}) + result: dict[str, str] = {} + for measure in component.get("measures", []): + result[measure.get("metric")] = measure.get("value", "-") + return result + + +def fetch_measures(host_url: str, project_key: str, token: str) -> dict[str, str]: + pull_request, branch = detect_analysis_scope() + scoped_url = build_measures_url(host_url, project_key, pull_request, branch) + + try: + return to_measure_map(api_get_json(scoped_url, token)) + except RuntimeError as scoped_error: + base_url = build_measures_url(host_url, project_key, None, None) + if base_url != scoped_url: + try: + print( + "Scoped measures query failed; retrying without branch/pullRequest context", + file=sys.stderr, + ) + return to_measure_map(api_get_json(base_url, token)) + except RuntimeError as base_error: + print(f"Unable to load Sonar measures: {base_error}", file=sys.stderr) + return {} + + print(f"Unable to load Sonar measures: {scoped_error}", file=sys.stderr) + return {} + + +def append_summary(text: str) -> None: + summary_file = os.getenv("GITHUB_STEP_SUMMARY") + if not summary_file: + return + with open(summary_file, "a", encoding="utf-8") as handle: + handle.write(text) + + +def is_missing_new_code_metrics_only(gate_status: str, conditions: list[dict], measures: dict[str, str]) -> bool: + if gate_status != "ERROR": + return False + + if measures.get("new_coverage", "-") not in {"-", "NO_VALUE", ""}: + return False + + if not conditions: + return False + + has_non_ok_condition = False + for condition in conditions: + status = (condition.get("status") or "").upper() + if status in {"", "OK"}: + continue + + has_non_ok_condition = True + metric_key = condition.get("metricKey", "") + actual_value = condition.get("actualValue") + normalized_actual = "-" if actual_value in (None, "") else str(actual_value) + + if not metric_key.startswith("new_"): + return False + if normalized_actual not in {"-", "NO_VALUE", "None"}: + return False + + return has_non_ok_condition + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--project-key", required=True) + parser.add_argument("--component-name", required=True) + parser.add_argument("--report-task-file", required=True) + parser.add_argument("--timeout-seconds", type=int, default=300) + parser.add_argument("--poll-seconds", type=int, default=5) + parser.add_argument( + "--allow-missing-new-code-metrics", + action="store_true", + help="Do not fail when Sonar returns ERROR only because new_* metrics are unavailable", + ) + args = parser.parse_args() + + token = os.getenv("SONAR_TOKEN", "") + if not token: + print("SONAR_TOKEN is missing", file=sys.stderr) + return 2 + + configured_host_url = (os.getenv("SONAR_HOST_URL") or "").strip() + report_task_path = Path(args.report_task_file) + if not report_task_path.exists(): + print(f"report-task.txt not found: {report_task_path}", file=sys.stderr) + return 2 + + report = parse_kv_file(report_task_path) + report_server_url = (report.get("serverUrl") or "").strip() + host_url = (configured_host_url or report_server_url or "https://sonarcloud.io").rstrip("/") + ce_task_url = report.get("ceTaskUrl") + if not ce_task_url: + print("ceTaskUrl is missing in report-task.txt", file=sys.stderr) + return 2 + + try: + print(f"Waiting for Sonar CE task: {ce_task_url}") + task = wait_for_ce_task(ce_task_url, token, args.timeout_seconds, args.poll_seconds) + task_status = task.get("status", "UNKNOWN") + analysis_id = task.get("analysisId") + + if task_status != "SUCCESS" or not analysis_id: + print(f"Sonar CE task status is {task_status}; analysisId={analysis_id}", file=sys.stderr) + return 1 + + qg_url = f"{host_url}/api/qualitygates/project_status?analysisId={urllib.parse.quote(analysis_id)}" + qg_payload = api_get_json(qg_url, token) + project_status = qg_payload.get("projectStatus", {}) + gate_status = project_status.get("status", "NONE") + conditions = project_status.get("conditions", []) + + measures = fetch_measures(host_url, args.project_key, token) + except (RuntimeError, TimeoutError) as error: + print(str(error), file=sys.stderr) + return 1 + + print(f"Quality Gate ({args.component_name}): {gate_status}") + print("Measures:") + for metric_key in [ + "coverage", + "new_coverage", + "bugs", + "new_bugs", + "vulnerabilities", + "new_vulnerabilities", + "code_smells", + "new_code_smells", + "duplicated_lines_density", + "new_duplicated_lines_density", + ]: + print(f" {metric_key}: {measures.get(metric_key, '-')}") + + summary_lines = [ + f"### SonarQube - {args.component_name}", + "", + f"- Quality Gate: **{gate_status}**", + f"- Project key: `{args.project_key}`", + "", + "| Metric | Value |", + "|---|---:|", + ] + for metric_key in [ + "coverage", + "new_coverage", + "bugs", + "new_bugs", + "vulnerabilities", + "new_vulnerabilities", + "code_smells", + "new_code_smells", + "duplicated_lines_density", + "new_duplicated_lines_density", + ]: + summary_lines.append(f"| `{metric_key}` | {measures.get(metric_key, '-')} |") + + summary_lines.extend(["", "| Condition metric | Status | Actual | Threshold |", "|---|---|---:|---:|"]) + if conditions: + for condition in conditions: + summary_lines.append( + "| `{}` | {} | {} | {} |".format( + condition.get("metricKey", "-"), + condition.get("status", "-"), + condition.get("actualValue", "-"), + condition.get("errorThreshold", "-"), + ) + ) + else: + summary_lines.append("| `-` | - | - | - |") + + summary_lines.append("\n") + append_summary("\n".join(summary_lines)) + + if gate_status != "OK": + if args.allow_missing_new_code_metrics and is_missing_new_code_metrics_only(gate_status, conditions, measures): + print( + "Quality Gate returned ERROR due to unavailable new-code metrics; treated as neutral for this run" + ) + return 0 + print("Quality Gate failed", file=sys.stderr) + return 1 + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b3f92c1..7bc43d1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,8 +1,5 @@ name: CI -env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - on: push: branches: ["**"] @@ -10,156 +7,203 @@ on: permissions: contents: read - checks: write - actions: write - pull-requests: write + actions: read jobs: - build: + backend: + name: Backend (Maven) runs-on: ubuntu-latest + permissions: + contents: read + actions: write + steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - fetch-depth: 0 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Set up JDK 25 uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 with: distribution: temurin - java-version: "25" + java-version: 25 cache: maven - - name: Cache SonarQube packages - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 - with: - path: ~/.sonar/cache - key: ${{ runner.os }}-sonar - restore-keys: ${{ runner.os }}-sonar - - - name: Build and verify + - name: Run backend tests run: mvn -B -ntp verify - - name: Upload Unit Test Reports + - name: Upload backend JaCoCo report if: always() - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - name: unit-test-reports - path: target/surefire-reports/TEST-*.xml + name: backend-jacoco-report + path: target/site/jacoco-merged/jacoco.xml if-no-files-found: warn retention-days: 7 - - name: Upload Integration Test Reports - if: always() - uses: actions/upload-artifact@v7 + sonar-backend: + name: SonarQube - backend + runs-on: ubuntu-latest + needs: [backend] + if: > + ((github.event_name == 'push' && github.ref == 'refs/heads/main') || + (github.event_name == 'pull_request' && github.actor != 'dependabot[bot]')) + outputs: + sonar_skipped: ${{ steps.sonar-check.outputs.sonar_skipped }} + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + SONAR_HOST_URL: ${{ vars.SONAR_HOST_URL }} + SONAR_ORGANIZATION: ${{ vars.SONAR_ORGANIZATION }} + SONAR_PROJECT_KEY: ${{ vars.SONAR_PROJECT_KEY }} + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: - name: integration-test-reports - path: target/failsafe-reports/TEST-*.xml - if-no-files-found: warn - retention-days: 7 + fetch-depth: 0 - - name: Test Reports Summary (Actions UI) - if: always() - shell: bash + - name: Check Sonar configuration + id: sonar-check run: | - unit_count=$(find target/surefire-reports -maxdepth 1 -name 'TEST-*.xml' 2>/dev/null | wc -l) - it_count=$(find target/failsafe-reports -maxdepth 1 -name 'TEST-*.xml' 2>/dev/null | wc -l) - { - echo "## Test Reports" - echo "- Unit JUnit XML files: ${unit_count} (artifact: unit-test-reports)" - echo "- Integration JUnit XML files: ${it_count} (artifact: integration-test-reports)" - } >> "$GITHUB_STEP_SUMMARY" - - - name: Unit Test Summary - if: always() - id: unit - uses: dorny/test-reporter@a43b3a5f7366b97d083190328d2c652e1a8b6aa2 # v3.0.0 + if [ -z "$SONAR_TOKEN" ]; then + echo "sonar_skipped=true" >> "$GITHUB_OUTPUT" + echo "SONAR_TOKEN not set; skipping backend analysis" + exit 0 + fi + host_url="${SONAR_HOST_URL:-https://sonarcloud.io}" + host_url="${host_url%/}" + if [ "$host_url" = "https://sonarcloud.io" ] && [ -z "$SONAR_ORGANIZATION" ]; then + echo "sonar_skipped=true" >> "$GITHUB_OUTPUT" + echo "SONAR_ORGANIZATION not set for SonarCloud; skipping backend analysis" + exit 0 + fi + echo "sonar_skipped=false" >> "$GITHUB_OUTPUT" + echo "sonar_enabled=true" >> "$GITHUB_OUTPUT" + + - name: Set up JDK 25 + if: steps.sonar-check.outputs.sonar_enabled == 'true' + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 with: - token: ${{ secrets.GITHUB_TOKEN }} - name: Unit Tests (JUnit) - path: target/surefire-reports/TEST-*.xml - reporter: java-junit - fail-on-error: false - fail-on-empty: false - use-actions-summary: true - - - name: Integration Test Summary - if: always() - id: integration - uses: dorny/test-reporter@a43b3a5f7366b97d083190328d2c652e1a8b6aa2 # v3.0.0 + distribution: temurin + java-version: 25 + cache: maven + + - name: Download backend JaCoCo report + if: steps.sonar-check.outputs.sonar_enabled == 'true' + continue-on-error: true + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: - token: ${{ secrets.GITHUB_TOKEN }} - name: Integration Tests (JUnit) - path: target/failsafe-reports/TEST-*.xml - reporter: java-junit - fail-on-error: false - fail-on-empty: false - use-actions-summary: true - - # ----------------------------- - # Telegram notification - # ----------------------------- + name: backend-jacoco-report + path: coverage + + - name: Run SonarQube analysis (backend) + if: steps.sonar-check.outputs.sonar_enabled == 'true' + shell: bash + env: + REPO_NAME: ${{ github.event.repository.name }} + EVENT_NAME: ${{ github.event_name }} + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_HEAD_REF: ${{ github.head_ref }} + PR_BASE_REF: ${{ github.base_ref }} + run: | + host_url="${SONAR_HOST_URL:-https://sonarcloud.io}" + sonar_project_key="${SONAR_PROJECT_KEY:-$REPO_NAME}" + report_path="" + found_report="" + if [ -d "coverage" ]; then + found_report=$(find coverage -type f -name "jacoco.xml" | head -1 || true) + fi + if [ -n "${found_report}" ]; then + report_path="$(pwd)/${found_report}" + fi + cmd=( + mvn -B -ntp + -Djacoco.skip=true + -DskipTests + verify + sonar:sonar + "-Dsonar.host.url=${host_url}" + "-Dsonar.projectKey=${sonar_project_key}" + "-Dsonar.token=${SONAR_TOKEN}" + ) + + if [ -n "${report_path}" ]; then + cmd+=("-Dsonar.coverage.jacoco.xmlReportPaths=${report_path}") + else + echo "JaCoCo XML report not found; Sonar will run without coverage input" + fi + + if [ -n "${SONAR_ORGANIZATION}" ]; then + cmd+=("-Dsonar.organization=${SONAR_ORGANIZATION}") + fi + + if [ "${EVENT_NAME}" = "pull_request" ] && [ -n "${PR_NUMBER}" ] && [ -n "${PR_HEAD_REF}" ] && [ -n "${PR_BASE_REF}" ]; then + cmd+=("-Dsonar.pullrequest.key=${PR_NUMBER}") + cmd+=("-Dsonar.pullrequest.branch=${PR_HEAD_REF}") + cmd+=("-Dsonar.pullrequest.base=${PR_BASE_REF}") + fi + + "${cmd[@]}" + + - name: Locate backend Sonar report-task + if: steps.sonar-check.outputs.sonar_enabled == 'true' + id: report-task + shell: bash + run: | + report_task=$(find . -type f \( -path "*/target/sonar/report-task.txt" -o -path "*/.scannerwork/report-task.txt" \) | head -1) + if [ -z "$report_task" ]; then + echo "Unable to find Sonar report-task.txt" >&2 + exit 1 + fi + echo "path=${report_task}" >> "$GITHUB_OUTPUT" + + - name: Wait for Quality Gate and publish backend Sonar summary + if: steps.sonar-check.outputs.sonar_enabled == 'true' + shell: bash + env: + REPO_NAME: ${{ github.event.repository.name }} + run: | + sonar_project_key="${SONAR_PROJECT_KEY:-$REPO_NAME}" + extra_args=() + if [ "${GITHUB_EVENT_NAME}" = "pull_request" ]; then + extra_args+=("--allow-missing-new-code-metrics") + fi + + python3 .github/scripts/sonar_quality_gate_report.py \ + --component-name "backend" \ + --project-key "${sonar_project_key}" \ + --report-task-file "${{ steps.report-task.outputs.path }}" \ + --timeout-seconds 900 \ + "${extra_args[@]}" + + notify-telegram: + name: Notify Telegram + runs-on: ubuntu-latest + needs: [backend, sonar-backend] + if: ${{ always() }} + permissions: + contents: read + env: + TELEGRAM_TO: ${{ secrets.TELEGRAM_TO }} + TELEGRAM_TOKEN: ${{ secrets.TELEGRAM_TOKEN }} + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Compose Telegram message + id: compose + run: python3 .github/scripts/compose_telegram_message.py + env: + BACKEND_RESULT: ${{ needs.backend.result }} + SONAR_BACKEND_RESULT: ${{ needs.sonar-backend.result }} + SONAR_BACKEND_SKIPPED: ${{ needs.sonar-backend.outputs.sonar_skipped }} + - name: Notify Telegram - if: always() - uses: appleboy/telegram-action@master + if: ${{ env.TELEGRAM_TO != '' && env.TELEGRAM_TOKEN != '' }} + uses: appleboy/telegram-action@221e6b684967abe813051ee4a37dd61770a83ad3 # v1.0.1 with: to: ${{ secrets.TELEGRAM_TO }} token: ${{ secrets.TELEGRAM_TOKEN }} - message: | - 🚀 jwt-demo-reactive CI завершён - - *Статус:* ${{ job.status }} - *Бранч:* ${{ github.ref_name }} - *Коммит:* ${{ github.sha }} - *Автор:* ${{ github.actor }} - - 🧪 *Unit Tests* - - Total: ${{ steps.unit.outputs.total }} - - Passed: ${{ steps.unit.outputs.passed }} - - Failed: ${{ steps.unit.outputs.failed }} - - Skipped: ${{ steps.unit.outputs.skipped }} - - 🔧 *Integration Tests* - - Total: ${{ steps.integration.outputs.total }} - - Passed: ${{ steps.integration.outputs.passed }} - - Failed: ${{ steps.integration.outputs.failed }} - - Skipped: ${{ steps.integration.outputs.skipped }} - format: markdown - - # ----------------------------- - # PR Comment with test results - # ----------------------------- - - name: Post Test Summary as PR Comment - if: github.event_name == 'pull_request' - uses: marocchino/sticky-pull-request-comment@v3 - with: - header: "Test Results" - message: | - ## 🧪 Test Results - - ### Unit Tests - - Total: ${{ steps.unit.outputs.total }} - - Passed: ${{ steps.unit.outputs.passed }} - - Failed: ${{ steps.unit.outputs.failed }} - - Skipped: ${{ steps.unit.outputs.skipped }} - - ### Integration Tests - - Total: ${{ steps.integration.outputs.total }} - - Passed: ${{ steps.integration.outputs.passed }} - - Failed: ${{ steps.integration.outputs.failed }} - - Skipped: ${{ steps.integration.outputs.skipped }} - - - name: SonarCloud analysis - if: > - (github.event_name == 'push' && github.ref == 'refs/heads/main') || - (github.event_name == 'pull_request' && github.actor != 'dependabot[bot]') - env: - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - SONAR_PROJECT_KEY: ${{ vars.SONAR_PROJECT_KEY }} - SONAR_ORGANIZATION: ${{ vars.SONAR_ORGANIZATION }} - run: >- - mvn -B -ntp sonar:sonar - -Dsonar.host.url=https://sonarcloud.io - -Dsonar.projectKey=${SONAR_PROJECT_KEY} - -Dsonar.organization=${SONAR_ORGANIZATION} - -Dsonar.coverage.jacoco.xmlReportPaths=target/site/jacoco-merged/jacoco.xml \ No newline at end of file + message: ${{ steps.compose.outputs.message }} + format: html + + - name: Telegram skipped (missing secrets) + if: ${{ env.TELEGRAM_TO == '' || env.TELEGRAM_TOKEN == '' }} + run: echo "TELEGRAM_TO and/or TELEGRAM_TOKEN are not set. Skipping Telegram notification." \ No newline at end of file