github.py 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235
  1. #
  2. # Copyright (c) 2022 Project CHIP Authors
  3. #
  4. # Licensed under the Apache License, Version 2.0 (the "License");
  5. # you may not use this file except in compliance with the License.
  6. # You may obtain a copy of the License at
  7. #
  8. # http://www.apache.org/licenses/LICENSE-2.0
  9. #
  10. # Unless required by applicable law or agreed to in writing, software
  11. # distributed under the License is distributed on an "AS IS" BASIS,
  12. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  13. # See the License for the specific language governing permissions and
  14. # limitations under the License.
  15. #
  16. """Utility wrapper for GitHub operations."""
  17. import itertools
  18. import logging
  19. import os
  20. from typing import Iterable, Mapping, Optional
  21. import dateutil # type: ignore
  22. import dateutil.parser # type: ignore
  23. import ghapi.all # type: ignore
  24. from memdf import Config, ConfigDescription
  25. def postprocess_config(config: Config, _key: str, _info: Mapping) -> None:
  26. """Postprocess --github-repository."""
  27. if config['github.repository']:
  28. owner, repo = config.get('github.repository').split('/', 1)
  29. config.put('github.owner', owner)
  30. config.put('github.repo', repo)
  31. if not config['github.token']:
  32. config['github.token'] = os.environ.get('GITHUB_TOKEN')
  33. if not config['github.token']:
  34. logging.error('Missing --github-token')
  35. CONFIG: ConfigDescription = {
  36. Config.group_def('github'): {
  37. 'title': 'github options',
  38. },
  39. 'github.token': {
  40. 'help': 'Github API token, or "SKIP" to suppress connecting to github',
  41. 'metavar': 'TOKEN',
  42. 'default': '',
  43. 'argparse': {
  44. 'alias': ['--github-api-token', '--token'],
  45. },
  46. },
  47. 'github.repository': {
  48. 'help': 'Github repostiory',
  49. 'metavar': 'OWNER/REPO',
  50. 'default': '',
  51. 'argparse': {
  52. 'alias': ['--repo'],
  53. },
  54. 'postprocess': postprocess_config,
  55. },
  56. 'github.dryrun-comment': {
  57. 'help': "Don't actually post comments",
  58. 'default': False,
  59. },
  60. 'github.keep': {
  61. 'help': "Don't remove PR artifacts",
  62. 'default': False,
  63. 'argparse': {
  64. 'alias': ['--keep'],
  65. },
  66. },
  67. 'github.limit-artifact-pages': {
  68. 'help': 'Examine no more than COUNT pages of artifacts',
  69. 'metavar': 'COUNT',
  70. 'default': 0,
  71. 'argparse': {
  72. 'type': int,
  73. },
  74. },
  75. }
  76. class Gh:
  77. """Utility wrapper for GitHub operations."""
  78. def __init__(self, config: Config):
  79. self.config = config
  80. self.ghapi: Optional[ghapi.all.GhApi] = None
  81. self.deleted_artifacts: set[int] = set()
  82. owner = config['github.owner']
  83. repo = config['github.repo']
  84. token = config['github.token']
  85. if owner and repo and token and token != 'SKIP':
  86. self.ghapi = ghapi.all.GhApi(owner=owner, repo=repo, token=token)
  87. def __bool__(self):
  88. return self.ghapi is not None
  89. def get_comments_for_pr(self, pr: int):
  90. """Iterate PR comments."""
  91. assert self.ghapi
  92. try:
  93. return itertools.chain.from_iterable(
  94. ghapi.all.paged(self.ghapi.issues.list_comments, pr))
  95. except Exception as e:
  96. logging.error('Failed to get comments for PR #%d: %s', pr, e)
  97. return []
  98. def get_commits_for_pr(self, pr: int):
  99. """Iterate PR commits."""
  100. assert self.ghapi
  101. try:
  102. return itertools.chain.from_iterable(
  103. ghapi.all.paged(self.ghapi.pulls.list_commits, pr))
  104. except Exception as e:
  105. logging.error('Failed to get commits for PR #%d: %s', pr, e)
  106. return []
  107. def get_artifacts(self, page_limit: int = -1, per_page: int = -1):
  108. """Iterate artifact descriptions."""
  109. if page_limit < 0:
  110. page_limit = self.config['github.limit-artifact-pages']
  111. if per_page < 0:
  112. per_page = self.config['github.artifacts-per-page'] or 100
  113. assert self.ghapi
  114. try:
  115. page = 0
  116. for i in ghapi.all.paged(
  117. self.ghapi.actions.list_artifacts_for_repo,
  118. per_page=per_page):
  119. if not i.artifacts:
  120. break
  121. for a in i.artifacts:
  122. yield a
  123. page += 1
  124. logging.debug('ASP: artifact page %d of %d', page, page_limit)
  125. if page_limit and page >= page_limit:
  126. break
  127. except Exception as e:
  128. logging.error('Failed to get artifact list: %s', e)
  129. def get_size_artifacts(self,
  130. page_limit: int = -1,
  131. per_page: int = -1,
  132. label: str = ''):
  133. """Iterate size artifact descriptions."""
  134. for a in self.get_artifacts(page_limit, per_page):
  135. # Size artifacts have names of the form:
  136. # Size,{group},{pr},{commit_hash},{parent_hash}[,{event}]
  137. # This information is added to the attribute record from GitHub.
  138. if a.name.startswith('Size,') and a.name.count(',') >= 4:
  139. _, group, pr, commit, parent, *etc = a.name.split(',')
  140. if label and group != label:
  141. continue
  142. a.group = group
  143. a.commit = commit
  144. a.parent = parent
  145. a.pr = pr
  146. a.created_at = dateutil.parser.isoparse(a.created_at)
  147. # Old artifact names don't include the event.
  148. if etc:
  149. event = etc[0]
  150. else:
  151. event = 'push' if pr == '0' else 'pull_request'
  152. a.event = event
  153. yield a
  154. def download_artifact(self, artifact_id: int):
  155. """Download a GitHub artifact, returning a binary zip object."""
  156. logging.debug('Downloading artifact %d', artifact_id)
  157. try:
  158. assert self.ghapi
  159. return self.ghapi.actions.download_artifact(artifact_id, 'zip')
  160. except Exception as e:
  161. logging.error('Failed to download artifact %d: %s', artifact_id, e)
  162. return None
  163. def delete_artifact(self, artifact_id: int) -> bool:
  164. """Delete a GitHub artifact."""
  165. if not artifact_id or artifact_id in self.deleted_artifacts:
  166. return True
  167. self.deleted_artifacts.add(artifact_id)
  168. if self.config['github.keep']:
  169. logging.info('Suppressed deleting artifact %d', artifact_id)
  170. return False
  171. try:
  172. assert self.ghapi
  173. logging.info('Deleting artifact %d', artifact_id)
  174. self.ghapi.actions.delete_artifact(artifact_id)
  175. return True
  176. except Exception as e:
  177. # During manual testing we sometimes lose the race against CI.
  178. logging.error('Failed to delete artifact %d: %s', artifact_id, e)
  179. return False
  180. def delete_artifacts(self, artifacts: Iterable[int]):
  181. for artifact_id in artifacts:
  182. self.delete_artifact(artifact_id)
  183. def create_comment(self, issue_id: int, text: str) -> bool:
  184. """Create a GitHub comment."""
  185. if self.config['github.dryrun-comment']:
  186. logging.info('Suppressed creating comment on #%d', issue_id)
  187. logging.debug('%s', text)
  188. return False
  189. assert self.ghapi
  190. logging.info('Creating comment on #%d', issue_id)
  191. try:
  192. self.ghapi.issues.create_comment(issue_id, text)
  193. return True
  194. except Exception as e:
  195. logging.error('Failed to created comment on #%d: %s', issue_id, e)
  196. return False
  197. def update_comment(self, comment_id: int, text: str) -> bool:
  198. """Update a GitHub comment."""
  199. if self.config['github.dryrun-comment']:
  200. logging.info('Suppressed updating comment #%d', comment_id)
  201. logging.debug('%s', text)
  202. return False
  203. logging.info('Updating comment #%d', comment_id)
  204. try:
  205. assert self.ghapi
  206. self.ghapi.issues.update_comment(comment_id, text)
  207. return True
  208. except Exception as e:
  209. logging.error('Failed to update comment %d: %s', comment_id, e)
  210. return False