#!/usr/bin/env python3 """Collect the past year of GitHub commits on this machine. Run it once per account. The GitHub token never leaves this machine. It walks every branch of every selected repository and deduplicates by commit SHA, matching the service's commit definition. The uploaded payload contains only a commit SHA, its author date, the repository (a full name for public repositories, a salted hash for private or internal ones), whether the default branch has the commit, the account label, and the optional `Change-Id:` trailer value. It has no login, commit message, URL or author address. Private and internal repository names are hashed by default, even when --no-redact-repos is used. A --label work report hashes every repository, public or private, because a public repository can still disclose what the work account was doing. Pass --redact-all to hash public repositories on a personal report, or --reveal-private-repos to publish private names (only for repositories you are willing to name publicly). The hash salt is read from --salt, then WORK_REPO_SALT, then the file named by --salt-file or TALLY_SALT_FILE, and finally ~/.config/tally/repo-salt. That file is generated with mode 0600 on first use, so no salt argument is needed; keep it stable and private, because changing it changes every hash. For --upload-url, the bearer token is read from TALLY_UPLOAD_TOKEN, then TALLY_UPLOAD_TOKEN_FILE, then ~/.config/tally/upload-token. The per-label state file records each repository's `pushed_at`. An unchanged repository is skipped on the next run without listing its branches or commits. Use --full to ignore that cache. Typical daily cron entries: GITHUB_TOKEN=... WORK_REPO_SALT=... \ /usr/bin/python3 github_activity.py --label personal \ --s3 s3://my-tally-state/inbox/personal-activity.json GITHUB_WORK_TOKEN=... WORK_REPO_SALT=... \ /usr/bin/python3 github_activity.py --label work \ --s3 s3://my-tally-state/inbox/work-activity.json The S3 credential only needs s3:PutObject on those keys; the private infra repo defines the bucket and write-only policy. """ from __future__ import annotations import argparse import hashlib import json import os import re import secrets import subprocess import sys import time import urllib.error import urllib.parse import urllib.request from datetime import datetime, timedelta, timezone from pathlib import Path API = "https://api.github.com" USER_AGENT = "tally-github-activity/1" def log(message: str) -> None: print(message, file=sys.stderr) def utc_now() -> datetime: return datetime.now(timezone.utc) def iso(value: datetime) -> str: return value.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") class GitHub: def __init__(self, token: str) -> None: self.token = token def _request(self, url: str) -> tuple[bytes, dict[str, str]]: request = urllib.request.Request( url, headers={ "Authorization": f"Bearer {self.token}", "Accept": "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28", "User-Agent": USER_AGENT, }, ) attempt = 0 while True: try: with urllib.request.urlopen(request, timeout=60) as response: return response.read(), dict(response.headers) except urllib.error.HTTPError as error: remaining = error.headers.get("X-RateLimit-Remaining") reset = error.headers.get("X-RateLimit-Reset") if error.code in (403, 429) and remaining == "0" and reset: wait = max(1, int(reset) - int(time.time()) + 2) log(f"rate limited; sleeping {wait}s") time.sleep(min(wait, 900)) attempt += 1 if attempt > 3: raise continue if error.code in (500, 502, 503, 504) and attempt < 3: attempt += 1 time.sleep(2**attempt) continue detail = error.read().decode("utf-8", "replace")[:300] raise RuntimeError(f"GitHub {error.code} for {url}: {detail}") from error def json(self, url: str) -> tuple[object, dict[str, str]]: body, headers = self._request(url) return json.loads(body), headers def paginate(self, path: str) -> list[dict]: out: list[dict] = [] url = path if path.startswith("https://") else f"{API}{path}" while url: data, headers = self.json(url) if not isinstance(data, list): raise RuntimeError(f"expected a list from {url}") out.extend(data) url = next_link(headers.get("Link", "")) return out def login(self) -> str: data, _ = self.json(f"{API}/user") if not isinstance(data, dict) or "login" not in data: raise RuntimeError("could not read the GitHub login") return str(data["login"]) def next_link(header: str) -> str: for part in header.split(","): part = part.strip() if part.endswith('rel="next"'): start = part.find("<") + 1 end = part.find(">") if start > 0 and end > start: return part[start:end] return "" def list_repos(github: GitHub, allowlist: list[str]) -> list[dict]: repos = github.paginate( "/user/repos?affiliation=owner,collaborator,organization_member" "&visibility=all&sort=pushed&direction=desc&per_page=100" ) by_name = {str(repo["full_name"]): repo for repo in repos} # An allowlist should work even for a repository the account is not a # member of but the token can still read. for full_name in allowlist: if full_name in by_name: continue data, _ = github.json(f"{API}/repos/{full_name}") if isinstance(data, dict): by_name[full_name] = data if allowlist: return [by_name[name] for name in allowlist if name in by_name] return list(by_name.values()) def list_branches(github: GitHub, repo: dict) -> list[str]: full_name = str(repo["full_name"]) branches = github.paginate(f"/repos/{full_name}/branches?per_page=100") return [str(branch["name"]) for branch in branches] def list_commits( github: GitHub, repo: dict, login: str, since: str, branch: str ) -> list[dict]: full_name = str(repo["full_name"]) quoted_login = urllib.parse.quote(login, safe="") quoted_since = urllib.parse.quote(since, safe="") quoted_branch = urllib.parse.quote(branch, safe="") path = ( f"/repos/{full_name}/commits?sha={quoted_branch}&author={quoted_login}" f"&since={quoted_since}&per_page=100" ) return github.paginate(path) def repo_hash(salt: str, full_name: str) -> str: digest = hashlib.sha256(f"{salt}\0{full_name}".encode("utf-8")).hexdigest() return f"h_{digest[:16]}" def repo_is_private(repo: dict) -> bool: """Treat internal/unknown visibility as private so it is hashed.""" visibility = str(repo.get("visibility") or "").lower() if visibility: return visibility != "public" return bool(repo.get("private", True)) def redacts_repo( repo: dict, label: str, redact_all: bool, reveal_private: bool, ) -> bool: """A work report hashes every name, including public ones.""" return ( redact_all or label == "work" or (repo_is_private(repo) and not reveal_private) ) def load_state(path: Path) -> dict: try: return json.loads(path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): return {} def save_state(path: Path, state: dict) -> None: path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(state, indent=2, sort_keys=True) + "\n", encoding="utf-8") os.chmod(path, 0o600) def default_salt_path() -> Path: config_home = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config") return Path(config_home) / "tally" / "repo-salt" def load_or_create_salt(path: Path) -> str: path = path.expanduser() try: value = path.read_text(encoding="utf-8").strip() except FileNotFoundError: value = "" except OSError as error: raise SystemExit(f"could not read the repository salt at {path}: {error}") from error if value: os.chmod(path, 0o600) return value if path.exists(): raise SystemExit( f"the repository salt file at {path} is empty; restore it or delete it " "to generate a new one" ) value = secrets.token_hex(32) path.parent.mkdir(parents=True, exist_ok=True) try: descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) except FileExistsError: # Another run created it between the read and the write. existing = path.read_text(encoding="utf-8").strip() if existing: return existing raise SystemExit(f"the repository salt file at {path} is empty") from None except OSError as error: raise SystemExit(f"could not create the repository salt at {path}: {error}") from error with os.fdopen(descriptor, "w", encoding="utf-8") as handle: handle.write(value + "\n") os.chmod(path, 0o600) log(f"generated a repository hash salt at {path}; keep this file private") return value def repository_salt(explicit: str | None, salt_file: str | None) -> str: if explicit: return explicit value = os.environ.get("WORK_REPO_SALT", "").strip() if value: return value path = Path(salt_file or os.environ.get("TALLY_SALT_FILE") or default_salt_path()) return load_or_create_salt(path) def default_upload_token_path() -> Path: config_home = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config") return Path(config_home) / "tally" / "upload-token" def upload_token() -> str: value = os.environ.get("TALLY_UPLOAD_TOKEN", "").strip() if value: return value path = Path(os.environ.get("TALLY_UPLOAD_TOKEN_FILE") or default_upload_token_path()) try: value = path.expanduser().read_text(encoding="utf-8").strip() except FileNotFoundError: return "" except OSError as error: raise SystemExit(f"could not read the upload token at {path}: {error}") from error if value: os.chmod(path.expanduser(), 0o600) return value def upload( payload_path: Path, s3: str | None, upload_url: str | None, upload_token: str | None, ) -> None: if s3: subprocess.run( [ "aws", "s3", "cp", str(payload_path), s3, "--content-type", "application/json", "--no-progress", ], check=True, ) return if upload_url: data = payload_path.read_bytes() headers = {"Content-Type": "application/json"} if upload_token: headers["Authorization"] = f"Bearer {upload_token}" request = urllib.request.Request( upload_url, data=data, method="PUT", headers=headers, ) with urllib.request.urlopen(request, timeout=60) as response: if response.status >= 300: raise RuntimeError(f"upload returned HTTP {response.status}") return log(f"wrote {payload_path}; pass --s3 or --upload-url to deliver it") def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( "--token-env", help="environment variable holding the token; defaults to GITHUB_TOKEN for --label personal and GITHUB_WORK_TOKEN otherwise", ) parser.add_argument("--login", help="GitHub login; defaults to the token's account") parser.add_argument( "--label", default="work", help="account label in the payload: personal or work (default: work)", ) parser.add_argument("--since-days", type=int, default=365) parser.add_argument( "--repos", default="", help="comma-separated owner/name allowlist; default is every repo the token can see", ) parser.add_argument("--salt", help="stable local salt for repository hashes") parser.add_argument( "--salt-file", help="file holding the repository salt; defaults to $TALLY_SALT_FILE or ~/.config/tally/repo-salt, created on first use", ) parser.add_argument( "--no-redact-repos", action="store_true", help="send full names for public repositories (the default for personal); has no effect on --label work", ) parser.add_argument( "--redact-all", action="store_true", help="hash every repository, including public ones; always on for --label work", ) parser.add_argument( "--reveal-private-repos", action="store_true", help="send full names for private and internal repositories too; personal reports only, and only when you are willing to name them publicly", ) parser.add_argument( "--state", help="state file; defaults to ~/.cache/tally/github-activity-