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
46 changes: 44 additions & 2 deletions apps/api/src/hackproof/cli.py
Original file line number Diff line number Diff line change
@@ -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")
Expand All @@ -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)
Expand All @@ -38,3 +78,5 @@ def main() -> None:
asyncio.run(run_reconcile())
case "export":
asyncio.run(run_export())
case "new-hackathon":
run_new_hackathon(args)
102 changes: 99 additions & 3 deletions apps/api/src/hackproof/hackathon.py
Original file line number Diff line number Diff line change
@@ -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"]

Expand Down Expand 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
39 changes: 39 additions & 0 deletions apps/api/tests/test_hackathon.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from hackproof.cli import main
from hackproof.config import get_settings
from hackproof.hackathon import get_hackathon, places


Expand All @@ -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()