Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
214 changes: 214 additions & 0 deletions .github/scripts/generate_loc_report.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
#!/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


# Crates whose entire contents are test infrastructure and should never
# appear in the "no tests" totals or per-crate listing.
TEST_ONLY_CRATES = frozenset({
"crates/common/test-fixtures",
})

# Individual files that are test infrastructure but live next to production
# code inside an otherwise-production crate. Their lines are folded into the
# owning crate's `tests` bucket.
TEST_ONLY_FILES = frozenset({
"crates/net/rpc/src/test_driver.rs",
})


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
is_test_only = owner in TEST_ONLY_CRATES or path in TEST_ONLY_FILES
if is_test_only:
# All lines from this file/crate count as tests.
buckets[owner]["tests"] += stats["main"]["code"] + stats["tests"]["code"]
else:
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:
# Test-only crates fold their lines into the tests bucket and have
# main == 0; skip them in the per-crate "no tests" listing.
if r["main"] == 0:
continue
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 = [
"<b>Weekly ethlambda LoC Report</b>",
f"Date: {date_utc} • Commit: <code>{esc(short)}</code>",
"",
"<b>Per-crate (no tests)</b>",
]
tg_lines += [
f"<b>{esc(r['path'])}</b>: {r['loc']} {r['diff']}".rstrip()
for r in per_crate
]
tg_lines += [
"",
f"<b>Total Rust LoC (no tests):</b> {total_main} {main_diff}".rstrip(),
f"<b>Total Rust LoC (with tests):</b> {total_with_tests} {with_diff}".rstrip(),
]
Path("loc_report_telegram.txt").write_text("\n".join(tg_lines) + "\n")


if __name__ == "__main__":
main()
22 changes: 22 additions & 0 deletions .github/scripts/publish_slack.sh
Original file line number Diff line number Diff line change
@@ -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 <payload_file>

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"
28 changes: 28 additions & 0 deletions .github/scripts/publish_telegram.sh
Original file line number Diff line number Diff line change
@@ -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 <message_file>

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")"
104 changes: 104 additions & 0 deletions .github/workflows/daily_loc_report.yml
Original file line number Diff line number Diff line change
@@ -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