From 58332fb6b8ad4631856484790f6cdd71c27505bd Mon Sep 17 00:00:00 2001 From: Greg V <6913307+gregv@users.noreply.github.com> Date: Thu, 23 Jul 2026 08:57:37 -0700 Subject: [PATCH] Link team repos to problem statements on approval; cache the issues proxy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix-forward for the project-page discoverability gap: approve_team created the GitHub repo and wrote github_links on the team doc only, so problem statements' github field stayed empty and /project/ showed no code. - approve_team now also appends {name, link} to the github array of the team's linked problem statement(s) via _link_repo_to_problem_statements: team.problem_statements refs preferred (string ids and DocumentReferences both handled); falls back to the nonprofit's problem statement only when it has exactly one (ambiguous cases skip + log). Dedupes by normalized link, converts legacy string-shaped github values, clears the cached get_single_problem_statement_old read, and never blocks the approval flow. - GET /api/github/issues responses are now cached 10 min per (org, repo, state) — the public project pages fetch this per repo, so the cache protects the shared GITHUB_TOKEN rate budget. Errors are not cached so transient GitHub failures retry. Note: GITHUB_TOKEN currently returns 401 Bad credentials in prod — the issues proxy (and admin issue summaries) won't work until it's rotated. Co-Authored-By: Claude Fable 5 --- api/github/github_service.py | 18 +++++++- api/teams/teams_service.py | 83 +++++++++++++++++++++++++++++++++++- 2 files changed, 98 insertions(+), 3 deletions(-) diff --git a/api/github/github_service.py b/api/github/github_service.py index cccff5b..670779b 100644 --- a/api/github/github_service.py +++ b/api/github/github_service.py @@ -1,11 +1,18 @@ 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 logger = logging.getLogger("api.github.github_service") logger.setLevel(logging.DEBUG) +# Live issue reads are hit by the public project pages, so cache per +# (org, repo, state) to protect the shared GITHUB_TOKEN rate budget. +# Only successful responses are cached — errors stay uncached so transient +# GitHub failures retry on the next request. +_ISSUES_CACHE = TTLCache(maxsize=512, ttl=600) + def get_github_organization_data(org_name: str) -> Dict[str, Any]: """ Get GitHub organization data including repositories and contributors. @@ -360,16 +367,23 @@ def get_github_issues(org_name: str, repo_name: str, state: str ) -> Dict[str, A if not org_name or not repo_name: return {"error": "Organization name and repository name are required"} + cache_key = (org_name, repo_name, state) + cached_result = _ISSUES_CACHE.get(cache_key) + if cached_result is not None: + return cached_result + # Get issues using the utility function issues = get_issues(org_name=org_name, repo_name=repo_name, state=state) - + if "error" in issues: return {"error": issues["error"]} - return { + result = { "success": True, "issues": issues } + _ISSUES_CACHE[cache_key] = result + return result except Exception as e: logger.error("Error getting GitHub issues for %s/%s: %s", org_name, repo_name, e) diff --git a/api/teams/teams_service.py b/api/teams/teams_service.py index 3f809b5..7c53287 100644 --- a/api/teams/teams_service.py +++ b/api/teams/teams_service.py @@ -752,6 +752,81 @@ def get_teams_by_hackathon_id(hackathon_id): logger.error(f"Error getting teams for hackathon {hackathon_id}: {str(e)}") return {"teams": []} + +def _normalize_repo_link(link): + return (link or "").strip().rstrip("/").lower() + + +def _link_repo_to_problem_statements(db, team_data, nonprofit, repo_name, repo_url): + """Append the team's newly created GitHub repo to the `github` array of the + problem statement(s) the team is working on, so the public /project/ + page shows the code. Historically the repo was only written to the team + doc, leaving most problem statements with an empty `github` field. + + Prefers the team's own `problem_statements` refs; falls back to the + nonprofit's problem statements only when there is exactly one (anything + else is ambiguous — skip and log). Never raises: repo linkage is + best-effort and must not fail the approval flow. + """ + try: + ps_refs = [] + for ps in team_data.get("problem_statements") or []: + if isinstance(ps, str): + ps_refs.append(db.collection("problem_statements").document(ps)) + elif ps is not None and hasattr(ps, "get"): # DocumentReference + ps_refs.append(ps) + + if not ps_refs: + # nonprofit comes from get_single_npo → doc_to_json, so entries are id strings + npo_ps = (nonprofit or {}).get("problem_statements") or [] + if len(npo_ps) == 1 and isinstance(npo_ps[0], str): + ps_refs.append(db.collection("problem_statements").document(npo_ps[0])) + else: + logger.info( + "Not linking repo %s to a problem statement: team has no " + "problem_statements and nonprofit has %d (ambiguous)", + repo_url, len(npo_ps), + ) + return + + for ps_ref in ps_refs: + ps_doc = ps_ref.get() + if not ps_doc.exists: + logger.warning("Problem statement %s not found; skipping repo link", ps_ref.id) + continue + ps_data = ps_doc.to_dict() or {} + existing = ps_data.get("github") + if isinstance(existing, list): + github_list = list(existing) + elif isinstance(existing, str) and existing.strip(): + # Legacy shape: a bare URL string — convert to the {name, link} form + legacy_link = existing.strip() + github_list = [{"name": legacy_link.rstrip("/").split("/")[-1], "link": legacy_link}] + else: + github_list = [] + + already_linked = { + _normalize_repo_link(entry.get("link") if isinstance(entry, dict) else entry) + for entry in github_list + } + if _normalize_repo_link(repo_url) in already_linked: + continue + + github_list.append({"name": repo_name, "link": repo_url}) + ps_ref.set({"github": github_list}, merge=True) + logger.info("Linked repo %s to problem statement %s", repo_url, ps_ref.id) + + # The public single-problem-statement read is cached (10 min) and NOT + # in the shared cache registry — clear it explicitly + try: + from api.messages.messages_service import get_single_problem_statement_old + get_single_problem_statement_old.cache_clear() + except Exception: + logger.warning("Could not clear problem statement cache", exc_info=True) + except Exception: + logger.error("Failed linking repo %s to problem statement(s)", repo_url, exc_info=True) + + def approve_team(admin_user_id, json): """ Admin function to approve a team and pair with nonprofit @@ -929,7 +1004,13 @@ def approve_team(admin_user_id, json): "approved_by": admin_user_id, "approved_at": datetime.now().isoformat() }, merge=True) - + + # Fix-forward: also link the repo to the problem statement(s) so the + # public project page shows the code (best-effort, never blocks approval) + _link_repo_to_problem_statements( + db, team_data, nonprofit, repo_name, full_github_repo_url + ) + # Clear cache clear_cache()