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 @@ + @@ -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 = ` + + + + + + + +
RepositoryCount
repo1010
repo22
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']