from __future__ import annotations import base64 import csv import io import json import logging from typing import Any import requests import streamlit as st logger = logging.getLogger(__name__) FIELDNAMES = [ "pair_index", "reviewer_id", "clinically_plausible", "pathology_preserved", "quality_score", "comments", "timestamp", ] def _get_config() -> dict[str, str]: import os if os.environ.get("GITHUB_TOKEN"): return { "token": os.environ["GITHUB_TOKEN"], "repo": os.environ.get("GITHUB_REPO", ""), "branch": os.environ.get("GITHUB_BRANCH", "validation-data"), "data_dir": os.environ.get("GITHUB_DATA_DIR", "validation_data"), } return { "token": st.secrets["github"]["token"], "repo": st.secrets["github"]["repo"], "branch": st.secrets["github"].get("branch", "validation-data"), "data_dir": st.secrets["github"].get("data_dir", "validation_data"), } def _headers(token: str) -> dict[str, str]: return { "Authorization": f"Bearer {token}", "Accept": "application/vnd.github.v3+json", "X-GitHub-Api-Version": "2022-11-28", } def _get_file(repo: str, path: str, token: str, branch: str) -> tuple[str | None, str | None]: url = f"https://api.github.com/repos/{repo}/contents/{path}" resp = requests.get(url, headers=_headers(token), params={"ref": branch}, timeout=15) if resp.status_code == 404: return None, None resp.raise_for_status() data = resp.json() content = base64.b64decode(data["content"]).decode("utf-8") return content, data["sha"] def _put_file( repo: str, path: str, content: str, token: str, branch: str, sha: str | None, message: str, ) -> None: url = f"https://api.github.com/repos/{repo}/contents/{path}" body: dict[str, Any] = { "message": message, "content": base64.b64encode(content.encode("utf-8")).decode("ascii"), "branch": branch, } if sha: body["sha"] = sha resp = requests.put(url, headers=_headers(token), json=body, timeout=15) resp.raise_for_status() def _ensure_branch(repo: str, token: str, branch: str) -> None: url = f"https://api.github.com/repos/{repo}/branches/{branch}" resp = requests.get(url, headers=_headers(token), timeout=15) if resp.status_code == 200: return main_url = f"https://api.github.com/repos/{repo}/git/refs/heads/main" main_resp = requests.get(main_url, headers=_headers(token), timeout=15) main_resp.raise_for_status() main_sha = main_resp.json()["object"]["sha"] create_url = f"https://api.github.com/repos/{repo}/git/refs" create_body = {"ref": f"refs/heads/{branch}", "sha": main_sha} create_resp = requests.post(create_url, headers=_headers(token), json=create_body, timeout=15) if create_resp.status_code not in (200, 201, 422): create_resp.raise_for_status() def is_configured() -> bool: import os if os.environ.get("GITHUB_TOKEN"): return True try: _get_config() return True except (KeyError, FileNotFoundError): return False def load_pairs() -> list[dict]: cfg = _get_config() content, _ = _get_file(cfg["repo"], f"{cfg['data_dir']}/pairs.json", cfg["token"], cfg["branch"]) if content is None: return [] return json.loads(content) def load_existing_reviews(reviewer_id: str) -> dict[int, dict]: cfg = _get_config() path = f"{cfg['data_dir']}/reviews_{reviewer_id}.csv" content, _ = _get_file(cfg["repo"], path, cfg["token"], cfg["branch"]) if content is None: return {} reviews: dict[int, dict] = {} reader = csv.DictReader(io.StringIO(content)) for row in reader: reviews[int(row["pair_index"])] = row return reviews def save_review(reviewer_id: str, pair_index: int, review: dict) -> None: cfg = _get_config() _ensure_branch(cfg["repo"], cfg["token"], cfg["branch"]) path = f"{cfg['data_dir']}/reviews_{reviewer_id}.csv" content, sha = _get_file(cfg["repo"], path, cfg["token"], cfg["branch"]) existing: dict[int, dict] = {} if content: reader = csv.DictReader(io.StringIO(content)) for row in reader: existing[int(row["pair_index"])] = row existing[pair_index] = review output = io.StringIO() writer = csv.DictWriter(output, fieldnames=FIELDNAMES) writer.writeheader() for idx in sorted(existing.keys()): writer.writerow(existing[idx]) _put_file( cfg["repo"], path, output.getvalue(), cfg["token"], cfg["branch"], sha, f"Review by {reviewer_id}: pair {pair_index}", ) def append_audit_log(reviewer_id: str, pair_index: int, action: str) -> None: import time cfg = _get_config() _ensure_branch(cfg["repo"], cfg["token"], cfg["branch"]) path = f"{cfg['data_dir']}/audit_log.csv" content, sha = _get_file(cfg["repo"], path, cfg["token"], cfg["branch"]) if content is None: content = "timestamp,reviewer_id,pair_index,action\n" sha = None timestamp = time.strftime("%Y-%m-%d %H:%M:%S UTC", time.gmtime()) content += f"{timestamp},{reviewer_id},{pair_index},{action}\n" _put_file( cfg["repo"], path, content, cfg["token"], cfg["branch"], sha, f"Audit: {action} by {reviewer_id}", )