From 85309d2e2f919b9c351d1c4e0de6184bb8dd7fba Mon Sep 17 00:00:00 2001 From: Stupidu05 <227017607+Stupidu05@users.noreply.github.com> Date: Sat, 7 Nov 2026 20:20:42 +0800 Subject: [PATCH] feat: add weekly markdown digest subcommand and discord workflow --- .github/workflows/survival.yml | 38 +++++++++ apps/api/src/hackproof/cli.py | 4 + apps/api/src/hackproof/digest.py | 129 +++++++++++++++++++++++++++++++ apps/api/tests/test_digest.py | 44 +++++++++++ 4 files changed, 215 insertions(+) create mode 100644 .github/workflows/survival.yml create mode 100644 apps/api/src/hackproof/digest.py create mode 100644 apps/api/tests/test_digest.py diff --git a/.github/workflows/survival.yml b/.github/workflows/survival.yml new file mode 100644 index 0000000..2019d2d --- /dev/null +++ b/.github/workflows/survival.yml @@ -0,0 +1,38 @@ +name: survival + +on: + schedule: + - cron: '0 9 * * 1' + workflow_dispatch: + +jobs: + digest: + runs-on: ubuntu-latest + defaults: + run: + working-directory: apps/api + services: + postgres: + image: postgres:17-alpine + env: + POSTGRES_USER: hackproof + POSTGRES_PASSWORD: hackproof + POSTGRES_DB: hackproof + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U hackproof" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + env: + DATABASE_URL: postgresql://hackproof:hackproof@localhost:5432/hackproof + DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v6 + with: + enable-cache: true + cache-dependency-glob: apps/api/uv.lock + - run: uv sync --frozen + - run: uv run hackproof digest diff --git a/apps/api/src/hackproof/cli.py b/apps/api/src/hackproof/cli.py index 2afb2dd..9e5403d 100644 --- a/apps/api/src/hackproof/cli.py +++ b/apps/api/src/hackproof/cli.py @@ -2,6 +2,7 @@ import asyncio import logging +from hackproof.digest import run_digest from hackproof.export.jobs import run_export from hackproof.github.jobs import run_discover, run_scan from hackproof.indexer.sync import run_sync @@ -23,6 +24,7 @@ def main() -> None: ) commands.add_parser("reconcile", help="link chain activity to repos and build projects") commands.add_parser("export", help="write one yaml file per project into data and its schema") + commands.add_parser("digest", help="render a weekly markdown digest and post to discord") args = parser.parse_args() logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s") @@ -38,3 +40,5 @@ def main() -> None: asyncio.run(run_reconcile()) case "export": asyncio.run(run_export()) + case "digest": + asyncio.run(run_digest()) diff --git a/apps/api/src/hackproof/digest.py b/apps/api/src/hackproof/digest.py new file mode 100644 index 0000000..b9b618e --- /dev/null +++ b/apps/api/src/hackproof/digest.py @@ -0,0 +1,129 @@ +import os +from typing import Any + +import asyncpg +import httpx + +from hackproof.db import open_pool, pool + + +def _format_delta(current: int, previous: int | None) -> str: + if previous is None: + return f"{current}" + diff = current - previous + sign = "+" if diff >= 0 else "" + return f"{current} ({sign}{diff})" + + +def render_digest(previous: dict[str, Any] | None, current: dict[str, Any]) -> str: + prev = previous or {} + lines = ["# HackProof Weekly Digest"] + + if "projects" in current: + p_curr = current["projects"] + p_prev = prev.get("projects", {}) + lines.append("") + lines.append("## Projects & Chain") + lines.append( + f"- Verified projects: {_format_delta(p_curr['verified'], p_prev.get('verified'))}" + ) + lines.append(f"- Likely projects: {_format_delta(p_curr['likely'], p_prev.get('likely'))}") + lines.append( + f"- Deployed contracts: {_format_delta(p_curr['contracts'], p_prev.get('contracts'))}" + ) + + if "survival" in current: + s_curr = current["survival"] + s_prev = prev.get("survival", {}) + lines.append("") + lines.append("## Survival") + lines.append(f"- Active repos: {_format_delta(s_curr['active'], s_prev.get('active'))}") + lines.append(f"- Total tracked: {_format_delta(s_curr['total'], s_prev.get('total'))}") + + if "graduations" in current: + g_curr = current["graduations"] + g_prev = prev.get("graduations", {}) + lines.append("") + lines.append("## Mainnet Graduations") + lines.append( + f"- Graduated projects: {_format_delta(g_curr['count'], g_prev.get('count'))}" + ) + + if "scf_links" in current: + l_curr = current["scf_links"] + l_prev = prev.get("scf_links", {}) + lines.append("") + lines.append("## SCF Links") + lines.append(f"- Linked awards: {_format_delta(l_curr['count'], l_prev.get('count'))}") + + return "\n".join(lines) + "\n" + + +async def _table_exists(connection: asyncpg.Connection, name: str) -> bool: + reg = await connection.fetchval("SELECT to_regclass($1)", f"public.{name}") + return reg is not None + + +async def collect_snapshot(connection: asyncpg.Connection) -> dict[str, Any]: + snapshot: dict[str, Any] = {} + if await _table_exists(connection, "projects"): + row = await connection.fetchrow( + """ + SELECT + COUNT(*) FILTER (WHERE tier = 'verified') AS verified, + COUNT(*) FILTER (WHERE tier = 'likely') AS likely, + COALESCE(SUM(contracts), 0) AS contracts + FROM projects + """ + ) + if row: + snapshot["projects"] = { + "verified": int(row["verified"]), + "likely": int(row["likely"]), + "contracts": int(row["contracts"]), + } + + if await _table_exists(connection, "survival_points"): + row = await connection.fetchrow( + """ + SELECT + COUNT(*) FILTER (WHERE active) AS active, + COUNT(*) AS total + FROM survival_points + """ + ) + if row: + snapshot["survival"] = {"active": int(row["active"]), "total": int(row["total"])} + + if await _table_exists(connection, "graduations"): + count = await connection.fetchval("SELECT COUNT(*) FROM graduations") + snapshot["graduations"] = {"count": int(count or 0)} + + if await _table_exists(connection, "scf_links"): + count = await connection.fetchval("SELECT COUNT(*) FROM scf_links") + snapshot["scf_links"] = {"count": int(count or 0)} + + return snapshot + + +async def post_or_print_digest(markdown: str, webhook_url: str | None = None) -> None: + target_url = ( + webhook_url + or os.environ.get("DISCORD_WEBHOOK_URL") + or os.environ.get("HACKPROOF_DISCORD_WEBHOOK_URL") + ) + if target_url: + async with httpx.AsyncClient(timeout=15.0) as client: + response = await client.post(target_url, json={"content": markdown}) + response.raise_for_status() + else: + print(markdown, end="") + + +async def run_digest() -> str: + await open_pool() + async with pool().acquire() as connection: + current = await collect_snapshot(connection) + markdown = render_digest(None, current) + await post_or_print_digest(markdown) + return markdown diff --git a/apps/api/tests/test_digest.py b/apps/api/tests/test_digest.py new file mode 100644 index 0000000..e9c89f1 --- /dev/null +++ b/apps/api/tests/test_digest.py @@ -0,0 +1,44 @@ +from hackproof.digest import collect_snapshot, post_or_print_digest, render_digest + + +def test_two_weekly_runs_produce_a_deterministic_digest_and_skip_missing_sections(): + run_1 = { + "projects": {"verified": 42, "likely": 18, "contracts": 65}, + "survival": {"active": 30, "total": 60}, + } + run_2 = { + "projects": {"verified": 45, "likely": 17, "contracts": 70}, + "survival": {"active": 28, "total": 62}, + } + + digest_a = render_digest(run_1, run_2) + digest_b = render_digest(run_1, run_2) + + assert digest_a == digest_b + assert digest_a == ( + "# HackProof Weekly Digest\n\n" + "## Projects & Chain\n" + "- Verified projects: 45 (+3)\n" + "- Likely projects: 17 (-1)\n" + "- Deployed contracts: 70 (+5)\n\n" + "## Survival\n" + "- Active repos: 28 (-2)\n" + "- Total tracked: 62 (+2)\n" + ) + assert "Mainnet Graduations" not in digest_a + assert "SCF Links" not in digest_a + + +async def test_collect_snapshot_skips_tables_that_do_not_exist(scratch_db): + snapshot = await collect_snapshot(scratch_db) + assert "projects" in snapshot + assert "survival" not in snapshot + assert "graduations" not in snapshot + assert "scf_links" not in snapshot + + +async def test_digest_prints_when_webhook_is_unset(monkeypatch, capsys): + monkeypatch.delenv("DISCORD_WEBHOOK_URL", raising=False) + monkeypatch.delenv("HACKPROOF_DISCORD_WEBHOOK_URL", raising=False) + await post_or_print_digest("# HackProof Weekly Digest\n") + assert capsys.readouterr().out == "# HackProof Weekly Digest\n"