#!/usr/bin/env python3
"""
PR review time and cycle time for a GitHub repo, from the GitHub CLI.

    python3 pr_review_stats.py OWNER/REPO [--prs 200]

Needs `gh` (https://cli.github.com) logged in with read access to the repo.
Standard library only. Reads the most recent merged PRs and prints:

  - time to first review: ready for review -> first human review
    (bots such as dependabot or AI reviewers are skipped)
  - time to merge: PR opened -> merged
  - cycle time: first commit -> merged
  - time to first review by PR size

Times are wall-clock hours, so nights and weekends count. That is what the
author feels while waiting.

From runmypr.com/blog/measure-pr-review-time-github/
"""
import argparse
import json
import statistics
import subprocess
import sys
from datetime import datetime

QUERY = """
query($owner: String!, $name: String!, $cursor: String) {
  repository(owner: $owner, name: $name) {
    pullRequests(states: MERGED, first: 50, after: $cursor,
                 orderBy: {field: CREATED_AT, direction: DESC}) {
      pageInfo { hasNextPage endCursor }
      nodes {
        number
        isDraft
        createdAt
        mergedAt
        additions
        deletions
        author { login }
        commits(first: 1) { nodes { commit { authoredDate } } }
        timelineItems(first: 1, itemTypes: [READY_FOR_REVIEW_EVENT]) {
          nodes { ... on ReadyForReviewEvent { createdAt } }
        }
        reviews(first: 100) {
          pageInfo { hasNextPage endCursor }
          nodes { submittedAt author { __typename login } }
        }
      }
    }
  }
}
"""

REVIEW_QUERY = """
query($owner: String!, $name: String!, $number: Int!, $cursor: String) {
  repository(owner: $owner, name: $name) {
    pullRequest(number: $number) {
      reviews(first: 100, after: $cursor) {
        pageInfo { hasNextPage endCursor }
        nodes { submittedAt author { __typename login } }
      }
    }
  }
}
"""

SIZE_BUCKETS = [(0, 50), (51, 200), (201, 400), (401, 1000), (1001, None)]


def ts(value):
    return datetime.fromisoformat(value.replace("Z", "+00:00"))


def hours(start, end):
    return (ts(end) - ts(start)).total_seconds() / 3600


def is_bot(author):
    if not author:
        return True
    return author.get("__typename") == "Bot" or author["login"].endswith("[bot]")


def fetch(owner, name, limit):
    prs, cursor = [], None
    while len(prs) < limit:
        cmd = ["gh", "api", "graphql", "-f", f"query={QUERY}",
               "-F", f"owner={owner}", "-F", f"name={name}"]
        if cursor:
            cmd += ["-F", f"cursor={cursor}"]
        out = subprocess.run(cmd, capture_output=True, text=True)
        if out.returncode != 0:
            sys.exit(out.stderr.strip() or "gh api failed")
        page = json.loads(out.stdout)["data"]["repository"]["pullRequests"]
        prs += page["nodes"]
        if not page["pageInfo"]["hasNextPage"]:
            break
        cursor = page["pageInfo"]["endCursor"]
    prs = prs[:limit]
    for pr in prs:
        reviews = pr["reviews"]
        while reviews["pageInfo"]["hasNextPage"]:
            cmd = ["gh", "api", "graphql", "-f", f"query={REVIEW_QUERY}",
                   "-F", f"owner={owner}", "-F", f"name={name}",
                   "-F", f"number={pr['number']}",
                   "-F", f"cursor={reviews['pageInfo']['endCursor']}"]
            out = subprocess.run(cmd, capture_output=True, text=True)
            if out.returncode != 0:
                sys.exit(out.stderr.strip() or "gh api failed")
            reviews = json.loads(out.stdout)["data"]["repository"]["pullRequest"]["reviews"]
            pr["reviews"]["nodes"].extend(reviews["nodes"])
            pr["reviews"]["pageInfo"] = reviews["pageInfo"]
    return prs


def measure(pr):
    ready = pr["timelineItems"]["nodes"]
    ready_at = ready[0]["createdAt"] if ready else pr["createdAt"]
    author = (pr["author"] or {}).get("login")
    human = [r["submittedAt"] for r in pr["reviews"]["nodes"]
             if r["submittedAt"] and not is_bot(r["author"])
             and r["author"]["login"] != author
             and ts(r["submittedAt"]) >= ts(ready_at)]
    first_review = min(human) if human else None
    commits = pr["commits"]["nodes"]
    first_commit = commits[0]["commit"]["authoredDate"] if commits else pr["createdAt"]
    return {
        "size": pr["additions"] + pr["deletions"],
        "first_review": hours(ready_at, first_review) if first_review else None,
        "merge": hours(pr["createdAt"], pr["mergedAt"]),
        "cycle": hours(first_commit, pr["mergedAt"]),
    }


def pct(values, p):
    values = sorted(values)
    k = (len(values) - 1) * p
    lo = int(k)
    hi = min(lo + 1, len(values) - 1)
    return values[lo] + (values[hi] - values[lo]) * (k - lo)


def fmt(h):
    return f"{h:.1f}h" if h < 48 else f"{h / 24:.1f}d"


def row(label, values):
    if not values:
        return f"  {label:<24} no data"
    return (f"  {label:<24} median {fmt(statistics.median(values)):>7}"
            f"   p75 {fmt(pct(values, 0.75)):>7}   p90 {fmt(pct(values, 0.9)):>7}"
            f"   n={len(values)}")


def main():
    ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
    ap.add_argument("repo", help="OWNER/REPO")
    ap.add_argument("--prs", type=int, default=200, help="merged PRs to read")
    args = ap.parse_args()
    owner, _, name = args.repo.partition("/")
    if not name:
        sys.exit("repo must look like OWNER/REPO")

    rows = [measure(pr) for pr in fetch(owner, name, args.prs)]
    reviewed = [r for r in rows if r["first_review"] is not None]
    print(f"{args.repo}: last {len(rows)} merged PRs, "
          f"{len(reviewed)} with a human review\n")
    print(row("Time to first review", [r["first_review"] for r in reviewed]))
    print(row("Time to merge", [r["merge"] for r in rows]))
    print(row("Cycle time", [r["cycle"] for r in rows]))
    print("\nTime to first review by PR size (lines added + deleted)")
    for lo, hi in SIZE_BUCKETS:
        label = f"{lo}+ lines" if hi is None else f"{lo}-{hi} lines"
        bucket = [r["first_review"] for r in reviewed
                  if r["size"] >= lo and (hi is None or r["size"] <= hi)]
        print(row(label, bucket))
    unreviewed = len(rows) - len(reviewed)
    if unreviewed:
        print(f"\n{unreviewed} PRs merged with no human review.")


if __name__ == "__main__":
    main()
