github_helpers.py 3.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109
  1. """GitHub GraphQL helpers.
  2. https://developer.github.com/v4/explorer/ is very useful for building queries.
  3. """
  4. __copyright__ = """
  5. Part of the Carbon Language project, under the Apache License v2.0 with LLVM
  6. Exceptions. See /LICENSE for license information.
  7. SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
  8. """
  9. import argparse
  10. import os
  11. from typing import Dict, Generator, Optional, Tuple
  12. # https://pypi.org/project/gql/
  13. import gql
  14. import gql.transport.requests
  15. _ENV_TOKEN = "GITHUB_ACCESS_TOKEN"
  16. # Query elements for pagination.
  17. PAGINATION = """pageInfo {
  18. hasNextPage
  19. endCursor
  20. }
  21. totalCount"""
  22. def add_access_token_arg(
  23. parser: argparse.ArgumentParser, permissions: str
  24. ) -> None:
  25. """Adds a flag to set the access token."""
  26. access_token = os.environ.get(_ENV_TOKEN, default=None)
  27. parser.add_argument(
  28. "--access-token",
  29. metavar="ACCESS_TOKEN",
  30. default=access_token,
  31. required=not access_token,
  32. help="The access token for use with GitHub. May also be specified in "
  33. "the environment as %s. The access token should have permissions: %s"
  34. % (_ENV_TOKEN, permissions),
  35. )
  36. class Client(object):
  37. """A GitHub GraphQL client."""
  38. def __init__(self, parsed_args: argparse.Namespace):
  39. """Connects to GitHub."""
  40. transport = gql.transport.requests.RequestsHTTPTransport(
  41. url="https://api.github.com/graphql",
  42. headers={"Authorization": "bearer %s" % parsed_args.access_token},
  43. )
  44. self._client = gql.Client(transport=transport) # type: ignore
  45. def execute(self, query: str) -> Dict:
  46. """Runs a query."""
  47. return self._client.execute(gql.gql(query)) # type: ignore
  48. def execute_and_paginate(
  49. self,
  50. query: str,
  51. path: Tuple[str, ...],
  52. first_page: Optional[Dict] = None,
  53. ) -> Generator[Dict, None, None]:
  54. """Runs a query with pagination.
  55. Arguments:
  56. query: The GraphQL query template, which must have both 'cursor' and
  57. 'pagination' fields to fill in. The cursor should be part of the
  58. location query (with 'first'), and the pagination should be at the
  59. same level as nodes.
  60. path: A list of strings indicating the path to the nodes in the
  61. result.
  62. first_page: An optional object for the first page of results, which
  63. will otherwise automatically be collected. This exists for callers
  64. to optimize by collecting other data with the first page.
  65. """
  66. format = {"cursor": "", "pagination": PAGINATION}
  67. count = 0
  68. exp_count = None
  69. while True:
  70. if first_page:
  71. result = first_page
  72. first_page = None
  73. else:
  74. result = self.execute(query % format)
  75. # Follow the path to the nodes being paginated.
  76. node_parent = result
  77. for entry in path:
  78. node_parent = node_parent[entry]
  79. # Store the total count of responses.
  80. if not exp_count:
  81. exp_count = node_parent["totalCount"]
  82. # Yield each node individually.
  83. for node in node_parent["nodes"]:
  84. yield node
  85. count += 1
  86. # Check for pagination, verifying the total count on exit.
  87. page_info = node_parent["pageInfo"]
  88. if not page_info["hasNextPage"]:
  89. assert exp_count == count, "exp %d != actual %d at path %s" % (
  90. exp_count,
  91. count,
  92. path,
  93. )
  94. return
  95. format["cursor"] = ' after: "%s"' % page_info["endCursor"]