Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[6.15.z] Add support for Jira Issue handler #14819

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/pull_request.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ on:
env:
PYCURL_SSL_LIBRARY: openssl
ROBOTTELO_BUGZILLA__API_KEY: ${{ secrets.BUGZILLA_KEY }}
ROBOTTELO_JIRA__API_KEY: ${{ secrets.JIRA_KEY }}

jobs:
codechecks:
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/weekly.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ jobs:
id: cscheck
env:
ROBOTTELO_BUGZILLA__API_KEY: ${{ secrets.BUGZILLA_KEY }}
ROBOTTELO_JIRA__API_KEY: ${{ secrets.JIRA_KEY }}

- name: Customer scenario status
run: |
Expand Down
5 changes: 5 additions & 0 deletions conf/jira.yaml.template
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
JIRA:
# url default value is set to 'https://issues.redhat.com' even if not provided.
URL: https://issues.redhat.com
# Provide api_key to access Jira REST API
API_KEY: replace-with-jira-api-key
115 changes: 111 additions & 4 deletions pytest_plugins/metadata_markers.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,22 @@
from robottelo.config import settings
from robottelo.hosts import get_sat_rhel_version
from robottelo.logging import collection_logger as logger
from robottelo.utils.issue_handlers.jira import are_any_jira_open

FMT_XUNIT_TIME = '%Y-%m-%dT%H:%M:%S'
IMPORTANCE_LEVELS = []
selected = []
deselected = []


def parse_comma_separated_list(option_value):
if isinstance(option_value, str):
if option_value.lower() == 'true':
return True
if option_value.lower() == 'false':
return False
return [item.strip() for item in option_value.split(',')]
return None


def pytest_addoption(parser):
Expand All @@ -26,6 +39,25 @@ def pytest_addoption(parser):
'--team',
help='Comma separated list of teams to include in test collection',
)
parser.addoption(
'--blocked-by',
type=parse_comma_separated_list,
nargs='?',
const=True,
default=True,
help='Comma separated list of Jiras to collect tests matching BlockedBy testimony marker. '
'If no issue is provided all the tests with BlockedBy testimony marker will be processed '
'and deselected if any issue is open.',
)
parser.addoption(
'--verifies-issues',
type=parse_comma_separated_list,
nargs='?',
const=True,
default=False,
help='Comma separated list of Jiras to collect tests matching Verifies testimony marker. '
'If no issue is provided all the tests with Verifies testimony marker will be selected.',
)


def pytest_configure(config):
Expand All @@ -34,6 +66,8 @@ def pytest_configure(config):
'importance: CaseImportance testimony token, use --importance to filter',
'component: Component testimony token, use --component to filter',
'team: Team testimony token, use --team to filter',
'blocked_by: BlockedBy testimony token, use --blocked-by to filter',
'verifies_issues: Verifies testimony token, use --verifies_issues to filter',
]:
config.addinivalue_line("markers", marker)

Expand All @@ -56,6 +90,57 @@ def pytest_configure(config):
re.IGNORECASE,
)

blocked_by_regex = re.compile(
# To match :BlockedBy: SAT-32932
r'\s*:BlockedBy:\s*(?P<blocked_by>.*\S*)',
re.IGNORECASE,
)

verifies_regex = re.compile(
# To match :Verifies: SAT-32932
r'\s*:Verifies:\s*(?P<verifies>.*\S*)',
re.IGNORECASE,
)


def handle_verification_issues(item, verifies_marker, verifies_issues):
"""Handles the logic for deselecting tests based on Verifies testimony token
and --verifies-issues pytest option.
"""
if verifies_issues:
if not verifies_marker:
log_and_deselect(item, '--verifies-issues')
return False
if isinstance(verifies_issues, list):
verifies_args = verifies_marker.args[0]
if all(issue not in verifies_issues for issue in verifies_args):
log_and_deselect(item, '--verifies-issues')
return False
return True


def handle_blocked_by(item, blocked_by_marker, blocked_by):
"""Handles the logic for deselecting tests based on BlockedBy testimony token
and --blocked-by pytest option.
"""
if isinstance(blocked_by, list):
if not blocked_by_marker:
log_and_deselect(item, '--blocked-by')
return False
if all(issue not in blocked_by for issue in blocked_by_marker.args[0]):
log_and_deselect(item, '--blocked-by')
return False
elif isinstance(blocked_by, bool) and blocked_by_marker:
if blocked_by and are_any_jira_open(blocked_by_marker.args[0]):
log_and_deselect(item, '--blocked-by')
return False
return True


