diff --git a/docs/PROJECT_STATUS.md b/docs/PROJECT_STATUS.md index c798a1f2..256b6b38 100644 --- a/docs/PROJECT_STATUS.md +++ b/docs/PROJECT_STATUS.md @@ -389,6 +389,40 @@ Step 7: If pass → production. If fail → drop to turbo3 or adjust per-layer p *Build: /tmp/llama-cpp-turboquant/build/bin/ (all binaries)* *Branch: feature/turboquant-kv-cache* +--- + +# Weekly Progress Updates + +**Tracking issue:** #76 +**Process established:** 2026-04-16 + +## Process + +1. **Weekly cadence** — Every Monday, generate and post a progress update as a comment on issue #76. +2. **Benchmark results** — Post as they happen (don't wait for weekly update). +3. **Blocker escalation** — New blockers posted within 24 hours with label `blocker`. +4. **PROJECT_STATUS.md** — Updated weekly with current state. + +## How to Generate + +```bash +# Auto-generated from git log + Gitea API +python3 scripts/weekly_update.py --post + +# Preview first +python3 scripts/weekly_update.py + +# Custom date range +python3 scripts/weekly_update.py --since 2026-04-01 + +# Raw JSON data +python3 scripts/weekly_update.py --json +``` + +## Template + +See `docs/WEEKLY_TEMPLATE.md` for manual updates. + --- diff --git a/docs/WEEKLY_TEMPLATE.md b/docs/WEEKLY_TEMPLATE.md new file mode 100644 index 00000000..f2f6aeaa --- /dev/null +++ b/docs/WEEKLY_TEMPLATE.md @@ -0,0 +1,26 @@ +# TurboQuant Weekly Update Template + +Use this template when posting manual weekly updates. For automated updates, run `scripts/weekly_update.py --post`. + +## Week of [START_DATE] to [END_DATE] + +### Completed +- [item 1] +- [item 2] + +### Benchmark Results +- [key metric or "No new benchmarks this week"] + +### In Progress +- [item being worked on — who's on it] + +### Blockers +- [blocker — impact — who needs to act] +- _None_ if clear + +### Next Week +- [planned item 1] +- [planned item 2] + +--- +_Generated by `scripts/weekly_update.py` or filled manually._ diff --git a/scripts/weekly_update.py b/scripts/weekly_update.py new file mode 100644 index 00000000..031f4e90 --- /dev/null +++ b/scripts/weekly_update.py @@ -0,0 +1,323 @@ +#!/usr/bin/env python3 +""" +TurboQuant Weekly Progress Update Generator + +Generates a structured weekly update from: +- Git log (commits since last week) +- Open/closed issues and PRs +- Benchmark results +- Blockers (open issues labeled 'blocker') + +Usage: + python3 scripts/weekly_update.py # This week + python3 scripts/weekly_update.py --since 2026-04-08 # Custom range + python3 scripts/weekly_update.py --post # Post as Gitea comment on tracking issue +""" + +import argparse +import json +import os +import subprocess +import sys +from datetime import datetime, timedelta +from pathlib import Path + +try: + import requests + HAS_REQUESTS = True +except ImportError: + HAS_REQUESTS = False + + +REPO_ROOT = Path(__file__).resolve().parent.parent +GITEA_URL = "https://forge.alexanderwhitestone.com" +REPO_PATH = "Timmy_Foundation/turboquant" +TRACKING_ISSUE = 76 # This issue + + +def git_log(since: str, until: str = None) -> list[dict]: + """Get commits since a date.""" + until = until or datetime.now().strftime("%Y-%m-%d") + cmd = [ + "git", "-C", str(REPO_ROOT), "log", + f"--since={since}", f"--until={until}", + "--format=%H|%an|%ae|%aI|%s", + "--all" + ] + result = subprocess.run(cmd, capture_output=True, text=True) + commits = [] + for line in result.stdout.strip().split("\n"): + if not line: + continue + parts = line.split("|", 4) + if len(parts) == 5: + commits.append({ + "hash": parts[0][:8], + "author": parts[1], + "email": parts[2], + "date": parts[3][:10], + "subject": parts[4], + }) + return commits + + +def git_diff_stats(since: str) -> dict: + """Get file change stats.""" + cmd = [ + "git", "-C", str(REPO_ROOT), "diff", + f"--stat", f"{since}..HEAD" + ] + result = subprocess.run(cmd, capture_output=True, text=True) + lines = result.stdout.strip().split("\n") + summary = lines[-1] if lines else "No changes" + return {"summary": summary, "files_changed": len([l for l in lines if "|" in l])} + + +def find_benchmarks() -> list[dict]: + """Scan benchmark results directory for recent results.""" + bench_dir = REPO_ROOT / "benchmarks" + results = [] + if not bench_dir.exists(): + return results + + for f in bench_dir.glob("*.json"): + try: + data = json.loads(f.read_text()) + results.append({"file": f.name, "data": data}) + except (json.JSONDecodeError, Exception): + pass + + # Also check for markdown reports + for f in bench_dir.glob("*.md"): + if f.name != "README.md": + stat = f.stat() + results.append({ + "file": f.name, + "type": "report", + "modified": datetime.fromtimestamp(stat.st_mtime).strftime("%Y-%m-%d"), + "size": stat.st_size, + }) + + return results + + +def get_gitea_state(token: str = None) -> dict: + """Fetch issue/PR state from Gitea API.""" + if not HAS_REQUESTS or not token: + return {"available": False} + + H = {"Authorization": f"token {token}"} + base = f"{GITEA_URL}/api/v1/repos/{REPO_PATH}" + + try: + # Open issues + r = requests.get(f"{base}/issues?state=open&limit=100", headers=H) + open_issues = r.json() if r.status_code == 200 else [] + + # Closed issues (recent) + r = requests.get(f"{base}/issues?state=closed&limit=50&sort=updated&order=desc", headers=H) + closed_issues = r.json() if r.status_code == 200 else [] + + # PRs + r = requests.get(f"{base}/pulls?state=open&limit=50", headers=H) + open_prs = r.json() if r.status_code == 200 else [] + + return { + "available": True, + "open_issues": open_issues, + "closed_issues": closed_issues, + "open_prs": open_prs, + } + except Exception as e: + return {"available": False, "error": str(e)} + + +def categorize_commits(commits: list[dict]) -> dict: + """Categorize commits by conventional prefix.""" + categories = { + "feat": [], "fix": [], "bench": [], "docs": [], + "test": [], "refactor": [], "chore": [], "other": [] + } + for c in commits: + subject = c["subject"].lower() + if subject.startswith("feat") or subject.startswith("feature"): + categories["feat"].append(c) + elif subject.startswith("fix"): + categories["fix"].append(c) + elif subject.startswith("bench") or subject.startswith("perf"): + categories["bench"].append(c) + elif subject.startswith("doc"): + categories["docs"].append(c) + elif subject.startswith("test"): + categories["test"].append(c) + elif subject.startswith("refactor"): + categories["refactor"].append(c) + elif subject.startswith("chore") or subject.startswith("ci"): + categories["chore"].append(c) + else: + categories["other"].append(c) + return {k: v for k, v in categories.items() if v} + + +def generate_update(since: str, gitea_state: dict = None) -> str: + """Generate the weekly update markdown.""" + now = datetime.now() + until = now.strftime("%Y-%m-%d") + week_label = f"Week of {since} to {until}" + + commits = git_log(since, until) + diff_stats = git_diff_stats(since) + categories = categorize_commits(commits) + benchmarks = find_benchmarks() + + lines = [ + f"## {week_label}", + "", + f"**Generated:** {now.strftime('%Y-%m-%d %H:%M UTC')}", + f"**Commits:** {len(commits)} | **Files changed:** {diff_stats['files_changed']}", + "", + ] + + # Completed work by category + lines.append("### Completed") + lines.append("") + if commits: + for cat, items in categories.items(): + label = { + "feat": "Features", "fix": "Fixes", "bench": "Benchmarks", + "docs": "Documentation", "test": "Tests", "refactor": "Refactoring", + "chore": "Maintenance", "other": "Other" + }.get(cat, cat) + lines.append(f"**{label}:**") + for c in items: + lines.append(f"- `{c['hash']}` {c['subject']} ({c['author']}, {c['date']})") + lines.append("") + else: + lines.append("- No commits this week") + lines.append("") + + # Benchmark results + if benchmarks: + lines.append("### Benchmark Results") + lines.append("") + for b in benchmarks: + if b.get("type") == "report": + lines.append(f"- **{b['file']}** (updated {b['modified']}, {b['size']} bytes)") + else: + lines.append(f"- **{b['file']}** — see `benchmarks/{b['file']}`") + lines.append("") + + # Gitea state (if available) + if gitea_state and gitea_state.get("available"): + open_issues = gitea_state["open_issues"] + open_prs = gitea_state["open_prs"] + closed = gitea_state["closed_issues"] + + lines.append("### In Progress") + lines.append("") + blockers = [] + for issue in open_issues: + labels = [l["name"] for l in issue.get("labels", [])] + prefix = "" + if "blocker" in labels: + blockers.append(issue) + prefix = "🚧 BLOCKER — " + assignee = issue.get("assignee", {}) + who = assignee.get("login", "unassigned") if assignee else "unassigned" + lines.append(f"- {prefix}#{issue['number']}: {issue['title']} ({who})") + + if open_prs: + lines.append("") + lines.append("**Open PRs:**") + for pr in open_prs: + lines.append(f"- #{pr['number']}: {pr['title']} ({pr['user']['login']})") + lines.append("") + + # Blockers + if blockers: + lines.append("### Blockers") + lines.append("") + for b in blockers: + lines.append(f"- #{b['number']}: {b['title']}") + if b.get("body"): + snippet = b["body"][:200].replace("\n", " ") + lines.append(f" > {snippet}...") + lines.append("") + + # Recently closed + recent_closed = [i for i in closed if i.get("closed_at")] + if recent_closed: + lines.append("### Closed This Period") + lines.append("") + for issue in recent_closed[:10]: + closed_date = issue.get("closed_at", "")[:10] + lines.append(f"- #{issue['number']}: {issue['title']} (closed {closed_date})") + lines.append("") + + # Next week + lines.append("### Next Week") + lines.append("") + lines.append("- _TBD — fill in planned work_") + lines.append("") + + return "\n".join(lines) + + +def post_gitea_comment(token: str, body: str, issue: int = TRACKING_ISSUE): + """Post the update as a comment on the tracking issue.""" + if not HAS_REQUESTS: + print("ERROR: requests library not available", file=sys.stderr) + return False + + H = {"Authorization": f"token {token}", "Content-Type": "application/json"} + url = f"{GITEA_URL}/api/v1/repos/{REPO_PATH}/issues/{issue}/comments" + r = requests.post(url, headers=H, json={"body": body}) + + if r.status_code in (200, 201): + print(f"Posted comment on issue #{issue}") + return True + else: + print(f"Failed to post: {r.status_code} {r.text}", file=sys.stderr) + return False + + +def main(): + parser = argparse.ArgumentParser(description="Generate TurboQuant weekly progress update") + parser.add_argument("--since", help="Start date (YYYY-MM-DD), default: 7 days ago") + parser.add_argument("--post", action="store_true", help="Post as Gitea comment on issue #76") + parser.add_argument("--json", action="store_true", help="Output raw data as JSON") + args = parser.parse_args() + + since = args.since or (datetime.now() - timedelta(days=7)).strftime("%Y-%m-%d") + + # Try to load Gitea token + token = None + token_path = Path.home() / ".config" / "gitea" / "token" + if token_path.exists(): + token = token_path.read_text().strip() + + gitea_state = get_gitea_state(token) if token else {"available": False} + + if args.json: + data = { + "since": since, + "commits": git_log(since), + "benchmarks": find_benchmarks(), + "gitea": {k: v for k, v in gitea_state.items() if k != "available"} if gitea_state.get("available") else None, + } + print(json.dumps(data, indent=2, default=str)) + return + + update = generate_update(since, gitea_state) + + if args.post: + if not token: + print("ERROR: No Gitea token found at ~/.config/gitea/token", file=sys.stderr) + sys.exit(1) + post_gitea_comment(token, update) + else: + print(update) + + +if __name__ == "__main__": + main() diff --git a/scripts/weekly_update.sh b/scripts/weekly_update.sh new file mode 100755 index 00000000..5cd65c1d --- /dev/null +++ b/scripts/weekly_update.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# TurboQuant Weekly Update — shell wrapper +# Generates and optionally posts a weekly progress update. +# +# Usage: +# ./scripts/weekly_update.sh # Print to stdout +# ./scripts/weekly_update.sh --post # Post as Gitea comment on #76 +# ./scripts/weekly_update.sh --since 2026-04-01 # Custom date range +# ./scripts/weekly_update.sh --json # Raw JSON data + +set -euo pipefail +cd "$(dirname "$0")/.." + +python3 scripts/weekly_update.py "$@" diff --git a/tests/test_weekly_update.py b/tests/test_weekly_update.py new file mode 100644 index 00000000..2eb1d41c --- /dev/null +++ b/tests/test_weekly_update.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +"""Quick test for weekly_update.py — verifies parsing, output format, and edge cases.""" + +import subprocess +import sys +import json +from pathlib import Path + +SCRIPT = Path(__file__).resolve().parent.parent / "scripts" / "weekly_update.py" + +def run(args: list[str]) -> str: + result = subprocess.run( + [sys.executable, str(SCRIPT)] + args, + capture_output=True, text=True, cwd=str(SCRIPT.parent.parent) + ) + return result.stdout, result.stderr, result.returncode + +def test_basic_output(): + """Script runs without error and produces markdown.""" + stdout, stderr, rc = run(["--since", "2026-01-01"]) + assert rc == 0, f"Exit code {rc}: {stderr}" + assert "## Week of" in stdout, f"Missing header: {stdout[:200]}" + assert "### Completed" in stdout, f"Missing Completed section: {stdout[:200]}" + assert "### Next Week" in stdout, f"Missing Next Week section: {stdout[-200:]}" + print("PASS: basic_output") + +def test_json_output(): + """Script outputs valid JSON in --json mode.""" + stdout, stderr, rc = run(["--json", "--since", "2026-01-01"]) + assert rc == 0, f"Exit code {rc}: {stderr}" + data = json.loads(stdout) + assert "commits" in data + assert "since" in data + print(f"PASS: json_output ({len(data['commits'])} commits)") + +def test_no_crash_future_date(): + """Script handles future date gracefully.""" + stdout, stderr, rc = run(["--since", "2030-01-01"]) + assert rc == 0, f"Exit code {rc}: {stderr}" + print("PASS: future_date_no_crash") + +def test_empty_range(): + """Script handles a very old date with no commits.""" + stdout, stderr, rc = run(["--since", "2020-01-01", "--since", "2020-01-02"]) + assert rc == 0 + print("PASS: empty_range") + +if __name__ == "__main__": + test_basic_output() + test_json_output() + test_no_crash_future_date() + print("\nAll tests passed.")