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
28 changes: 28 additions & 0 deletions .github/workflows/deadline-reminders.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
name: ⏰ Submission deadline reminders

# Hourly cron that pings POST /api/hackathons/deadlines/remind-due. The
# route itself iterates every currently-running event x {24,6,1} hour
# thresholds and no-ops (`only_if_due=True`) outside each due window, so
# calling it once an hour never double-sends reminders. Requires the
# BACKEND_CRON_TOKEN repo secret to match the backend's own
# BACKEND_CRON_TOKEN env var (checked via common.utils.api_key.check_api_key).

on:
schedule:
- cron: "7 * * * *"
workflow_dispatch: {}

permissions:
contents: read

jobs:
remind:
name: 🔔 Send due reminders
runs-on: ubuntu-latest
steps:
- name: 📣 POST remind-due
run: |
curl -fsS -X POST "${BACKEND_URL:-https://api.ohack.dev}/api/hackathons/deadlines/remind-due" \
-H "X-Api-Key: ${{ secrets.BACKEND_CRON_TOKEN }}"
env:
BACKEND_URL: ${{ vars.BACKEND_URL }}
44 changes: 44 additions & 0 deletions CLAUDE.md

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,8 @@ def add_headers(response):
from api.praisebot import praisebot_views
from api.jobs import jobs_views
from api.broadcasts import broadcasts_views
from api.submissions import submissions_views
from api.peer_votes import peer_votes_views

app.register_blueprint(messages_views.bp)
app.register_blueprint(exception_views.bp)
Expand Down Expand Up @@ -219,5 +221,7 @@ def add_headers(response):
app.register_blueprint(praisebot_views.bp)
app.register_blueprint(jobs_views.bp)
app.register_blueprint(broadcasts_views.bp)
app.register_blueprint(submissions_views.bp)
app.register_blueprint(peer_votes_views.bp)

return app
59 changes: 58 additions & 1 deletion api/github/github_service.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import re
import logging
from typing import Dict, Any, List
from cachetools import TTLCache
from db.db import get_db
from common.utils.github import create_issue, get_issues
from common.utils.github import create_issue, get_issues, get_repo_activity

logger = logging.getLogger("api.github.github_service")
logger.setLevel(logging.DEBUG)
Expand All @@ -13,6 +14,62 @@
# GitHub failures retry on the next request.
_ISSUES_CACHE = TTLCache(maxsize=512, ttl=600)

# Team dashboard "Code activity" card (GET /api/github/activity). Separate,
# shorter-TTL cache from _ISSUES_CACHE since this is meant to feel live-ish.
_ACTIVITY_CACHE = TTLCache(maxsize=512, ttl=300)

# GitHub org/repo name charset. Loose but enough to reject path-injection-ish
# input before it reaches PyGithub.
_GITHUB_NAME_RE = re.compile(r"^[A-Za-z0-9_.-]{1,100}$")


def get_github_activity(org_name: str, repo_name: str) -> Dict[str, Any]:
"""
GET /api/github/activity backing service. Success-only cache (mirrors
get_github_issues): a rate-limit or repo-not-found response is never
cached, so the next request retries against GitHub instead of pinning a
transient failure for the TTL window.

Returns (payload, status):
200 {success, repo, commits, contributors, open_prs}
400 {"error": "invalid_repo"} — org/repo fails the name regex
404 {"error": "repo_not_found"}
503 {"error": "github_rate_limited", "reset_at"}
502 {"error": "github_unavailable"} — anything else from PyGithub
"""
if not org_name or not repo_name or not _GITHUB_NAME_RE.match(org_name) or not _GITHUB_NAME_RE.match(repo_name):
return {"error": "invalid_repo"}, 400

cache_key = (org_name, repo_name)
cached = _ACTIVITY_CACHE.get(cache_key)
if cached is not None:
return cached, 200

from github import UnknownObjectException, RateLimitExceededException, GithubException

try:
result = get_repo_activity(org_name, repo_name)
except UnknownObjectException:
logger.info("get_github_activity: repo not found org=%s repo=%s", org_name, repo_name)
return {"error": "repo_not_found"}, 404
except RateLimitExceededException as e:
reset_at = None
try:
reset_at = e.headers.get("x-ratelimit-reset") if e.headers else None
except Exception:
reset_at = None
logger.warning("get_github_activity: rate limited org=%s repo=%s", org_name, repo_name)
return {"error": "github_rate_limited", "reset_at": reset_at}, 503
except GithubException as e:
logger.error("get_github_activity: GitHub error org=%s repo=%s: %s", org_name, repo_name, e)
return {"error": "github_unavailable"}, 502
except Exception as e:
logger.error("get_github_activity: unexpected error org=%s repo=%s: %s", org_name, repo_name, e)
return {"error": "github_unavailable"}, 502

_ACTIVITY_CACHE[cache_key] = result
return result, 200

def get_github_organization_data(org_name: str) -> Dict[str, Any]:
"""
Get GitHub organization data including repositories and contributors.
Expand Down
37 changes: 33 additions & 4 deletions api/github/github_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
get_github_contributors_by_org,
get_github_contributors_by_repo,
create_github_issue,
get_github_issues
get_github_issues,
get_github_activity,
)
from common.auth import auth, auth_user, getOrgId

Expand Down Expand Up @@ -180,7 +181,9 @@

Query Parameters:
repo: Required. The repository name.
org: Optional. The organization name.
org: Required. The organization name (the service 400s without it
anyway — this used to let a missing org through to a 200 error
body instead of a real 400).
state: Optional. The state of the issues ('open', 'closed', 'all'). Defaults to 'open'.

Returns:
Expand All @@ -193,13 +196,39 @@

if not repo_name:
return jsonify({"error": "repo parameter is required"}), 400
if not org_name:
return jsonify({"error": "org parameter is required"}), 400

logger.info("Getting issues for repo: %s, org: %s, state: %s", repo_name, org_name, state)
issues = get_github_issues(repo_name=repo_name, org_name=org_name, state=state)
logger.info("Retrieved %d issues for repo: %s", len(issues), repo_name)
# `issues` is the service's response dict ({"success", "issues": [...]}
# or {"error": ...}) — logging len(issues) here was logging the dict's
# KEY COUNT, not the issue count.
issue_count = len(issues.get("issues", [])) if isinstance(issues, dict) else 0
logger.info("Retrieved %d issues for repo: %s", issue_count, repo_name)

return jsonify(issues)

except Exception as e:
logger.error("Error getting issues: %s", str(e))
return jsonify({"error": str(e)}), 500
return jsonify({"error": str(e)}), 500


@bp.route("/activity", methods=["GET"])
def get_activity_api():
"""
Team dashboard "Code activity" card: last commit, commits in the last
24h, top contributors, open PR count. Public (same trust level as
/issues — no team-membership check; the payload has nothing private).

Query Parameters:
org: Required. The GitHub organization name.
repo: Required. The repository name.
"""
org_name = request.args.get('org')
repo_name = request.args.get('repo')
if not org_name or not repo_name:
return jsonify({"error": "org and repo parameters are required"}), 400

payload, status = get_github_activity(org_name, repo_name)
return jsonify(payload), status
Empty file added api/github/tests/__init__.py
Empty file.
Loading
Loading