def log_and_deselect(item, option):
logger.debug(f'Deselected test {item.nodeid} due to "{option}" pytest option.')
deselected.append(item)


@pytest.hookimpl(tryfirst=True)
def pytest_collection_modifyitems(items, config):
Expand All @@ -81,9 +166,8 @@ def pytest_collection_modifyitems(items, config):
importance = [i for i in (config.getoption('importance') or '').split(',') if i != '']
component = [c for c in (config.getoption('component') or '').split(',') if c != '']
team = [a.lower() for a in (config.getoption('team') or '').split(',') if a != '']

selected = []
deselected = []
verifies_issues = config.getoption('verifies_issues')
blocked_by = config.getoption('blocked_by')
logger.info('Processing test items to add testimony token markers')
for item in items:
item.user_properties.append(
Expand All @@ -100,6 +184,8 @@ def pytest_collection_modifyitems(items, config):
for d in map(inspect.getdoc, (item.function, getattr(item, 'cls', None), item.module))
if d is not None
]
blocked_by_marks_to_add = []
verifies_marks_to_add = []
for docstring in item_docstrings:
item_mark_names = [m.name for m in item.iter_markers()]
# Add marker starting at smallest docstring scope
Expand All @@ -113,6 +199,18 @@ def pytest_collection_modifyitems(items, config):
doc_team = team_regex.findall(docstring)
if doc_team and 'team' not in item_mark_names:
item.add_marker(pytest.mark.team(doc_team[0].lower()))
doc_verifies = verifies_regex.findall(docstring)
if doc_verifies and 'verifies_issues' not in item_mark_names:
verifies_marks_to_add.extend(str(b.strip()) for b in doc_verifies[-1].split(','))
doc_blocked_by = blocked_by_regex.findall(docstring)
if doc_blocked_by and 'blocked_by' not in item_mark_names:
blocked_by_marks_to_add.extend(
str(b.strip()) for b in doc_blocked_by[-1].split(',')
)
if blocked_by_marks_to_add:
item.add_marker(pytest.mark.blocked_by(blocked_by_marks_to_add))
if verifies_marks_to_add:
item.add_marker(pytest.mark.verifies_issues(verifies_marks_to_add))

# add markers as user_properties so they are recorded in XML properties of the report
# pytest-ibutsu will include user_properties dict in testresult metadata
Expand Down Expand Up @@ -169,7 +267,16 @@ def pytest_collection_modifyitems(items, config):
deselected.append(item)
continue

selected.append(item)
if verifies_issues or blocked_by:
# Filter tests based on --verifies-issues and --blocked-by pytest options
# and Verifies and BlockedBy testimony tokens.
verifies_marker = item.get_closest_marker('verifies_issues', False)
blocked_by_marker = item.get_closest_marker('blocked_by', False)
if not handle_verification_issues(item, verifies_marker, verifies_issues):
continue
if not handle_blocked_by(item, blocked_by_marker, blocked_by):
continue
selected.append(item)

# selected will be empty if no filter option was passed, defaulting to full items list
items[:] = selected if deselected else items
Expand Down
4 changes: 4 additions & 0 deletions robottelo/config/validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,10 @@
must_exist=True,
),
],
jira=[
Validator('jira.url', default='https://issues.redhat.com'),
Validator('jira.api_key', must_exist=True),
],
ldap=[
Validator(
'ldap.basedn',
Expand Down
7 changes: 6 additions & 1 deletion robottelo/constants/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -1742,10 +1742,15 @@ class Colored(Box):
),
}


# Bugzilla statuses used by Robottelo issue handler.
OPEN_STATUSES = ("NEW", "ASSIGNED", "POST", "MODIFIED")
CLOSED_STATUSES = ("ON_QA", "VERIFIED", "RELEASE_PENDING", "CLOSED")
WONTFIX_RESOLUTIONS = ("WONTFIX", "CANTFIX", "DEFERRED")
# Jira statuses used by Robottelo issue handler.
JIRA_OPEN_STATUSES = ("New", "Backlog", "Refinement", "To Do", "In Progress")
JIRA_ONQA_STATUS = "Review"
JIRA_CLOSED_STATUSES = ("Release Pending", "Closed")
JIRA_WONTFIX_RESOLUTIONS = "Obsolete"

