blob: bcd14c597701a2c2729858c49fb36ee1712004e8 [file] [log] [blame]
#
# 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