• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

snarfed / granary / b6af8e37-b4b4-426a-8df1-c35e34f21237

07 Jul 2026 10:15PM UTC coverage: 96.645%. Remained the same
b6af8e37-b4b4-426a-8df1-c35e34f21237

push

circleci

github-actions[bot]
build(deps): bump cbor2 from 6.1.2 to 6.1.3

Bumps [cbor2](https://github.com/agronholm/cbor2) from 6.1.2 to 6.1.3.
- [Release notes](https://github.com/agronholm/cbor2/releases)
- [Commits](https://github.com/agronholm/cbor2/compare/6.1.2...6.1.3)

---
updated-dependencies:
- dependency-name: cbor2
  dependency-version: 6.1.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

7836 of 8108 relevant lines covered (96.65%)

1.93 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

94.74
/granary/github.py
1
"""GitHub source class. Uses the v4 GraphQL API and the v3 REST API.
2

3
API docs:
4

5
* https://developer.github.com/v4/
6
* https://developer.github.com/v3/
7
* https://developer.github.com/apps/building-oauth-apps/authorization-options-for-oauth-apps/#web-application-flow
8
"""
9
import datetime
2✔
10
import email.utils
2✔
11
import html
2✔
12
import logging
2✔
13
import re
2✔
14
import urllib.parse
2✔
15

16
import requests
2✔
17
from webutil import util
2✔
18
from webutil.util import json_dumps, json_loads
2✔
19

20
from . import as1
2✔
21
from . import source
2✔
22

23
logger = logging.getLogger(__name__)
2✔
24

25
REST_BASE = 'https://api.github.com'
2✔
26
REST_ISSUE = REST_BASE + '/repos/%s/%s/issues/%s'
2✔
27
REST_CREATE_ISSUE = REST_BASE + '/repos/%s/%s/issues'
2✔
28
REST_ISSUE_LABELS = REST_BASE + '/repos/%s/%s/issues/%s/labels'
2✔
29
REST_COMMENTS = REST_BASE + '/repos/%s/%s/issues/%s/comments'
2✔
30
REST_REACTIONS = REST_BASE + '/repos/%s/%s/issues/%s/reactions'
2✔
31
REST_COMMENT = REST_BASE + '/repos/%s/%s/%s/comments/%s'
2✔
32
REST_COMMENT_REACTIONS = REST_BASE + '/repos/%s/%s/%s/comments/%s/reactions'
2✔
33
REST_MARKDOWN = REST_BASE + '/markdown'
2✔
34
REST_NOTIFICATIONS = REST_BASE + '/notifications?all=true&participating=true'
2✔
35
GRAPHQL_BASE = 'https://api.github.com/graphql'
2✔
36
GRAPHQL_BOT_FIELDS = 'id avatarUrl createdAt login url'
2✔
37
GRAPHQL_ORG_FIELDS = 'id avatarUrl description location login name url websiteUrl'
2✔
38
GRAPHQL_USER_FIELDS = 'id avatarUrl bio company createdAt location login name url websiteUrl' # not email, it requires an additional oauth scope
2✔
39
GRAPHQL_USER = """
2✔
40
query {
41
  user(login: "%(login)s") {
42
    """ + GRAPHQL_USER_FIELDS + """
43
  }
44
}
45
"""
46
GRAPHQL_VIEWER = """
2✔
47
query {
48
  viewer {
49
    """ + GRAPHQL_USER_FIELDS + """
50
  }
51
}
52
"""
53
GRAPHQL_USER_ISSUES = """
2✔
54
query {
55
  viewer {
56
    issues(last: 10) {
57
      edges {
58
        node {
59
          id url
60
        }
61
      }
62
    }
63
  }
64
}
65
"""
66
GRAPHQL_REPO = """
2✔
67
query {
68
  repository(owner: "%(owner)s", name: "%(repo)s") {
69
    id
70
  }
71
}
72
"""
73
GRAPHQL_REPO_ISSUES = """
2✔
74
query {
75
  viewer {
76
    repositories(last: 100) {
77
      edges {
78
        node {
79
          issues(last: 10) {
80
            edges {
81
              node {
82
                id url
83
              }
84
            }
85
          }
86
        }
87
      }
88
    }
89
  }
90
}
91
"""
92
GRAPHQL_REPO_LABELS = """
2✔
93
query {
94
  repository(owner: "%(owner)s", name: "%(repo)s") {
95
    labels(first:100) {
96
      nodes {
97
        name
98
      }
99
    }
100
  }
101
}
102
"""
103
GRAPHQL_ISSUE_OR_PR = """
2✔
104
query {
105
  repository(owner: "%(owner)s", name: "%(repo)s") {
106
    issueOrPullRequest(number: %(number)s) {
107
      ... on Issue {id title}
108
      ... on PullRequest {id title}
109
    }
110
  }
111
}
112
"""
113
GRAPHQL_COMMENT = """
2✔
114
query {
115
  node(id:"%(id)s") {
116
    ... on IssueComment {
117
      id url body createdAt
118
      author {
119
        ... on Bot {""" + GRAPHQL_BOT_FIELDS + """}
120
        ... on Organization {""" + GRAPHQL_ORG_FIELDS + """}
121
        ... on User {""" + GRAPHQL_USER_FIELDS + """}
122
      }
123
    }
124
  }
125
}
126
"""
127
GRAPHQL_ADD_COMMENT = """
2✔
128
mutation {
129
  addComment(input: {subjectId: "%(subject_id)s", body: "%(body)s"}) {
130
    commentEdge {
131
      node {
132
        id url
133
      }
134
    }
135
  }
136
}
137
"""
138
GRAPHQL_ADD_STAR = """
2✔
139
mutation {
140
  addStar(input: {starrableId: "%(starrable_id)s"}) {
141
    starrable {
142
      id
143
    }
144
  }
145
}
146
"""
147
GRAPHQL_ADD_REACTION = """
2✔
148
mutation {
149
  addReaction(input: {subjectId: "%(subject_id)s", content: %(content)s}) {
150
    reaction {
151
      id content
152
      user {login}
153
    }
154
  }
155
}
156
"""
157

158
# key is unicode emoji string, value is GraphQL ReactionContent enum value.
159
# https://developer.github.com/v4/enum/reactioncontent/
160
# https://developer.github.com/v3/reactions/#reaction-types
161
REACTIONS_GRAPHQL = {
2✔
162
  u'👍': 'THUMBS_UP',
163
  u'👎': 'THUMBS_DOWN',
164
  u'😆': 'LAUGH',
165
  u'😕': 'CONFUSED',
166
  u'❤️': 'HEART',
167
  u'🎉': 'HOORAY',
168
  u'🚀': 'ROCKET',
169
  u'👀': 'EYES',
170
}
171
# key is 'content' field value, value is unicode emoji string.
172
# https://developer.github.com/v3/reactions/#reaction-types
173
REACTIONS_REST = {
2✔
174
  u'👍': '+1',
175
  u'👎': '-1',
176
  u'😆': 'laugh',
177
  u'😕': 'confused',
178
  u'❤️': 'heart',
179
  u'🎉': 'hooray',
180
  u'🚀': 'rocket',
181
  u'👀': 'eyes',
182
}
183
REACTIONS_REST_CHARS = {char: name for name, char, in REACTIONS_REST.items()}
2✔
184

185

186
# preserve some HTML elements instead of converting them. eg <code> so that
187
# GitHub renders HTML entities like &gt; inside them instead of leaving them
188
# escaped. background:
189
# https://chat.indieweb.org/dev/2019-12-24#t1577174464779200
190
# https://github.com/snarfed/bridgy/issues/957
191
PRESERVE_TAGS = ('code', 'blockquote')
2✔
192

193
HTTP_NON_FATAL_CODES = (
2✔
194
  404,  # issue/PR or repo was probably deleted
195
  410,  # ditto
196
  451,  # Unavailable for Legal Reasons, eg DMCA takedown
197
)
198

199

200
def tag_placeholders(self, tag, attrs, start):
2✔
201
  if tag in PRESERVE_TAGS:
2✔
202
    self.o(f'~~~BRIDGY-{tag}-TAG-START~~~' if start else f'~~~BRIDGY-{tag}-TAG-END~~~')
2✔
203
    return True
2✔
204

205

206
def replace_placeholders(text):
2✔
207
  for tag in PRESERVE_TAGS:
2✔
208
    text = text.replace(f'~~~BRIDGY-{tag}-TAG-START~~~', f'<{tag}>')\
2✔
209
               .replace(f'~~~BRIDGY-{tag}-TAG-END~~~', f'</{tag}>')
210

211
  return text
2✔
212

213

214
class GitHub(source.Source):
2✔
215
  """GitHub source class. See file docstring and :class:`Source` for details.
216

217
  Attributes:
218
    access_token (str): optional, OAuth access token
219
  """
220
  DOMAIN = 'github.com'
2✔
221
  BASE_URL = 'https://github.com/'
2✔
222
  NAME = 'GitHub'
2✔
223
  # username:repo:id
224
  POST_ID_RE = re.compile(r'[A-Za-z0-9-]+:[A-Za-z0-9_.-]+:[0-9]+')
2✔
225
  # https://github.com/shinnn/github-username-regex#readme
226
  # (this slightly overspecifies; it allows multiple consecutive hyphens and
227
  # leading/trailing hyphens. oh well.)
228
  USER_NAME_RE = re.compile(r'[A-Za-z0-9-]+')
2✔
229
  USER_URL_RE = re.compile(fr"""
2✔
230
    (?<!]\(|=['"])  # don't match on Markdown links or HTML attributes
231
    \b
232
    {re.escape(BASE_URL)}
233
    ({USER_NAME_RE.pattern})
234
    \b
235
    (?![/?_+#@.])  # don't match on URLs that continue past username
236
  """, re.VERBOSE)
237

238
  # https://github.com/moby/moby/issues/679#issuecomment-18307522
239
  REPO_NAME_RE = re.compile(r'[A-Za-z0-9_.-]+')
2✔
240
  # https://github.com/Alir3z4/html2text/blob/master/docs/usage.md#available-options
241
  HTML2TEXT_OPTIONS = {
2✔
242
    'ignore_images': False,
243
    'ignore_links': False,
244
    'protect_links': False,
245
    'use_automatic_links': False,
246
    'tag_callback': tag_placeholders,
247
  }
248
  OPTIMIZED_COMMENTS = True
2✔
249

250
  def __init__(self, access_token=None):
2✔
251
    """Constructor.
252

253
    Args:
254
      access_token (str): optional OAuth access token
255
    """
256
    self.access_token = access_token
2✔
257

258
  def user_url(self, username):
2✔
259
    return self.BASE_URL + username
×
260

261
  @classmethod
2✔
262
  def base_id(cls, url):
2✔
263
    """Extracts and returns a ``USERNAME:REPO:ID`` id for an issue or PR.
264

265
    Args:
266
      url (str):
267

268
    Returns:
269
      str or None:
270
    """
271
    parts = urllib.parse.urlparse(url).path.strip('/').split('/')
2✔
272
    if len(parts) == 4 and util.is_int(parts[3]):
2✔
273
      return ':'.join((parts[0], parts[1], parts[3]))
2✔
274

275
  def graphql(self, graphql, kwargs):
2✔
276
    """Makes a v4 GraphQL API call.
277

278
    Args:
279
      graphql (str): GraphQL operation
280

281
    Returns:
282
      dict: parsed JSON response
283
    """
284
    escaped = {k: (email.utils.quote(v) if isinstance(v, str) else v)
2✔
285
               for k, v in kwargs.items()}
286
    resp = util.requests_post(
2✔
287
      GRAPHQL_BASE, json={'query': graphql % escaped},
288
      headers={
289
        'Authorization': f'bearer {self.access_token}',
290
      })
291
    resp.raise_for_status()
2✔
292
    result = resp.json()
2✔
293

294
    errs = result.get('errors')
2✔
295
    if errs:
2✔
296
      logger.warning(result)
×
297
      raise ValueError('\n'.join(e.get('message') for e in errs))
×
298

299
    return result['data']
2✔
300

301
  def rest(self, url, data=None, parse_json=True, **kwargs):
2✔
302
    """Makes a v3 REST API call.
303

304
    Uses HTTP POST if data is provided, otherwise GET.
305

306
    Args:
307
      data (dict): JSON payload for POST requests
308
      json (bool): whether to parse the response body as JSON and return it as a
309
        dict. If False, returns a :class:`requests.Response` instead.
310

311
    Returns:
312
      dict: decoded from JSON response if ``json=True``, otherwise
313
        :class:`requests.Response`
314
    """
315
    kwargs['headers'] = kwargs.get('headers') or {}
2✔
316
    kwargs['headers'].update({
2✔
317
      'Authorization': f'token {self.access_token}',
318
    })
319

320
    if data is None:
2✔
321
      resp = util.requests_get(url, **kwargs)
2✔
322
    else:
323
      resp = util.requests_post(url, json=data, **kwargs)
2✔
324
    resp.raise_for_status()
2✔
325

326
    return resp.json() if parse_json else resp
2✔
327

328
  def get_activities_response(self, user_id=None, group_id=None, app_id=None,
2✔
329
                              activity_id=None, start_index=0, count=0,
330
                              etag=None, min_id=None, cache=None,
331
                              fetch_replies=False, fetch_likes=False,
332
                              fetch_shares=False, fetch_events=False,
333
                              fetch_mentions=False, search_query=None,
334
                              public_only=True, **kwargs):
335
    """Fetches issues and comments and converts them to ActivityStreams activities.
336

337
    See :meth:`Source.get_activities_response` for details.
338

339
    *Not comprehensive!* Uses the notifications API (v3 REST).
340

341
    Also note that start_index is not currently supported.
342

343
    * https://developer.github.com/v3/activity/notifications/
344
    * https://developer.github.com/v3/issues/
345
    * https://developer.github.com/v3/issues/comments/
346

347
    ``fetch_likes`` determines whether emoji reactions are fetched:
348
    https://help.github.com/articles/about-conversations-on-github#reacting-to-ideas-in-comments
349

350
    The notifications API call supports ``Last-Modified``/``If-Modified-Since``
351
    headers and ``304 Not Changed`` responses. If provided, ``etag`` should be
352
    an RFC2822 timestamp, usually the exact value returned in a
353
    ``Last-Modified`` header. It will also be passed to the comments API
354
    endpoint as the ``since=`` value (converted to ISO8601).
355
    """
356
    if fetch_shares or fetch_events or fetch_mentions or search_query:
2✔
357
      raise NotImplementedError()
2✔
358

359
    etag_parsed = email.utils.parsedate(etag)
2✔
360
    since = datetime.datetime(*etag_parsed[:6]) if etag_parsed else None
2✔
361
    issues = []
2✔
362
    activities = []
2✔
363

364
    if activity_id:
2✔
365
      # single issue
366
      parts = tuple(activity_id.split(':'))
2✔
367
      if len(parts) != 3:
2✔
368
        raise ValueError('GitHub activity ids must be of the form USER:REPO:ISSUE_OR_PR')
2✔
369
      try:
2✔
370
        issues = [self.rest(REST_ISSUE % parts)]
2✔
371
        activities = [self.issue_to_object(issues[0])]
2✔
372
      except BaseException as e:
2✔
373
        code, body = util.interpret_http_exception(e)
2✔
374
        if util.is_int(code) and int(code) in HTTP_NON_FATAL_CODES:
2✔
375
          activities = []
2✔
376
        else:
377
          raise
×
378

379
    else:
380
      # all issues/PRs, based on notifications
381
      url = REST_NOTIFICATIONS
2✔
382
      if count:
2✔
383
        url += f'&per_page={count}'
2✔
384
      resp = self.rest(url, parse_json=False,
2✔
385
                       headers={'If-Modified-Since': etag} if etag else None)
386
      etag = resp.headers.get('Last-Modified')
2✔
387
      notifs = [] if resp.status_code == 304 else resp.json()
2✔
388

389
      for notif in notifs:
2✔
390
        id = notif.get('id')
2✔
391
        subject_url = notif.get('subject').get('url')
2✔
392
        if not subject_url:
2✔
393
          logger.info(f'Skipping thread {id}, missing subject!')
×
394
          continue
×
395
        split = subject_url.split('/')
2✔
396
        if len(split) <= 2 or split[-2] not in ('issues', 'pulls'):
2✔
397
          logger.info(
×
398
            'Skipping thread %s with subject %s, only issues and PRs right now',
399
            id, subject_url)
400
          continue
×
401

402
        try:
2✔
403
          issue = self.rest(subject_url)
2✔
404
        except requests.HTTPError as e:
2✔
405
          if e.response.status_code in HTTP_NON_FATAL_CODES:
2✔
406
            util.interpret_http_exception(e)
2✔
407
            continue
2✔
408
          raise
×
409

410
        obj = self.issue_to_object(issue)
2✔
411

412
        private = notif.get('repository', {}).get('private')
2✔
413
        if private is not None:
2✔
414
          obj['to'] = [{
2✔
415
            'objectType': 'group',
416
            'alias': '@private' if private else '@public',
417
          }]
418

419
        issues.append(issue)
2✔
420
        activities.append(obj)
2✔
421

422
    # add comments and reactions, if requested
423
    assert len(issues) == len(activities)
2✔
424
    for issue, obj in zip(issues, activities):
2✔
425
      comments_url = issue.get('comments_url')
2✔
426
      if fetch_replies and comments_url:
2✔
427
        if since:
2✔
428
          comments_url += f'?since={since.isoformat()}' + 'Z'
2✔
429
        comments = self.rest(comments_url)
2✔
430
        comment_objs = list(util.trim_nulls(
2✔
431
          self.comment_to_object(c) for c in comments))
432
        obj['replies'] = {
2✔
433
          'items': comment_objs,
434
          'totalItems': len(comment_objs),
435
        }
436

437
      if fetch_likes:
2✔
438
        issue_url = issue['url'].replace('pulls', 'issues')
2✔
439
        reactions = self.rest(issue_url + '/reactions')
2✔
440
        obj.setdefault('tags', []).extend(
2✔
441
          self.reaction_to_object(r, obj) for r in reactions)
442

443
    response = self.make_activities_base_response(util.trim_nulls(activities))
2✔
444
    response['etag'] = etag
2✔
445
    return response
2✔
446

447
  def get_actor(self, user_id=None):
2✔
448
    """Fetches and returns a user.
449

450
    Args:
451
      user_id (str): defaults to current user
452

453
    Returns:
454
      dict: ActivityStreams actor object
455
    """
456
    if user_id:
2✔
457
      user = self.graphql(GRAPHQL_USER, {'login': user_id})['user']
2✔
458
    else:
459
      user = self.graphql(GRAPHQL_VIEWER, {})['viewer']
2✔
460

461
    return self.to_as1_actor(user)
2✔
462

463
  def get_comment(self, comment_id, **kwargs):
2✔
464
    """Fetches and returns a comment.
465

466
    Args:
467
      comment_id (str): comment id (either REST or GraphQL), of the form
468
        ``REPO-OWNER:REPO-NAME:ID``, e.g. ``snarfed:bridgy:456789``
469

470
    Returns:
471
      dict: an ActivityStreams comment object
472
    """
473
    parts = comment_id.split(':')
2✔
474
    if len(parts) != 3:
2✔
475
      raise ValueError('GitHub comment ids must be of the form USER:REPO:COMMENT_ID')
2✔
476

477
    id = parts[-1]
2✔
478
    if util.is_int(id):  # REST API id
2✔
479
      parts.insert(2, 'issues')
2✔
480
      comment = self.rest(REST_COMMENT % tuple(parts))
2✔
481
    else:  # GraphQL node id
482
      comment = self.graphql(GRAPHQL_COMMENT, {'id': id})['node']
2✔
483

484
    return self.comment_to_object(comment)
2✔
485

486
  def render_markdown(self, markdown, owner, repo):
2✔
487
    """Uses the GitHub API to render GitHub-flavored Markdown to HTML.
488

489
    * https://developer.github.com/v3/markdown/
490
    * https://github.github.com/gfm/
491

492
    Args:
493
      markdown (str): input
494
      owner and repo (strs): the repo to render in, for context. affects git
495
        hashes #XXX for issues/PRs.
496

497
    Returns:
498
      str: rendered HTML
499
    """
500
    return self.rest(REST_MARKDOWN, {
2✔
501
      'text': markdown,
502
      'mode': 'gfm',
503
      'context': f'{owner}/{repo}',
504
    }, parse_json=False).text
505

506
  def create(self, obj, include_link=source.OMIT_LINK, ignore_formatting=False):
2✔
507
    """Creates a new issue or comment.
508

509
    Args:
510
      obj (dict): ActivityStreams object
511
      include_link (str)
512
      ignore_formatting (bool)
513

514
    Returns:
515
      CreationResult or None: contents will be a dict with ``id`` and
516
      ``url`` keys for the newly created GitHub object
517
    """
518
    return self._create(obj, preview=False, include_link=include_link,
2✔
519
                        ignore_formatting=ignore_formatting)
520

521
  def preview_create(self, obj, include_link=source.OMIT_LINK,
2✔
522
                     ignore_formatting=False):
523
    """Previews creating an issue or comment.
524

525
    Args:
526
      obj (dict): ActivityStreams object
527
      include_link (str)
528
      ignore_formatting (bool)
529

530
    Returns:
531
      CreationResult or None: ``contents`` will be a str HTML snippet
532
    """
533
    return self._create(obj, preview=True, include_link=include_link,
2✔
534
                        ignore_formatting=ignore_formatting)
535

536
  def _create(self, obj, preview=None, include_link=source.OMIT_LINK,
2✔
537
              ignore_formatting=False):
538
    """Creates a new issue or comment.
539

540
    When creating a new issue, if the authenticated user is a collaborator on
541
    the repo, tags that match existing labels are converted to those labels and
542
    included.
543

544
    * https://developer.github.com/v4/guides/forming-calls/#about-mutations
545
    * https://developer.github.com/v4/mutation/addcomment/
546
    * https://developer.github.com/v4/mutation/addreaction/
547
    * https://developer.github.com/v3/issues/#create-an-issue
548

549
    Args:
550
      obj (dict): ActivityStreams object
551
      preview (bool)
552
      include_link (str)
553
      ignore_formatting (bool)
554

555
    Returns:
556
      CreationResult: If preview is True, the contents will be a str HTML
557
      snippet. If False, it will be a dict with ``id`` and ``url`` keys for the
558
      newly created GitHub object.
559
    """
560
    assert preview in (False, True)
2✔
561

562
    type = as1.object_type(obj)
2✔
563
    if type and type not in ('issue', 'comment', 'activity', 'note', 'article',
2✔
564
                             'like', 'favorite', 'tag'):
565
      return source.creation_result(
×
566
        abort=False, error_plain=f'Cannot publish {type} to GitHub')
567

568
    base_obj = self.base_object(obj)
2✔
569
    base_url = base_obj.get('url')
2✔
570
    if not base_url:
2✔
571
      return source.creation_result(
2✔
572
        abort=True,
573
        error_plain='You need an in-reply-to GitHub repo, issue, PR, or comment URL.')
574

575
    content = orig_content = replace_placeholders(html.escape(
2✔
576
      self._content_for_create(obj, ignore_formatting=ignore_formatting),
577
      quote=False))
578

579
    # convert GitHub user profile URLs to @-mentions,
580
    # eg https://github.com/snarfed to @snarfed
581
    content = self.USER_URL_RE.sub(r'@\1', content)
2✔
582

583
    url = obj.get('url')
2✔
584
    if include_link == source.INCLUDE_LINK and url:
2✔
585
      content += f'\n\n(Originally published at: {url})'
×
586

587
    parsed = urllib.parse.urlparse(base_url)
2✔
588
    path = parsed.path.strip('/').split('/')
2✔
589
    owner, repo = path[:2]
2✔
590
    if len(path) == 4:
2✔
591
      number = path[3]
2✔
592

593
    # TODO: support #pullrequestreview-* URLs for top-level PR comments too.
594
    # Haven't yet gotten those to work via either the issues or pulls APIs.
595
    # https://github.com/snarfed/bridgy/issues/955#issuecomment-788478848
596
    comment_id = re.fullmatch(r'(discussion_r|issuecomment-)([0-9]+)', parsed.fragment)
2✔
597
    comment_type = None
2✔
598
    if comment_id:
2✔
599
      comment_type = 'issues' if comment_id.group(1) == 'issuecomment-' else 'pulls'
2✔
600
      comment_id = comment_id.group(2)
2✔
601
    elif parsed.fragment:
2✔
602
      return source.creation_result(
2✔
603
        abort=True,
604
        error_plain=f'Please remove the fragment #{parsed.fragment} from your in-reply-to URL.')
605

606
    if type == 'comment':  # comment or reaction
2✔
607
      if not (len(path) == 4 and path[2] in ('issues', 'pull')):
2✔
608
        return source.creation_result(
×
609
          abort=True, error_plain='GitHub comment requires in-reply-to issue or PR URL.')
610

611
      is_reaction = orig_content in REACTIONS_GRAPHQL
2✔
612
      if preview:
2✔
613
        if comment_id:
2✔
614
          comment = self.rest(REST_COMMENT % (owner, repo, comment_type, comment_id))
2✔
615
          comment_body = util.parse_html(comment['body']).get_text(' ', strip=True)
2✔
616
          target_link = f"<a href=\"{base_url}\">a comment on {owner}/{repo}#{number}, <em>{util.ellipsize(comment_body)}</em></a>"
2✔
617
        else:
618
          resp = self.graphql(GRAPHQL_ISSUE_OR_PR, locals())
2✔
619
          title = ''
2✔
620
          if issue := (resp.get('repository') or {}).get('issueOrPullRequest'):
2✔
621
            title = util.parse_html(issue['title']).get_text(' ', strip=True)
2✔
622
          target_link = '<a href="%s">%s/%s#%s%s</a>' % (
2✔
623
            base_url, owner, repo, number, (', <em>%s</em>' % title) if title else '')
624

625
        if is_reaction:
2✔
626
          preview_content = None
2✔
627
          desc = f'<span class="verb">react {orig_content}</span> to {target_link}.'
2✔
628
        else:
629
          preview_content = self.render_markdown(content, owner, repo)
2✔
630
          desc = f'<span class="verb">comment</span> on {target_link}:'
2✔
631
        return source.creation_result(content=preview_content, description=desc)
2✔
632

633
      else:  # create
634
        # we originally used the GraphQL API to create issue comments and
635
        # reactions, but it often gets rejected against org repos due to access
636
        # controls. oddly, the REST API works fine in those same cases.
637
        # https://github.com/snarfed/bridgy/issues/824
638
        if is_reaction:
2✔
639
          if comment_id:
2✔
640
            api_url = REST_COMMENT_REACTIONS % (owner, repo, comment_type,
2✔
641
                                                    comment_id)
642
            reacted = self.rest(api_url, data={
2✔
643
              'content': REACTIONS_REST.get(orig_content),
644
            })
645
            url = base_url
2✔
646
          else:
647
            api_url = REST_REACTIONS % (owner, repo, number)
2✔
648
            reacted = self.rest(api_url, data={
2✔
649
              'content': REACTIONS_REST.get(orig_content),
650
            })
651
            url = f"{base_url}#{reacted['content'].lower()}-by-{reacted['user']['login']}"
2✔
652

653
          return source.creation_result({
2✔
654
            'id': reacted.get('id'),
655
            'url': url,
656
            'type': 'react',
657
          })
658

659
        else:
660
          try:
2✔
661
            api_url = REST_COMMENTS % (owner, repo, number)
2✔
662
            commented = self.rest(api_url, data={'body': content})
2✔
663
            return source.creation_result({
2✔
664
              'id': commented.get('id'),
665
              'url': commented.get('html_url'),
666
              'type': 'comment',
667
            })
668
          except ValueError as e:
×
669
            return source.creation_result(abort=True, error_plain=str(e))
×
670

671
    elif type in ('like', 'favorite'):  # star
2✔
672
      if not (len(path) == 2 or (len(path) == 3 and path[2] == 'issues')):
2✔
673
        return source.creation_result(
×
674
          abort=True, error_plain='GitHub like requires in-reply-to repo URL.')
675

676
      if preview:
2✔
677
        return source.creation_result(
2✔
678
          description=f'<span class="verb">star</span> <a href="{base_url}">{owner}/{repo}</a>.')
679

680
      issue = self.graphql(GRAPHQL_REPO, locals())
2✔
681
      resp = self.graphql(GRAPHQL_ADD_STAR, {
2✔
682
        'starrable_id': issue['repository']['id'],
683
      })
684
      return source.creation_result({
2✔
685
        'url': base_url + '/stargazers',
686
      })
687

688
    elif type == 'tag':  # add label
2✔
689
      if not (len(path) == 4 and path[2] in ('issues', 'pull')):
2✔
690
        return source.creation_result(
×
691
          abort=True, error_plain='GitHub tag post requires tag-of issue or PR URL.')
692

693
      tags = set(util.trim_nulls(t.get('displayName', '').strip()
2✔
694
                                 for t in util.get_list(obj, 'object')))
695
      if not tags:
2✔
696
        return source.creation_result(
2✔
697
          abort=True, error_plain='No tags found in tag post!')
698

699
      existing_labels = self.existing_labels(owner, repo)
2✔
700
      labels = sorted(tags & existing_labels)
2✔
701
      issue_link = f'<a href="{base_url}">{owner}/{repo}#{number}</a>'
2✔
702
      if not labels:
2✔
703
        safe_tags = ', '.join(util.parse_html(t).get_text(' ', strip=True)
2✔
704
                              for t in sorted(tags))
705
        safe_labels = ', '.join(util.parse_html(l).get_text(' ', strip=True)
2✔
706
                                for l in sorted(existing_labels))
707
        return source.creation_result(
2✔
708
          abort=True,
709
          error_html=f"No tags in [{safe_tags}] matched {issue_link}'s existing labels [{safe_labels}].")
710

711
      if preview:
2✔
712
        safe_label_list = ', '.join(util.parse_html(l).get_text(' ', strip=True)
2✔
713
                                    for l in labels)
714
        return source.creation_result(
2✔
715
          description=f"add label{'s' if len(labels) > 1 else ''} <span class=\"verb\">{safe_label_list}</span> to {issue_link}.")
716

717
      resp = self.rest(REST_ISSUE_LABELS % (owner, repo, number), labels)
2✔
718
      return source.creation_result({
2✔
719
        'url': base_url,
720
        'type': 'tag',
721
        'tags': labels,
722
      })
723

724
    else:  # new issue
725
      if not (len(path) == 2 or (len(path) == 3 and path[2] == 'issues')):
2✔
726
        return source.creation_result(
×
727
          abort=True, error_plain='New GitHub issue requires in-reply-to repo URL')
728

729
      title = util.ellipsize(obj.get('displayName') or obj.get('title') or
2✔
730
                             orig_content)
731
      tags = set(util.trim_nulls(t.get('displayName', '').strip()
2✔
732
                                 for t in util.get_list(obj, 'tags')))
733
      labels = sorted(tags & self.existing_labels(owner, repo))
2✔
734

735
      if preview:
2✔
736
        preview_content = f'<b>{title}</b><hr>{self.render_markdown(content, owner, repo)}'
2✔
737
        preview_labels = ''
2✔
738
        if labels:
2✔
739
          safe_label_list = ', '.join(util.parse_html(l).get_text(' ', strip=True) for l in labels)
2✔
740
          preview_labels = f" and attempt to add label{'s' if len(labels) > 1 else ''} <span class=\"verb\">{safe_label_list}</span>"
2✔
741
        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}:""")
2✔
742
      else:
743
        resp = self.rest(REST_CREATE_ISSUE % (owner, repo), {
2✔
744
          'title': title,
745
          'body': content,
746
          'labels': labels,
747
        })
748
        resp['url'] = resp.pop('html_url')
2✔
749
        return source.creation_result(resp)
2✔
750

751
    return source.creation_result(
752
      abort=False,
753
      error_plain=f"{base_url} doesn't look like a GitHub repo, issue, or PR URL.")
754

755
  def existing_labels(self, owner, repo):
2✔
756
    """Fetches and returns a repo's labels.
757

758
    Args:
759
      owner (str): GitHub username or org that owns the repo
760
      repo (str)
761

762
    Returns:
763
      set of str:
764
    """
765
    resp = self.graphql(GRAPHQL_REPO_LABELS, locals())
2✔
766

767
    repo = resp.get('repository')
2✔
768
    if not repo:
2✔
769
      return set()
2✔
770

771
    return {node['name'] for node in repo['labels']['nodes']}
2✔
772

773
  def issue_to_as1(self, issue):
2✔
774
    """Converts a GitHub issue or pull request to ActivityStreams.
775

776
    Handles both v4 GraphQL and v3 REST API issue and PR objects.
777

778
    * https://developer.github.com/v4/object/issue/
779
    * https://developer.github.com/v4/object/pullrequest/
780
    * https://developer.github.com/v3/issues/
781
    * https://developer.github.com/v3/pulls/
782

783
    Args:
784
      issue (dict): GitHub issue or PR
785

786
    Returns:
787
      dict: ActivityStreams object
788
    """
789
    obj = self._to_as1(issue, repo_id=True)
2✔
790
    if not obj:
2✔
791
      return obj
2✔
792

793
    repo_url = re.sub(r'/(issue|pull)s?/[0-9]+$', '', obj['url'])
2✔
794
    if issue.get('merged') is not None:
2✔
795
      type = 'pull-request'
2✔
796
      in_reply_to = repo_url + '/tree/' + (issue.get('base', {}).get('ref') or 'master')
2✔
797
    else:
798
      type = 'issue'
2✔
799
      in_reply_to = repo_url + '/issues'
2✔
800

801
    obj.update({
2✔
802
      'objectType': type,
803
      'inReplyTo': [{'url': in_reply_to}],
804
      'tags': [{
805
        'displayName': l['name'],
806
        'url': f"{repo_url}/labels/{urllib.parse.quote(l['name'])}",
807
      } for l in issue.get('labels', []) if l.get('name')],
808
    })
809
    return self.postprocess_object(obj)
2✔
810

811
  issue_to_object = issue_to_as1
2✔
812
  """Deprecated! Use :meth:`issue_to_as1` instead."""
1✔
813

814
  pr_to_as1 = issue_to_as1
2✔
815

816
  pr_to_object = pr_to_as1
2✔
817
  """Deprecated! Use :meth:`pr_to_as1` instead."""
1✔
818

819
  def comment_to_as1(self, comment):
2✔
820
    """Converts a GitHub comment to ActivityStreams.
821

822
    Handles both v4 GraphQL and v3 REST API issue objects.
823

824
    * https://developer.github.com/v4/object/issue/
825
    * https://developer.github.com/v3/issues/
826

827
    Args:
828
      comment (dict): GitHub issue
829

830
    Returns:
831
      dict: ActivityStreams comment
832
    """
833
    obj = self._to_as1(comment, repo_id=True)
2✔
834
    if not obj:
2✔
835
      return obj
2✔
836

837
    obj.update({
2✔
838
      'objectType': 'comment',
839
      # url is e.g. https://github.com/foo/bar/pull/123#issuecomment-456
840
      'inReplyTo': [{'url': util.fragmentless(obj['url'])}],
841
    })
842
    return self.postprocess_object(obj)
2✔
843

844
  comment_to_object = comment_to_as1
2✔
845
  """Deprecated! Use :meth:`comment_to_as1` instead."""
1✔
846

847
  def reaction_to_as1(self, reaction, target):
2✔
848
    """Converts a GitHub emoji reaction to ActivityStreams.
849

850
    Handles v3 REST API reaction objects.
851

852
    https://developer.github.com/v3/reactions/
853

854
    Args:
855
      reaction (dict): v3 GitHub reaction
856
      target (dict): ActivityStreams object of reaction
857

858
    Returns:
859
      dict: ActivityStreams reaction
860
    """
861
    obj = self._to_as1(reaction)
2✔
862
    if not obj:
2✔
863
      return obj
×
864

865
    content = REACTIONS_REST_CHARS.get(reaction.get('content'))
2✔
866
    enum = (REACTIONS_GRAPHQL.get(content) or '').lower()
2✔
867
    author = self.to_as1_actor(reaction.get('user'))
2✔
868
    username = author.get('username')
2✔
869

870
    obj.update({
2✔
871
      'objectType': 'activity',
872
      'verb': 'react',
873
      'id': target['id'] + f'_{enum}_by_{username}',
874
      'url': target['url'] + f'#{enum}-by-{username}',
875
      'author': author,
876
      'content': content,
877
      'object': {'url': target['url']},
878
    })
879
    return self.postprocess_object(obj)
2✔
880

881
  reaction_to_object = reaction_to_as1
2✔
882
  """Deprecated! Use :meth:`reaction_to_as1` instead."""
1✔
883

884
  def to_as1_actor(self, user):
2✔
885
    """Converts a GitHub user to an ActivityStreams actor.
886

887
    Handles both v4 GraphQL and v3 REST API user objects.
888

889
    * https://developer.github.com/v4/object/user/
890
    * https://developer.github.com/v3/users/
891

892
    Args:
893
      user (dict): GitHub user
894

895
    Returns:
896
      dict: ActivityStreams actor
897
    """
898
    actor = self._to_as1(user)
2✔
899
    if not actor:
2✔
900
      return actor
2✔
901

902
    username = user.get('login')
2✔
903
    desc = user.get('bio') or user.get('description')
2✔
904

905
    actor.update({
2✔
906
      # TODO: orgs, bots
907
      'objectType': 'person',
908
      'displayName': user.get('name') or username,
909
      'username': username,
910
      'email': user.get('email'),
911
      'description': desc,
912
      'summary': desc,
913
      'image': {'url': user.get('avatarUrl') or user.get('avatar_url') or user.get('url')},
914
      'location': {'displayName': user.get('location')},
915
    })
916

917
    # extract web site links. extract_links uniquifies and preserves order
918
    urls = sum((util.extract_links(user.get(field)) for field in (
2✔
919
      'html_url',  # REST
920
      'url',  # both
921
      'websiteUrl',  # GraphQL
922
      'blog',  # REST
923
      'bio',   # both
924
    )), [])
925
    urls = [u for u in urls if util.domain_from_link(u) != 'api.github.com']
2✔
926
    if urls:
2✔
927
      actor['url'] = urls[0]
2✔
928
      if len(urls) > 1:
2✔
929
        actor['urls'] = [{'value': u} for u in urls]
2✔
930

931
    return self.postprocess_object(actor)
2✔
932

933
  def _to_as1(self, input, repo_id=False):
2✔
934
    """Starts to convert a GraphQL or REST API object to ActivityStreams.
935

936
    Args:
937
      input (dict): GraphQL or REST object
938
      repo_id (bool): whether to inject repo owner and name into id
939

940
    Returns:
941
      dict: ActivityStreams object
942
    """
943
    if not input:
2✔
944
      return {}
2✔
945

946
    id = input.get('node_id') or input.get('id')
2✔
947
    number = input.get('number')
2✔
948
    url = input.get('html_url') or input.get('url') or ''
2✔
949
    if repo_id and id and url:
2✔
950
      # inject repo owner and name
951
      path = urllib.parse.urlparse(url).path.strip('/').split('/')
2✔
952
      owner, repo = path[:2]
2✔
953
      # join with : because github allows ., _, and - in repo names. (see
954
      # REPO_NAME_RE.)
955
      id = ':'.join((owner, repo, str(number or id)))
2✔
956

957
    return {
2✔
958
      'id': self.tag_uri(id),
959
      'url': url,
960
      'author': self.to_as1_actor(input.get('author') or input.get('user')),
961
      'displayName': input.get('title'),
962
      'content': (input.get('body') or '').replace('\r\n', '\n'),
963
      'published': util.maybe_iso8601_to_rfc3339(input.get('createdAt') or
964
                                                 input.get('created_at')),
965
      'updated': util.maybe_iso8601_to_rfc3339(input.get('lastEditedAt') or
966
                                               input.get('updated_at')),
967
    }
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc