"""GitHub source class. Uses the v4 GraphQL API and the v3 REST API.
API docs:
* https://developer.github.com/v4/
* https://developer.github.com/v3/
* https://developer.github.com/apps/building-oauth-apps/authorization-options-for-oauth-apps/#web-application-flow
"""
import datetime
import email.utils
import html
import logging
import re
import urllib.parse
from oauth_dropins.webutil import util
from oauth_dropins.webutil.util import json_dumps, json_loads
import requests
from . import as1
from . import source
logger = logging.getLogger(__name__)
REST_BASE = 'https://api.github.com'
REST_ISSUE = REST_BASE + '/repos/%s/%s/issues/%s'
REST_CREATE_ISSUE = REST_BASE + '/repos/%s/%s/issues'
REST_ISSUE_LABELS = REST_BASE + '/repos/%s/%s/issues/%s/labels'
REST_COMMENTS = REST_BASE + '/repos/%s/%s/issues/%s/comments'
REST_REACTIONS = REST_BASE + '/repos/%s/%s/issues/%s/reactions'
REST_COMMENT = REST_BASE + '/repos/%s/%s/%s/comments/%s'
REST_COMMENT_REACTIONS = REST_BASE + '/repos/%s/%s/%s/comments/%s/reactions'
REST_MARKDOWN = REST_BASE + '/markdown'
REST_NOTIFICATIONS = REST_BASE + '/notifications?all=true&participating=true'
GRAPHQL_BASE = 'https://api.github.com/graphql'
GRAPHQL_BOT_FIELDS = 'id avatarUrl createdAt login url'
GRAPHQL_ORG_FIELDS = 'id avatarUrl description location login name url websiteUrl'
GRAPHQL_USER_FIELDS = 'id avatarUrl bio company createdAt location login name url websiteUrl' # not email, it requires an additional oauth scope
GRAPHQL_USER = """
query {
user(login: "%(login)s") {
""" + GRAPHQL_USER_FIELDS + """
}
}
"""
GRAPHQL_VIEWER = """
query {
viewer {
""" + GRAPHQL_USER_FIELDS + """
}
}
"""
GRAPHQL_USER_ISSUES = """
query {
viewer {
issues(last: 10) {
edges {
node {
id url
}
}
}
}
}
"""
GRAPHQL_REPO = """
query {
repository(owner: "%(owner)s", name: "%(repo)s") {
id
}
}
"""
GRAPHQL_REPO_ISSUES = """
query {
viewer {
repositories(last: 100) {
edges {
node {
issues(last: 10) {
edges {
node {
id url
}
}
}
}
}
}
}
}
"""
GRAPHQL_REPO_LABELS = """
query {
repository(owner: "%(owner)s", name: "%(repo)s") {
labels(first:100) {
nodes {
name
}
}
}
}
"""
GRAPHQL_ISSUE_OR_PR = """
query {
repository(owner: "%(owner)s", name: "%(repo)s") {
issueOrPullRequest(number: %(number)s) {
... on Issue {id title}
... on PullRequest {id title}
}
}
}
"""
GRAPHQL_COMMENT = """
query {
node(id:"%(id)s") {
... on IssueComment {
id url body createdAt
author {
... on Bot {""" + GRAPHQL_BOT_FIELDS + """}
... on Organization {""" + GRAPHQL_ORG_FIELDS + """}
... on User {""" + GRAPHQL_USER_FIELDS + """}
}
}
}
}
"""
GRAPHQL_ADD_COMMENT = """
mutation {
addComment(input: {subjectId: "%(subject_id)s", body: "%(body)s"}) {
commentEdge {
node {
id url
}
}
}
}
"""
GRAPHQL_ADD_STAR = """
mutation {
addStar(input: {starrableId: "%(starrable_id)s"}) {
starrable {
id
}
}
}
"""
GRAPHQL_ADD_REACTION = """
mutation {
addReaction(input: {subjectId: "%(subject_id)s", content: %(content)s}) {
reaction {
id content
user {login}
}
}
}
"""
# key is unicode emoji string, value is GraphQL ReactionContent enum value.
# https://developer.github.com/v4/enum/reactioncontent/
# https://developer.github.com/v3/reactions/#reaction-types
REACTIONS_GRAPHQL = {
u'👍': 'THUMBS_UP',
u'👎': 'THUMBS_DOWN',
u'😆': 'LAUGH',
u'😕': 'CONFUSED',
u'❤️': 'HEART',
u'🎉': 'HOORAY',
u'🚀': 'ROCKET',
u'👀': 'EYES',
}
# key is 'content' field value, value is unicode emoji string.
# https://developer.github.com/v3/reactions/#reaction-types
REACTIONS_REST = {
u'👍': '+1',
u'👎': '-1',
u'😆': 'laugh',
u'😕': 'confused',
u'❤️': 'heart',
u'🎉': 'hooray',
u'🚀': 'rocket',
u'👀': 'eyes',
}
REACTIONS_REST_CHARS = {char: name for name, char, in REACTIONS_REST.items()}
# preserve some HTML elements instead of converting them. eg <code> so that
# GitHub renders HTML entities like > inside them instead of leaving them
# escaped. background:
# https://chat.indieweb.org/dev/2019-12-24#t1577174464779200
# https://github.com/snarfed/bridgy/issues/957
PRESERVE_TAGS = ('code', 'blockquote')
HTTP_NON_FATAL_CODES = (
404, # issue/PR or repo was probably deleted
410, # ditto
451, # Unavailable for Legal Reasons, eg DMCA takedown
)
def tag_placeholders(self, tag, attrs, start):
if tag in PRESERVE_TAGS:
self.o(f'~~~BRIDGY-{tag}-TAG-START~~~' if start else f'~~~BRIDGY-{tag}-TAG-END~~~')
return True
def replace_placeholders(text):
for tag in PRESERVE_TAGS:
text = text.replace(f'~~~BRIDGY-{tag}-TAG-START~~~', f'<{tag}>')\
.replace(f'~~~BRIDGY-{tag}-TAG-END~~~', f'</{tag}>')
return text
[docs]
class GitHub(source.Source):
"""GitHub source class. See file docstring and :class:`Source` for details.
Attributes:
access_token (str): optional, OAuth access token
"""
DOMAIN = 'github.com'
BASE_URL = 'https://github.com/'
NAME = 'GitHub'
# username:repo:id
POST_ID_RE = re.compile(r'^[A-Za-z0-9-]+:[A-Za-z0-9_.-]+:[0-9]+$')
# https://github.com/shinnn/github-username-regex#readme
# (this slightly overspecifies; it allows multiple consecutive hyphens and
# leading/trailing hyphens. oh well.)
USER_NAME_RE = re.compile(r'[A-Za-z0-9-]+')
USER_URL_RE = re.compile(fr"""
(?<!]\(|=['"]) # don't match on Markdown links or HTML attributes
\b
{re.escape(BASE_URL)}
({USER_NAME_RE.pattern})
\b
(?![/?_+#@.]) # don't match on URLs that continue past username
""", re.VERBOSE)
# https://github.com/moby/moby/issues/679#issuecomment-18307522
REPO_NAME_RE = re.compile(r'[A-Za-z0-9_.-]+')
# https://github.com/Alir3z4/html2text/blob/master/docs/usage.md#available-options
HTML2TEXT_OPTIONS = {
'ignore_images': False,
'ignore_links': False,
'protect_links': False,
'use_automatic_links': False,
'tag_callback': tag_placeholders,
}
OPTIMIZED_COMMENTS = True
[docs]
def __init__(self, access_token=None):
"""Constructor.
Args:
access_token (str): optional OAuth access token
"""
self.access_token = access_token
[docs]
def user_url(self, username):
return self.BASE_URL + username
[docs]
@classmethod
def base_id(cls, url):
"""Extracts and returns a ``USERNAME:REPO:ID`` id for an issue or PR.
Args:
url (str):
Returns:
str or None:
"""
parts = urllib.parse.urlparse(url).path.strip('/').split('/')
if len(parts) == 4 and util.is_int(parts[3]):
return ':'.join((parts[0], parts[1], parts[3]))
[docs]
def graphql(self, graphql, kwargs):
"""Makes a v4 GraphQL API call.
Args:
graphql (str): GraphQL operation
Returns:
dict: parsed JSON response
"""
escaped = {k: (email.utils.quote(v) if isinstance(v, str) else v)
for k, v in kwargs.items()}
resp = util.requests_post(
GRAPHQL_BASE, json={'query': graphql % escaped},
headers={
'Authorization': f'bearer {self.access_token}',
})
resp.raise_for_status()
result = resp.json()
errs = result.get('errors')
if errs:
logger.warning(result)
raise ValueError('\n'.join(e.get('message') for e in errs))
return result['data']
[docs]
def rest(self, url, data=None, parse_json=True, **kwargs):
"""Makes a v3 REST API call.
Uses HTTP POST if data is provided, otherwise GET.
Args:
data (dict): JSON payload for POST requests
json (bool): whether to parse the response body as JSON and return it as a
dict. If False, returns a :class:`requests.Response` instead.
Returns:
dict: decoded from JSON response if ``json=True``, otherwise
:class:`requests.Response`
"""
kwargs['headers'] = kwargs.get('headers') or {}
kwargs['headers'].update({
'Authorization': f'token {self.access_token}',
})
if data is None:
resp = util.requests_get(url, **kwargs)
else:
resp = util.requests_post(url, json=data, **kwargs)
resp.raise_for_status()
return resp.json() if parse_json else resp
[docs]
def get_activities_response(self, user_id=None, group_id=None, app_id=None,
activity_id=None, start_index=0, count=0,
etag=None, min_id=None, cache=None,
fetch_replies=False, fetch_likes=False,
fetch_shares=False, fetch_events=False,
fetch_mentions=False, search_query=None,
public_only=True, **kwargs):
"""Fetches issues and comments and converts them to ActivityStreams activities.
See :meth:`Source.get_activities_response` for details.
*Not comprehensive!* Uses the notifications API (v3 REST).
Also note that start_index is not currently supported.
* https://developer.github.com/v3/activity/notifications/
* https://developer.github.com/v3/issues/
* https://developer.github.com/v3/issues/comments/
``fetch_likes`` determines whether emoji reactions are fetched:
https://help.github.com/articles/about-conversations-on-github#reacting-to-ideas-in-comments
The notifications API call supports ``Last-Modified``/``If-Modified-Since``
headers and ``304 Not Changed`` responses. If provided, ``etag`` should be
an RFC2822 timestamp, usually the exact value returned in a
``Last-Modified`` header. It will also be passed to the comments API
endpoint as the ``since=`` value (converted to ISO8601).
"""
if fetch_shares or fetch_events or fetch_mentions or search_query:
raise NotImplementedError()
etag_parsed = email.utils.parsedate(etag)
since = datetime.datetime(*etag_parsed[:6]) if etag_parsed else None
issues = []
activities = []
if activity_id:
# single issue
parts = tuple(activity_id.split(':'))
if len(parts) != 3:
raise ValueError('GitHub activity ids must be of the form USER:REPO:ISSUE_OR_PR')
try:
issues = [self.rest(REST_ISSUE % parts)]
activities = [self.issue_to_object(issues[0])]
except BaseException as e:
code, body = util.interpret_http_exception(e)
if util.is_int(code) and int(code) in HTTP_NON_FATAL_CODES:
activities = []
else:
raise
else:
# all issues/PRs, based on notifications
url = REST_NOTIFICATIONS
if count:
url += f'&per_page={count}'
resp = self.rest(url, parse_json=False,
headers={'If-Modified-Since': etag} if etag else None)
etag = resp.headers.get('Last-Modified')
notifs = [] if resp.status_code == 304 else resp.json()
for notif in notifs:
id = notif.get('id')
subject_url = notif.get('subject').get('url')
if not subject_url:
logger.info(f'Skipping thread {id}, missing subject!')
continue
split = subject_url.split('/')
if len(split) <= 2 or split[-2] not in ('issues', 'pulls'):
logger.info(
'Skipping thread %s with subject %s, only issues and PRs right now',
id, subject_url)
continue
try:
issue = self.rest(subject_url)
except requests.HTTPError as e:
if e.response.status_code in HTTP_NON_FATAL_CODES:
util.interpret_http_exception(e)
continue
raise
obj = self.issue_to_object(issue)
private = notif.get('repository', {}).get('private')
if private is not None:
obj['to'] = [{
'objectType': 'group',
'alias': '@private' if private else '@public',
}]
issues.append(issue)
activities.append(obj)
# add comments and reactions, if requested
assert len(issues) == len(activities)
for issue, obj in zip(issues, activities):
comments_url = issue.get('comments_url')
if fetch_replies and comments_url:
if since:
comments_url += f'?since={since.isoformat()}' + 'Z'
comments = self.rest(comments_url)
comment_objs = list(util.trim_nulls(
self.comment_to_object(c) for c in comments))
obj['replies'] = {
'items': comment_objs,
'totalItems': len(comment_objs),
}
if fetch_likes:
issue_url = issue['url'].replace('pulls', 'issues')
reactions = self.rest(issue_url + '/reactions')
obj.setdefault('tags', []).extend(
self.reaction_to_object(r, obj) for r in reactions)
response = self.make_activities_base_response(util.trim_nulls(activities))
response['etag'] = etag
return response
[docs]
def get_actor(self, user_id=None):
"""Fetches and returns a user.
Args:
user_id (str): defaults to current user
Returns:
dict: ActivityStreams actor object
"""
if user_id:
user = self.graphql(GRAPHQL_USER, {'login': user_id})['user']
else:
user = self.graphql(GRAPHQL_VIEWER, {})['viewer']
return self.user_to_actor(user)
[docs]
def render_markdown(self, markdown, owner, repo):
"""Uses the GitHub API to render GitHub-flavored Markdown to HTML.
* https://developer.github.com/v3/markdown/
* https://github.github.com/gfm/
Args:
markdown (str): input
owner and repo (strs): the repo to render in, for context. affects git
hashes #XXX for issues/PRs.
Returns:
str: rendered HTML
"""
return self.rest(REST_MARKDOWN, {
'text': markdown,
'mode': 'gfm',
'context': f'{owner}/{repo}',
}, parse_json=False).text
[docs]
def create(self, obj, include_link=source.OMIT_LINK, ignore_formatting=False):
"""Creates a new issue or comment.
Args:
obj (dict): ActivityStreams object
include_link (str)
ignore_formatting (bool)
Returns:
CreationResult or None: contents will be a dict with ``id`` and
``url`` keys for the newly created GitHub object
"""
return self._create(obj, preview=False, include_link=include_link,
ignore_formatting=ignore_formatting)
[docs]
def preview_create(self, obj, include_link=source.OMIT_LINK,
ignore_formatting=False):
"""Previews creating an issue or comment.
Args:
obj (dict): ActivityStreams object
include_link (str)
ignore_formatting (bool)
Returns:
CreationResult or None: ``contents`` will be a str HTML snippet
"""
return self._create(obj, preview=True, include_link=include_link,
ignore_formatting=ignore_formatting)
def _create(self, obj, preview=None, include_link=source.OMIT_LINK,
ignore_formatting=False):
"""Creates a new issue or comment.
When creating a new issue, if the authenticated user is a collaborator on
the repo, tags that match existing labels are converted to those labels and
included.
* https://developer.github.com/v4/guides/forming-calls/#about-mutations
* https://developer.github.com/v4/mutation/addcomment/
* https://developer.github.com/v4/mutation/addreaction/
* https://developer.github.com/v3/issues/#create-an-issue
Args:
obj (dict): ActivityStreams object
preview (bool)
include_link (str)
ignore_formatting (bool)
Returns:
CreationResult: If preview is True, the contents will be a str HTML
snippet. If False, it will be a dict with ``id`` and ``url`` keys for the
newly created GitHub object.
"""
assert preview in (False, True)
type = as1.object_type(obj)
if type and type not in ('issue', 'comment', 'activity', 'note', 'article',
'like', 'favorite', 'tag'):
return source.creation_result(
abort=False, error_plain=f'Cannot publish {type} to GitHub')
base_obj = self.base_object(obj)
base_url = base_obj.get('url')
if not base_url:
return source.creation_result(
abort=True,
error_plain='You need an in-reply-to GitHub repo, issue, PR, or comment URL.')
content = orig_content = replace_placeholders(html.escape(
self._content_for_create(obj, ignore_formatting=ignore_formatting),
quote=False))
# convert GitHub user profile URLs to @-mentions,
# eg https://github.com/snarfed to @snarfed
content = self.USER_URL_RE.sub(r'@\1', content)
url = obj.get('url')
if include_link == source.INCLUDE_LINK and url:
content += f'\n\n(Originally published at: {url})'
parsed = urllib.parse.urlparse(base_url)
path = parsed.path.strip('/').split('/')
owner, repo = path[:2]
if len(path) == 4:
number = path[3]
# TODO: support #pullrequestreview-* URLs for top-level PR comments too.
# Haven't yet gotten those to work via either the issues or pulls APIs.
# https://github.com/snarfed/bridgy/issues/955#issuecomment-788478848
comment_id = re.match(r'^(discussion_r|issuecomment-)([0-9]+)$', parsed.fragment)
comment_type = None
if comment_id:
comment_type = 'issues' if comment_id.group(1) == 'issuecomment-' else 'pulls'
comment_id = comment_id.group(2)
elif parsed.fragment:
return source.creation_result(
abort=True,
error_plain=f'Please remove the fragment #{parsed.fragment} from your in-reply-to URL.')
if type == 'comment': # comment or reaction
if not (len(path) == 4 and path[2] in ('issues', 'pull')):
return source.creation_result(
abort=True, error_plain='GitHub comment requires in-reply-to issue or PR URL.')
is_reaction = orig_content in REACTIONS_GRAPHQL
if preview:
if comment_id:
comment = self.rest(REST_COMMENT % (owner, repo, comment_type,
comment_id))
target_link = f"<a href=\"{base_url}\">a comment on {owner}/{repo}#{number}, <em>{util.ellipsize(comment['body'])}</em></a>"
else:
resp = self.graphql(GRAPHQL_ISSUE_OR_PR, locals())
issue = (resp.get('repository') or {}).get('issueOrPullRequest')
target_link = '<a href="%s">%s/%s#%s%s</a>' % (
base_url, owner, repo, number,
(', <em>%s</em>' % issue['title']) if issue else '')
if is_reaction:
preview_content = None
desc = f'<span class="verb">react {orig_content}</span> to {target_link}.'
else:
preview_content = self.render_markdown(content, owner, repo)
desc = f'<span class="verb">comment</span> on {target_link}:'
return source.creation_result(content=preview_content, description=desc)
else: # create
# we originally used the GraphQL API to create issue comments and
# reactions, but it often gets rejected against org repos due to access
# controls. oddly, the REST API works fine in those same cases.
# https://github.com/snarfed/bridgy/issues/824
if is_reaction:
if comment_id:
api_url = REST_COMMENT_REACTIONS % (owner, repo, comment_type,
comment_id)
reacted = self.rest(api_url, data={
'content': REACTIONS_REST.get(orig_content),
})
url = base_url
else:
api_url = REST_REACTIONS % (owner, repo, number)
reacted = self.rest(api_url, data={
'content': REACTIONS_REST.get(orig_content),
})
url = f"{base_url}#{reacted['content'].lower()}-by-{reacted['user']['login']}"
return source.creation_result({
'id': reacted.get('id'),
'url': url,
'type': 'react',
})
else:
try:
api_url = REST_COMMENTS % (owner, repo, number)
commented = self.rest(api_url, data={'body': content})
return source.creation_result({
'id': commented.get('id'),
'url': commented.get('html_url'),
'type': 'comment',
})
except ValueError as e:
return source.creation_result(abort=True, error_plain=str(e))
elif type in ('like', 'favorite'): # star
if not (len(path) == 2 or (len(path) == 3 and path[2] == 'issues')):
return source.creation_result(
abort=True, error_plain='GitHub like requires in-reply-to repo URL.')
if preview:
return source.creation_result(
description=f'<span class="verb">star</span> <a href="{base_url}">{owner}/{repo}</a>.')
issue = self.graphql(GRAPHQL_REPO, locals())
resp = self.graphql(GRAPHQL_ADD_STAR, {
'starrable_id': issue['repository']['id'],
})
return source.creation_result({
'url': base_url + '/stargazers',
})
elif type == 'tag': # add label
if not (len(path) == 4 and path[2] in ('issues', 'pull')):
return source.creation_result(
abort=True, error_plain='GitHub tag post requires tag-of issue or PR URL.')
tags = set(util.trim_nulls(t.get('displayName', '').strip()
for t in util.get_list(obj, 'object')))
if not tags:
return source.creation_result(
abort=True, error_plain='No tags found in tag post!')
existing_labels = self.existing_labels(owner, repo)
labels = sorted(tags & existing_labels)
issue_link = f'<a href="{base_url}">{owner}/{repo}#{number}</a>'
if not labels:
return source.creation_result(
abort=True,
error_html=f"No tags in [{', '.join(sorted(tags))}] matched {issue_link}'s existing labels [{', '.join(sorted(existing_labels))}].")
if preview:
return source.creation_result(
description=f"add label{'s' if len(labels) > 1 else ''} <span class=\"verb\">{', '.join(labels)}</span> to {issue_link}.")
resp = self.rest(REST_ISSUE_LABELS % (owner, repo, number), labels)
return source.creation_result({
'url': base_url,
'type': 'tag',
'tags': labels,
})
else: # new issue
if not (len(path) == 2 or (len(path) == 3 and path[2] == 'issues')):
return source.creation_result(
abort=True, error_plain='New GitHub issue requires in-reply-to repo URL')
title = util.ellipsize(obj.get('displayName') or obj.get('title') or
orig_content)
tags = set(util.trim_nulls(t.get('displayName', '').strip()
for t in util.get_list(obj, 'tags')))
labels = sorted(tags & self.existing_labels(owner, repo))
if preview:
preview_content = f'<b>{title}</b><hr>{self.render_markdown(content, owner, repo)}'
preview_labels = ''
if labels:
preview_labels = f" and attempt to add label{'s' if len(labels) > 1 else ''} <span class=\"verb\">{', '.join(labels)}</span>"
return source.creation_result(content=preview_content, description=f"""<span class="verb">create a new issue</span> on <a href="{base_url}">{owner}/{repo}</a>{preview_labels}:""")
else:
resp = self.rest(REST_CREATE_ISSUE % (owner, repo), {
'title': title,
'body': content,
'labels': labels,
})
resp['url'] = resp.pop('html_url')
return source.creation_result(resp)
return source.creation_result(
abort=False,
error_plain=f"{base_url} doesn't look like a GitHub repo, issue, or PR URL.")
[docs]
def existing_labels(self, owner, repo):
"""Fetches and returns a repo's labels.
Args:
owner (str): GitHub username or org that owns the repo
repo (str)
Returns:
set of str:
"""
resp = self.graphql(GRAPHQL_REPO_LABELS, locals())
repo = resp.get('repository')
if not repo:
return set()
return {node['name'] for node in repo['labels']['nodes']}
[docs]
def issue_to_object(self, issue):
"""Converts a GitHub issue or pull request to ActivityStreams.
Handles both v4 GraphQL and v3 REST API issue and PR objects.
* https://developer.github.com/v4/object/issue/
* https://developer.github.com/v4/object/pullrequest/
* https://developer.github.com/v3/issues/
* https://developer.github.com/v3/pulls/
Args:
issue (dict): GitHub issue or PR
Returns:
dict: ActivityStreams object
"""
obj = self._to_object(issue, repo_id=True)
if not obj:
return obj
repo_url = re.sub(r'/(issue|pull)s?/[0-9]+$', '', obj['url'])
if issue.get('merged') is not None:
type = 'pull-request'
in_reply_to = repo_url + '/tree/' + (issue.get('base', {}).get('ref') or 'master')
else:
type = 'issue'
in_reply_to = repo_url + '/issues'
obj.update({
'objectType': type,
'inReplyTo': [{'url': in_reply_to}],
'tags': [{
'displayName': l['name'],
'url': f"{repo_url}/labels/{urllib.parse.quote(l['name'])}",
} for l in issue.get('labels', []) if l.get('name')],
})
return self.postprocess_object(obj)
pr_to_object = issue_to_object
[docs]
def reaction_to_object(self, reaction, target):
"""Converts a GitHub emoji reaction to ActivityStreams.
Handles v3 REST API reaction objects.
https://developer.github.com/v3/reactions/
Args:
reaction (dict): v3 GitHub reaction
target (dict): ActivityStreams object of reaction
Returns:
dict: ActivityStreams reaction
"""
obj = self._to_object(reaction)
if not obj:
return obj
content = REACTIONS_REST_CHARS.get(reaction.get('content'))
enum = (REACTIONS_GRAPHQL.get(content) or '').lower()
author = self.user_to_actor(reaction.get('user'))
username = author.get('username')
obj.update({
'objectType': 'activity',
'verb': 'react',
'id': target['id'] + f'_{enum}_by_{username}',
'url': target['url'] + f'#{enum}-by-{username}',
'author': author,
'content': content,
'object': {'url': target['url']},
})
return self.postprocess_object(obj)
[docs]
def user_to_actor(self, user):
"""Converts a GitHub user to an ActivityStreams actor.
Handles both v4 GraphQL and v3 REST API user objects.
* https://developer.github.com/v4/object/user/
* https://developer.github.com/v3/users/
Args:
user (dict): GitHub user
Returns:
dict: ActivityStreams actor
"""
actor = self._to_object(user)
if not actor:
return actor
username = user.get('login')
desc = user.get('bio') or user.get('description')
actor.update({
# TODO: orgs, bots
'objectType': 'person',
'displayName': user.get('name') or username,
'username': username,
'email': user.get('email'),
'description': desc,
'summary': desc,
'image': {'url': user.get('avatarUrl') or user.get('avatar_url') or user.get('url')},
'location': {'displayName': user.get('location')},
})
# extract web site links. extract_links uniquifies and preserves order
urls = sum((util.extract_links(user.get(field)) for field in (
'html_url', # REST
'url', # both
'websiteUrl', # GraphQL
'blog', # REST
'bio', # both
)), [])
urls = [u for u in urls if util.domain_from_link(u) != 'api.github.com']
if urls:
actor['url'] = urls[0]
if len(urls) > 1:
actor['urls'] = [{'value': u} for u in urls]
return self.postprocess_object(actor)
def _to_object(self, input, repo_id=False):
"""Starts to convert a GraphQL or REST API object to ActivityStreams.
Args:
input (dict): GraphQL or REST object
repo_id (bool): whether to inject repo owner and name into id
Returns:
dict: ActivityStreams object
"""
if not input:
return {}
id = input.get('node_id') or input.get('id')
number = input.get('number')
url = input.get('html_url') or input.get('url') or ''
if repo_id and id and url:
# inject repo owner and name
path = urllib.parse.urlparse(url).path.strip('/').split('/')
owner, repo = path[:2]
# join with : because github allows ., _, and - in repo names. (see
# REPO_NAME_RE.)
id = ':'.join((owner, repo, str(number or id)))
return {
'id': self.tag_uri(id),
'url': url,
'author': self.user_to_actor(input.get('author') or input.get('user')),
'title': input.get('title'),
'content': (input.get('body') or '').replace('\r\n', '\n'),
'published': util.maybe_iso8601_to_rfc3339(input.get('createdAt') or
input.get('created_at')),
'updated': util.maybe_iso8601_to_rfc3339(input.get('lastEditedAt') or
input.get('updated_at')),
}