#! /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 ' 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())