Skip to content
Open
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
38 changes: 38 additions & 0 deletions .github/workflows/survival.yml
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions apps/api/src/hackproof/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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")
Expand All @@ -38,3 +40,5 @@ def main() -> None:
asyncio.run(run_reconcile())
case "export":
asyncio.run(run_export())
case "digest":
asyncio.run(run_digest())
129 changes: 129 additions & 0 deletions apps/api/src/hackproof/digest.py
Original file line number Diff line number Diff line change
@@ -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
44 changes: 44 additions & 0 deletions apps/api/tests/test_digest.py
Original file line number Diff line number Diff line change
@@ -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"