#! /usr/bin/env python #coding=utf8 import json import os import re import sys import time from datetime import date, datetime, timedelta from io import StringIO from urllib.error import HTTPError, URLError from urllib.parse import quote, urlencode, urlparse from urllib.request import Request, urlopen import ruamel.yaml from alerts import AlertDeliveryError, send_alert from atomic_io import atomic_write_many_text from history_store import ( append_snapshot, field_on_or_before, load_history, serialize_history, ) sys.stdout.reconfigure(encoding='utf-8') GH_CREDENTIALS_PATH = '/home/slisakov/gh_credentials' GITLAB_CREDENTIALS_PATH = '/home/slisakov/gitlab_credentials' STARS_DIFF_DAYS = 30 STAR_DROP_WARNING_THRESHOLD = 20 FETCH_ATTEMPTS = 3 RETRYABLE_HTTP_CODES = {429, 500, 502, 503, 504} MAX_API_RESPONSE_BYTES = 5 * 1024 * 1024 yaml = ruamel.yaml.YAML() yaml.indent(mapping=4, sequence=4, offset=2) yaml.preserve_quotes = True def sources_list(data_item): sources = data_item.get('source') if not isinstance(sources, list): sources = [sources] return sources def github_repo_api_url(source): parsed = urlparse(str(source)) if parsed.netloc not in ('github.com', 'www.github.com'): return None parts = [part for part in parsed.path.split('/') if part] if len(parts) < 2: return None owner = parts[0] repo = re.sub(r'\.git$', '', parts[1]) return 'https://api.github.com/repos/{}/{}'.format(owner, repo) def gitlab_repo_api_url(source): parsed = urlparse(str(source)) if parsed.netloc not in ('gitlab.com', 'www.gitlab.com'): return None parts = [part for part in parsed.path.split('/') if part] if '-' in parts: parts = parts[:parts.index('-')] if len(parts) < 2: return None parts[-1] = re.sub(r'\.git$', '', parts[-1]) project_path = '/'.join(parts) return '{}://{}/api/v4/projects/{}'.format( parsed.scheme or 'https', parsed.netloc, quote(project_path, safe=''), ) def repo_sources(data_item): sources = [] seen = set() for source in sources_list(data_item): github_api_url = github_repo_api_url(source) if github_api_url: key = ('github', github_api_url) if key in seen: raise RuntimeError('duplicate repository source: {}'.format(source)) sources.append({'provider': 'github', 'api_url': github_api_url, 'url': str(source)}) seen.add(key) continue gitlab_api_url = gitlab_repo_api_url(source) if gitlab_api_url: key = ('gitlab', gitlab_api_url) if key in seen: raise RuntimeError('duplicate repository source: {}'.format(source)) sources.append({'provider': 'gitlab', 'api_url': gitlab_api_url, 'url': str(source)}) seen.add(key) continue parsed = urlparse(str(source)) if parsed.netloc.lower() in ( 'github.com', 'www.github.com', 'gitlab.com', 'www.gitlab.com', ): raise RuntimeError('malformed repository source URL: {}'.format(source)) return sources def token_from_file(path): if not os.path.exists(path): return None with open(path, 'r', encoding='utf-8') as f: for line in f: parts = line.split() if len(parts) >= 2: return parts[1] if len(parts) == 1: return parts[0] return None def retry_delay(headers, attempt): retry_after = headers.get('Retry-After') if headers else None if retry_after and str(retry_after).isdigit(): return min(max(int(retry_after), 1), 60) return 2 ** (attempt - 1) def fetch_json(url, headers, on_anomaly=None): for attempt in range(1, FETCH_ATTEMPTS + 1): request = Request(url, headers=headers) try: with urlopen(request, timeout=60) as response: raw_body = response.read(MAX_API_RESPONSE_BYTES + 1) if len(raw_body) > MAX_API_RESPONSE_BYTES: raise RuntimeError( 'API response for {} exceeds {} bytes'.format( url, MAX_API_RESPONSE_BYTES, ) ) body = raw_body.decode('utf-8') final_url = response.geturl() try: return json.loads(body), response.headers, final_url except json.JSONDecodeError as error: raise RuntimeError('API returned invalid JSON for {}: {}'.format(url, error)) except HTTPError as error: body = error.read(2048).decode('utf-8', errors='replace') lower_body = body.lower() if ( (error.headers or {}).get('cf-mitigated', '').lower() == 'challenge' or 'challenge.cloudflare.com' in lower_body or 'just a moment' in lower_body ): detail = 'Cloudflare challenge page' elif '<html' in lower_body: detail = 'HTML error page' else: detail = body.replace('\n', ' ')[:500] message = 'API returned {} for {}: {}'.format( error.code, url, detail, ) if error.code not in RETRYABLE_HTTP_CODES or attempt == FETCH_ATTEMPTS: raise RuntimeError(message) error_headers = error.headers except (URLError, TimeoutError) as error: message = 'Could not fetch {}: {}'.format( url, getattr(error, 'reason', error), ) if attempt == FETCH_ATTEMPTS: raise RuntimeError(message) error_headers = getattr(error, 'headers', None) retry_message = 'Temporary API error; retrying {}/{}: {}'.format( attempt + 1, FETCH_ATTEMPTS, message, ) print(retry_message, file=sys.stderr) if on_anomaly: on_anomaly('API request required a retry', retry_message) time.sleep(retry_delay(error_headers, attempt)) def require_api_fields(api_data, fields, url): if not isinstance(api_data, dict): raise RuntimeError('API returned an unexpected response for {}'.format(url)) missing = [field for field in fields if field not in api_data] if missing: raise RuntimeError( 'API response for {} is missing required fields: {}'.format( url, ', '.join(missing), ) ) def require_nonnegative_int(api_data, field, url): value = api_data.get(field) if isinstance(value, bool) or not isinstance(value, int) or value < 0: raise RuntimeError( 'API field {} for {} must be a non-negative integer, got {!r}'.format( field, url, value, ) ) return value def require_url(value, field, url): parsed = urlparse(str(value)) if parsed.scheme != 'https' or not parsed.netloc: raise RuntimeError( 'API field {} for {} is not a valid HTTPS URL: {!r}'.format( field, url, value, ) ) return str(value) def replace_source_url(item, old_url, new_url): sources = item.get('source') if isinstance(sources, list): for index, source in enumerate(sources): if str(source) == old_url: sources[index] = new_url return True return False if str(sources) == old_url: item['source'] = new_url return True return False def github_headers(token): headers = { 'Accept': 'application/vnd.github+json', 'User-Agent': 'open-source-comments-updater', } if token: headers['Authorization'] = 'token {}'.format(token) return headers def gitlab_headers(token): headers = { 'Accept': 'application/json', 'User-Agent': 'open-source-comments-updater', } if token: headers['PRIVATE-TOKEN'] = token return headers def api_date(value): if not value: return 'undefined' raw_date = str(value)[:10] try: datetime.strptime(raw_date, '%Y-%m-%d') except ValueError: raise RuntimeError('API returned an invalid date: {!r}'.format(value)) return raw_date.replace('-', '‑') def github_license(api_data, item): license_data = api_data.get('license') if not license_data: return item.get('license') or 'undefined' return license_data.get('spdx_id') or license_data.get('name') or 'undefined' def gitlab_license(api_data, item): license_data = api_data.get('license') if isinstance(license_data, dict): return license_data.get('key') or license_data.get('nickname') or license_data.get('name') or 'undefined' return item.get('license') or 'undefined' def github_commit_date(api_url, default_branch, token, on_anomaly=None): branch = quote(default_branch or 'master', safe='') commit_url = '{}/commits/{}'.format(api_url, branch) commit_data, _headers, _final_url = fetch_json( commit_url, github_headers(token), on_anomaly=on_anomaly, ) commit_date = commit_data.get('commit', {}).get('committer', {}).get('date') if not commit_date: raise RuntimeError('API response for {} has no commit date'.format(commit_url)) return api_date(commit_date) def gitlab_commit_date(api_url, default_branch, token, on_anomaly=None): branch = default_branch or 'master' branch_url = '{}/repository/branches/{}'.format(api_url, quote(branch, safe='')) branch_data, _headers, _final_url = fetch_json( branch_url, gitlab_headers(token), on_anomaly=on_anomaly, ) if not isinstance(branch_data, dict) or branch_data.get('name') != branch: raise RuntimeError('API returned an unexpected branch for {}'.format(branch_url)) commit_data = branch_data.get('commit') if not isinstance(commit_data, dict): raise RuntimeError('API response for {} has no branch commit'.format(branch_url)) commit_date = commit_data.get('committed_date') or commit_data.get('created_at') if not commit_date: raise RuntimeError('API response for {} has no commit date'.format(branch_url)) return api_date(commit_date) def gitlab_open_issue_count(api_url, token, on_anomaly=None): url = '{}/issues_statistics?scope=all'.format(api_url) data, _headers, _final_url = fetch_json( url, gitlab_headers(token), on_anomaly=on_anomaly, ) value = data.get('statistics', {}).get('counts', {}).get('opened') if isinstance(value, bool) or not isinstance(value, int) or value < 0: raise RuntimeError( 'API response for {} has invalid opened issue count: {!r}'.format(url, value) ) return value def gitlab_open_merge_request_count(api_url, token, on_anomaly=None): params = urlencode({'state': 'opened', 'per_page': 1}) url = '{}/merge_requests?{}'.format(api_url, params) _data, headers, _final_url = fetch_json( url, gitlab_headers(token), on_anomaly=on_anomaly, ) total = headers.get('X-Total') if total is None or not str(total).isdigit(): raise RuntimeError('API response for {} has invalid X-Total header: {!r}'.format(url, total)) return int(total) def fetch_github_repo(source, token, item, on_anomaly=None): api_data, _headers, final_url = fetch_json( source['api_url'], github_headers(token), on_anomaly=on_anomaly, ) require_api_fields( api_data, ('stargazers_count', 'created_at', 'open_issues_count', 'html_url'), source['api_url'], ) stars = require_nonnegative_int(api_data, 'stargazers_count', source['api_url']) open_issues = require_nonnegative_int(api_data, 'open_issues_count', source['api_url']) canonical_url = require_url(api_data['html_url'], 'html_url', source['api_url']) canonical_api_url = github_repo_api_url(canonical_url) if not canonical_api_url: raise RuntimeError( 'GitHub returned an invalid canonical repository URL for {}: {}'.format( source['api_url'], canonical_url, ) ) api_url = source['api_url'] if ( final_url.rstrip('/') != source['api_url'].rstrip('/') or canonical_api_url.rstrip('/') != source['api_url'].rstrip('/') ): api_url = canonical_api_url if replace_source_url(item, source['url'], canonical_url): move_notice = 'Repository moved: {} -> {}'.format(source['url'], canonical_url) print(move_notice) if on_anomaly: on_anomaly('Repository moved permanently', move_notice) default_branch = api_data.get('default_branch') or 'master' if not isinstance(default_branch, str): raise RuntimeError('GitHub returned an invalid default branch for {}'.format(api_url)) last_commit = github_commit_date(api_url, default_branch, token, on_anomaly=on_anomaly) return { 'provider': 'github', 'stars': stars, 'created': api_date(api_data.get('created_at')), 'open_issues': open_issues, 'license': github_license(api_data, item), 'last_commit': last_commit, } def fetch_gitlab_repo(source, token, item, on_anomaly=None): api_data, _headers, _final_url = fetch_json( source['api_url'], gitlab_headers(token), on_anomaly=on_anomaly, ) require_api_fields( api_data, ('star_count', 'created_at'), source['api_url'], ) stars = require_nonnegative_int(api_data, 'star_count', source['api_url']) default_branch = api_data.get('default_branch') or 'master' if not isinstance(default_branch, str): raise RuntimeError('GitLab returned an invalid default branch for {}'.format(source['api_url'])) open_issues = gitlab_open_issue_count(source['api_url'], token, on_anomaly=on_anomaly) open_issues += gitlab_open_merge_request_count( source['api_url'], token, on_anomaly=on_anomaly, ) last_commit = gitlab_commit_date( source['api_url'], default_branch, token, on_anomaly=on_anomaly, ) return { 'provider': 'gitlab', 'stars': stars, 'created': api_date(api_data.get('created_at')), 'open_issues': open_issues, 'license': gitlab_license(api_data, item), 'last_commit': last_commit, } def fetch_repo_stats(source, tokens, item, on_anomaly=None): if source['provider'] == 'github': return fetch_github_repo( source, tokens.get('github'), item, on_anomaly=on_anomaly, ) if source['provider'] == 'gitlab': return fetch_gitlab_repo( source, tokens.get('gitlab'), item, on_anomaly=on_anomaly, ) raise RuntimeError('Unsupported repo provider: {}'.format(source['provider'])) def primary_repo_stats(stats): return stats[0] def display_star_stats(stats): winner = max(stats, key=lambda repo: repo['stars']) other_repos = [repo for repo in stats if repo is not winner and repo['stars']] other_stars = sum(repo['stars'] for repo in other_repos) other_providers = [repo['provider'] for repo in other_repos] stars_total = sum(repo['stars'] for repo in stats) provider_stars = {} for repo in stats: provider = repo['provider'] provider_stars[provider] = provider_stars.get(provider, 0) + repo['stars'] return winner['stars'], other_stars, winner['provider'], other_providers, stars_total, provider_stars def old_provider_stars(history, name, provider, comparison_date, current_stars, provider_count): provider_field = 'stars_{}'.format(provider) old_stars = field_on_or_before(history, name, provider_field, comparison_date) if old_stars is not None: return int(old_stars) old_display_stars = field_on_or_before(history, name, 'stars', comparison_date) if old_display_stars is not None and (provider == 'github' or provider_count == 1): return int(old_display_stars) provider_events = history['projects'].get(name, {}).get(provider_field, []) if provider_events: return int(provider_events[0][1]) return current_stars def stars_diff(history, name, comparison_date, provider_stars): diff = 0 for provider, current_stars in provider_stars.items(): old_stars = old_provider_stars( history, name, provider, comparison_date, current_stars, len(provider_stars), ) if old_stars is None: return '?' if old_stars == current_stars: continue if old_stars > 0: diff += current_stars - old_stars continue if current_stars == 0: continue return '?' return diff class RepositoryEvents: def __init__(self, name): self.name = name self.messages = [] self.emitted = set() def callback(self, source_url): def notify(kind, detail): self.notify(kind, detail, source_url) return notify def notify(self, kind, detail, source_url): message = '{}: {}'.format(kind, detail) if message not in self.messages: self.messages.append(message[:1000]) if kind in self.emitted: return self.emitted.add(kind) try: send_alert( 'open-source-comments repository warning: {}'.format(self.name), 'Repository: {}\nSource: {}\nEvent: {}\n\n{}'.format( self.name, source_url, kind, detail, ), ) except AlertDeliveryError as error: delivery_message = 'Alert delivery failed: {}'.format(error) if delivery_message not in self.messages: self.messages.append(delivery_message[:1000]) print('ERROR: {}'.format(delivery_message), file=sys.stderr) def tooltip(self): return ' | '.join(self.messages)[:3000] def set_update_warning(item, events, snapshot_date): if events.messages: item['update_warning'] = events.tooltip() item['update_warning_at'] = snapshot_date else: item.pop('update_warning', None) item.pop('update_warning_at', None) def comparable_date(value): return str(value).replace('‑', '-') if value else None def check_repository_changes(history, name, snapshot_date, primary_stats, provider_stars, notify): safe_to_update = True old_created = field_on_or_before(history, name, 'created', snapshot_date) if ( old_created not in (None, 'undefined') and primary_stats['created'] not in (None, 'undefined') and old_created != primary_stats['created'] ): message = 'creation date changed from {} to {}'.format( old_created, primary_stats['created'], ) notify('Repository identity changed', message) safe_to_update = False old_license = field_on_or_before(history, name, 'license', snapshot_date) if ( old_license not in (None, 'undefined', 'NOASSERTION') and primary_stats['license'] not in (None, 'undefined', 'NOASSERTION') and old_license != primary_stats['license'] ): notify( 'Repository license changed', 'license changed from {} to {}'.format(old_license, primary_stats['license']), ) old_commit = field_on_or_before(history, name, 'last_commit', snapshot_date) if ( old_commit and primary_stats['last_commit'] and comparable_date(primary_stats['last_commit']) < comparable_date(old_commit) ): notify( 'Latest commit date moved backwards', 'latest commit changed from {} to {}'.format( old_commit, primary_stats['last_commit'], ), ) for provider, current_stars in provider_stars.items(): provider_field = 'stars_{}'.format(provider) old_stars = field_on_or_before(history, name, provider_field, snapshot_date) if old_stars is None and provider == 'github': old_stars = field_on_or_before(history, name, 'stars', snapshot_date) if old_stars is None: continue old_stars = int(old_stars) if old_stars - current_stars >= STAR_DROP_WARNING_THRESHOLD: notify( '{} star count decreased'.format(provider), '{} stars decreased from {} to {}'.format( provider, old_stars, current_stars, ), ) elif ( old_stars > 0 and current_stars - old_stars >= 1000 and current_stars >= old_stars * 2 ): notify( '{} star count jumped unexpectedly'.format(provider), '{} stars jumped from {} to {}'.format( provider, old_stars, current_stars, ), ) return safe_to_update def main(): snapshot_date = str(date.today()) comparison_date = str(date.today() - timedelta(days=STARS_DIFF_DAYS)) tokens = { 'github': token_from_file(GH_CREDENTIALS_PATH), 'gitlab': token_from_file(GITLAB_CREDENTIALS_PATH), } with open('data.yaml', 'r', encoding='utf-8') as f: data = yaml.load(f) history = load_history() snapshot = {} repository_issues = [] print('{:<27}{:<8}{:<6}{:<7}{}'.format('Name', 'Stars', 'Δ★', 'I+PR', 'Created')) for name, item in data.items(): if 'pelican_static' in name: continue events = RepositoryEvents(name) try: sources = repo_sources(item) except Exception as error: message = '{}: invalid repository configuration: {}'.format(name, error) repository_issues.append(message) events.notify( 'Invalid repository configuration', message, item.get('source'), ) set_update_warning(item, events, snapshot_date) print('ERROR: {}'.format(message), file=sys.stderr) continue if not sources: set_update_warning(item, events, snapshot_date) continue stats = [] for source in sources: notify = events.callback(source['url']) try: repo_stats = fetch_repo_stats( source, tokens, item, on_anomaly=notify, ) stats.append(repo_stats) except Exception as error: message = '{} ({}): {}'.format(name, source['url'], error) repository_issues.append(message) notify('Repository update failed', message) print('ERROR: {}'.format(message), file=sys.stderr) if len(stats) != len(sources): set_update_warning(item, events, snapshot_date) continue try: primary_stats = primary_repo_stats(stats) ( stars, stars_extra, stars_provider, stars_extra_providers, stars_total, provider_stars, ) = display_star_stats(stats) stars_dif = stars_diff(history, name, comparison_date, provider_stars) safe_to_update = check_repository_changes( history, name, snapshot_date, primary_stats, provider_stars, events.callback(item.get('source')), ) except Exception as error: events.notify( 'Could not process repository result', str(error), item.get('source'), ) safe_to_update = False if not safe_to_update: message = '{}: repository result was not trusted; previous values retained'.format(name) repository_issues.append(message) set_update_warning(item, events, snapshot_date) print('ERROR: {}'.format(message), file=sys.stderr) continue item['stars'] = stars item['stars_dif'] = stars_dif item['open_issues'] = primary_stats['open_issues'] item['created'] = primary_stats['created'] item['license'] = primary_stats['license'] item['stars_provider'] = stars_provider if stars_extra: item['stars_extra'] = stars_extra item['stars_extra_provider'] = ','.join(stars_extra_providers) else: item.pop('stars_extra', None) item.pop('stars_extra_provider', None) if primary_stats['last_commit']: item['last_committed'] = primary_stats['last_commit'] set_update_warning(item, events, snapshot_date) snapshot[name] = { 'stars': stars, 'stars_total': stars_total, 'created': primary_stats['created'], 'open_issues': primary_stats['open_issues'], 'license': primary_stats['license'], } for provider, provider_stars_value in provider_stars.items(): snapshot[name]['stars_{}'.format(provider)] = provider_stars_value if primary_stats['last_commit']: snapshot[name]['last_commit'] = primary_stats['last_commit'] print('{:<27}{:<8}{:<6}{:<7}{}'.format(name, stars, stars_dif, primary_stats['open_issues'], primary_stats['created'])) if repository_issues: print( '\nUpdate completed with {} repository warning(s). ' 'Affected repositories retained their previous trusted values and were marked in the table.'.format( len(repository_issues) ), file=sys.stderr, ) append_snapshot(history, snapshot_date, snapshot) yaml_output = StringIO() yaml.dump(data, stream=yaml_output) atomic_write_many_text( { 'data.yaml': yaml_output.getvalue(), 'apigh/history.json': serialize_history(history), } ) return 0 if __name__ == '__main__': raise SystemExit(main())