GROUP_MEMBERSHIP_MAPPER = {
"config": {
Expand Down
4 changes: 2 additions & 2 deletions robottelo/utils/issue_handlers/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ Issue handler should expose 3 functions.

### `is_open_<handler_code>(issue, data=None)`

e.g: `is_open_bz, is_open_gh, is_open_jr` for Bugzilla, Github and Jira.
e.g: `is_open_bz, is_open_gh, is_open_jira` for Bugzilla, Github and Jira.

This function is dispatched from `robottelo.helpers.is_open` that is also used
to check for status in the `pytest.mark.skip_if_open` marker.
Expand Down Expand Up @@ -78,10 +78,10 @@ Example of `collected_data`:
## Issue handlers implemented

- `.bugzilla.py`: BZ:123456
- `.jira.py`: SAT-22761

## Issue handlers to be implemented

- `.github.py`: GH:satelliteqe/robottelo#123
- `.gitlab.py`: GL:path/to/repo#123
- `.jira.py`: JR:SATQE-4561
- `.redmine.py`: RM:pulp.plan.io#5580
22 changes: 13 additions & 9 deletions robottelo/utils/issue_handlers/__init__.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import re

# Methods related to issue handlers in general
from robottelo.utils.issue_handlers import bugzilla
from robottelo.utils.issue_handlers import bugzilla, jira

handler_methods = {'BZ': bugzilla.is_open_bz}
SUPPORTED_HANDLERS = tuple(f"{handler}:" for handler in handler_methods)
handler_methods = {'BZ': bugzilla.is_open_bz, 'SAT': jira.is_open_jira}
SUPPORTED_HANDLERS = tuple(f"{handler}" for handler in handler_methods)


def add_workaround(data, matches, usage, validation=(lambda *a, **k: True), **kwargs):
Expand All @@ -16,10 +18,11 @@ def add_workaround(data, matches, usage, validation=(lambda *a, **k: True), **kw
def should_deselect(issue, data=None):
"""Check if test should be deselected based on marked issue."""
# Handlers can be extended to support different issue trackers.
handlers = {'BZ': bugzilla.should_deselect_bz}
supported_handlers = tuple(f"{handler}:" for handler in handlers)
handlers = {'BZ': bugzilla.should_deselect_bz, 'SAT': jira.should_deselect_jira}
supported_handlers = tuple(f"{handler}" for handler in handlers)
if str(issue).startswith(supported_handlers):
handler_code = str(issue).partition(":")[0]
res = re.split(':|-', issue)
handler_code = res[0]
return handlers[handler_code.strip()](issue.strip(), data)
return None

Expand All @@ -29,19 +32,20 @@ def is_open(issue, data=None):

Issue must be prefixed by its handler e.g:

Bugzilla: BZ:123456
Bugzilla: BZ:123456, Jira: SAT-12345

Arguments:
issue {str} -- A string containing handler + number e.g: BZ:123465
data {dict} -- Issue data indexed by <handler>:<number> or None
"""
# Handlers can be extended to support different issue trackers.
if str(issue).startswith(SUPPORTED_HANDLERS):
handler_code = str(issue).partition(":")[0]
res = re.split(':|-', issue)
handler_code = res[0]
else: # EAFP
raise AttributeError(
"is_open argument must be a string starting with a handler code "
"e.g: 'BZ:123456'"
"e.g: 'BZ:123456' for Bugzilla and 'SAT-12345' for Jira."
f"supported handlers are: {SUPPORTED_HANDLERS}"
)
return handler_methods[handler_code.strip()](issue.strip(), data)
8 changes: 4 additions & 4 deletions robottelo/utils/issue_handlers/bugzilla.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ def collect_data_bz(collected_data, cached_data): # pragma: no cover


def collect_dupes(bz, collected_data, cached_data=None): # pragma: no cover
"""Recursivelly find for duplicates"""
"""Recursively find for duplicates"""
cached_data = cached_data or {}
if bz.get('resolution') == 'DUPLICATE':
# Collect duplicates
Expand Down Expand Up @@ -180,15 +180,15 @@ def collect_clones(bz, collected_data, cached_data=None): # pragma: no cover


@retry(
stop=stop_after_attempt(4), # Retry 3 times before raising
wait=wait_fixed(20), # Wait seconds between retries
stop=stop_after_attempt(4),
wait=wait_fixed(20),
)
def get_data_bz(bz_numbers, cached_data=None): # pragma: no cover
"""Get a list of marked BZ data and query Bugzilla REST API.

Arguments:
bz_numbers {list of str} -- ['123456', ...]
cached_data
cached_data {dict} -- Cached data previous loaded from API

Returns:
[list of dicts] -- [{'id':..., 'status':..., 'resolution': ...}]
Expand Down
Loading