From ed75cf50d7ef2078c5d932191fbb00e62f8d0c0a Mon Sep 17 00:00:00 2001
From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com>
Date: Sun, 26 Jul 2026 18:41:24 -0400
Subject: [PATCH] feat: add pr metrics
---
.github/workflows/update-pages.yml | 9 +-
gh-pages-template/assets/js/pr-metrics.js | 103 ++++
gh-pages-template/index.html | 2 +
src/builder.py | 9 +-
src/pr_metrics.py | 691 ++++++++++++++++++++++
src/updater.py | 22 +
tests/pr_metrics.test.js | 91 +++
tests/unit/test_builder.py | 11 +
tests/unit/test_pr_metrics.py | 450 ++++++++++++++
tests/unit/test_updater.py | 25 +
10 files changed, 1408 insertions(+), 5 deletions(-)
create mode 100644 gh-pages-template/assets/js/pr-metrics.js
create mode 100644 src/pr_metrics.py
create mode 100644 tests/pr_metrics.test.js
create mode 100644 tests/unit/test_pr_metrics.py
diff --git a/.github/workflows/update-pages.yml b/.github/workflows/update-pages.yml
index 00694106fe..cd97618b14 100644
--- a/.github/workflows/update-pages.yml
+++ b/.github/workflows/update-pages.yml
@@ -49,10 +49,11 @@ jobs:
run: |
mkdir -p gh-pages
git fetch --depth=1 origin gh-pages
- git archive origin/gh-pages github/commitActivity | tar -x -C gh-pages
- if git cat-file -e origin/gh-pages:github/commitActivityHashes; then
- git archive origin/gh-pages github/commitActivityHashes | tar -x -C gh-pages
- fi
+ for cache_path in github/commitActivity github/commitActivityHashes github/prMetrics; do
+ if git cat-file -e "origin/gh-pages:${cache_path}"; then
+ git archive origin/gh-pages "${cache_path}" | tar -x -C gh-pages
+ fi
+ done
- name: Collect data
env:
diff --git a/gh-pages-template/assets/js/pr-metrics.js b/gh-pages-template/assets/js/pr-metrics.js
new file mode 100644
index 0000000000..8562ec35fd
--- /dev/null
+++ b/gh-pages-template/assets/js/pr-metrics.js
@@ -0,0 +1,103 @@
+'use strict';
+
+function sortableValue(cell) {
+ const text = cell.textContent.trim();
+ if (!text || text === '—') return null;
+
+ const duration = text.match(/^(-?[\d,.]+)\s*([dh])(?:\s|$)/i);
+ if (duration) {
+ const hours = Number(duration[1].replaceAll(',', ''));
+ return duration[2].toLowerCase() === 'd' ? hours * 24 : hours;
+ }
+
+ const numeric = text.match(/^-?[\d,.]+(?:%|\s|$)/);
+ if (numeric) return Number(numeric[0].replaceAll(/[,%\s]/g, ''));
+ return text.toLocaleLowerCase();
+}
+
+function compareSortValues(left, right, direction) {
+ if (left === null) return right === null ? 0 : 1;
+ if (right === null) return -1;
+ if (typeof left === 'number' && typeof right === 'number') {
+ return (left - right) * direction;
+ }
+ return String(left).localeCompare(String(right), undefined, { numeric: true }) * direction;
+}
+
+function sortTable(table, column, direction) {
+ const body = table.tBodies[0];
+ if (!body) return;
+ const rows = Array.from(body.rows).map((row, index) => ({ row, index }));
+ rows.sort((left, right) => (
+ compareSortValues(
+ sortableValue(left.row.cells[column]),
+ sortableValue(right.row.cells[column]),
+ direction,
+ ) || left.index - right.index
+ ));
+ rows.forEach(({ row }) => body.append(row));
+}
+
+function resetSortIndicators(headers) {
+ headers.forEach(header => {
+ header.removeAttribute('aria-sort');
+ header.querySelector('.pr-metrics-sort-indicator').textContent = '↕';
+ });
+}
+
+function activateSort(table, headers, header, indicator, column) {
+ const ascending = header.getAttribute('aria-sort') !== 'ascending';
+ resetSortIndicators(headers);
+ header.setAttribute('aria-sort', ascending ? 'ascending' : 'descending');
+ indicator.textContent = ascending ? '↑' : '↓';
+ sortTable(table, column, ascending ? 1 : -1);
+}
+
+function handleSortKey(event, activate) {
+ if (['Enter', ' '].includes(event.key)) {
+ event.preventDefault();
+ activate();
+ }
+}
+
+function initializeHeader(table, headers, header, column) {
+ header.tabIndex = 0;
+ header.style.cursor = 'pointer';
+ header.style.userSelect = 'none';
+ header.setAttribute('role', 'button');
+ header.setAttribute('title', 'Sort by this column');
+ const indicator = document.createElement('span');
+ indicator.className = 'ms-1 pr-metrics-sort-indicator';
+ indicator.setAttribute('aria-hidden', 'true');
+ indicator.textContent = '↕';
+ header.append(indicator);
+
+ const activate = () => activateSort(table, headers, header, indicator, column);
+ header.addEventListener('click', activate);
+ header.addEventListener('keydown', event => handleSortKey(event, activate));
+}
+
+function initializeTable(table) {
+ if (table.dataset.sortableInitialized) return;
+ table.dataset.sortableInitialized = 'true';
+ const headers = Array.from(table.querySelectorAll('thead th'));
+ headers.forEach((header, column) => initializeHeader(table, headers, header, column));
+}
+
+function initializeSortableTables(root = document) {
+ root.querySelectorAll('table.pr-metrics-sortable').forEach(table => {
+ initializeTable(table);
+ });
+}
+
+document.addEventListener('DOMContentLoaded', () => initializeSortableTables());
+
+/* istanbul ignore next */
+if (typeof module !== 'undefined' && module.exports) {
+ module.exports = {
+ sortableValue,
+ compareSortValues,
+ sortTable,
+ initializeSortableTables,
+ };
+}
diff --git a/gh-pages-template/index.html b/gh-pages-template/index.html
index 269fdbac08..69fccbf492 100644
--- a/gh-pages-template/index.html
+++ b/gh-pages-template/index.html
@@ -58,6 +58,7 @@
Issues
Code Scanning
PRs
+ PR Metrics
License
Coverage
Commit Activity
@@ -99,6 +100,7 @@ History
Open Pull Requests
+ View organization and repository PR metrics
By Status
PR Details
diff --git a/src/builder.py b/src/builder.py
index dbf697f5b6..472072affe 100644
--- a/src/builder.py
+++ b/src/builder.py
@@ -8,6 +8,7 @@
# local imports
from src import BASE_DIR, TEMPLATE_DIR
from src import helpers
+from src import pr_metrics
from src.logger import log
@@ -264,6 +265,7 @@ def build():
commit_activity = []
star_history = []
code_scanning_history = []
+ pr_metric_caches = {}
for repo in raw_repos:
if repo.get('private') or repo.get('archived'):
@@ -282,6 +284,8 @@ def build():
star_history.extend(_get_star_history(BASE_DIR, name))
code_scanning_open = _get_code_scanning_open(BASE_DIR, name)
code_scanning_history.extend(_get_code_scanning_history(BASE_DIR, name))
+ if pr_metrics.is_active_repo(repo):
+ pr_metric_caches[name] = pr_metrics.load_cache(BASE_DIR, name)
repos.append(_build_repo_entry(repo, coverage, languages, prs, issues, rtd_repos, code_scanning_open))
prs_all.extend({'repo': name, **pr} for pr in prs)
@@ -301,10 +305,13 @@ def write_json(filename, data):
write_json('commit_activity.json', commit_activity)
write_json('star_history.json', star_history)
write_json('code_scanning_history.json', code_scanning_history)
+ write_json('pr_metrics.json', pr_metric_caches)
+ now = datetime.now(timezone.utc)
write_json('metadata.json', {
- 'updated_at': datetime.now(timezone.utc).isoformat(),
+ 'updated_at': now.isoformat(),
'repo_count': len(repos),
})
+ pr_metrics.write_report_pages(TEMPLATE_DIR, pr_metric_caches, now)
log.info('Dashboard build complete.')
diff --git a/src/pr_metrics.py b/src/pr_metrics.py
new file mode 100644
index 0000000000..fad9680d4f
--- /dev/null
+++ b/src/pr_metrics.py
@@ -0,0 +1,691 @@
+"""Collect, calculate, and render pull-request metrics."""
+
+# standard imports
+import json
+import os
+from datetime import datetime, timedelta, timezone
+from pathlib import Path
+from statistics import median
+from urllib.parse import quote_plus
+
+# local imports
+from src import helpers
+
+GRAPHQL_URL = 'https://api.github.com/graphql'
+GITHUB_OWNER = 'LizardByte'
+CACHE_VERSION = 2
+HISTORY_DAYS = 365
+REPORT_DAYS = 90
+CACHE_MAX_AGE = timedelta(hours=24)
+STALE_DAYS = 30
+SEARCH_OPEN = 'is:open'
+SEARCH_READY = '-is:draft'
+SEARCH_DRAFT = 'is:draft'
+SEARCH_MERGED = 'is:merged'
+SEARCH_NO_REVIEW = 'review:none'
+SORTABLE_TABLE = '{: .pr-metrics-sortable}'
+REACTION_EMOJI = {
+ 'THUMBS_UP': '👍',
+ 'THUMBS_DOWN': '👎',
+ 'LAUGH': '😄',
+ 'HOORAY': '🎉',
+ 'CONFUSED': '😕',
+ 'HEART': '❤️',
+ 'ROCKET': '🚀',
+ 'EYES': '👀',
+}
+
+PULL_REQUEST_QUERY = """
+query($owner: String!, $name: String!, $states: [PullRequestState!], $cursor: String) {
+ repository(owner: $owner, name: $name) {
+ pullRequests(
+ first: 100
+ after: $cursor
+ states: $states
+ orderBy: {field: UPDATED_AT, direction: DESC}
+ ) {
+ nodes {
+ number
+ title
+ url
+ state
+ isDraft
+ createdAt
+ updatedAt
+ closedAt
+ mergedAt
+ additions
+ deletions
+ changedFiles
+ reviewDecision
+ author { login }
+ reviews(
+ first: 1
+ states: [APPROVED, CHANGES_REQUESTED, COMMENTED, DISMISSED]
+ ) {
+ totalCount
+ nodes {
+ submittedAt
+ }
+ }
+ approvals: reviews(first: 1, states: [APPROVED]) {
+ nodes { submittedAt }
+ }
+ reactionGroups {
+ content
+ reactors(first: 1) { totalCount }
+ }
+ }
+ pageInfo {
+ endCursor
+ hasNextPage
+ }
+ }
+ }
+}
+"""
+
+
+def _parse_datetime(value: str | None) -> datetime | None:
+ """Parse a GitHub timestamp into an aware datetime."""
+ if not value:
+ return None
+ parsed = datetime.fromisoformat(value.replace('Z', '+00:00'))
+ if parsed.tzinfo is None:
+ parsed = parsed.replace(tzinfo=timezone.utc)
+ return parsed
+
+
+def _isoformat(value: datetime | None) -> str | None:
+ """Return a UTC ISO timestamp when a datetime is present."""
+ return value.astimezone(timezone.utc).isoformat() if value else None
+
+
+def _window_cutoff(now: datetime, days: int) -> datetime:
+ """Return a whole-second UTC cutoff shared by calculations and search links."""
+ return (now.astimezone(timezone.utc) - timedelta(days=days)).replace(microsecond=0)
+
+
+def _search_timestamp(value: datetime) -> str:
+ """Format a datetime for an exact GitHub search qualifier."""
+ return value.strftime('%Y-%m-%dT%H:%M:%SZ')
+
+
+def is_active_repo(repo: dict) -> bool:
+ """Return whether a repository belongs in the public dashboard metrics."""
+ topics = repo.get('topics') or []
+ return not repo.get('private') and not repo.get('archived') and 'package-manager' not in topics
+
+
+def cache_path(base_dir: str, repository: str) -> str:
+ """Return the cache path without its JSON extension."""
+ return os.path.join(base_dir, 'github', 'prMetrics', os.path.basename(repository))
+
+
+def load_cache(base_dir: str, repository: str) -> dict | None:
+ """Load a valid repository metrics cache, returning ``None`` on failure."""
+ try:
+ with open(f'{cache_path(base_dir, repository)}.json') as cache_file:
+ cache = json.load(cache_file)
+ if isinstance(cache, dict) and isinstance(cache.get('pull_requests'), list):
+ return cache
+ except Exception:
+ pass
+ return None
+
+
+def cache_is_fresh(cache: dict | None, now: datetime) -> bool:
+ """Return whether a cache has the current schema window and is less than one day old."""
+ if (
+ not cache
+ or cache.get('cache_version') != CACHE_VERSION
+ or cache.get('history_days') != HISTORY_DAYS
+ ):
+ return False
+ try:
+ collected_at = _parse_datetime(cache.get('collected_at'))
+ age = now - collected_at
+ return timedelta(0) <= age < CACHE_MAX_AGE
+ except Exception:
+ return False
+
+
+def _graphql_connection(session, headers: dict, variables: dict) -> dict:
+ """Request one pull-request connection page from GitHub GraphQL."""
+ response = session.post(
+ url=GRAPHQL_URL,
+ json={'query': PULL_REQUEST_QUERY, 'variables': variables},
+ headers=headers,
+ )
+ try:
+ payload = response.json()
+ except Exception as error:
+ raise RuntimeError(f'Invalid GitHub GraphQL response: {response.text}') from error
+
+ if response.status_code != 200 or payload.get('errors'):
+ detail = payload.get('errors') or payload
+ raise RuntimeError(f'GitHub GraphQL request failed: {detail}')
+
+ repository = (payload.get('data') or {}).get('repository')
+ if repository is None:
+ raise RuntimeError('GitHub GraphQL response did not include the repository')
+ return repository['pullRequests']
+
+
+def _normalize_pull(repository: str, pull: dict) -> dict:
+ """Convert a GraphQL pull-request node into the stable cache schema."""
+ review_times = _review_timestamps(pull.get('reviews'))
+ approval_times = _review_timestamps(pull.get('approvals'))
+ reactions = _reaction_counts(pull.get('reactionGroups'))
+ author = pull.get('author') or {}
+
+ return {
+ 'repository': repository,
+ 'number': pull['number'],
+ 'title': pull.get('title') or '',
+ 'url': pull.get('url') or '',
+ 'state': (pull.get('state') or '').lower(),
+ 'draft': bool(pull.get('isDraft')),
+ 'author': author.get('login'),
+ 'created_at': pull.get('createdAt'),
+ 'updated_at': pull.get('updatedAt'),
+ 'closed_at': pull.get('closedAt'),
+ 'merged_at': pull.get('mergedAt'),
+ 'additions': int(pull.get('additions') or 0),
+ 'deletions': int(pull.get('deletions') or 0),
+ 'changed_files': int(pull.get('changedFiles') or 0),
+ 'review_count': int((pull.get('reviews') or {}).get('totalCount') or 0),
+ 'review_decision': pull.get('reviewDecision'),
+ 'first_review_at': _isoformat(review_times[0]) if review_times else None,
+ 'first_approval_at': _isoformat(approval_times[0]) if approval_times else None,
+ 'reactions': reactions,
+ }
+
+
+def _review_timestamps(connection: dict | None) -> list[datetime]:
+ """Return sorted submitted timestamps from a GraphQL review connection."""
+ timestamps = [
+ _parse_datetime(review.get('submittedAt'))
+ for review in (connection or {}).get('nodes') or []
+ if review.get('submittedAt')
+ ]
+ return sorted(timestamp for timestamp in timestamps if timestamp is not None)
+
+
+def _reaction_counts(groups: list[dict] | None) -> list[dict]:
+ """Normalize non-zero GraphQL reaction groups."""
+ reactions = []
+ for group in groups or []:
+ count = int((group.get('reactors') or {}).get('totalCount') or 0)
+ if group.get('content') and count:
+ reactions.append({'content': group['content'], 'count': count})
+ return reactions
+
+
+def _fetch_connection(
+ repository,
+ headers: dict,
+ session,
+ states: list[str],
+ cutoff: datetime | None = None,
+) -> list[dict]:
+ """Fetch and normalize one state group, stopping once updated records cross the cutoff."""
+ cursor = None
+ pulls = []
+
+ while True:
+ connection = _graphql_connection(session, headers, {
+ 'owner': repository.owner.login,
+ 'name': repository.name,
+ 'states': states,
+ 'cursor': cursor,
+ })
+ reached_cutoff = False
+ for pull in connection.get('nodes') or []:
+ updated_at = _parse_datetime(pull.get('updatedAt'))
+ if cutoff and updated_at and updated_at < cutoff:
+ reached_cutoff = True
+ break
+ pulls.append(_normalize_pull(repository.name, pull))
+
+ page_info = connection.get('pageInfo') or {}
+ if reached_cutoff or not page_info.get('hasNextPage'):
+ break
+ cursor = page_info.get('endCursor')
+ if not cursor:
+ raise RuntimeError('GitHub GraphQL pagination did not return an end cursor')
+
+ return pulls
+
+
+def fetch_repository(repository, headers: dict, session, now: datetime) -> list[dict]:
+ """Fetch every open PR and one year of recently updated completed PRs."""
+ cutoff = now - timedelta(days=HISTORY_DAYS)
+ pulls = _fetch_connection(repository, headers, session, ['OPEN'])
+ pulls.extend(_fetch_connection(repository, headers, session, ['CLOSED', 'MERGED'], cutoff))
+ unique = {pull['number']: pull for pull in pulls}
+ return sorted(unique.values(), key=lambda pull: pull.get('updated_at') or '', reverse=True)
+
+
+def refresh_repository(repository, base_dir: str, headers: dict, session, now: datetime | None = None) -> bool:
+ """Refresh one repository cache when stale, returning whether it was written."""
+ now = now or datetime.now(tz=timezone.utc)
+ existing = load_cache(base_dir, repository.name)
+ if cache_is_fresh(existing, now):
+ return False
+
+ pulls = fetch_repository(repository, headers, session, now)
+ helpers.write_json_files(
+ file_path=cache_path(base_dir, repository.name),
+ data={
+ 'repository': repository.name,
+ 'collected_at': now.isoformat(),
+ 'cache_version': CACHE_VERSION,
+ 'history_days': HISTORY_DAYS,
+ 'pull_requests': pulls,
+ },
+ )
+ return True
+
+
+def _hours_between(start: str | None, end: str | None) -> float | None:
+ """Return elapsed hours between two timestamps."""
+ start_time = _parse_datetime(start)
+ end_time = _parse_datetime(end)
+ if not start_time or not end_time:
+ return None
+ return (end_time - start_time).total_seconds() / 3600
+
+
+def _percentile(values: list[float], percentile: float) -> float | None:
+ """Return a linearly interpolated percentile."""
+ if not values:
+ return None
+ ordered = sorted(values)
+ index = (len(ordered) - 1) * percentile
+ lower = int(index)
+ upper = min(lower + 1, len(ordered) - 1)
+ fraction = index - lower
+ return ordered[lower] + (ordered[upper] - ordered[lower]) * fraction
+
+
+def _on_or_after(timestamp: str | None, cutoff: datetime) -> bool:
+ """Return whether a timestamp is present and on or after a cutoff."""
+ parsed = _parse_datetime(timestamp)
+ return parsed is not None and parsed >= cutoff
+
+
+def _elapsed_values(pulls: list[dict], end_field: str) -> list[float]:
+ """Return elapsed hours from PR creation to a selected event."""
+ elapsed_values = []
+ for pull in pulls:
+ elapsed = _hours_between(pull.get('created_at'), pull.get(end_field))
+ if elapsed is not None:
+ elapsed_values.append(elapsed)
+ return elapsed_values
+
+
+def _pending_pulls(ready_pulls: list[dict], now: datetime) -> list[dict]:
+ """Add age and inactivity values to ready pull requests."""
+ pending = []
+ ordered = sorted(
+ ready_pulls,
+ key=lambda item: (item.get('created_at') is None, item.get('created_at') or ''),
+ )
+ for pull in ordered:
+ created_at = _parse_datetime(pull.get('created_at'))
+ updated_at = _parse_datetime(pull.get('updated_at'))
+ pending.append({
+ **pull,
+ 'age_days': (now - created_at).days if created_at else 0,
+ 'inactive_days': (now - updated_at).days if updated_at else 0,
+ })
+ return pending
+
+
+def _is_not_reviewed(pull: dict) -> bool:
+ """Match GitHub's ``review:none`` current review-decision bucket."""
+ return pull.get('review_decision') is None
+
+
+def _is_awaiting_approval(pull: dict) -> bool:
+ """Return whether a ready PR has review activity but no current decision."""
+ decision = pull.get('review_decision')
+ return decision == 'REVIEW_REQUIRED' or (decision is None and bool(pull.get('review_count')))
+
+
+def calculate(pulls: list[dict], now: datetime, days: int = REPORT_DAYS) -> dict:
+ """Calculate current-backlog and completed-PR metrics."""
+ cutoff = _window_cutoff(now, days)
+ stale_cutoff = _window_cutoff(now, STALE_DAYS)
+ open_pulls = [pull for pull in pulls if pull.get('state') == 'open']
+ ready_pulls = [pull for pull in open_pulls if not pull.get('draft')]
+ merged_pulls = [pull for pull in pulls if _on_or_after(pull.get('merged_at'), cutoff)]
+ closed_unmerged = [
+ pull for pull in pulls
+ if pull.get('state') == 'closed'
+ and not pull.get('merged_at')
+ and _on_or_after(pull.get('closed_at'), cutoff)
+ ]
+ opened_pulls = [pull for pull in pulls if _on_or_after(pull.get('created_at'), cutoff)]
+
+ merge_hours = _elapsed_values(merged_pulls, 'merged_at')
+ review_hours = _elapsed_values(merged_pulls, 'first_review_at')
+ approval_hours = _elapsed_values(merged_pulls, 'first_approval_at')
+ completed_count = len(merged_pulls) + len(closed_unmerged)
+
+ return {
+ 'days': days,
+ 'open': len(open_pulls),
+ 'draft': len(open_pulls) - len(ready_pulls),
+ 'ready': len(ready_pulls),
+ 'not_reviewed': sum(1 for pull in ready_pulls if _is_not_reviewed(pull)),
+ 'awaiting_approval': sum(1 for pull in ready_pulls if _is_awaiting_approval(pull)),
+ 'changes_requested': sum(
+ 1 for pull in ready_pulls if pull.get('review_decision') == 'CHANGES_REQUESTED'
+ ),
+ 'stale': sum(
+ 1 for pull in open_pulls
+ if (_parse_datetime(pull.get('updated_at')) or now) < stale_cutoff
+ ),
+ 'opened': len(opened_pulls),
+ 'merged': len(merged_pulls),
+ 'closed_unmerged': len(closed_unmerged),
+ 'merge_rate': (len(merged_pulls) / completed_count * 100) if completed_count else None,
+ 'median_first_review_hours': median(review_hours) if review_hours else None,
+ 'p75_first_review_hours': _percentile(review_hours, 0.75),
+ 'median_first_approval_hours': median(approval_hours) if approval_hours else None,
+ 'p75_first_approval_hours': _percentile(approval_hours, 0.75),
+ 'median_merge_hours': median(merge_hours) if merge_hours else None,
+ 'p75_merge_hours': _percentile(merge_hours, 0.75),
+ 'merged_without_review': sum(1 for pull in merged_pulls if not pull.get('review_count')),
+ 'merged_without_approval': sum(1 for pull in merged_pulls if not pull.get('first_approval_at')),
+ 'additions': sum(int(pull.get('additions') or 0) for pull in merged_pulls),
+ 'deletions': sum(int(pull.get('deletions') or 0) for pull in merged_pulls),
+ 'pending': _pending_pulls(ready_pulls, now),
+ }
+
+
+def _format_duration(hours: float | None) -> str:
+ """Format an elapsed-hour metric for a report table."""
+ if hours is None:
+ return '—'
+ if hours < 24:
+ return f'{hours:.1f} h'
+ return f'{hours / 24:.1f} d'
+
+
+def _format_percent(value: float | None) -> str:
+ """Format a percentage metric for a report table."""
+ return '—' if value is None else f'{value:.1f}%'
+
+
+def _markdown_text(value: object) -> str:
+ """Escape text for use inside a Markdown table cell."""
+ return (
+ str(value or '')
+ .replace('\\', '\\\\')
+ .replace('[', '\\[')
+ .replace(']', '\\]')
+ .replace('\n', ' ')
+ .replace('|', '\\|')
+ )
+
+
+def _github_search(repository: str | None, qualifiers: list[str]) -> str:
+ """Build a GitHub pull-request search URL for an organization or repository."""
+ scope = f'repo:{GITHUB_OWNER}/{repository}' if repository else f'org:{GITHUB_OWNER}'
+ query = ' '.join(['is:pr', scope, *qualifiers])
+ return f'https://github.com/pulls?q={quote_plus(query)}'
+
+
+def _metric_link(value: int, repository: str | None, *qualifiers: str) -> str:
+ """Link a displayed metric count to the closest matching GitHub search."""
+ return f'[{value}]({_github_search(repository, list(qualifiers))})'
+
+
+def _format_reactions(reactions: list[dict] | None) -> str:
+ """Format reaction groups as a sortable total followed by emoji counts."""
+ groups = [reaction for reaction in (reactions or []) if reaction.get('count')]
+ groups.sort(key=lambda reaction: list(REACTION_EMOJI).index(reaction['content'])
+ if reaction.get('content') in REACTION_EMOJI else len(REACTION_EMOJI))
+ total = sum(int(reaction['count']) for reaction in groups)
+ if not groups:
+ return '0'
+ details = ' '.join(
+ f"{REACTION_EMOJI.get(reaction['content'], reaction['content'])} {reaction['count']}"
+ for reaction in groups
+ )
+ return f'{total} — {details}'
+
+
+def _format_collected_at(collected_at: str | None) -> str:
+ """Format a collection timestamp for display, tolerating invalid cache data."""
+ if not collected_at:
+ return 'unavailable'
+ try:
+ return _parse_datetime(collected_at).strftime('%Y-%m-%d %H:%M UTC')
+ except (AttributeError, ValueError):
+ return 'unavailable'
+
+
+def _review_status(pull: dict) -> str:
+ """Return the display status for a ready pull request."""
+ decision = pull.get('review_decision')
+ if decision == 'CHANGES_REQUESTED':
+ return 'Changes requested'
+ if decision == 'APPROVED' or pull.get('first_approval_at'):
+ return 'Approved'
+ if _is_awaiting_approval(pull):
+ return 'Awaiting approval'
+ return 'Not reviewed'
+
+
+def _pending_table(pending: list[dict]) -> list[str]:
+ """Render the longest-pending ready pull-request table."""
+ if not pending:
+ return ['No ready pull requests are currently open.']
+
+ lines = [
+ '| PR | Author | Reactions | Age | Inactive | Review status |',
+ '| --- | --- | ---: | ---: | ---: | --- |',
+ ]
+ for pull in pending[:10]:
+ title = _markdown_text(pull.get('title'))
+ number = pull.get('number')
+ url = pull.get('url')
+ author = _markdown_text(pull.get('author') or 'unknown')
+ reactions = _format_reactions(pull.get('reactions'))
+ lines.append(
+ f'| [#{number} — {title}]({url}) | @{author} | [{reactions}]({url}) | {pull["age_days"]} d | '
+ f'{pull["inactive_days"]} d | {_review_status(pull)} |'
+ )
+ lines.append(SORTABLE_TABLE)
+ return lines
+
+
+def render_repository_page(repository: str, cache: dict | None, now: datetime) -> str:
+ """Render one repository's Jekyll Markdown report."""
+ pulls = cache.get('pull_requests', []) if cache else []
+ metrics = calculate(pulls, now)
+ collected_text = _format_collected_at(cache.get('collected_at') if cache else None)
+ report_cutoff = _search_timestamp(_window_cutoff(now, REPORT_DAYS))
+ stale_cutoff = _search_timestamp(_window_cutoff(now, STALE_DAYS))
+ current_links = {
+ 'open': _metric_link(metrics['open'], repository, SEARCH_OPEN),
+ 'ready': _metric_link(metrics['ready'], repository, SEARCH_OPEN, SEARCH_READY),
+ 'draft': _metric_link(metrics['draft'], repository, SEARCH_OPEN, SEARCH_DRAFT),
+ 'not_reviewed': _metric_link(
+ metrics['not_reviewed'], repository, SEARCH_OPEN, SEARCH_READY, SEARCH_NO_REVIEW),
+ 'approval': str(metrics['awaiting_approval']),
+ 'changes': _metric_link(
+ metrics['changes_requested'], repository, SEARCH_OPEN, SEARCH_READY, 'review:changes_requested'),
+ 'stale': _metric_link(metrics['stale'], repository, SEARCH_OPEN, f'updated:<{stale_cutoff}'),
+ }
+ completed_links = {
+ 'opened': _metric_link(metrics['opened'], repository, f'created:>={report_cutoff}'),
+ 'merged': _metric_link(metrics['merged'], repository, SEARCH_MERGED, f'merged:>={report_cutoff}'),
+ 'closed': _metric_link(
+ metrics['closed_unmerged'], repository, 'is:closed', 'is:unmerged', f'closed:>={report_cutoff}'),
+ 'no_review': str(metrics['merged_without_review']),
+ 'no_approval': str(metrics['merged_without_approval']),
+ }
+ lines = [
+ '---',
+ f'title: "PR Metrics - {repository}"',
+ 'layout: page',
+ 'full-width: true',
+ 'js:',
+ ' - /assets/js/pr-metrics.js',
+ f'permalink: /pr-metrics/{repository}/',
+ '---',
+ '',
+ "[← All PR metrics]({{ '/pr-metrics/' | relative_url }})",
+ '',
+ f'Data collected: **{collected_text}**. Reporting window: **{REPORT_DAYS} days**.',
+ '',
+ ]
+ if not cache:
+ lines.extend([
+ '> PR metrics have not been collected for this repository yet.',
+ '',
+ ])
+
+ lines.extend([
+ '## Current backlog',
+ '',
+ '| Open | Ready | Draft | Not reviewed | Awaiting approval | Changes requested | Stale |',
+ '| ---: | ---: | ---: | ---: | ---: | ---: | ---: |',
+ f"| {current_links['open']} | {current_links['ready']} | {current_links['draft']} | "
+ f"{current_links['not_reviewed']} | {current_links['approval']} | {current_links['changes']} | "
+ f"{current_links['stale']} |",
+ SORTABLE_TABLE,
+ '',
+ f'`Stale` means no activity for at least {STALE_DAYS} days. `Not reviewed` follows GitHub\'s '
+ '`review:none` status; comment-only reviews can also be awaiting approval.',
+ '',
+ f'## Completed work — last {REPORT_DAYS} days',
+ '',
+ '| Opened | Merged | Closed unmerged | Merge rate | No review | No approval |',
+ '| ---: | ---: | ---: | ---: | ---: | ---: |',
+ f"| {completed_links['opened']} | {completed_links['merged']} | {completed_links['closed']} | "
+ f"{_format_percent(metrics['merge_rate'])} | {completed_links['no_review']} | "
+ f"{completed_links['no_approval']} |",
+ SORTABLE_TABLE,
+ '',
+ '| Lead time | Median | 75th percentile |',
+ '| --- | ---: | ---: |',
+ f"| First review | {_format_duration(metrics['median_first_review_hours'])} | "
+ f"{_format_duration(metrics['p75_first_review_hours'])} |",
+ f"| First approval | {_format_duration(metrics['median_first_approval_hours'])} | "
+ f"{_format_duration(metrics['p75_first_approval_hours'])} |",
+ f"| Merge | {_format_duration(metrics['median_merge_hours'])} | "
+ f"{_format_duration(metrics['p75_merge_hours'])} |",
+ SORTABLE_TABLE,
+ '',
+ f"Merged changes: **+{metrics['additions']:,} / -{metrics['deletions']:,} lines**.",
+ '',
+ '## Longest-pending ready PRs',
+ '',
+ ])
+
+ lines.extend(_pending_table(metrics['pending']))
+ lines.append('')
+ return '\n'.join(lines)
+
+
+def render_index_page(caches: dict[str, dict | None], now: datetime) -> str:
+ """Render the organization-wide Jekyll Markdown report."""
+ all_pulls = [
+ pull
+ for cache in caches.values()
+ for pull in (cache.get('pull_requests', []) if cache else [])
+ ]
+ totals = calculate(all_pulls, now)
+ report_cutoff = _search_timestamp(_window_cutoff(now, REPORT_DAYS))
+ stale_cutoff = _search_timestamp(_window_cutoff(now, STALE_DAYS))
+ overview_links = {
+ 'open': _metric_link(totals['open'], None, SEARCH_OPEN),
+ 'draft': _metric_link(totals['draft'], None, SEARCH_OPEN, SEARCH_DRAFT),
+ 'not_reviewed': _metric_link(
+ totals['not_reviewed'], None, SEARCH_OPEN, SEARCH_READY, SEARCH_NO_REVIEW),
+ 'approval': str(totals['awaiting_approval']),
+ 'stale': _metric_link(totals['stale'], None, SEARCH_OPEN, f'updated:<{stale_cutoff}'),
+ 'merged': _metric_link(totals['merged'], None, SEARCH_MERGED, f'merged:>={report_cutoff}'),
+ }
+ lines = [
+ '---',
+ 'title: "Pull Request Metrics"',
+ 'layout: page',
+ 'full-width: true',
+ 'js:',
+ ' - /assets/js/pr-metrics.js',
+ 'permalink: /pr-metrics/',
+ '---',
+ '',
+ f'Organization-wide PR health for active repositories. Reporting window: **{REPORT_DAYS} days**.',
+ '',
+ '## Overview',
+ '',
+ '| Open | Draft | Not reviewed | Awaiting approval | Stale | Merged | Median merge time |',
+ '| ---: | ---: | ---: | ---: | ---: | ---: | ---: |',
+ f"| {overview_links['open']} | {overview_links['draft']} | {overview_links['not_reviewed']} | "
+ f"{overview_links['approval']} | {overview_links['stale']} | {overview_links['merged']} | "
+ f"{_format_duration(totals['median_merge_hours'])} |",
+ SORTABLE_TABLE,
+ '',
+ '## Repositories',
+ '',
+ '| Repository | Open | Draft | Not reviewed | Awaiting approval | Stale | Merged | Median merge |',
+ '| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |',
+ ]
+ repository_rows = []
+ for repository, cache in caches.items():
+ metrics = calculate(cache.get('pull_requests', []) if cache else [], now)
+ repository_rows.append((repository, metrics, cache is not None))
+ repository_rows.sort(key=lambda row: (-row[1]['open'], row[0].lower()))
+
+ for repository, metrics, available in repository_rows:
+ availability = '' if available else ' ⚠️'
+ repo_links = {
+ 'open': _metric_link(metrics['open'], repository, SEARCH_OPEN),
+ 'draft': _metric_link(metrics['draft'], repository, SEARCH_OPEN, SEARCH_DRAFT),
+ 'not_reviewed': _metric_link(
+ metrics['not_reviewed'], repository, SEARCH_OPEN, SEARCH_READY, SEARCH_NO_REVIEW),
+ 'approval': str(metrics['awaiting_approval']),
+ 'stale': _metric_link(metrics['stale'], repository, SEARCH_OPEN, f'updated:<{stale_cutoff}'),
+ 'merged': _metric_link(
+ metrics['merged'], repository, SEARCH_MERGED, f'merged:>={report_cutoff}'),
+ }
+ lines.append(
+ f"| [{repository}]({{{{ '/pr-metrics/{repository}/' | relative_url }}}}){availability} | "
+ f"{repo_links['open']} | {repo_links['draft']} | {repo_links['not_reviewed']} | "
+ f"{repo_links['approval']} | {repo_links['stale']} | {repo_links['merged']} | "
+ f"{_format_duration(metrics['median_merge_hours'])} |"
+ )
+ lines.extend([
+ SORTABLE_TABLE,
+ '',
+ f'Generated **{now.strftime("%Y-%m-%d %H:%M UTC")}**. ⚠️ indicates unavailable repository data.',
+ '',
+ ])
+ return '\n'.join(lines)
+
+
+def write_report_pages(template_dir: str, caches: dict[str, dict | None], now: datetime) -> None:
+ """Write the organization index and all repository report pages."""
+ report_dir = Path(template_dir) / 'pr-metrics'
+ report_dir.mkdir(parents=True, exist_ok=True)
+ expected = {'index.md'}
+ for repository, cache in caches.items():
+ filename = f'{os.path.basename(repository)}.md'
+ expected.add(filename)
+ (report_dir / filename).write_text(
+ render_repository_page(repository, cache, now),
+ encoding='utf-8',
+ )
+ (report_dir / 'index.md').write_text(render_index_page(caches, now), encoding='utf-8')
+
+ for existing in report_dir.glob('*.md'):
+ if existing.name not in expected:
+ existing.unlink()
diff --git a/src/updater.py b/src/updater.py
index 61eab4e229..31d1c7f3a6 100644
--- a/src/updater.py
+++ b/src/updater.py
@@ -16,6 +16,7 @@
# local imports
from src import BASE_DIR
from src import helpers
+from src import pr_metrics
from src.logger import log
COMMIT_ACTIVITY_READY = 'ready'
@@ -743,6 +744,25 @@ def _collect_open_pulls(repo) -> list[dict]:
return pulls_data
+def _collect_pr_metrics(repos: list, headers: dict) -> None:
+ """Refresh cached pull-request metrics for active dashboard repositories."""
+ for repo in tqdm(
+ iterable=repos,
+ desc='Collecting GitHub PR metrics',
+ ):
+ _run_github_repo_step(
+ repo,
+ 'PR metrics',
+ lambda current_repo=repo: pr_metrics.refresh_repository(
+ current_repo,
+ BASE_DIR,
+ headers,
+ helpers.s,
+ ),
+ timeout=180,
+ )
+
+
def _is_pull_request_issue(issue) -> bool:
"""
Return whether a GitHub issue object represents a pull request.
@@ -981,6 +1001,8 @@ def update_github():
active_repos = [repo for repo in repos if not repo.archived]
_collect_commit_activity(active_repos, headers)
+ metric_repos = [repo for repo in active_repos if pr_metrics.is_active_repo(repo.raw_data)]
+ _collect_pr_metrics(metric_repos, headers)
for repo in tqdm(
iterable=active_repos,
diff --git a/tests/pr_metrics.test.js b/tests/pr_metrics.test.js
new file mode 100644
index 0000000000..65ae5a8b46
--- /dev/null
+++ b/tests/pr_metrics.test.js
@@ -0,0 +1,91 @@
+const {
+ describe,
+ test,
+ expect,
+ beforeEach,
+} = require('@jest/globals');
+
+function buildTable() {
+ document.body.innerHTML = `
+
+ | Repository | Count |
+
+ | repo10 | 10 |
+ | repo2 | 2 |
+ | unknown | — |
+
+
`;
+}
+
+describe('pr-metrics.js', () => {
+ let mod;
+
+ beforeEach(() => {
+ jest.resetModules();
+ buildTable();
+ mod = require('../gh-pages-template/assets/js/pr-metrics.js');
+ });
+
+ test('sortableValue parses empty, numeric, duration, reaction, and text values', () => {
+ const cell = textContent => ({ textContent });
+ expect(mod.sortableValue(cell(''))).toBeNull();
+ expect(mod.sortableValue(cell('—'))).toBeNull();
+ expect(mod.sortableValue(cell('1.5 h'))).toBe(1.5);
+ expect(mod.sortableValue(cell('2 d'))).toBe(48);
+ expect(mod.sortableValue(cell('1,234%'))).toBe(1234);
+ expect(mod.sortableValue(cell('5 — 👍 3 ❤️ 2'))).toBe(5);
+ expect(mod.sortableValue(cell('Repo 10'))).toBe('repo 10');
+ });
+
+ test('compareSortValues keeps missing values last and compares numbers and text', () => {
+ expect(mod.compareSortValues(null, null, 1)).toBe(0);
+ expect(mod.compareSortValues(null, 1, 1)).toBe(1);
+ expect(mod.compareSortValues(1, null, -1)).toBe(-1);
+ expect(mod.compareSortValues(1, 2, 1)).toBeLessThan(0);
+ expect(mod.compareSortValues(1, 2, -1)).toBeGreaterThan(0);
+ expect(mod.compareSortValues(1, '2', 1)).toBeLessThan(0);
+ expect(mod.compareSortValues('repo2', 'repo10', 1)).toBeLessThan(0);
+ });
+
+ test('sortTable orders rows and preserves stable ties', () => {
+ const table = document.querySelector('table');
+ mod.sortTable(table, 1, 1);
+ expect(Array.from(table.tBodies[0].rows, row => row.cells[0].textContent)).toEqual([
+ 'repo2', 'repo10', 'unknown',
+ ]);
+
+ table.tBodies[0].rows[0].cells[1].textContent = '10';
+ mod.sortTable(table, 1, -1);
+ expect(Array.from(table.tBodies[0].rows, row => row.cells[0].textContent)).toEqual([
+ 'repo2', 'repo10', 'unknown',
+ ]);
+ expect(() => mod.sortTable({ tBodies: [] }, 0, 1)).not.toThrow();
+ });
+
+ test('initializeSortableTables handles click and keyboard sorting once', () => {
+ mod.initializeSortableTables();
+ mod.initializeSortableTables();
+ const table = document.querySelector('table');
+ const headers = table.querySelectorAll('th');
+
+ expect(headers[0].getAttribute('role')).toBe('button');
+ expect(headers[0].querySelector('.pr-metrics-sort-indicator').textContent).toBe('↕');
+
+ headers[1].click();
+ expect(headers[1].getAttribute('aria-sort')).toBe('ascending');
+ expect(headers[1].textContent).toContain('↑');
+
+ headers[1].click();
+ expect(headers[1].getAttribute('aria-sort')).toBe('descending');
+ expect(headers[1].textContent).toContain('↓');
+
+ headers[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
+ headers[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }));
+ expect(headers[0].getAttribute('aria-sort')).toBe('ascending');
+ headers[0].dispatchEvent(new KeyboardEvent('keydown', { key: ' ' }));
+ expect(headers[0].getAttribute('aria-sort')).toBe('descending');
+
+ document.dispatchEvent(new Event('DOMContentLoaded'));
+ expect(table.dataset.sortableInitialized).toBe('true');
+ });
+});
diff --git a/tests/unit/test_builder.py b/tests/unit/test_builder.py
index 35b22b0176..4f27ced2fd 100644
--- a/tests/unit/test_builder.py
+++ b/tests/unit/test_builder.py
@@ -202,6 +202,12 @@ def test_build_end_to_end(monkeypatch, tmp_path):
_write_json(base / 'github' / 'starHistory' / 'demo.json', [{'date': '2026-01-01', 'stars': 4}])
_write_json(base / 'github' / 'codeScanning' / 'demo.json', {'open': 5})
_write_json(base / 'github' / 'codeScanningHistory' / 'demo.json', [{'date': '2026-01-04', 'open': 5}])
+ pr_metric_cache = {
+ 'repository': 'demo',
+ 'collected_at': '2026-01-05T00:00:00+00:00',
+ 'pull_requests': [],
+ }
+ _write_json(base / 'github' / 'prMetrics' / 'demo.json', pr_metric_cache)
_write_json(base / 'readthedocs' / 'projects.json', [
{'repository': {'url': 'https://github.com/LizardByte/demo.git'}}])
@@ -234,6 +240,11 @@ def now(cls, tz=None):
assert metadata['repo_count'] == 1
assert metadata['updated_at'] == fixed_now.isoformat()
+ metrics = json.loads((data_dir / 'pr_metrics.json').read_text(encoding='utf-8'))
+ assert metrics == {'demo': pr_metric_cache}
+ assert 'Pull Request Metrics' in (template / 'pr-metrics' / 'index.md').read_text(encoding='utf-8')
+ assert 'PR Metrics - demo' in (template / 'pr-metrics' / 'demo.md').read_text(encoding='utf-8')
+
def test_build_logs_error_when_repos_missing(monkeypatch, tmp_path):
monkeypatch.setattr(builder, 'BASE_DIR', str(tmp_path / 'gh-pages'))
diff --git a/tests/unit/test_pr_metrics.py b/tests/unit/test_pr_metrics.py
new file mode 100644
index 0000000000..f87af9693c
--- /dev/null
+++ b/tests/unit/test_pr_metrics.py
@@ -0,0 +1,450 @@
+# standard imports
+import json
+from datetime import datetime, timedelta, timezone
+from types import SimpleNamespace
+
+# lib imports
+import pytest
+
+# local imports
+from src import pr_metrics
+
+
+class FakeResponse:
+ def __init__(self, payload=None, status=200, text='error', error=None):
+ self.payload = payload if payload is not None else {}
+ self.status_code = status
+ self.text = text
+ self.error = error
+
+ def json(self):
+ if self.error:
+ raise self.error
+ return self.payload
+
+
+class FakeSession:
+ def __init__(self, responses):
+ self.responses = list(responses)
+ self.calls = []
+
+ def post(self, **kwargs):
+ self.calls.append(kwargs)
+ return self.responses.pop(0)
+
+
+def _node(number=1, state='OPEN', updated='2026-03-01T00:00:00Z', reviews=None, reactions=None):
+ reviews = reviews if reviews is not None else []
+ reactions = reactions if reactions is not None else []
+ approvals = [review for review in reviews if review.get('state') == 'APPROVED']
+ return {
+ 'number': number,
+ 'title': f'PR {number}',
+ 'url': f'https://github.com/LizardByte/demo/pull/{number}',
+ 'state': state,
+ 'isDraft': False,
+ 'createdAt': '2026-01-01T00:00:00Z',
+ 'updatedAt': updated,
+ 'closedAt': None,
+ 'mergedAt': None,
+ 'additions': 10,
+ 'deletions': 2,
+ 'changedFiles': 3,
+ 'reviewDecision': None,
+ 'author': {'login': 'author'},
+ 'reviews': {'totalCount': len(reviews), 'nodes': reviews},
+ 'approvals': {'nodes': approvals},
+ 'reactionGroups': reactions,
+ }
+
+
+def _pull(number, state='open', **overrides):
+ pull = {
+ 'repository': 'demo',
+ 'number': number,
+ 'title': f'PR {number}',
+ 'url': f'https://github.com/LizardByte/demo/pull/{number}',
+ 'state': state,
+ 'draft': False,
+ 'author': 'author',
+ 'created_at': '2026-01-01T00:00:00+00:00',
+ 'updated_at': '2026-03-01T00:00:00+00:00',
+ 'closed_at': None,
+ 'merged_at': None,
+ 'additions': 10,
+ 'deletions': 2,
+ 'changed_files': 3,
+ 'labels': [],
+ 'review_count': 0,
+ 'review_decision': None,
+ 'first_review_at': None,
+ 'first_approval_at': None,
+ 'reactions': [],
+ }
+ pull.update(overrides)
+ return pull
+
+
+def test_datetime_and_active_repo_helpers():
+ assert pr_metrics._parse_datetime(None) is None
+ assert pr_metrics._parse_datetime('2026-01-01T00:00:00').tzinfo == timezone.utc
+ parsed = pr_metrics._parse_datetime('2026-01-01T00:00:00Z')
+ assert pr_metrics._isoformat(parsed) == '2026-01-01T00:00:00+00:00'
+ assert pr_metrics._isoformat(None) is None
+ now = datetime(2026, 1, 2, 3, 4, 5, 6789, tzinfo=timezone.utc)
+ cutoff = pr_metrics._window_cutoff(now, 1)
+ assert cutoff == datetime(2026, 1, 1, 3, 4, 5, tzinfo=timezone.utc)
+ assert pr_metrics._search_timestamp(cutoff) == '2026-01-01T03:04:05Z'
+
+ assert pr_metrics.is_active_repo({'topics': None})
+ assert not pr_metrics.is_active_repo({'private': True})
+ assert not pr_metrics.is_active_repo({'archived': True})
+ assert not pr_metrics.is_active_repo({'topics': ['package-manager']})
+
+
+def test_cache_helpers(monkeypatch, tmp_path):
+ now = datetime(2026, 3, 20, tzinfo=timezone.utc)
+ assert pr_metrics.cache_path(str(tmp_path), 'x/demo').endswith(('prMetrics\\demo', 'prMetrics/demo'))
+ assert pr_metrics.load_cache(str(tmp_path), 'demo') is None
+ assert not pr_metrics.cache_is_fresh(None, now)
+
+ path = tmp_path / 'github' / 'prMetrics' / 'demo.json'
+ path.parent.mkdir(parents=True)
+ path.write_text('{bad', encoding='utf-8')
+ assert pr_metrics.load_cache(str(tmp_path), 'demo') is None
+ path.write_text('[]', encoding='utf-8')
+ assert pr_metrics.load_cache(str(tmp_path), 'demo') is None
+
+ cache = {
+ 'cache_version': pr_metrics.CACHE_VERSION,
+ 'history_days': pr_metrics.HISTORY_DAYS,
+ 'collected_at': (now - timedelta(hours=1)).isoformat(),
+ 'pull_requests': [],
+ }
+ path.write_text(json.dumps(cache), encoding='utf-8')
+ assert pr_metrics.load_cache(str(tmp_path), 'demo') == cache
+ assert pr_metrics.cache_is_fresh(cache, now)
+
+ assert not pr_metrics.cache_is_fresh({**cache, 'cache_version': 1}, now)
+ assert not pr_metrics.cache_is_fresh({**cache, 'history_days': 1}, now)
+ assert not pr_metrics.cache_is_fresh({**cache, 'collected_at': 'bad'}, now)
+ assert not pr_metrics.cache_is_fresh(
+ {**cache, 'collected_at': (now - pr_metrics.CACHE_MAX_AGE).isoformat()}, now)
+ assert not pr_metrics.cache_is_fresh(
+ {**cache, 'collected_at': (now + timedelta(seconds=1)).isoformat()}, now)
+
+
+def test_graphql_connection_success_and_errors():
+ connection = {'nodes': [], 'pageInfo': {'hasNextPage': False}}
+ session = FakeSession([FakeResponse({'data': {'repository': {'pullRequests': connection}}})])
+ assert pr_metrics._graphql_connection(session, {'A': 'b'}, {'name': 'demo'}) == connection
+ assert session.calls[0]['url'] == pr_metrics.GRAPHQL_URL
+
+ invalid_session = FakeSession([FakeResponse(error=ValueError('bad'))])
+ with pytest.raises(RuntimeError, match='Invalid'):
+ pr_metrics._graphql_connection(invalid_session, {}, {})
+
+ graphql_error_session = FakeSession([FakeResponse({'errors': [{'message': 'bad'}]})])
+ with pytest.raises(RuntimeError, match='failed'):
+ pr_metrics._graphql_connection(graphql_error_session, {}, {})
+
+ http_error_session = FakeSession([FakeResponse({'message': 'bad'}, status=500)])
+ with pytest.raises(RuntimeError, match='failed'):
+ pr_metrics._graphql_connection(http_error_session, {}, {})
+
+ missing_repo_session = FakeSession([FakeResponse({'data': {'repository': None}})])
+ with pytest.raises(RuntimeError, match='did not include'):
+ pr_metrics._graphql_connection(missing_repo_session, {}, {})
+
+
+def test_normalize_pull_handles_reviews_and_missing_optional_data():
+ reviews = [
+ {'state': 'COMMENTED', 'submittedAt': '2026-01-03T00:00:00Z', 'author': {'login': 'r1'}},
+ {'state': 'APPROVED', 'submittedAt': '2026-01-02T00:00:00Z', 'author': {'login': 'r2'}},
+ {'state': 'PENDING', 'submittedAt': None, 'author': None},
+ ]
+ node = _node(reviews=reviews, reactions=[
+ {'content': 'THUMBS_UP', 'reactors': {'totalCount': 2}},
+ {'content': 'HEART', 'reactors': {'totalCount': 0}},
+ {'reactors': {'totalCount': 1}},
+ ])
+ normalized = pr_metrics._normalize_pull('demo', node)
+ assert normalized['author'] == 'author'
+ assert normalized['review_count'] == 3
+ assert normalized['first_review_at'] == '2026-01-02T00:00:00+00:00'
+ assert normalized['first_approval_at'] == '2026-01-02T00:00:00+00:00'
+ assert normalized['reactions'] == [{'content': 'THUMBS_UP', 'count': 2}]
+
+ minimal = _node(number=2, reviews=[])
+ minimal.update({
+ 'title': None,
+ 'url': None,
+ 'state': None,
+ 'isDraft': 1,
+ 'additions': None,
+ 'deletions': None,
+ 'changedFiles': None,
+ 'author': None,
+ 'reviews': None,
+ 'approvals': None,
+ 'reactionGroups': None,
+ })
+ normalized_minimal = pr_metrics._normalize_pull('demo', minimal)
+ assert normalized_minimal['title'] == ''
+ assert normalized_minimal['state'] == ''
+ assert normalized_minimal['draft'] is True
+ assert normalized_minimal['author'] is None
+ assert normalized_minimal['review_count'] == 0
+
+
+def test_fetch_connection_paginates_and_stops_at_cutoff(monkeypatch):
+ repo = SimpleNamespace(name='demo', owner=SimpleNamespace(login='LizardByte'))
+ pages = [
+ {
+ 'nodes': [_node(1, updated='2026-03-01T00:00:00Z')],
+ 'pageInfo': {'hasNextPage': True, 'endCursor': 'next'},
+ },
+ {
+ 'nodes': [
+ _node(2, updated=None),
+ _node(3, updated='2025-01-01T00:00:00Z'),
+ _node(4, updated='2026-02-01T00:00:00Z'),
+ ],
+ 'pageInfo': {'hasNextPage': True, 'endCursor': 'unused'},
+ },
+ ]
+ variables = []
+
+ def fake_connection(session, headers, current_variables):
+ variables.append(current_variables)
+ return pages.pop(0)
+
+ monkeypatch.setattr(pr_metrics, '_graphql_connection', fake_connection)
+ pulls = pr_metrics._fetch_connection(
+ repo, {}, object(), ['CLOSED'], datetime(2026, 1, 1, tzinfo=timezone.utc))
+
+ assert [pull['number'] for pull in pulls] == [1, 2]
+ assert variables[0]['cursor'] is None
+ assert variables[1]['cursor'] == 'next'
+
+
+def test_fetch_connection_requires_cursor(monkeypatch):
+ repo = SimpleNamespace(name='demo', owner=SimpleNamespace(login='LizardByte'))
+ monkeypatch.setattr(
+ pr_metrics,
+ '_graphql_connection',
+ lambda *args: {'nodes': [], 'pageInfo': {'hasNextPage': True}},
+ )
+ with pytest.raises(RuntimeError, match='end cursor'):
+ pr_metrics._fetch_connection(repo, {}, object(), ['OPEN'])
+
+
+def test_fetch_repository_combines_deduplicates_and_sorts(monkeypatch):
+ now = datetime(2026, 3, 20, tzinfo=timezone.utc)
+ repo = SimpleNamespace(name='demo', owner=SimpleNamespace(login='LizardByte'))
+ calls = []
+
+ def fake_fetch(repository, headers, session, states, cutoff=None):
+ calls.append((states, cutoff))
+ if states == ['OPEN']:
+ return [_pull(1, updated_at='2026-03-01T00:00:00+00:00')]
+ return [
+ _pull(1, state='merged', updated_at='2026-02-01T00:00:00+00:00'),
+ _pull(2, state='merged', updated_at='2026-03-10T00:00:00+00:00'),
+ ]
+
+ monkeypatch.setattr(pr_metrics, '_fetch_connection', fake_fetch)
+ pulls = pr_metrics.fetch_repository(repo, {}, object(), now)
+
+ assert [pull['number'] for pull in pulls] == [2, 1]
+ assert calls[0] == (['OPEN'], None)
+ assert calls[1][0] == ['CLOSED', 'MERGED']
+ assert calls[1][1] == now - timedelta(days=pr_metrics.HISTORY_DAYS)
+
+
+def test_refresh_repository_uses_fresh_cache_and_writes_stale_cache(monkeypatch, tmp_path):
+ now = datetime(2026, 3, 20, tzinfo=timezone.utc)
+ repo = SimpleNamespace(name='demo', owner=SimpleNamespace(login='LizardByte'))
+ fresh = {
+ 'repository': 'demo',
+ 'collected_at': (now - timedelta(hours=1)).isoformat(),
+ 'cache_version': pr_metrics.CACHE_VERSION,
+ 'history_days': pr_metrics.HISTORY_DAYS,
+ 'pull_requests': [],
+ }
+ path = tmp_path / 'github' / 'prMetrics' / 'demo.json'
+ path.parent.mkdir(parents=True)
+ path.write_text(json.dumps(fresh), encoding='utf-8')
+ monkeypatch.setattr(pr_metrics, 'fetch_repository', lambda *args: pytest.fail('unexpected fetch'))
+ assert not pr_metrics.refresh_repository(repo, str(tmp_path), {}, object(), now)
+
+ path.write_text(json.dumps({**fresh, 'collected_at': '2026-01-01T00:00:00+00:00'}), encoding='utf-8')
+ monkeypatch.setattr(pr_metrics, 'fetch_repository', lambda *args: [_pull(1)])
+ assert pr_metrics.refresh_repository(repo, str(tmp_path), {}, object(), now)
+ written = json.loads(path.read_text(encoding='utf-8'))
+ assert written['pull_requests'][0]['number'] == 1
+ assert written['collected_at'] == now.isoformat()
+ assert written['cache_version'] == pr_metrics.CACHE_VERSION
+
+ path.unlink()
+ assert pr_metrics.refresh_repository(repo, str(tmp_path), {}, object())
+
+
+def test_calculation_and_formatting_helpers():
+ now = datetime(2026, 3, 20, tzinfo=timezone.utc)
+ pulls = [
+ _pull(1, draft=True),
+ _pull(2, updated_at='2026-01-01T00:00:00+00:00'),
+ _pull(3, review_count=1),
+ _pull(4, review_count=1, review_decision='CHANGES_REQUESTED'),
+ _pull(5, review_count=1, review_decision='APPROVED', first_approval_at='2026-01-02T00:00:00+00:00'),
+ _pull(6, created_at=None, updated_at=None),
+ _pull(
+ 7,
+ state='merged',
+ created_at='2026-02-01T00:00:00+00:00',
+ updated_at='2026-02-04T00:00:00+00:00',
+ merged_at='2026-02-04T00:00:00+00:00',
+ closed_at='2026-02-04T00:00:00+00:00',
+ review_count=1,
+ first_review_at='2026-02-02T00:00:00+00:00',
+ first_approval_at='2026-02-03T00:00:00+00:00',
+ ),
+ _pull(
+ 8,
+ state='merged',
+ created_at='2026-02-10T00:00:00+00:00',
+ updated_at='2026-02-12T00:00:00+00:00',
+ merged_at='2026-02-12T00:00:00+00:00',
+ closed_at='2026-02-12T00:00:00+00:00',
+ ),
+ _pull(9, state='closed', closed_at='2026-02-15T00:00:00+00:00'),
+ _pull(
+ 10,
+ state='merged',
+ created_at='2025-01-01T00:00:00+00:00',
+ updated_at='2025-01-02T00:00:00+00:00',
+ merged_at='2025-01-02T00:00:00+00:00',
+ ),
+ ]
+ metrics = pr_metrics.calculate(pulls, now)
+
+ assert metrics['open'] == 6
+ assert metrics['draft'] == 1
+ assert metrics['not_reviewed'] == 3
+ assert metrics['awaiting_approval'] == 1
+ assert metrics['changes_requested'] == 1
+ assert metrics['stale'] == 1
+ assert metrics['opened'] == 8
+ assert metrics['merged'] == 2
+ assert metrics['closed_unmerged'] == 1
+ assert metrics['merge_rate'] == pytest.approx(200 / 3)
+ assert metrics['median_merge_hours'] == pytest.approx(60)
+ assert metrics['p75_merge_hours'] == pytest.approx(66)
+ assert metrics['merged_without_review'] == 1
+ assert metrics['merged_without_approval'] == 1
+ assert metrics['additions'] == 20
+ assert metrics['pending'][-1]['age_days'] == 0
+ assert pr_metrics._is_awaiting_approval(_pull(11, review_decision='REVIEW_REQUIRED'))
+
+ reopened = _pull(12, closed_at='2026-03-01T00:00:00+00:00')
+ assert pr_metrics.calculate([reopened], now)['closed_unmerged'] == 0
+
+ empty = pr_metrics.calculate([], now)
+ assert empty['merge_rate'] is None
+ assert empty['median_merge_hours'] is None
+ assert pr_metrics._hours_between(None, None) is None
+ assert pr_metrics._percentile([], 0.75) is None
+ assert pr_metrics._percentile([4], 0.75) == 4
+ assert pr_metrics._format_duration(None) == '—'
+ assert pr_metrics._format_duration(12) == '12.0 h'
+ assert pr_metrics._format_duration(48) == '2.0 d'
+ assert pr_metrics._format_percent(None) == '—'
+ assert pr_metrics._format_percent(50) == '50.0%'
+ assert pr_metrics._markdown_text('a\\[b]|c\nd') == r'a\\\[b\]\|c d'
+ assert pr_metrics._markdown_text(None) == ''
+ org_search = pr_metrics._github_search(None, ['is:open', 'review:none'])
+ assert 'org%3ALizardByte' in org_search
+ assert 'review%3Anone' in org_search
+ repo_search = pr_metrics._metric_link(2, 'demo', 'is:merged')
+ assert repo_search.startswith('[2](https://github.com/pulls?q=')
+ assert 'repo%3ALizardByte%2Fdemo' in repo_search
+ assert pr_metrics._format_reactions(None) == '0'
+ assert pr_metrics._format_reactions([
+ {'content': 'CUSTOM', 'count': 1},
+ {'content': 'HEART', 'count': 2},
+ {'content': 'THUMBS_UP', 'count': 3},
+ {'content': 'LAUGH', 'count': 0},
+ ]) == '6 — 👍 3 ❤️ 2 CUSTOM 1'
+
+
+def test_render_repository_page_covers_pending_statuses():
+ now = datetime(2026, 3, 20, tzinfo=timezone.utc)
+ cache = {
+ 'collected_at': '2026-03-20T00:00:00+00:00',
+ 'pull_requests': [
+ _pull(1, title='Needs | review', reactions=[
+ {'content': 'THUMBS_UP', 'count': 2},
+ {'content': 'HEART', 'count': 1},
+ ]),
+ _pull(2, review_count=1, review_decision='CHANGES_REQUESTED'),
+ _pull(3, review_count=1, review_decision='APPROVED'),
+ _pull(4, review_count=1),
+ _pull(5, review_count=1, first_approval_at='2026-03-01T00:00:00+00:00'),
+ ],
+ }
+ page = pr_metrics.render_repository_page('demo', cache, now)
+ assert 'PR Metrics - demo' in page
+ assert 'Needs \\| review' in page
+ assert 'Not reviewed' in page
+ assert 'Changes requested' in page
+ assert 'Approved' in page
+ assert 'Awaiting approval' in page
+ assert '[3 — 👍 2 ❤️ 1](https://github.com/LizardByte/demo/pull/1)' in page
+ assert "{{ '/pr-metrics/' | relative_url }}" in page
+ assert '{{ site.baseurl }}' not in page
+ assert 'pr-metrics-sortable' in page
+ assert ' - /assets/js/pr-metrics.js' in page
+ assert 'repo%3ALizardByte%2Fdemo' in page
+ assert 'created%3A%3E%3D2025-12-20T00%3A00%3A00Z' in page
+ assert '-review%3Anone' not in page
+ assert '-review%3Aapproved' not in page
+
+ unavailable = pr_metrics.render_repository_page('empty', None, now)
+ assert 'have not been collected' in unavailable
+ assert 'No ready pull requests' in unavailable
+
+ invalid_timestamp = pr_metrics.render_repository_page(
+ 'invalid', {'collected_at': 'bad', 'pull_requests': []}, now)
+ assert 'Data collected: **unavailable**' in invalid_timestamp
+
+
+def test_render_index_and_write_report_pages(tmp_path):
+ now = datetime(2026, 3, 20, tzinfo=timezone.utc)
+ caches = {
+ 'z-empty': None,
+ 'demo': {
+ 'collected_at': now.isoformat(),
+ 'pull_requests': [_pull(1)],
+ },
+ }
+ index = pr_metrics.render_index_page(caches, now)
+ assert 'Pull Request Metrics' in index
+ assert index.index('[demo]') < index.index('[z-empty]')
+ assert '⚠️' in index
+ assert "{{ '/pr-metrics/demo/' | relative_url }}" in index
+ assert '{{ site.baseurl }}' not in index
+ assert 'org%3ALizardByte' in index
+ assert 'pr-metrics-sortable' in index
+
+ report_dir = tmp_path / 'pr-metrics'
+ report_dir.mkdir()
+ (report_dir / 'stale.md').write_text('old', encoding='utf-8')
+ (report_dir / 'keep.txt').write_text('keep', encoding='utf-8')
+ pr_metrics.write_report_pages(str(tmp_path), caches, now)
+
+ assert (report_dir / 'index.md').exists()
+ assert (report_dir / 'demo.md').exists()
+ assert (report_dir / 'z-empty.md').exists()
+ assert not (report_dir / 'stale.md').exists()
+ assert (report_dir / 'keep.txt').exists()
diff --git a/tests/unit/test_updater.py b/tests/unit/test_updater.py
index 866c3a8bed..d4520d4d90 100644
--- a/tests/unit/test_updater.py
+++ b/tests/unit/test_updater.py
@@ -471,6 +471,24 @@ def fake_fetch(repo, headers, sha=None):
assert warnings == []
+def test_collect_pr_metrics(monkeypatch):
+ repos = [FakeRepo('one'), FakeRepo('two')]
+ calls = []
+
+ monkeypatch.setattr(
+ updater.pr_metrics,
+ 'refresh_repository',
+ lambda repo, base_dir, headers, session: calls.append((repo.name, base_dir, headers, session)),
+ )
+
+ updater._collect_pr_metrics(repos, {'Authorization': 'token'})
+
+ assert calls == [
+ ('one', updater.BASE_DIR, {'Authorization': 'token'}, updater.helpers.s),
+ ('two', updater.BASE_DIR, {'Authorization': 'token'}, updater.helpers.s),
+ ]
+
+
def test_seed_star_history(monkeypatch):
repo = FakeRepo(stars=250)
history = updater._seed_star_history(repo, total=250, initial_samples=5)
@@ -629,6 +647,12 @@ def get_user(self, name):
'_collect_commit_activity',
lambda repos, headers: commit_repos.extend(repo.name for repo in repos),
)
+ metric_repos = []
+ monkeypatch.setattr(
+ updater,
+ '_collect_pr_metrics',
+ lambda repos, headers: metric_repos.extend(repo.name for repo in repos),
+ )
processed = []
monkeypatch.setattr(updater, '_process_github_repo', lambda repo, headers, graphql_url: processed.append(repo.name))
monkeypatch.setattr(updater, 'BASE_DIR', 'base')
@@ -637,6 +661,7 @@ def get_user(self, name):
assert any(path.endswith(('github\\repos', 'github/repos')) for path, _ in writes)
assert commit_repos == ['active', 'pending']
+ assert metric_repos == ['active', 'pending']
assert processed == ['active', 'pending']