#!/usr/bin/env python3
"""PR Pulse: how fast does your team really ship? (Davar Labs, free tool)

Reads merged pull requests with your own GitHub CLI login and prints, per month:
merged PRs, feature PRs, the share of fixes, median PR size and time to merge, and
active developers. Then it compares the last 30 days with the 30 before, in feature
PRs per developer-week: the number the Agent Workflow Install is measured on.

Read-only. It only calls `gh`, and sends nothing anywhere.

Requirements: Python 3.8+ and the GitHub CLI (https://cli.github.com), logged in
with `gh auth login` to an account that can read the repos.

Usage:
  python3 pr-pulse.py owner/repo [owner/repo ...] [--since 2026-01] [--author login ...]

Feature and fix PRs are recognised from the title (Conventional Commits: "feat: ...",
"fix(api): ..."). If your team doesn't title PRs that way, the "unlabelled" column
tells you how much the counts miss.

More: https://davarlabs.com/equipos
"""
import argparse
import datetime as dt
import json
import re
import statistics
import subprocess
import sys

FETCH_LIMIT = 1000  # gh's maximum per call; we query one month at a time to stay under it
TIMEOUT_S = 180
FEAT = re.compile(r"^\s*feat(\(.*?\))?!?:", re.I)
FIX = re.compile(r"^\s*(fix|hotfix|revert)(\(.*?\))?!?:|^\s*revert\b", re.I)


def gh(repo, args):
    """Runs gh and returns stdout. Exits with a readable message on errors."""
    try:
        return subprocess.run(["gh", *args], capture_output=True, text=True, timeout=TIMEOUT_S, check=True).stdout
    except FileNotFoundError:
        sys.exit("PR Pulse needs the GitHub CLI: https://cli.github.com (then run `gh auth login`).")
    except subprocess.TimeoutExpired:
        sys.exit(f"GitHub didn't answer within {TIMEOUT_S}s for {repo}. Try again, or use a later --since.")
    except subprocess.CalledProcessError as e:
        sys.exit(f"gh failed for {repo}: {e.stderr.strip() or e}")


def gh_merged(repo, start, end):
    """Merged PRs of one repo in [start, end), as dicts."""
    query = f"merged:{start.isoformat()}..{(end - dt.timedelta(days=1)).isoformat()}"
    print(f"reading {repo} {start:%Y-%m}…", file=sys.stderr)
    out = gh(repo, ["pr", "list", "-R", repo, "--state", "merged", "--limit", str(FETCH_LIMIT),
                    "--search", query, "--json", "number,title,author,createdAt,mergedAt,additions,deletions"])
    prs = json.loads(out or "[]")
    if len(prs) >= FETCH_LIMIT:
        print(f"warning: {repo} has {FETCH_LIMIT}+ merged PRs in {start:%Y-%m}; that month is undercounted.", file=sys.stderr)
    for p in prs:
        p["repo"] = repo
    return prs


def parse_time(s):
    return dt.datetime.fromisoformat(s.replace("Z", "+00:00"))


def is_bot(login):
    login = login or ""
    return login.endswith("[bot]") or login.startswith("app/") or login.lower().endswith("-bot")


def month_starts(since, today):
    d = since.replace(day=1)
    while d <= today:
        yield d
        d = (d.replace(day=28) + dt.timedelta(days=4)).replace(day=1)


def summarise(prs, days):
    """Numbers for one period of `days` days."""
    if not prs:
        return None
    authors = {p["author"]["login"] for p in prs}
    feat = sum(1 for p in prs if FEAT.match(p["title"]))
    fix = sum(1 for p in prs if FIX.match(p["title"]))
    hours = [(parse_time(p["mergedAt"]) - parse_time(p["createdAt"])).total_seconds() / 3600 for p in prs]
    weeks = days / 7
    return {
        "prs": len(prs), "feat": feat, "fix_share": fix / len(prs), "unlabelled": (len(prs) - feat - fix) / len(prs),
        "median_lines": statistics.median(p["additions"] + p["deletions"] for p in prs),
        "median_hours": statistics.median(hours), "devs": len(authors),
        "feat_per_dev_week": feat / len(authors) / weeks,
    }


