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
6 changes: 6 additions & 0 deletions apps/api/src/hackproof/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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")
Expand All @@ -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())
159 changes: 159 additions & 0 deletions apps/api/src/hackproof/export/judges.py
Original file line number Diff line number Diff line change
@@ -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 = """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
<Default Extension="xml" ContentType="application/xml"/>
<Override PartName="/xl/workbook.xml"
ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>
<Override PartName="/xl/worksheets/sheet1.xml"
ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>
</Types>
"""

RELS_XML = """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1"
Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument"
Target="xl/workbook.xml"/>
</Relationships>
"""

WORKBOOK_XML = """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"
xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
<sheets>
<sheet name="Submissions" sheetId="1" r:id="rId1"/>
</sheets>
</workbook>
"""

WORKBOOK_RELS_XML = """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1"
Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet"
Target="worksheets/sheet1.xml"/>
</Relationships>
"""


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'<c r="{ref}" t="n"><v>{value}</v></c>')
else:
escaped = escape(str(value))
cells.append(f'<c r="{ref}" t="inlineStr"><is><t>{escaped}</t></is></c>')
xml_rows.append(f'<row r="{r_idx}">{"".join(cells)}</row>')
body = "".join(xml_rows)
return (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'
f"<sheetData>{body}</sheetData>"
"</worksheet>"
)


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
39 changes: 39 additions & 0 deletions apps/api/tests/test_judge_export.py
Original file line number Diff line number Diff line change
@@ -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
24 changes: 24 additions & 0 deletions docs/judge-export.md
Original file line number Diff line number Diff line change
@@ -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) |