#!/usr/bin/env bash
# Imladris Labs — codebase measurement script
# https://imladrislabs.dev
#
# Computes the aggregate numbers our intake asks for:
#   contributors, total LoC, PRs, merged PRs, avg LoC per PR,
#   and the Simple / Standard / Rich PR mix.
#
# Nothing leaves your machine. The script reads your local git history
# (and your forge's API, if reachable) and prints numbers for you to
# paste into the form. Read it before you run it — it's short.
#
# Usage:
#   curl -fsSL https://imladrislabs.dev/measure.sh | bash          # run inside a repo
#   curl -fsSL https://imladrislabs.dev/measure.sh | bash -s -- ~/code/repo1 ~/code/repo2
#   curl -fsSL https://imladrislabs.dev/measure.sh | bash -s -- https://gitlab.com/org/repo.git
#
# Needs only git. Works with GitHub, GitLab, Bitbucket, self-hosted —
# anything git. Extras used ONLY if you happen to have them:
#   - curl:                exact PR/MR totals from github.com / gitlab.com
#   - GITHUB_TOKEN / gh / GITLAB_TOKEN: same, for private repos
#   - python3 + a GitHub token: exact PR sizes instead of git-history estimates

set -u
export GIT_TERMINAL_PROMPT=0   # never prompt: prompts would swallow the piped script

say() { printf '%s\n' "$*" >&2; }
die() { say "error: $*"; exit 1; }

command -v git >/dev/null 2>&1 || die "git is required"

HAVE_CURL=0; command -v curl >/dev/null 2>&1 && HAVE_CURL=1
HAVE_PY=0;   command -v python3 >/dev/null 2>&1 && HAVE_PY=1

GH_TOKEN_VAL="${GITHUB_TOKEN:-}"
if [ -z "$GH_TOKEN_VAL" ] && command -v gh >/dev/null 2>&1; then
  GH_TOKEN_VAL="$(gh auth token 2>/dev/null || true)"
fi
GL_TOKEN_VAL="${GITLAB_TOKEN:-}"

WORK="$(mktemp -d "${TMPDIR:-/tmp}/imladris-measure.XXXXXX")"
trap 'rm -rf "$WORK"' EXIT

# Size buckets: lines added + deleted per merged PR, bot authors excluded.
SIMPLE_LT=50    # Simple:   < 50
RICH_GT=300     # Standard: 50–300, Rich: > 300

# Bot authors, matched two ways: the literal "[bot]" suffix as a substring
# (never as regex — brackets would turn into a character class), plus known
# bot names that lack the suffix.
BOT_WORDS='dependabot|renovate|github-actions|gitlab-bot|snyk-bot|greenkeeper|semantic-release|coderabbit|blacksmith'
is_bot() { case "$1" in *'[bot]'*) return 0 ;; esac; printf '%s' "$1" | grep -Eiq "$BOT_WORDS"; }

# ---------------------------------------------------------------- inputs
# Args: local repo paths, or clone URLs (any forge), or GitHub owner/repo
# slugs. No args = the repo you are standing in.
REPO_DIRS="$WORK/dirs"; : > "$REPO_DIRS"
if [ "$#" -eq 0 ]; then
  top="$(git rev-parse --show-toplevel 2>/dev/null)" \
    || die "not inside a git repo — pass repos: bash -s -- /path/to/repo [more...]"
  printf '%s\n' "$top" >> "$REPO_DIRS"
