From 935bdba616c01885d8996af16b829629811ac77b Mon Sep 17 00:00:00 2001
From: Stupidu05 <227017607+Stupidu05@users.noreply.github.com>
Date: Sat, 7 Nov 2026 17:23:51 +0800
Subject: [PATCH] feat: add export-judges subcommand for csv and xlsx judge
spreadsheets
---
apps/api/src/hackproof/cli.py | 6 +
apps/api/src/hackproof/export/judges.py | 159 ++++++++++++++++++++++++
apps/api/tests/test_judge_export.py | 39 ++++++
docs/judge-export.md | 24 ++++
4 files changed, 228 insertions(+)
create mode 100644 apps/api/src/hackproof/export/judges.py
create mode 100644 apps/api/tests/test_judge_export.py
create mode 100644 docs/judge-export.md
diff --git a/apps/api/src/hackproof/cli.py b/apps/api/src/hackproof/cli.py
index 2afb2dd..c95b721 100644
--- a/apps/api/src/hackproof/cli.py
+++ b/apps/api/src/hackproof/cli.py
@@ -3,6 +3,7 @@
import logging
from hackproof.export.jobs import run_export
+from hackproof.export.judges import run_export_judges
from hackproof.github.jobs import run_discover, run_scan
from hackproof.indexer.sync import run_sync
from hackproof.linking.reconcile import run_reconcile
@@ -23,6 +24,9 @@ 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(
+ "export-judges", help="write objective judge csv and xlsx spreadsheets into data"
+ )
args = parser.parse_args()
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s")
@@ -38,3 +42,5 @@ def main() -> None:
asyncio.run(run_reconcile())
case "export":
asyncio.run(run_export())
+ case "export-judges":
+ asyncio.run(run_export_judges())
diff --git a/apps/api/src/hackproof/export/judges.py b/apps/api/src/hackproof/export/judges.py
new file mode 100644
index 0000000..cc8765e
--- /dev/null
+++ b/apps/api/src/hackproof/export/judges.py
@@ -0,0 +1,159 @@
+import csv
+import logging
+import zipfile
+from pathlib import Path
+from xml.sax.saxutils import escape
+
+from hackproof.config import ROOT
+from hackproof.db import open_pool, pool
+from hackproof.export.build import STRICT_TIERS, build_export
+from hackproof.export.models import HackathonExport, Project
+from hackproof.hackathon import get_hackathon
+
+log = logging.getLogger("hackproof.export.judges")
+
+JUDGE_COLUMNS: tuple[str, ...] = (
+ "repo_url",
+ "evidence_tier",
+ "contracts",
+ "deploys",
+ "first_deploy_time",
+ "invocations",
+ "failed_transactions",
+ "has_tests",
+ "test_file_count",
+ "has_ci",
+ "readme_length",
+ "readme_sections",
+ "has_demo_url",
+ "has_video_url",
+ "ai_attribution_present",
+ "mainnet_status",
+)
+
+CONTENT_TYPES_XML = """
+
+
+
+
+
+
+"""
+
+RELS_XML = """
+
+
+
+"""
+
+WORKBOOK_XML = """
+
+
+
+
+
+"""
+
+WORKBOOK_RELS_XML = """
+
+
+
+"""
+
+
+def _col_letter(index: int) -> str:
+ letters = ""
+ current = index + 1
+ while current > 0:
+ current, remainder = divmod(current - 1, 26)
+ letters = chr(65 + remainder) + letters
+ return letters
+
+
+def _project_row(project: Project) -> dict[str, str | int]:
+ deploys = project.chain.contracts
+ first_deploy = (
+ min(c.deployed_at for c in deploys).strftime("%Y-%m-%dT%H:%M:%SZ") if deploys else ""
+ )
+ return {
+ "repo_url": project.repo_url,
+ "evidence_tier": project.tier,
+ "contracts": project.chain.contract_count,
+ "deploys": len(deploys),
+ "first_deploy_time": first_deploy,
+ "invocations": "",
+ "failed_transactions": project.chain.failed_tx_count,
+ "has_tests": "",
+ "test_file_count": "",
+ "has_ci": "",
+ "readme_length": "",
+ "readme_sections": "",
+ "has_demo_url": "true" if project.homepage else "false",
+ "has_video_url": "",
+ "ai_attribution_present": "",
+ "mainnet_status": "",
+ }
+
+
+def _sheet_xml(rows: list[list[str | int]]) -> str:
+ xml_rows: list[str] = []
+ for r_idx, row in enumerate(rows, start=1):
+ cells: list[str] = []
+ for c_idx, value in enumerate(row):
+ ref = f"{_col_letter(c_idx)}{r_idx}"
+ if isinstance(value, int):
+ cells.append(f'{value}')
+ else:
+ escaped = escape(str(value))
+ cells.append(f'{escaped}')
+ xml_rows.append(f'{"".join(cells)}
')
+ body = "".join(xml_rows)
+ return (
+ ''
+ ''
+ f"{body}"
+ ""
+ )
+
+
+def write_judge_files(export: HackathonExport, output_dir: Path) -> tuple[Path, Path]:
+ output_dir.mkdir(parents=True, exist_ok=True)
+ csv_path = output_dir / "judges.csv"
+ xlsx_path = output_dir / "judges.xlsx"
+
+ dict_rows = [_project_row(project) for project in export.projects]
+ with csv_path.open("w", newline="", encoding="utf-8") as handle:
+ writer = csv.DictWriter(handle, fieldnames=list(JUDGE_COLUMNS))
+ writer.writeheader()
+ writer.writerows(dict_rows)
+
+ matrix: list[list[str | int]] = [list(JUDGE_COLUMNS)]
+ for row in dict_rows:
+ matrix.append([row[col] for col in JUDGE_COLUMNS])
+
+ with zipfile.ZipFile(xlsx_path, "w", compression=zipfile.ZIP_DEFLATED) as archive:
+ archive.writestr("[Content_Types].xml", CONTENT_TYPES_XML)
+ archive.writestr("_rels/.rels", RELS_XML)
+ archive.writestr("xl/workbook.xml", WORKBOOK_XML)
+ archive.writestr("xl/_rels/workbook.xml.rels", WORKBOOK_RELS_XML)
+ archive.writestr("xl/worksheets/sheet1.xml", _sheet_xml(matrix))
+
+ return csv_path, xlsx_path
+
+
+async def run_export_judges(output_dir: Path | None = None) -> tuple[Path, Path]:
+ hackathon = get_hackathon()
+ await open_pool()
+ async with pool().acquire() as connection:
+ export = await build_export(connection, hackathon, STRICT_TIERS)
+ target = output_dir or (ROOT / "data" / str(export.hackathon.starts_at.year))
+ csv_path, xlsx_path = write_judge_files(export, target)
+ log.info("wrote judge exports to %s and %s", csv_path, xlsx_path)
+ return csv_path, xlsx_path
diff --git a/apps/api/tests/test_judge_export.py b/apps/api/tests/test_judge_export.py
new file mode 100644
index 0000000..268634c
--- /dev/null
+++ b/apps/api/tests/test_judge_export.py
@@ -0,0 +1,39 @@
+import csv
+import re
+import xml.etree.ElementTree as ET
+import zipfile
+
+from hackproof.config import ROOT
+from hackproof.export.judges import JUDGE_COLUMNS, write_judge_files
+from tests.test_export import populated_export
+
+
+def documented_columns() -> list[str]:
+ doc = (ROOT / "docs" / "judge-export.md").read_text(encoding="utf-8")
+ return re.findall(r"^\| `([a-z_]+)` \|", doc, flags=re.MULTILINE)
+
+
+async def test_judge_export_headers_match_documented_columns_and_open_cleanly(
+ scratch_db, tmp_path
+):
+ export = await populated_export(scratch_db)
+ csv_path, xlsx_path = write_judge_files(export, tmp_path)
+
+ expected = documented_columns()
+ assert list(JUDGE_COLUMNS) == expected
+
+ with csv_path.open(encoding="utf-8") as handle:
+ rows = list(csv.reader(handle))
+ assert rows[0] == expected
+ assert len(rows) == 2
+ assert rows[1][0] == "https://github.com/team/app"
+ assert rows[1][1] == "verified"
+
+ with zipfile.ZipFile(xlsx_path) as archive:
+ sheet_xml = archive.read("xl/worksheets/sheet1.xml")
+ root = ET.fromstring(sheet_xml)
+ ns = {"s": "http://schemas.openxmlformats.org/spreadsheetml/2006/main"}
+ first_row = root.find(".//s:row[@r='1']", ns)
+ assert first_row is not None
+ xlsx_headers = [t.text for t in first_row.findall(".//s:t", ns)]
+ assert xlsx_headers == expected
diff --git a/docs/judge-export.md b/docs/judge-export.md
new file mode 100644
index 0000000..265c2cd
--- /dev/null
+++ b/docs/judge-export.md
@@ -0,0 +1,24 @@
+# Judge Export
+
+`uv run hackproof export-judges` writes `judges.csv` and `judges.xlsx` with one row per submission and objective columns only. No subjective scores are included.
+
+## Columns
+
+| Column | Source / Meaning | Status |
+| --- | --- | --- |
+| `repo_url` | Public GitHub repository URL (`project.repo_url`) | Populated |
+| `evidence_tier` | Attribution strength (`verified` or `likely`) | Populated |
+| `contracts` | Count of Soroban contracts deployed inside the event window | Populated |
+| `deploys` | Number of attributed contract deployment transactions in the window | Populated |
+| `first_deploy_time` | Earliest `deployed_at` UTC timestamp across window contract deploys, empty when none | Populated |
+| `invocations` | Contract invocation count during the event window | Empty (not indexed yet) |
+| `failed_transactions` | Count of failed Soroban transactions by attributed accounts | Populated |
+| `has_tests` | Whether the repository contains automated test files | Empty (not indexed yet) |
+| `test_file_count` | Number of test files detected in the repository | Empty (not indexed yet) |
+| `has_ci` | Whether the repository configures a CI workflow | Empty (not indexed yet) |
+| `readme_length` | Character length of the repository README | Empty (not indexed yet) |
+| `readme_sections` | Count of Markdown headings in the repository README | Empty (not indexed yet) |
+| `has_demo_url` | `"true"` when the repository sets a homepage/demo URL, `"false"` otherwise | Populated |
+| `has_video_url` | Whether the repository links a demo video | Empty (not indexed yet) |
+| `ai_attribution_present` | Whether AI tool attribution is declared in commits or docs | Empty (not indexed yet) |
+| `mainnet_status` | Whether the project has deployed on Stellar pubnet/mainnet | Empty (not indexed yet) |