blob: 620ecd5346cfe9a6c2691e2a4154857398aa70af [file] [log] [blame]
# Copyright 2023 The Chromium Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Provides a layer of abstraction for the issue tracker API."""
from http import client as http_client
import json
import logging
from apiclient import discovery
from apiclient import errors
from application import utils
_DISCOVERY_URI = ('https://monorail-prod.appspot.com'
'/_ah/api/discovery/v1/apis/{api}/{apiVersion}/rest')
STATUS_DUPLICATE = 'Duplicate'
MAX_DISCOVERY_RETRIES = 3
MAX_REQUEST_RETRIES = 5
class IssueTrackerClient:
"""Class for updating perf issues."""
def __init__(self):
"""Initializes an object for communicate to the issue tracker.
This object can be re-used to make multiple requests without calling
apliclient.discovery.build multiple times.
This class makes requests to the Monorail API.
API explorer: https://goo.gl/xWd0dX
Args:
http: A Http object that requests will be made through; this should be an
Http object that's already authenticated via OAuth2.
"""
http = utils.ServiceAccountHttp()
http.timeout = 30
# Retry connecting at least 3 times.
attempt = 1
while attempt != MAX_DISCOVERY_RETRIES:
try:
self._service = discovery.build(
'monorail', 'v1', discoveryServiceUrl=_DISCOVERY_URI, http=http)
break
except http_client.HTTPException as e:
logging.error('Attempt #%d: %s', attempt, e)
if attempt == MAX_DISCOVERY_RETRIES:
raise
attempt += 1
def GetIssuesList(self, project='chromium', **kwargs):
"""Makes a request to the issue tracker to list issues."""
# Normalize the project in case it is empty or None.
project = 'chromium' if project is None or not project.strip() else project
request = self._service.issues().list(projectId=project, **kwargs)
response = self._ExecuteRequest(request)
return response.get('items', []) if response else []
def GetIssue(self, issue_id, project='chromium'):
"""Makes a request to the issue tracker to get an issue."""
# Normalize the project in case it is empty or None.
project = 'chromium' if project is None or not project.strip() else project
request = self._service.issues().get(projectId=project, issueId=issue_id)
return self._ExecuteRequest(request)
def GetIssueComments(self, issue_id, project='chromium'):
"""Gets all the comments for the given issue.
Args:
issue_id: Issue ID of the issue to update.
Returns:
A list of comments
"""
request = self._service.issues().comments().list(
projectId=project, issueId=issue_id, maxResults=1000)
response = self._ExecuteRequest(request)
if not response:
return None
return [{
'id': r['id'],
'author': r['author'].get('name'),
'content': r['content'],
'published': r['published'],
'updates': r['updates']
} for r in response.get('items')]
def NewIssue(self,
title,
description,
project='chromium',
labels=None,
components=None,
owner=None,
cc=None,
status=None):
project = 'chromium' if project is None or not project.strip() else project
body = {
'title': title,
'summary': title,
'description': description,
'labels': labels or [],
'components': components or [],
'status': status or ('Assigned' if owner else 'Unconfirmed'),
'projectId': project,
}
if owner:
body['owner'] = {'name': owner}
if cc:
# We deduplicate the CC'ed emails to avoid having to forward
# those to the issue tracker.
accounts = set(email.strip() for email in cc if email.strip())
body['cc'] = [{'name': account} for account in accounts if account]
request = self._service.issues().insert(
projectId=project, sendEmail=True, body=body)
logging.info('Making create issue request with body %s', body)
logging.info('Issue tracker project = %s', project)
try:
response = self._ExecuteRequest(request)
if response and 'id' in response:
return {'issue_id': response['id'], 'project_id': project}
logging.error('Failed to create new issue; response %s', response)
except errors.HttpError as e:
reason = _GetErrorReason(e)
return {'error': reason}
except http_client.HTTPException as e:
return {'error': str(e)}
return {'error': 'Unknown failure creating issue.'}
def NewComment(self,
issue_id,
project='chromium',
comment='',
title=None,
status=None,
merge_issue=None,
owner=None,
cc=None,
components=None,
labels=None,
send_email=True):
if not issue_id or issue_id < 0:
return {'error': 'Missing issue id.'}
# Normalize the project in case it is empty or None.
project = 'chromium' if project is None or not project.strip() else project
body = {'content': comment}
updates = {}
# Mark issue as duplicate when relevant issue ID is found in the datastore.
# Avoid marking an issue as duplicate of itself.
if merge_issue and int(merge_issue) != issue_id:
status = STATUS_DUPLICATE
updates['mergedInto'] = '%s:%s' % (project, merge_issue)
logging.info('Issue %s marked as duplicate of %s', issue_id, merge_issue)
if title:
updates['summary'] = title
if status:
updates['status'] = status
if cc:
updates['cc'] = cc
if labels:
updates['labels'] = labels
if owner:
updates['owner'] = owner
if components:
updates['components'] = components
body['updates'] = updates
return self._MakeCommentRequest(
issue_id, body, project=project, send_email=send_email)
def _MakeCommentRequest(self,
issue_id,
body,
project='chromium',
retry=True,
send_email=True):
request = self._service.issues().comments().insert(
projectId=project, issueId=issue_id, sendEmail=send_email, body=body)
try:
response = self._ExecuteRequest(request)
logging.debug('Monorail response = %s', response)
if response is not None:
return {
'Comment id': response['id'],
'Content': response['content']
}
except errors.HttpError as e:
logging.error('Monorail error: %s', str(e))
reason = _GetErrorReason(e)
if reason is None:
reason = ''
# Retry without owner if we cannot set owner to this issue.
if retry and 'The user does not exist' in reason:
# Remove both the owner and the cc list.
# TODO (crbug.com/806392): We should probably figure out which user it
# is rather than removing all of them.
if 'owner' in body['updates']:
del body['updates']['owner']
if 'cc' in body['updates']:
del body['updates']['cc']
return self._MakeCommentRequest(issue_id, body, retry=False)
if retry and 'Issue owner must be a project member' in reason:
# Remove the owner but retain the cc list.
if 'owner' in body['updates']:
del body['updates']['owner']
return self._MakeCommentRequest(issue_id, body, retry=False)
# This error reason is received when issue is deleted.
if 'User is not allowed to view this issue' in reason:
logging.warning(
'Unable to update issue %s with body %s', issue_id, body)
return {'error': reason}
err_msg = 'Error updating issue %s:%s with body %s' % (
project, issue_id, body)
logging.error(err_msg
)
return {'error': err_msg}
def _ExecuteRequest(self, request):
"""Makes a request to the issue tracker.
Args:
request: The request object, which has a execute method.
Returns:
The response if there was one, or else None.
"""
response = request.execute(
num_retries=MAX_REQUEST_RETRIES, http=utils.ServiceAccountHttp())
return response
def _GetErrorReason(request_error):
if request_error.resp.get('content-type', '').startswith('application/json'):
error_json = json.loads(request_error.content).get('error')
if error_json:
return error_json.get('message')
return None