Skip to content
Merged
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
56 changes: 5 additions & 51 deletions .github/scripts/auto_extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -261,57 +261,11 @@ def extract_with_openai(page_content, additional_notes=""):
util.fail(f"OpenAI API error: {str(e)}")


def parse_issue_body(body):
"""Parse the issue body to get URL and notes."""
lines = body.strip().split("\n")
data = {}

current_field = None
current_value = []

for line in lines:
if line.startswith("### "):
if current_field and current_value:
data[current_field] = "\n".join(current_value).strip()
# Convert "Link to Hackathon" -> "link_to_hackathon"
current_field = line[4:].strip().lower().replace(" ", "_").replace("?", "").replace("(", "").replace(")", "")
current_value = []
elif current_field:
if line.strip() and line.strip() != "_No response_":
current_value.append(line)

if current_field and current_value:
data[current_field] = "\n".join(current_value).strip()

return data


def parse_state(data):
"""Map optional issue status field to listing state."""
status = (data.get("status", "") or "").strip().lower()
if "soon" in status:
return "opens_soon"
if "open" in status:
return "open"
# Sensible default for auto-extracted listings.
return "open"


def parse_deadline(data):
"""Parse optional deadline and normalize to ISO YYYY-MM-DD."""
raw = (data.get("deadline_optional", "") or data.get("deadline", "")).strip()
if not raw:
return None
try:
return util.parse_deadline_date(raw).isoformat()
except ValueError:
util.fail("Invalid deadline format. Please use YYYY-MM-DD or MM/DD/YYYY.")


def extract_url_from_body(body):
"""Try multiple methods to extract URL from issue body."""
# Method 1: Parse structured fields
data = parse_issue_body(body)
# Method 1: Parse structured fields (strip_symbols matches the field keys
# this script looks up, e.g. deadline_optional).
data = util.parse_issue_body(body, strip_symbols=True)

