| # |
| # Copyright (c) 2022 Project CHIP Authors |
| # |
| # Licensed under the Apache License, Version 2.0 (the "License"); |
| # you may not use this file except in compliance with the License. |
| # You may obtain a copy of the License at |
| # |
| # http://www.apache.org/licenses/LICENSE-2.0 |
| # |
| # Unless required by applicable law or agreed to in writing, software |
| # distributed under the License is distributed on an "AS IS" BASIS, |
| # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| # See the License for the specific language governing permissions and |
| # limitations under the License. |
| # |
| """Utility wrapper for GitHub operations.""" |
| |
| import itertools |
| import logging |
| import os |
| |
| from typing import Iterable, Mapping, Optional |
| |
| import dateutil # type: ignore |
| import dateutil.parser # type: ignore |
| import ghapi.all # type: ignore |
| |
| from memdf import Config, ConfigDescription |
| |
| |
| def postprocess_config(config: Config, _key: str, _info: Mapping) -> None: |
| """Postprocess --github-repository.""" |
| if config['github.repository']: |
| owner, repo = config.get('github.repository').split('/', 1) |
| config.put('github.owner', owner) |
| config.put('github.repo', repo) |
| if not config['github.token']: |
| config['github.token'] = os.environ.get('GITHUB_TOKEN') |
| if not config['github.token']: |
| logging.error('Missing --github-token') |
| |
| |
| CONFIG: ConfigDescription = { |
| Config.group_def('github'): { |
| 'title': 'github options', |
| }, |
| 'github.token': { |
| 'help': 'Github API token, or "SKIP" to suppress connecting to github', |
| 'metavar': 'TOKEN', |
| 'default': '', |
| 'argparse': { |
| 'alias': ['--github-api-token', '--token'], |
| }, |
| }, |
| 'github.repository': { |
| 'help': 'Github repostiory', |
| 'metavar': 'OWNER/REPO', |
| 'default': '', |
| 'argparse': { |
| 'alias': ['--repo'], |
| }, |
| 'postprocess': postprocess_config, |
| }, |
| 'github.dryrun-comment': { |
| 'help': "Don't actually post comments", |
| 'default': False, |
| }, |
| 'github.keep': { |
| 'help': "Don't remove PR artifacts", |
| 'default': False, |
| 'argparse': { |
| 'alias': ['--keep'], |
| }, |
| }, |
| 'github.limit-artifact-pages': { |
| 'help': 'Examine no more than COUNT pages of artifacts', |
| 'metavar': 'COUNT', |
| 'default': 0, |
| 'argparse': { |
| 'type': int, |
| }, |
| }, |
| } |
| |
| |
| class Gh: |
| """Utility wrapper for GitHub operations.""" |
| |
| def __init__(self, config: Config): |
| self.config = config |
| self.ghapi: Optional[ghapi.all.GhApi] = None |
| self.deleted_artifacts: set[int] = set() |
| |
| owner = config['github.owner'] |
| repo = config['github.repo'] |
| token = config['github.token'] |
| if owner and repo and token and token != 'SKIP': |
| self.ghapi = ghapi.all.GhApi(owner=owner, repo=repo, token=token) |
| |
| def __bool__(self): |
| return self.ghapi is not None |
| |
| def get_comments_for_pr(self, pr: int): |
| """Iterate PR comments.""" |
| assert self.ghapi |
| try: |
| return itertools.chain.from_iterable( |
| ghapi.all.paged(self.ghapi.issues.list_comments, pr)) |
| except Exception as e: |
| logging.error('Failed to get comments for PR #%d: %s', pr, e) |
| return [] |
| |
| def get_commits_for_pr(self, pr: int): |
| """Iterate PR commits.""" |
| assert self.ghapi |
| try: |
| return itertools.chain.from_iterable( |
| ghapi.all.paged(self.ghapi.pulls.list_commits, pr)) |
| except Exception as e: |
| logging.error('Failed to get commits for PR #%d: %s', pr, e) |
| return [] |
| |
| def get_artifacts(self, page_limit: int = -1, per_page: int = -1): |
| """Iterate artifact descriptions.""" |
| if page_limit < 0: |
| page_limit = self.config['github.limit-artifact-pages'] |
| if per_page < 0: |
| per_page = self.config['github.artifacts-per-page'] or 100 |
| |
| assert self.ghapi |
| try: |
| page = 0 |
| for i in ghapi.all.paged( |
| self.ghapi.actions.list_artifacts_for_repo, |
| per_page): |
| if not i.artifacts: |
| break |
| for a in i.artifacts: |
| yield a |
| page += 1 |
| logging.debug('ASP: artifact page %d of %d', page, page_limit) |
| if page_limit and page >= page_limit: |
| break |
| except Exception as e: |
| logging.error('Failed to get artifact list: %s', e) |
| |
| def get_size_artifacts(self, |
| page_limit: int = -1, |
| per_page: int = -1, |
| label: str = ''): |
| """Iterate size artifact descriptions.""" |
| for a in self.get_artifacts(page_limit, per_page): |
| # Size artifacts have names of the form: |
| # Size,{group},{pr},{commit_hash},{parent_hash}[,{event}] |
| # This information is added to the attribute record from GitHub. |
| if a.name.startswith('Size,') and a.name.count(',') >= 4: |
| _, group, pr, commit, parent, *etc = a.name.split(',') |
| if label and group != label: |
| continue |
| a.group = group |
| a.commit = commit |
| a.parent = parent |
| a.pr = pr |
| a.created_at = dateutil.parser.isoparse(a.created_at) |
| # Old artifact names don't include the event. |
| if etc: |
| event = etc[0] |
| else: |
| event = 'push' if pr == '0' else 'pull_request' |
| a.event = event |
| yield a |
| |
| def download_artifact(self, artifact_id: int): |
| """Download a GitHub artifact, returning a binary zip object.""" |
| logging.debug('Downloading artifact %d', artifact_id) |
| try: |
| assert self.ghapi |
| return self.ghapi.actions.download_artifact(artifact_id, 'zip') |
| except Exception as e: |
| logging.error('Failed to download artifact %d: %s', artifact_id, e) |
| return None |
| |
| def delete_artifact(self, artifact_id: int) -> bool: |
| """Delete a GitHub artifact.""" |
| if not artifact_id or artifact_id in self.deleted_artifacts: |
| return True |
| self.deleted_artifacts.add(artifact_id) |
| |
| if self.config['github.keep']: |
| logging.info('Suppressed deleting artifact %d', artifact_id) |
| return False |
| |
| try: |
| assert self.ghapi |
| logging.info('Deleting artifact %d', artifact_id) |
| self.ghapi.actions.delete_artifact(artifact_id) |
| return True |
| except Exception as e: |
| # During manual testing we sometimes lose the race against CI. |
| logging.error('Failed to delete artifact %d: %s', artifact_id, e) |
| return False |
| |
| def delete_artifacts(self, artifacts: Iterable[int]): |
| for artifact_id in artifacts: |
| self.delete_artifact(artifact_id) |
| |
| def create_comment(self, issue_id: int, text: str) -> bool: |
| """Create a GitHub comment.""" |
| if self.config['github.dryrun-comment']: |
| logging.info('Suppressed creating comment on #%d', issue_id) |
| logging.debug('%s', text) |
| return False |
| |
| assert self.ghapi |
| logging.info('Creating comment on #%d', issue_id) |
| try: |
| self.ghapi.issues.create_comment(issue_id, text) |
| return True |
| except Exception as e: |
| logging.error('Failed to created comment on #%d: %s', issue_id, e) |
| return False |
| |
| def update_comment(self, comment_id: int, text: str) -> bool: |
| """Update a GitHub comment.""" |
| if self.config['github.dryrun-comment']: |
| logging.info('Suppressed updating comment #%d', comment_id) |
| logging.debug('%s', text) |
| return False |
| |
| logging.info('Updating comment #%d', comment_id) |
| try: |
| assert self.ghapi |
| self.ghapi.issues.update_comment(comment_id, text) |
| return True |
| except Exception as e: |
| logging.error('Failed to update comment %d: %s', comment_id, e) |
| return False |