diff --git a/.github/scripts/generate_loc_report.py b/.github/scripts/generate_loc_report.py new file mode 100755 index 00000000..44209a47 --- /dev/null +++ b/.github/scripts/generate_loc_report.py @@ -0,0 +1,191 @@ +#!/usr/bin/env python3 +""" +Counts Rust lines of code in the ethlambda workspace via cargo-warloc and +produces report files for Slack, Telegram, and the GitHub Actions step summary. + +`cargo warloc` reports per-file `main`/`tests` line counts using a Rust AST +parser, so inline `#[cfg(test)]` blocks are correctly classified as test code. + +Inputs (optional): + loc_report.json.old Previous run's report. Used to compute deltas. + +Outputs: + loc_report.json Machine-readable report for caching. + loc_report_slack.json Slack Block Kit payload (daily). + loc_report_telegram.txt Telegram HTML body (weekly). + loc_report_github.txt Plain-text block for the workflow step summary. +""" + +from __future__ import annotations + +import html +import json +import os +import subprocess +from datetime import datetime, timezone +from pathlib import Path + + +def _run(cmd: list[str]) -> str: + return subprocess.check_output(cmd, text=True) + + +def warloc_by_file() -> dict: + return json.loads(_run(["cargo", "warloc", "--by-file", "-o", "json"])) + + +def workspace_crates() -> list[str]: + md = json.loads(_run(["cargo", "metadata", "--no-deps", "--format-version", "1"])) + cwd = os.getcwd() + "/" + crates = [] + for pkg in md["packages"]: + path = pkg["manifest_path"][: -len("/Cargo.toml")] + if path.startswith(cwd): + path = path[len(cwd):] + crates.append(path) + # Sort longest first so longest-prefix match wins when grouping files. + crates.sort(key=len, reverse=True) + return crates + + +def group_by_crate(by_file: dict, crates: list[str]) -> dict[str, dict[str, int]]: + buckets = {c: {"main": 0, "tests": 0} for c in crates} + for raw_path, stats in by_file["files"].items(): + path = raw_path[2:] if raw_path.startswith("./") else raw_path + owner = next((c for c in crates if path.startswith(c + "/")), None) + if owner is None: + continue + buckets[owner]["main"] += stats["main"]["code"] + buckets[owner]["tests"] += stats["tests"]["code"] + return buckets + + +def format_diff(cur: int, old: int) -> str: + if cur > old: + return f"(+{cur - old})" + if cur < old: + return f"(-{old - cur})" + return "" + + +def main() -> None: + by_file = warloc_by_file() + crates = workspace_crates() + buckets = group_by_crate(by_file, crates) + + rows = [ + {"path": c, "main": b["main"], "tests": b["tests"]} + for c, b in buckets.items() + ] + rows.sort(key=lambda r: -r["main"]) + + total_main = sum(r["main"] for r in rows) + total_tests = sum(r["tests"] for r in rows) + total_with_tests = total_main + total_tests + + new_report = { + "total_main": total_main, + "total_tests": total_tests, + "total_with_tests": total_with_tests, + "crates": rows, + } + Path("loc_report.json").write_text(json.dumps(new_report)) + + # Resolve previous values (default = current → blank deltas on first run). + old_path = Path("loc_report.json.old") + if old_path.exists(): + old = json.loads(old_path.read_text()) + old_main = old.get("total_main", total_main) + old_with = old.get("total_with_tests", total_with_tests) + old_crates = {c["path"]: c["main"] for c in old.get("crates", [])} + else: + old_main = total_main + old_with = total_with_tests + old_crates = {r["path"]: r["main"] for r in rows} + + main_diff = format_diff(total_main, old_main) + with_diff = format_diff(total_with_tests, old_with) + + sha = os.environ.get("GITHUB_SHA") or _run(["git", "rev-parse", "HEAD"]).strip() + short = sha[:7] + date_utc = datetime.now(timezone.utc).strftime("%Y-%m-%d") + + per_crate = [] + for r in rows: + old_loc = old_crates.get(r["path"], r["main"]) + per_crate.append({ + "path": r["path"], + "loc": r["main"], + "diff": format_diff(r["main"], old_loc), + }) + + # --- GitHub step summary ------------------------------------------------- + gh_lines = [ + "```", + f"ethlambda lines of code ({date_utc}, {short})", + "============================================", + "", + "Per-crate (no tests)", + "--------------------", + ] + gh_lines += [f"{r['path']}: {r['loc']} {r['diff']}".rstrip() for r in per_crate] + gh_lines += [ + "", + f"Total Rust LoC (no tests): {total_main} {main_diff}".rstrip(), + f"Total Rust LoC (with tests): {total_with_tests} {with_diff}".rstrip(), + "```", + ] + Path("loc_report_github.txt").write_text("\n".join(gh_lines) + "\n") + + # --- Slack Block Kit ------------------------------------------------------ + per_crate_slack = "\n".join( + f"*{r['path']}*: {r['loc']} {r['diff']}".rstrip() for r in per_crate + ) + totals_slack = ( + f"*Total (no tests):* {total_main} {main_diff}".rstrip() + + "\n" + + f"*Total (with tests):* {total_with_tests} {with_diff}".rstrip() + ) + slack_payload = { + "blocks": [ + {"type": "header", + "text": {"type": "plain_text", "text": "Daily ethlambda LoC Report"}}, + {"type": "section", + "text": {"type": "mrkdwn", + "text": f"_Date:_ {date_utc} • _Commit:_ `{short}`"}}, + {"type": "divider"}, + {"type": "header", + "text": {"type": "plain_text", "text": "Per-crate (no tests)"}}, + {"type": "section", + "text": {"type": "mrkdwn", "text": per_crate_slack}}, + {"type": "divider"}, + {"type": "section", + "text": {"type": "mrkdwn", "text": totals_slack}}, + ] + } + Path("loc_report_slack.json").write_text(json.dumps(slack_payload)) + + # --- Telegram (HTML parse mode) ------------------------------------------ + def esc(s: str) -> str: + return html.escape(s, quote=False) + + tg_lines = [ + "Weekly ethlambda LoC Report", + f"Date: {date_utc} • Commit: {esc(short)}", + "", + "Per-crate (no tests)", + ] + tg_lines += [ + f"{esc(r['path'])}: {r['loc']} {r['diff']}".rstrip() + for r in per_crate + ] + tg_lines += [ + "", + f"Total Rust LoC (no tests): {total_main} {main_diff}".rstrip(), + f"Total Rust LoC (with tests): {total_with_tests} {with_diff}".rstrip(), + ] + Path("loc_report_telegram.txt").write_text("\n".join(tg_lines) + "\n") + + +if __name__ == "__main__": + main() diff --git a/.github/scripts/publish_slack.sh b/.github/scripts/publish_slack.sh new file mode 100755 index 00000000..a99459c2 --- /dev/null +++ b/.github/scripts/publish_slack.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# +# POSTs a Slack Block Kit payload to an incoming webhook. +# +# Required env: +# SLACK_WEBHOOK Incoming-webhook URL. Read from the env (not argv) so it +# doesn't leak into the process list. +# +# Usage: publish_slack.sh + +set -euo pipefail + +PAYLOAD_FILE="${1:?payload file required}" + +if [[ -z "${SLACK_WEBHOOK:-}" ]]; then + echo "::error::SLACK_WEBHOOK resolved to an empty value — check the secret configured for this trigger (scheduled vs manual)" + exit 1 +fi + +curl --fail-with-body -X POST "$SLACK_WEBHOOK" \ + -H 'Content-Type: application/json; charset=utf-8' \ + --data @"$PAYLOAD_FILE" diff --git a/.github/scripts/publish_telegram.sh b/.github/scripts/publish_telegram.sh new file mode 100755 index 00000000..a124df62 --- /dev/null +++ b/.github/scripts/publish_telegram.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# +# POSTs the contents of a file as an HTML-formatted Telegram message. +# +# Required env: +# TELEGRAM_BOT_TOKEN Bot token used to authenticate the request. +# TELEGRAM_ETHLAMBDA_CHAT_ID Destination chat ID. +# +# Usage: publish_telegram.sh + +set -euo pipefail + +MESSAGE_FILE="${1:?message file required}" + +if [[ -z "${TELEGRAM_BOT_TOKEN:-}" ]]; then + echo "::error::TELEGRAM_BOT_TOKEN secret is not set — skipping Telegram post" + exit 1 +fi + +if [[ -z "${TELEGRAM_ETHLAMBDA_CHAT_ID:-}" ]]; then + echo "::error::TELEGRAM_ETHLAMBDA_CHAT_ID resolved to an empty value — check that the appropriate secret is configured for this trigger (scheduled vs manual)" + exit 1 +fi + +curl --fail-with-body -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \ + -d chat_id="$TELEGRAM_ETHLAMBDA_CHAT_ID" \ + -d parse_mode=HTML \ + --data-urlencode text="$(cat "$MESSAGE_FILE")" diff --git a/.github/workflows/daily_loc_report.yml b/.github/workflows/daily_loc_report.yml new file mode 100644 index 00000000..0df914d2 --- /dev/null +++ b/.github/workflows/daily_loc_report.yml @@ -0,0 +1,104 @@ +name: Daily Lines of Code Report + +on: + schedule: + # Every day at UTC midnight (Slack daily, Telegram on Monday only) + - cron: "0 0 * * *" + workflow_dispatch: + inputs: + target: + description: "Where to post (test channel/chat or prod)" + required: true + default: "test" + type: choice + options: + - test + - prod + post_telegram: + description: "Also post to Telegram on this manual run" + required: false + default: false + type: boolean + +permissions: + contents: read + actions: write + +env: + CARGO_NET_GIT_FETCH_WITH_CLI: "true" + CARGO_NET_RETRY: "10" + +jobs: + loc: + name: Count ethlambda LoC and publish report + runs-on: ubuntu-latest + steps: + - name: Checkout sources + uses: actions/checkout@v6 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@master + with: + toolchain: "1.92.0" + + - name: Setup cache + uses: Swatinem/rust-cache@v2 + + - name: Install cargo-warloc + run: cargo install cargo-warloc --locked --version 0.1.1 + + - name: Restore previous LoC report + id: cache-loc-report + uses: actions/cache/restore@v5 + with: + path: loc_report.json + key: loc-report-${{ github.ref_name }}-${{ github.run_id }} + restore-keys: | + loc-report-${{ github.ref_name }}- + + - name: Stash previous report as .old for delta computation + if: steps.cache-loc-report.outputs.cache-hit != '' + run: mv loc_report.json loc_report.json.old + + - name: Generate LoC report + run: python3 .github/scripts/generate_loc_report.py + + - name: Save new LoC report to cache + if: success() + uses: actions/cache/save@v5 + with: + path: loc_report.json + key: loc-report-${{ github.ref_name }}-${{ github.run_id }} + + - name: Post results to workflow summary + run: cat loc_report_github.txt >> "$GITHUB_STEP_SUMMARY" + + - name: Post to Slack + env: + SLACK_WEBHOOK: >- + ${{ (github.event_name == 'schedule' || inputs.target == 'prod') + && secrets.ETHLAMBDA_GENERAL_SLACK_WEBHOOK + || secrets.ETHLAMBDA_TEST_SLACK_WEBHOOK }} + run: bash .github/scripts/publish_slack.sh loc_report_slack.json + + - name: Post to Telegram (weekly, or manual opt-in) + env: + TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} + TELEGRAM_ETHLAMBDA_CHAT_ID: >- + ${{ (github.event_name == 'schedule' || inputs.target == 'prod') + && secrets.TELEGRAM_ETHLAMBDA_CHAT_ID + || secrets.TELEGRAM_ETHLAMBDA_TEST_CHAT_ID }} + run: | + # Scheduled runs only post to Telegram on Monday (UTC). + # Manual runs require post_telegram=true to opt in. + if [[ "${{ github.event_name }}" == "schedule" ]]; then + day_of_week=$(date -u +%u) # 1=Monday .. 7=Sunday + if [[ "$day_of_week" != "1" ]]; then + echo "Skipping Telegram post (scheduled run, only sent on Monday)" + exit 0 + fi + elif [[ "${{ inputs.post_telegram }}" != "true" ]]; then + echo "Skipping Telegram post (manual run, post_telegram not enabled)" + exit 0 + fi + bash .github/scripts/publish_telegram.sh loc_report_telegram.txt