def main():
    ap = argparse.ArgumentParser(description="Merged-PR throughput per month, from your own GitHub CLI login. Read-only.")
    ap.add_argument("repos", nargs="+", help="owner/repo")
    ap.add_argument("--since", default=None, help="first month, YYYY-MM (default: 6 months ago)")
    ap.add_argument("--author", action="append", default=[], help="only PRs by this login (repeatable)")
    args = ap.parse_args()

    today = dt.date.today()
    if args.since:
        try:
            since = dt.datetime.strptime(args.since, "%Y-%m").date()
        except ValueError:
            sys.exit("--since must look like 2026-01")
    else:
        since = (today.replace(day=1) - dt.timedelta(days=150)).replace(day=1)
    # The 30-day comparison needs 60 days of data even when --since is recent.
    since = min(since, (today - dt.timedelta(days=60)).replace(day=1))

    prs = []
    for repo in args.repos:
        # gh answers an empty list, not an error, for a repo it can't see: check first.
        gh(repo, ["repo", "view", repo, "--json", "name"])
        for start in month_starts(since, today):
            end = (start.replace(day=28) + dt.timedelta(days=4)).replace(day=1)
            prs += gh_merged(repo, start, min(end, today + dt.timedelta(days=1)))
    prs = [p for p in prs if not is_bot(p["author"]["login"])]
    if args.author:
        wanted = {a.lower() for a in args.author}
        prs = [p for p in prs if p["author"]["login"].lower() in wanted]
    if not prs:
        sys.exit("No merged PRs found for those repos and dates.")

    print(f"\nPR Pulse · {', '.join(args.repos)}" + (f" · authors: {', '.join(args.author)}" if args.author else ""))
    print("\nmonth      merged  feat  fix%  unlabelled%  median lines  median hrs to merge  devs  feat/dev-week")
    for start in month_starts(since, today):
        key = start.strftime("%Y-%m")
        month = [p for p in prs if p["mergedAt"].startswith(key)]
        end = (start.replace(day=28) + dt.timedelta(days=4)).replace(day=1)
        days = (min(end, today + dt.timedelta(days=1)) - start).days
        s = summarise(month, days)
        if not s:
            continue
        partial = " (so far)" if end > today else ""
        print(f"{key}{partial:9} {s['prs']:5} {s['feat']:5} {s['fix_share']:5.0%} {s['unlabelled']:11.0%}"
              f" {s['median_lines']:13.0f} {s['median_hours']:20.1f} {s['devs']:5} {s['feat_per_dev_week']:14.1f}")

    now = dt.datetime.now(dt.timezone.utc)
    last = [p for p in prs if now - dt.timedelta(days=30) <= parse_time(p["mergedAt"])]
    prev = [p for p in prs if now - dt.timedelta(days=60) <= parse_time(p["mergedAt"]) < now - dt.timedelta(days=30)]
    a, b = summarise(prev, 30), summarise(last, 30)
    print("\nLast 30 days vs the 30 before")
    for label, s in (("previous 30 days", a), ("last 30 days", b)):
        if s:
            print(f"  {label:17} {s['feat']:4} feature PRs · {s['devs']} dev{'s' if s['devs'] != 1 else ''} · {s['feat_per_dev_week']:.1f} feature PRs per dev-week"
                  f" · {s['fix_share']:.0%} fixes · median {s['median_lines']:.0f} lines")
        else:
            print(f"  {label:17} no merged PRs")
    if a and b and a["unlabelled"] > 0.5:
        print("\n  Most PR titles don't start with feat:/fix:, so feature counts are low. Use Conventional Commit titles to measure properly.")
    print("\nWant that number to go up? https://davarlabs.com/equipos\n")


if __name__ == "__main__":
    main()
