From a6fc0da747f67410a232ca53577a293f2a155e0d Mon Sep 17 00:00:00 2001 From: Stupidu05 <227017607+Stupidu05@users.noreply.github.com> Date: Sat, 7 Nov 2026 17:19:24 +0800 Subject: [PATCH] feat: add new-hackathon scaffolding subcommand --- apps/api/src/hackproof/cli.py | 46 ++++++++++++- apps/api/src/hackproof/hackathon.py | 102 +++++++++++++++++++++++++++- apps/api/tests/test_hackathon.py | 39 +++++++++++ 3 files changed, 182 insertions(+), 5 deletions(-) diff --git a/apps/api/src/hackproof/cli.py b/apps/api/src/hackproof/cli.py index 2afb2dd..38081d0 100644 --- a/apps/api/src/hackproof/cli.py +++ b/apps/api/src/hackproof/cli.py @@ -1,14 +1,47 @@ import argparse import asyncio import logging +from pathlib import Path from hackproof.export.jobs import run_export from hackproof.github.jobs import run_discover, run_scan +from hackproof.hackathon import scaffold_hackathon from hackproof.indexer.sync import run_sync from hackproof.linking.reconcile import run_reconcile -def main() -> None: +def _prompt_if_missing(value: str | None, label: str) -> str: + if value: + return value + return input(f"{label}: ").strip() + + +def run_new_hackathon(args: argparse.Namespace) -> Path: + slug = _prompt_if_missing(args.slug, "Hackathon slug (e.g. hackmeridian-2027)") + name = _prompt_if_missing(args.name, "Hackathon name (e.g. HackMeridian 2027)") + start = _prompt_if_missing(args.start, "Window start UTC (e.g. 2027-10-25T09:00:00Z)") + end = _prompt_if_missing(args.end, "Window end UTC (e.g. 2027-10-26T12:00:00Z)") + output_dir = Path(args.output_dir) if args.output_dir else None + path = scaffold_hackathon( + slug=slug, + name=name, + start=start, + end=end, + pull_start=args.pull_start, + output_dir=output_dir, + ) + print(f"Wrote {path}") + print("Next steps:") + print(f" export HACKPROOF_HACKATHON_FILE={path}") + print(" uv run hackproof sync") + print(" uv run hackproof discover") + print(" uv run hackproof scan") + print(" uv run hackproof reconcile") + print(" uv run hackproof export") + return path + + +def main(argv: list[str] | None = None) -> None: parser = argparse.ArgumentParser(prog="hackproof") commands = parser.add_subparsers(dest="command", required=True) commands.add_parser("sync", help="index the hack window from testnet") @@ -23,7 +56,14 @@ 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") - args = parser.parse_args() + new_hack = commands.add_parser("new-hackathon", help="scaffold a new hackathon yaml config") + new_hack.add_argument("--slug", help="event slug, e.g. hackmeridian-2027") + new_hack.add_argument("--name", help="event display name, e.g. HackMeridian 2027") + new_hack.add_argument("--start", help="official window start, ISO 8601 UTC") + new_hack.add_argument("--end", help="official submission deadline, ISO 8601 UTC") + new_hack.add_argument("--pull-start", help="ledger pull start, ISO 8601 UTC") + new_hack.add_argument("--output-dir", help="directory to write the yaml file into") + args = parser.parse_args(argv) logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s") logging.getLogger("httpx").setLevel(logging.WARNING) @@ -38,3 +78,5 @@ def main() -> None: asyncio.run(run_reconcile()) case "export": asyncio.run(run_export()) + case "new-hackathon": + run_new_hackathon(args) diff --git a/apps/api/src/hackproof/hackathon.py b/apps/api/src/hackproof/hackathon.py index 5424aec..1da5f33 100644 --- a/apps/api/src/hackproof/hackathon.py +++ b/apps/api/src/hackproof/hackathon.py @@ -1,11 +1,12 @@ -from datetime import datetime +from datetime import datetime, timedelta from functools import lru_cache -from typing import Literal +from pathlib import Path +from typing import Any, Literal import yaml from pydantic import BaseModel -from hackproof.config import get_settings +from hackproof.config import ROOT, get_settings Place = Literal["first", "runner-up"] @@ -47,3 +48,98 @@ def get_hackathon() -> Hackathon: def places() -> dict[str, Place]: return {award.project_id: award.place for award in get_hackathon().awards} + + +DEFAULT_CODE_QUERIES = [ + "soroban-sdk filename:Cargo.toml", + "@stellar/stellar-sdk filename:package.json", + "@stellar/freighter-api filename:package.json", + "@creit.tech/stellar-wallets-kit filename:package.json", + "stellar-base filename:package.json", + "stellar-sdk filename:requirements.txt", + "stellar-sdk filename:pyproject.toml", + "github.com/stellar/go filename:go.mod", +] + +DEFAULT_STRING_QUERIES = [ + "Test SDF Network ; September 2015", + "soroban-testnet.stellar.org", + "friendbot.stellar.org", +] + +DEFAULT_FINGERPRINTS: dict[str, list[str]] = { + "rust": ["soroban-sdk", "stellar-xdr", "stellar-strkey"], + "js": [ + "@stellar/stellar-sdk", + "@stellar/stellar-base", + "@stellar/freighter-api", + "@creit.tech/stellar-wallets-kit", + "@stellar/typescript-wallet-sdk", + "stellar-sdk", + "stellar-base", + ], + "python": ["stellar-sdk", "stellar_sdk"], + "go": ["github.com/stellar/go"], +} + +DEFAULT_EXCLUDE_OWNERS = ["stellar", "stellar-expert", "soroban", "creit-tech", "stellar-light"] +DEFAULT_EXCLUDE_REPOS = ["stellar-scaffold/cli", "NethermindEth/stellar-private-payments"] + + +def _iso_utc(value: datetime) -> str: + return value.strftime("%Y-%m-%dT%H:%M:%SZ") + + +def scaffold_hackathon( + slug: str, + name: str, + start: str, + end: str, + pull_start: str | None = None, + output_dir: Path | None = None, +) -> Path: + parsed_start = datetime.fromisoformat(start.replace("Z", "+00:00")) + parsed_end = datetime.fromisoformat(end.replace("Z", "+00:00")) + parsed_pull = ( + datetime.fromisoformat(pull_start.replace("Z", "+00:00")) + if pull_start + else parsed_start - timedelta(hours=1) + ) + keyword = slug.split("-")[0].lower() + name_query = name.lower() + github_queries = [ + f"{keyword} in:name,description,readme,topics", + f'"{name_query}" in:name,description,readme', + "stellar in:name,description,readme,topics", + "soroban in:name,description,readme,topics", + "stellar-sdk in:readme", + "freighter in:description,readme", + "topic:stellar", + "topic:soroban", + f"topic:{keyword}", + ] + payload: dict[str, Any] = { + "slug": slug, + "name": name, + "window": { + "start": _iso_utc(parsed_start), + "pull_start": _iso_utc(parsed_pull), + "end": _iso_utc(parsed_end), + }, + "awards": [], + "discovery": { + "github_queries": github_queries, + "github_code_queries": list(DEFAULT_CODE_QUERIES), + "github_string_queries": list(DEFAULT_STRING_QUERIES), + "dependency_fingerprints": dict(DEFAULT_FINGERPRINTS), + "exclude_owners": list(DEFAULT_EXCLUDE_OWNERS), + "exclude_repos": list(DEFAULT_EXCLUDE_REPOS), + "known_infra_accounts": [], + }, + } + validated = Hackathon.model_validate(payload) + target_dir = output_dir or (ROOT / "data") + target_dir.mkdir(parents=True, exist_ok=True) + path = target_dir / f"{validated.slug}.yaml" + path.write_text(yaml.safe_dump(payload, sort_keys=False, allow_unicode=True, width=120)) + return path diff --git a/apps/api/tests/test_hackathon.py b/apps/api/tests/test_hackathon.py index 604b6fb..cca34c1 100644 --- a/apps/api/tests/test_hackathon.py +++ b/apps/api/tests/test_hackathon.py @@ -1,3 +1,5 @@ +from hackproof.cli import main +from hackproof.config import get_settings from hackproof.hackathon import get_hackathon, places @@ -10,3 +12,40 @@ def test_awards_name_each_project_once(): def test_places_maps_every_award(): assert places() == {award.project_id: award.place for award in get_hackathon().awards} + + +def test_new_hackathon_with_flags_writes_yaml_that_loads_through_get_hackathon( + tmp_path, monkeypatch, capsys +): + main( + [ + "new-hackathon", + "--slug", + "hackmeridian-2027", + "--name", + "HackMeridian 2027", + "--start", + "2027-10-25T09:00:00Z", + "--end", + "2027-10-26T12:00:00Z", + "--output-dir", + str(tmp_path), + ] + ) + out = capsys.readouterr().out + yaml_path = tmp_path / "hackmeridian-2027.yaml" + assert yaml_path.exists() + assert "uv run hackproof sync" in out + + monkeypatch.setenv("HACKPROOF_HACKATHON_FILE", str(yaml_path)) + get_settings.cache_clear() + get_hackathon.cache_clear() + try: + loaded = get_hackathon() + assert loaded.slug == "hackmeridian-2027" + assert loaded.name == "HackMeridian 2027" + assert "topic:hackmeridian" in loaded.discovery.github_queries + assert '"hackmeridian 2027" in:name,description,readme' in loaded.discovery.github_queries + finally: + get_settings.cache_clear() + get_hackathon.cache_clear()