else
  i=0
  for a in "$@"; do
    if [ -d "$a" ]; then
      git -C "$a" rev-parse --show-toplevel >/dev/null 2>&1 || die "'$a' is not a git repo"
      printf '%s\n' "$(cd "$a" && git rev-parse --show-toplevel)" >> "$REPO_DIRS"
    else
      url="$a"
      case "$a" in
        http://*|https://*|git@*|ssh://*) ;;
        */*) url="https://github.com/$a.git" ;;   # bare slug = GitHub shorthand
        *) die "'$a' is not a directory, URL, or owner/repo slug" ;;
      esac
      i=$((i + 1)); dir="$WORK/clone$i"
      say "cloning $url ..."
      git clone --quiet "$url" "$dir" 2>/dev/null \
        || die "cannot clone $url — for a private repo, clone it yourself first and pass the local path"
      printf '%s\n' "$dir" >> "$REPO_DIRS"
    fi
  done
fi

# ---------------------------------------------------------------- helpers
origin_of() { git -C "$1" remote get-url origin 2>/dev/null || true; }

# "host<TAB>path" from any git remote URL, e.g. github.com<TAB>owner/repo
host_path() {
  printf '%s\n' "$1" | sed -E '
    s#^(git\+)?(ssh://)?git@([^:/]+)[:/]#\3/#;
    s#^https?://([^@/]+@)?#*#; s#^\*##;
    s#\.git/?$##; s#^([^/]+)/#\1\t#'
}

api_get() { # $1 url, $2 optional auth header
  if [ -n "${2:-}" ]; then curl -fsS --max-time 30 -H "$2" "$1" 2>/dev/null
  else curl -fsS --max-time 30 "$1" 2>/dev/null; fi
}

# ---------------------------------------------------------------- per-repo measures
ALL_SIZES="$WORK/sizes"; : > "$ALL_SIZES"          # one merged-PR size per line
ALL_PEOPLE="$WORK/people"; : > "$ALL_PEOPLE"       # one contributor key per line
PER_REPO="$WORK/perrepo"; : > "$PER_REPO"          # label TAB loc TAB prs TAB merged TAB src
TOTAL_LOC=0; TOTAL_PRS=0; TOTAL_MERGED=0
PRS_EXACT=1   # flips to 0 if any repo had to fall back to git history

while IFS= read -r dir; do
  origin="$(origin_of "$dir")"
  hp="$(host_path "${origin:-}")"
  host="$(printf '%s\n' "$hp" | cut -f1)"
  path="$(printf '%s\n' "$hp" | cut -f2 -s)"
  label="$path"; [ -n "$label" ] || label="$(basename "$dir")"
  say "measuring $label ..."

  # --- LoC: lines in tracked text files (git skips binaries), minus
  #     lockfiles, minified/map/svg, vendored dirs. Comments and blanks count.
  loc="$(git -C "$dir" grep -I --cached -c -e '' -- . 2>/dev/null | awk '
    { n = $0; sub(/.*:/, "", n); f = $0; sub(/:[0-9]+$/, "", f); lf = tolower(f)
      base = lf; sub(/.*\//, "", base)
      if (base ~ /^(package-lock\.json|yarn\.lock|pnpm-lock\.yaml|bun\.lock|bun\.lockb|uv\.lock|poetry\.lock|pipfile\.lock|cargo\.lock|composer\.lock|gemfile\.lock|go\.sum|flake\.lock|packages\.lock\.json|gradle\.lockfile)$/) next
      if (lf ~ /\.(min\.js|min\.css|map|svg|lock)$/) next
      if (lf ~ /(^|\/)(node_modules|vendor|third_party|dist|build|__pycache__|\.next)\//) next
      total += n }
    END { print total + 0 }')"
  TOTAL_LOC=$((TOTAL_LOC + loc))

  # --- contributors: unique humans across all branches. GitHub noreply
  #     addresses collapse to the account name; otherwise keyed by email.
  # %aE/%aN so a .mailmap, if the repo has one, is honored.
  git -C "$dir" log --all --format='%aE%x09%aN' 2>/dev/null | awk -F'\t' -v bots="$BOT_WORDS" '
    { e = tolower($1); n = tolower($2); gsub(/  +/, " ", n)
      if (index(e, "[bot]") || index(n, "[bot]") || e ~ bots || n ~ bots) next
      if (e ~ /@users\.noreply\.github\.com$/) { sub(/@.*/, "", e); sub(/^[0-9]+\+/, "", e) }
      print e "\t" n }' | sort -u >> "$ALL_PEOPLE"

  # --- merged PRs visible in git history: PR-numbered merge/squash commits
  #     on any branch, deduped by number. Falls back to plain merge commits
  #     for repos that never used a forge's PR flow.
  commits="$WORK/commits"
  git -C "$dir" log --all --format='%H%x09%P%x09%ae%x09%s' 2>/dev/null | awk -F'\t' '
    { n = "" }
    $4 ~ /^Merge pull request #[0-9]+/ { n = $4; sub(/^Merge pull request #/, "", n); sub(/[^0-9].*/, "", n) }
    n == "" && $4 ~ /\(#[0-9]+\)$/     { n = $4; sub(/.*\(#/, "", n); sub(/\)$/, "", n) }
    n == "" && $4 ~ /\(pull request #[0-9]+\)/ { n = $4; sub(/.*\(pull request #/, "", n); sub(/[^0-9].*/, "", n) }
    n != "" && !(n in seen) { seen[n] = 1; print $1 "\t" $3 }' > "$commits"
  # GitLab keeps the MR number in the body, not the subject.
  git -C "$dir" log --all --grep='See merge request' --format='%H%x09%ae' 2>/dev/null | while IFS="$(printf '\t')" read -r h e; do
    mr="$(git -C "$dir" log -1 --format=%b "$h" 2>/dev/null | sed -n 's/.*See merge request [^!]*!\([0-9][0-9]*\).*/gl\1/p' | head -1)"
    [ -n "$mr" ] && printf '%s\t%s\t%s\n' "$h" "$e" "$mr"
  done | awk -F'\t' '!($3 in seen) { seen[$3] = 1; print $1 "\t" $2 }' >> "$commits"
  if [ ! -s "$commits" ]; then
    git -C "$dir" log --first-parent --merges --format='%H%x09%ae' 2>/dev/null > "$commits"
  fi

  git_merged="$(wc -l < "$commits" | tr -d ' ')"
  sizes="$WORK/repo_sizes"; : > "$sizes"
  while IFS="$(printf '\t')" read -r h ae; do
    is_bot "$ae" && continue
    git -C "$dir" diff --shortstat "$h^1" "$h" 2>/dev/null | awk '
      { ins = 0; del = 0
        if (match($0, /[0-9]+ insertion/)) { ins = substr($0, RSTART, RLENGTH); sub(/ .*/, "", ins) }
        if (match($0, /[0-9]+ deletion/))  { del = substr($0, RSTART, RLENGTH); sub(/ .*/, "", del) }
        print ins + del }' >> "$sizes"
  done < "$commits"

  # --- forge API, if reachable: exact totals (git history is a lower bound —
  #     deleted branches and rebase-merges leave no trace).
  prs=""; merged=""; src="git history (lower bound)"
  if [ "$HAVE_CURL" -eq 1 ] && [ "$host" = "github.com" ] && [ -n "$path" ]; then
    auth=""; [ -n "$GH_TOKEN_VAL" ] && auth="Authorization: Bearer $GH_TOKEN_VAL"
    t="$(api_get "https://api.github.com/search/issues?q=repo%3A$path+type%3Apr" "$auth" | sed -n 's/.*"total_count": *\([0-9]*\).*/\1/p' | head -1)"
    m="$(api_get "https://api.github.com/search/issues?q=repo%3A$path+type%3Apr+is%3Amerged" "$auth" | sed -n 's/.*"total_count": *\([0-9]*\).*/\1/p' | head -1)"
    if [ -n "$t" ] && [ -n "$m" ]; then prs="$t"; merged="$m"; src="GitHub API counts, git-history sizes"; fi
    # Exact per-PR sizes when we can: GraphQL needs python3 and any token.
    if [ "$HAVE_PY" -eq 1 ] && [ -n "$GH_TOKEN_VAL" ]; then
      if IMLADRIS_SLUG="$path" IMLADRIS_TOKEN="$GH_TOKEN_VAL" python3 - > "$WORK/gql_sizes" <<'PYEOF'
import json, os, sys, urllib.request
slug = os.environ["IMLADRIS_SLUG"]; owner, name = slug.split("/", 1)
Q = """query($o:String!,$n:String!,$c:String){repository(owner:$o,name:$n){
pullRequests(first:100,after:$c,orderBy:{field:CREATED_AT,direction:ASC}){
totalCount pageInfo{hasNextPage endCursor}
nodes{state additions deletions author{login __typename}}}}}"""
def gql(vars):
    r = urllib.request.Request("https://api.github.com/graphql",
        json.dumps({"query": Q, "variables": vars}).encode(),
        {"Authorization": "Bearer " + os.environ["IMLADRIS_TOKEN"],
         "User-Agent": "imladris-measure"})
    out = json.load(urllib.request.urlopen(r, timeout=60))
    if out.get("errors"): raise RuntimeError(out["errors"][0].get("message"))
    return out["data"]["repository"]["pullRequests"]
cursor, total, merged, sizes, seen = None, 0, 0, [], 0
while True:
    b = gql({"o": owner, "n": name, "c": cursor})
    total = b["totalCount"]
    for x in b["nodes"]:
        a = x.get("author") or {}
        bot = a.get("__typename") == "Bot" or str(a.get("login", "")).endswith("[bot]")
        if x["state"] == "MERGED":
            merged += 1
            if not bot: sizes.append(x["additions"] + x["deletions"])
    seen += len(b["nodes"])
    print(f"  {slug}: fetched {seen} of {total} PRs", file=sys.stderr)
    if not b["pageInfo"]["hasNextPage"]: break
    cursor = b["pageInfo"]["endCursor"]
print(total); print(merged)
for s in sizes: print(s)
PYEOF
      then
        prs="$(sed -n 1p "$WORK/gql_sizes")"; merged="$(sed -n 2p "$WORK/gql_sizes")"
        sed -n '3,$p' "$WORK/gql_sizes" > "$sizes"; src="GitHub API (exact sizes)"
      fi
    fi
  elif [ "$HAVE_CURL" -eq 1 ] && [ "$host" = "gitlab.com" ] && [ -n "$path" ]; then
    enc="$(printf '%s' "$path" | sed 's,/,%2F,g')"
    hdr=""; [ -n "$GL_TOKEN_VAL" ] && hdr="PRIVATE-TOKEN: $GL_TOKEN_VAL"
    gt() { { if [ -n "$hdr" ]; then curl -fsSI --max-time 30 -H "$hdr" "$1"; else curl -fsSI --max-time 30 "$1"; fi; } 2>/dev/null | tr -d '\r' | awk -F': ' 'tolower($1) == "x-total" { print $2 }'; }
    t="$(gt "https://gitlab.com/api/v4/projects/$enc/merge_requests?scope=all&state=all&per_page=1")"
    m="$(gt "https://gitlab.com/api/v4/projects/$enc/merge_requests?scope=all&state=merged&per_page=1")"
    if [ -n "$t" ] && [ -n "$m" ]; then prs="$t"; merged="$m"; src="GitLab API"; fi
  fi
  if [ -z "$merged" ]; then merged="$git_merged"; PRS_EXACT=0; fi
  if [ -z "$prs" ]; then prs="-"; PRS_EXACT=0; else TOTAL_PRS=$((TOTAL_PRS + prs)); fi
  TOTAL_MERGED=$((TOTAL_MERGED + merged))
  cat "$sizes" >> "$ALL_SIZES"
  printf '%s\t%s\t%s\t%s\t%s\n' "$label" "$loc" "$prs" "$merged" "$src" >> "$PER_REPO"
done < "$REPO_DIRS"

# ---------------------------------------------------------------- report
# One person often commits under several emails and name spellings. Merge
# identities that share an email OR a name (union-find), and count groups.
CONTRIBUTORS="$(sort -u "$ALL_PEOPLE" | awk -F'\t' '
  function find(x) { while (parent[x] != x) { parent[x] = parent[parent[x]]; x = parent[x] } return x }
  function union(a, b,  ra, rb) { ra = find(a); rb = find(b); if (ra != rb) parent[ra] = rb }
  function node(k) { if (!(k in parent)) parent[k] = k; return k }
  {
    e = ($1 != "") ? "E:" $1 : ""; n = ($2 != "") ? "N:" $2 : ""
    if (e != "") node(e); if (n != "") node(n)
    if (e != "" && n != "") union(e, n)
  }
  END { for (k in parent) if (find(k) == k) roots++; print roots + 0 }')"
NREPOS="$(wc -l < "$PER_REPO" | tr -d ' ')"

stats="$(sort -n "$ALL_SIZES" | awk -v s="$SIMPLE_LT" -v r="$RICH_GT" '
  { v[NR] = $1; sum += $1
    if ($1 < s) simple++; else if ($1 <= r) standard++; else rich++ }
  END {
    if (NR == 0) { print "- - - - -"; exit }
    med = (NR % 2) ? v[(NR + 1) / 2] : int((v[NR / 2] + v[NR / 2 + 1]) / 2)
    printf "%d %d %.0f %.0f %.0f\n", int(sum / NR + 0.5), med,
      100 * simple / NR, 100 * standard / NR, 100 * rich / NR }')"
AVG="$(printf '%s' "$stats" | awk '{print $1}')"
MED="$(printf '%s' "$stats" | awk '{print $2}')"
PSIMPLE="$(printf '%s' "$stats" | awk '{print $3}')"
PSTANDARD="$(printf '%s' "$stats" | awk '{print $4}')"
PRICH="$(printf '%s' "$stats" | awk '{print $5}')"
NSIZED="$(wc -l < "$ALL_SIZES" | tr -d ' ')"

n() { # thousands separators, locale-independent
  if [ "$1" = "-" ]; then printf 'n/a'; else
    printf '%s' "$1" | awk '{ s = $1 ""; out = ""
      while (length(s) > 3) { out = "," substr(s, length(s) - 2) out; s = substr(s, 1, length(s) - 3) }
      printf "%s%s", s, out }'
  fi
}
pctf() { if [ "$1" = "-" ]; then printf 'n/a'; else printf '%s%%' "$1"; fi; }
PRS_OUT="$TOTAL_PRS"; [ "$PRS_EXACT" -eq 1 ] || PRS_OUT="-"

W=66
line() { printf '%*s\n' "$W" '' | tr ' ' '='; }
echo
line
echo "  Imladris Labs — codebase measurements"
line
awk -F'\t' '{ printf "  %-34s %10s LoC  %5s PRs (%s merged) — %s\n", $1, $2, $3, $4, $5 }' "$PER_REPO"
[ "$NREPOS" -gt 1 ] && line
echo "  # of contributors:  $(n "$CONTRIBUTORS") (unique humans across all branches, bots excluded)"
echo "  Total LoC:          $(n "$TOTAL_LOC") (tracked text files; lockfiles, vendored and binary files excluded)"
echo "  # of PRs:           $(n "$PRS_OUT")"
echo "  # of merged PRs:    $(n "$TOTAL_MERGED")$([ "$PRS_EXACT" -eq 1 ] || printf ' (at least — from git history; deleted branches are invisible)')"
echo "  Avg LoC per PR:     $(n "$AVG") (merged, bots excluded; median $(n "$MED"), measured over $(n "$NSIZED"))"
echo "  % Simple PRs:       $(pctf "$PSIMPLE") (< $SIMPLE_LT lines changed)"
echo "  % Standard PRs:     $(pctf "$PSTANDARD") (${SIMPLE_LT}–${RICH_GT} lines changed)"
echo "  % Rich PRs:         $(pctf "$PRICH") (> $RICH_GT lines changed)"
line
if [ "$PRS_EXACT" -eq 0 ]; then
  echo "  PR counts came from git history alone, so they are a floor, not"
  echo "  an exact figure. For exact numbers: on GitHub, set GITHUB_TOKEN"
  echo "  (or log in with gh) and run again; on GitLab, set GITLAB_TOKEN."
  echo "  On other forges, read the PR/MR totals off your forge's UI."
  line
fi
echo
echo "JSON (same numbers, for the form's notes field):"
jnum() { if [ "$1" = "-" ]; then printf 'null'; else printf '%s' "$1"; fi; }
repos_json="$(awk -F'\t' '{ printf "%s\"%s\"", (NR > 1 ? "," : ""), $1 }' "$PER_REPO")"
printf '{"repos":[%s],"contributors":%s,"total_loc":%s,"prs":%s,"merged_prs":%s,"avg_loc_per_merged_pr":%s,"median_loc_per_merged_pr":%s,"pct_simple":%s,"pct_standard":%s,"pct_rich":%s,"pr_counts_exact":%s,"definitions":{"loc":"lines in git-tracked text files, lockfiles/vendored/binary excluded","pr_size":"additions+deletions on merged PRs, bot authors excluded","buckets":"simple <%s, standard %s-%s, rich >%s"}}\n' \
  "$repos_json" "$(jnum "$CONTRIBUTORS")" "$(jnum "$TOTAL_LOC")" "$(jnum "$PRS_OUT")" "$(jnum "$TOTAL_MERGED")" \
  "$(jnum "$AVG")" "$(jnum "$MED")" "$(jnum "$PSIMPLE")" "$(jnum "$PSTANDARD")" "$(jnum "$PRICH")" \
  "$([ "$PRS_EXACT" -eq 1 ] && printf 'true' || printf 'false')" \
  "$SIMPLE_LT" "$SIMPLE_LT" "$RICH_GT" "$RICH_GT"