# Try various field names
url_fields = [
Expand Down Expand Up @@ -365,8 +319,8 @@ def main():
print(f"Extracted URL: {url}")

notes = data.get("any_additional_context_optional", "") or data.get("notes", "")
state = parse_state(data)
deadline = parse_deadline(data)
state = util.parse_state(data)
deadline = util.parse_deadline(data, "deadline_optional", "deadline")

print(f"Fetching content from: {url}")

Expand Down
64 changes: 3 additions & 61 deletions .github/scripts/contribution_approved.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,64 +26,6 @@ def get_first(data, *keys):
return ""


def parse_state(data):
"""Map issue fields to listing state."""
status = get_first(data, "status")
status_lc = status.lower()
if "soon" in status_lc:
return "opens_soon"
if "open" in status_lc:
return "open"

# Backward compatibility with older template field.
active_str = get_first(
data,
"is_this_hackathon_currently_open_for_registration?",
"is_this_hackathon_currently_open_for_registration",
).lower()
if active_str == "no":
return "opens_soon"
return "open"


def parse_deadline(data):
"""Parse optional deadline and normalize to ISO YYYY-MM-DD."""
raw = get_first(data, "deadline", "deadline_(optional)")
if not raw:
return None
try:
return util.parse_deadline_date(raw).isoformat()
except ValueError:
util.fail("Invalid deadline format. Please use YYYY-MM-DD or MM/DD/YYYY.")


def parse_issue_body(body, labels):
"""Parse the issue body based on issue type."""
lines = body.strip().split("\n")
data = {}

current_field = None
current_value = []

for line in lines:
# Check for field headers (### Field Name)
if line.startswith("### "):
if current_field and current_value:
data[current_field] = "\n".join(current_value).strip()
current_field = line[4:].strip().lower().replace(" ", "_")
current_value = []
elif current_field:
# Skip "_No response_" placeholders
if line.strip() != "_No response_":
current_value.append(line)

# Don't forget the last field
if current_field and current_value:
data[current_field] = "\n".join(current_value).strip()

return data


def handle_new_opportunity(data, username, is_quick_add=False):
"""Handle adding a new hackathon."""
listings = util.get_listings_from_json()
Expand Down Expand Up @@ -125,8 +67,8 @@ def handle_new_opportunity(data, username, is_quick_add=False):
# Get prize (optional)
prize = get_first(data, "prize_pool_(optional)", "prize") or "—"
# Map user-submitted status/deadline fields into canonical listing fields.
state = parse_state(data)
deadline = parse_deadline(data)
state = util.parse_state(data)
deadline = util.parse_deadline(data, "deadline", "deadline_(optional)")

# Create new listing
new_listing = {
Expand Down Expand Up @@ -223,7 +165,7 @@ def main():
username = issue.get("user", {}).get("login", "unknown")

# Parse the issue body
data = parse_issue_body(body, labels)
data = util.parse_issue_body(body)

# Check if this is a quick add
is_quick_add = "quick_add" in labels
Expand Down
12 changes: 10 additions & 2 deletions .github/scripts/generate_gallery.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,12 +51,20 @@ def esc(text):

def tile(photo):
image = esc(photo["image"])
hackathon = esc(photo.get("hackathon", ""))
# Surface caption + credit so contributor attribution isn't dropped. In a
# justified image collage there's no room for a visible caption line, so the
# metadata rides along in the alt/title (hover tooltip + accessibility).
parts = [p for p in (photo.get("hackathon", ""), photo.get("caption", "")) if p]
credit = photo.get("credit", "")
credit_url = photo.get("credit_url", "")
if credit:
parts.append(f"Photo: {credit}" + (f" ({credit_url})" if credit_url else ""))
label = esc(" · ".join(parts)) if parts else esc(photo.get("hackathon", ""))
# Fixed height + no width keeps each photo's aspect ratio, so they tile
# together like a collage instead of sitting in separate boxes.
return (
f'<a href="{image}">'
f'<img src="{image}" height="{ROW_HEIGHT}" alt="{hackathon}"></a>'
f'<img src="{image}" height="{ROW_HEIGHT}" alt="{label}" title="{label}"></a>'
)


Expand Down
21 changes: 13 additions & 8 deletions .github/scripts/test_scripts.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
from datetime import date

import util
import contribution_approved as ca
import auto_extract as ax


Expand Down Expand Up @@ -55,24 +54,30 @@ def test_parses_field_sections(self):
"### Hackathon Name\nHackMIT 2026\n\n"
"### Link to Hackathon Page\nhttps://hackmit.org\n"
)
data = ca.parse_issue_body(body, [])
data = util.parse_issue_body(body)
self.assertEqual(data["hackathon_name"], "HackMIT 2026")
self.assertEqual(data["link_to_hackathon_page"], "https://hackmit.org")

def test_skips_no_response_placeholder(self):
data = ca.parse_issue_body("### Prize Pool (optional)\n_No response_\n", [])
data = util.parse_issue_body("### Prize Pool (optional)\n_No response_\n")
self.assertFalse(data.get("prize_pool_(optional)"))

def test_strip_symbols_normalizes_keys(self):
data = util.parse_issue_body("### Deadline (optional)\n2026-07-04\n", strip_symbols=True)
self.assertEqual(data.get("deadline_optional"), "2026-07-04")


class ParseStateAndDeadline(unittest.TestCase):
def test_state(self):
self.assertEqual(ca.parse_state({"status": "Opens soon"}), "opens_soon")
self.assertEqual(ca.parse_state({"status": "Open now"}), "open")
self.assertEqual(ca.parse_state({}), "open")
self.assertEqual(util.parse_state({"status": "Opens soon"}), "opens_soon")
self.assertEqual(util.parse_state({"status": "Open now"}), "open")
self.assertEqual(util.parse_state({}), "open")

def test_deadline(self):
self.assertEqual(ca.parse_deadline({"deadline": "2026-07-04"}), "2026-07-04")
self.assertIsNone(ca.parse_deadline({}))
self.assertEqual(
util.parse_deadline({"deadline": "2026-07-04"}, "deadline"), "2026-07-04"
)
self.assertIsNone(util.parse_deadline({}, "deadline"))


class SsrfGuard(unittest.TestCase):
Expand Down
74 changes: 74 additions & 0 deletions .github/scripts/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,80 @@ def generate_uuid():
return str(uuid.uuid4())


DEFAULT_REPO = "Jose-Gael-Cruz-Lopez/hackhq"


def repo_slug():
"""owner/repo — from GITHUB_REPOSITORY (set by Actions) or the default."""
return os.environ.get("GITHUB_REPOSITORY") or DEFAULT_REPO


def repo_url():
"""Base GitHub URL for this repository."""
return f"https://github.com/{repo_slug()}"


def parse_issue_body(body, strip_symbols=False):
"""Parse a GitHub issue body's '### Field' sections into a {field: value} dict.

Field names are lowercased with spaces -> underscores. Set strip_symbols=True
to also drop ? ( ) from keys (used where field lookups omit those chars).
Shared by contribution_approved.py and auto_extract.py.
"""
data = {}
current_field = None
current_value = []
for line in body.strip().split("\n"):
if line.startswith("### "):
if current_field and current_value:
data[current_field] = "\n".join(current_value).strip()
name = line[4:].strip().lower().replace(" ", "_")
if strip_symbols:
name = name.replace("?", "").replace("(", "").replace(")", "")
current_field = name
current_value = []
elif current_field:
if line.strip() and line.strip() != "_No response_":
current_value.append(line)
if current_field and current_value:
data[current_field] = "\n".join(current_value).strip()
return data


def parse_state(data):
"""Map issue status fields to a listing state (open / opens_soon)."""
status = str(data.get("status") or "").strip().lower()
if "soon" in status:
return "opens_soon"
if "open" in status:
return "open"
# Backward compatibility with the older registration-open field.
active = str(
data.get("is_this_hackathon_currently_open_for_registration?")
or data.get("is_this_hackathon_currently_open_for_registration")
or ""
).strip().lower()
if active == "no":
return "opens_soon"
return "open"


def parse_deadline(data, *keys):
"""Return an ISO deadline from the first present key, or None (fail on bad format)."""
raw = ""
for key in keys:
value = data.get(key)
if value and str(value).strip():
raw = str(value).strip()
break
if not raw:
return None
try:
return parse_deadline_date(raw).isoformat()
except ValueError:
fail("Invalid deadline format. Please use YYYY-MM-DD or MM/DD/YYYY.")


def clean_url(url):
"""Clean and normalize a URL."""
url = url.strip()
Expand Down
6 changes: 4 additions & 2 deletions .github/scripts/weekly_digest.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo

import util

PST = ZoneInfo("America/Los_Angeles")
README = os.path.join(os.path.dirname(__file__), "..", "..", "README.md")
DIGEST = os.path.join(os.path.dirname(__file__), "..", "..", "digest.md")
Expand Down Expand Up @@ -123,7 +125,7 @@ def main():
out = []
out.append(f"# 📬 Weekly Digest — {today.strftime('%B %d, %Y')}\n")
out.append(
f"_Auto-generated. Source: [README.md](https://github.com/Jose-Gael-Cruz-Lopez/hackhq)._\n"
f"_Auto-generated. Source: [README.md]({util.repo_url()})._\n"
)

if closing_rows:
Expand All @@ -142,7 +144,7 @@ def main():
out.append("\nNo new entries or closing-soon flags this week. Stay tuned 🌱\n")

out.append(
f"\n---\n_Want to contribute? [Open an issue](https://github.com/Jose-Gael-Cruz-Lopez/hackhq/issues/new/choose)._"
f"\n---\n_Want to contribute? [Open an issue]({util.repo_url()}/issues/new/choose)._"
)

with open(DIGEST, "w") as f:
Expand Down
1 change: 0 additions & 1 deletion georgetown

This file was deleted.

8 changes: 4 additions & 4 deletions web/components/hq/deck.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { useMemo, useState } from "react";
import { motion, useReducedMotion } from "framer-motion";
import type { Hackathon, HackState } from "@/lib/types-hq";
import { STATE_META, countdown } from "@/lib/types-hq";
import { useHQ } from "./store";
import { useSelection, useTracker } from "./store";

type StatusFilter = "all" | HackState;
type FormatFilter = "all" | "In-Person" | "Virtual";
Expand Down Expand Up @@ -169,7 +169,7 @@ function FilterPill({
}

function SaveHeart({ h, dark }: { h: Hackathon; dark?: boolean }) {
const { isTracked, save, remove } = useHQ();
const { isTracked, save, remove } = useTracker();
const tracked = isTracked(h.id);
return (
<button
Expand Down Expand Up @@ -225,7 +225,7 @@ const SPRING_SLOW = { type: "spring", bounce: 0.2, duration: 1.5 } as const;
* lifts and tilts out of the folder on hover while the flap swings open.
*/
function HackCard({ h }: { h: Hackathon }) {
const { setSelected } = useHQ();
const { setSelected } = useSelection();
const reduceMotion = useReducedMotion();
const meta = STATE_META[h.state];
const cd = countdown(h);
Expand Down Expand Up @@ -340,7 +340,7 @@ function HackCard({ h }: { h: Hackathon }) {
}

function HackRow({ h }: { h: Hackathon }) {
const { setSelected } = useHQ();
const { setSelected } = useSelection();
const meta = STATE_META[h.state];
const cd = countdown(h);

Expand Down
10 changes: 6 additions & 4 deletions web/components/hq/detail-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,21 @@

import { useEffect } from "react";
import { STATE_META, countdown, deadlineDisplay } from "@/lib/types-hq";
import { useHQ } from "./store";
import { lockScroll } from "@/lib/scroll-lock";
import { useSelection, useTracker } from "./store";

export function DetailModal() {
const { selected, setSelected, isTracked, save, remove } = useHQ();
const { selected, setSelected } = useSelection();
const { isTracked, save, remove } = useTracker();

useEffect(() => {
if (!selected) return;
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setSelected(null);
window.addEventListener("keydown", onKey);
document.body.style.overflow = "hidden";
const release = lockScroll();
return () => {
window.removeEventListener("keydown", onKey);
document.body.style.overflow = "";
release();
};
}, [selected, setSelected]);

Expand Down
Loading
Loading