Skip to content

Commit 16e1a8e

Browse files
dbasunagpre-commit-ci[bot]myakovernetser
authored
jira validation tool (#62)
* jira initial pr This reverts commit 017044d. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * updates to jira * Update apps/jira_utils/get_jira_information.py Co-authored-by: Meni Yakove <441263+myakove@users.noreply.github.com> * Update apps/jira_utils/get_jira_information.py Co-authored-by: Meni Yakove <441263+myakove@users.noreply.github.com> * address review comments * fix typo * Update apps/jira_utils/get_jira_information.py Co-authored-by: Ruth Netser <rnetser@redhat.com> * Update apps/jira_utils/README.md Co-authored-by: Ruth Netser <rnetser@redhat.com> * updates based on review comments * remove jira tests, it would be covered in a follow up PR * updates based on review comments * refacot code * minor adjustments * update poetry.lock * addressed comments * update lock file --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Meni Yakove <441263+myakove@users.noreply.github.com> Co-authored-by: Ruth Netser <rnetser@redhat.com> Co-authored-by: Meni Yakove <myakove@gmail.com>
1 parent 92515f9 commit 16e1a8e

File tree

7 files changed

+534
-7
lines changed

7 files changed

+534
-7
lines changed

apps/jira_utils/README.md

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
# pyutils-jira
2+
Helper to identify wrong jira card references in a repository files. e.g. closed tickets or tickets with not relevant target versions in a test repository branch would be identified.
3+
It looks for following patterns in files of a pytest repository:
4+
```text
5+
- jira_id=ABC-12345 # When jira id is present in a function call
6+
- <jira url>/browse/ABC-12345 # when jira is in a link in comments
7+
- pytest.mark.jira_utils(ABC-12345) # when jira is in a marker
8+
```
9+
10+
## Usage
11+
12+
```bash
13+
pyutils-jira
14+
pyutils-jira --help
15+
```
16+
17+
## Config file
18+
A config file with the jira connection parameters like url, token, resolved_statuses, skip_project_ids, target_versions should be passed to command line option `--cfg-file`
19+
20+
### Example:
21+
22+
```yaml
23+
pyutils-jira:
24+
url: <jira_url>
25+
token: mytoken
26+
resolved_statuses:
27+
- verified
28+
- release pending
29+
- closed
30+
skip_project_ids:
31+
- ABC
32+
- DEF
33+
target_versions:
34+
- 1.0.0
35+
- 2.0.1
36+
issue_pattern: "([A-Z]+-[0-9]+)"
37+
version_string_not_targeted_jiras: "vfuture"
38+
```
39+
This would skip version checks on any jira ids associated with project ABC and DEF
40+
This would also check if the current repository branch is pointing to any jira card that is not targeted for 1.0.0 or 2.0.1 version
41+
42+
To run from CLI with `--target-versions`
43+
44+
```bash
45+
pyutils-jira --target-versions '1.0.0,2.0.1'
46+
```
47+
#### Note:
48+
To mark to skip a jira from these checks, one can add `<skip-jira-utils-check>` as a comment to the same line containing the specific jira
49+
50+
Example:
51+
```text
52+
#https://issues.redhat.com/browse/OSD-5716 <skip-jira-utils-check>
53+
```
54+
If resolved_statuses is not provided in config file, by default the following status would be considered as resolved statuses:
55+
56+
```text
57+
["verified", "release pending", "closed", "resolved"]
58+
```
59+
60+
Default value for `version_string_not_targeted_jiras` is "vfuture"

apps/jira_utils/__init__.py

Whitespace-only changes.
Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
import logging
2+
import re
3+
import sys
4+
import os
5+
import concurrent.futures
6+
from functools import lru_cache
7+
8+
import click
9+
from jira import JIRA, JIRAError, Issue
10+
11+
from simple_logger.logger import get_logger
12+
13+
from apps.utils import ListParamType
14+
from typing import Dict, List, Set, Tuple, Any
15+
16+
from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_fixed
17+
from apps.utils import all_python_files, get_util_config
18+
19+
LOGGER = get_logger(name=__name__)
20+
21+
22+
@retry(retry=retry_if_exception_type(JIRAError), stop=stop_after_attempt(3), wait=wait_fixed(2))
23+
@lru_cache
24+
def get_issue(
25+
jira: JIRA,
26+
jira_id: str,
27+
) -> Issue:
28+
LOGGER.debug(f"Retry staistics for {jira_id}: {get_issue.statistics}")
29+
return jira.issue(id=jira_id, fields="status, issuetype, fixVersions")
30+
31+
32+
def get_jira_ids_from_file_content(file_content: str, issue_pattern: str, jira_url: str) -> Set[str]:
33+
"""
34+
Try to find all Jira tickets in a given file content.
35+
36+
Looking for the following patterns:
37+
- jira_id=ABC-12345 # When jira id is present in a function call
38+
- <jira_url>/browse/ABC-12345 # when jira is in a link in comments
39+
- pytest.mark.jira_utils(ABC-12345) # when jira is in a marker
40+
41+
Args:
42+
file_content (str): The content of a given file.
43+
issue_pattern (str): regex pattern for jira ids
44+
45+
Returns:
46+
set: A set of jira tickets.
47+
"""
48+
_pytest_jira_marker_bugs = re.findall(rf"pytest.mark.jira.*?{issue_pattern}.*", file_content, re.DOTALL)
49+
_jira_id_arguments = re.findall(rf"jira_id\s*=[\s*\"\']*{issue_pattern}.*", file_content)
50+
_jira_url_jiras = re.findall(
51+
rf"{jira_url}/browse/{issue_pattern}",
52+
file_content,
53+
)
54+
55+
return set(_pytest_jira_marker_bugs + _jira_id_arguments + _jira_url_jiras)
56+
57+
58+
def get_jiras_from_python_files(issue_pattern: str, jira_url: str) -> Dict[str, Set[str]]:
59+
"""
60+
Get all python files from the current directory and get list of jira ids from each of them
61+
62+
Args:
63+
issue_pattern (str): regex pattern for jira ids
64+
jira_url (str): jira url that could be used to look for possible presence of jira references in a file
65+
66+
Returns:
67+
Dict: A dict of filenames and associated jira tickets.
68+
69+
Note: any line containing <skip-jira_utils-check> would be not be checked for presence of a jira id
70+
"""
71+
jira_found: Dict[str, Set[str]] = {}
72+
for filename in all_python_files():
73+
file_content = []
74+
with open(filename) as fd:
75+
file_content = fd.readlines()
76+
# if <skip-jira-utils-check> appears in a line, exclude that line from jira check
77+
if unique_jiras := get_jira_ids_from_file_content(
78+
file_content="\n".join([line for line in file_content if "<skip-jira-utils-check>" not in line]),
79+
issue_pattern=issue_pattern,
80+
jira_url=jira_url,
81+
):
82+
jira_found[filename] = unique_jiras
83+
84+
if jira_found:
85+
_jira_found = "\n\t".join([f"{key}: {val}" for key, val in jira_found.items()])
86+
LOGGER.debug(f"Following jiras are found: \n\t{_jira_found}")
87+
88+
return jira_found
89+
90+
91+
def get_jira_information(
92+
jira_object: JIRA,
93+
jira_id: str,
94+
skip_project_ids: List[str],
95+
resolved_status: List[str],
96+
jira_target_versions: List[str],
97+
target_version_str: str,
98+
file_name: str,
99+
) -> Tuple[str, str]:
100+
jira_error_string = ""
101+
try:
102+
# check resolved status:
103+
jira_issue_metadata = get_issue(jira=jira_object, jira_id=jira_id).fields
104+
current_jira_status = jira_issue_metadata.status.name.lower()
105+
LOGGER.debug(f"Jira: {jira_id}, status: {current_jira_status}")
106+
if current_jira_status in resolved_status:
107+
jira_error_string += f"{jira_id} current status: {current_jira_status} is resolved."
108+
109+
# validate correct target version if provided:
110+
if jira_target_versions:
111+
if skip_project_ids and jira_id.startswith(tuple(skip_project_ids)):
112+
return file_name, jira_error_string
113+
114+
fix_version = (
115+
re.search(r"([\d.]+)", jira_issue_metadata.fixVersions[0].name)
116+
if (jira_issue_metadata.fixVersions)
117+
else None
118+
)
119+
current_target_version = fix_version.group(1) if fix_version else target_version_str
120+
if not any([current_target_version == version for version in jira_target_versions]):
121+
jira_error_string += (
122+
f"{jira_id} target version: {current_target_version}, does not match expected "
123+
f"version {jira_target_versions}."
124+
)
125+
except JIRAError as exp:
126+
jira_error_string += f"{jira_id} JiraError status code: {exp.status_code}, details: {exp.text}]."
127+
128+
return file_name, jira_error_string
129+
130+
131+
def process_jira_command_line_config_file(
132+
config_file_path: str,
133+
url: str,
134+
token: str,
135+
issue_pattern: str,
136+
resolved_statuses: List[str],
137+
version_string_not_targeted_jiras: str,
138+
target_versions: List[str],
139+
skip_projects: List[str],
140+
) -> Dict[str, Any]:
141+
# Process all the arguments passed from command line or config file or environment variable
142+
config_dict = get_util_config(util_name="pyutils-jira", config_file_path=config_file_path)
143+
url = url or config_dict.get("url", "")
144+
token = token or config_dict.get("token", "")
145+
if not (url and token):
146+
LOGGER.error("Jira url and token are required.")
147+
sys.exit(1)
148+
149+
return {
150+
"url": url,
151+
"token": token,
152+
"issue_pattern": issue_pattern or config_dict.get("issue_pattern", ""),
153+
"resolved_status": resolved_statuses or config_dict.get("resolved_statuses", []),
154+
"not_targeted_version_str": config_dict.get(
155+
"version_string_not_targeted_jiras", version_string_not_targeted_jiras
156+
),
157+
"target_versions": target_versions or config_dict.get("target_versions", []),
158+
"skip_project_ids": skip_projects or config_dict.get("skip_project_ids", []),
159+
}
160+
161+
162+
@click.command()
163+
@click.option(
164+
"--config-file-path",
165+
help="Provide absolute path to the jira_utils config file.",
166+
type=click.Path(exists=True),
167+
)
168+
@click.option(
169+
"--target-versions",
170+
help="Provide comma separated list of Jira target version, for version validation against a repo branch.",
171+
type=ListParamType(),
172+
)
173+
@click.option(
174+
"--skip-projects",
175+
help="Provide comma separated list of Jira Project keys, against which version check should be skipped.",
176+
type=ListParamType(),
177+
)
178+
@click.option("--url", help="Provide the Jira server URL", type=click.STRING, default=os.getenv("JIRA_SERVER_URL"))
179+
@click.option("--token", help="Provide the Jira token.", type=click.STRING, default=os.getenv("JIRA_TOKEN"))
180+
@click.option(
181+
"--issue-pattern",
182+
help="Provide the regex for Jira ids",
183+
type=click.STRING,
184+
show_default=True,
185+
default="([A-Z]+-[0-9]+)",
186+
)
187+
@click.option(
188+
"--resolved-statuses",
189+
help="Comma separated list of Jira resolved statuses",
190+
type=ListParamType(),
191+
show_default=True,
192+
default="verified, release pending, closed, resolved",
193+
)
194+
@click.option(
195+
"--version-string-not-targeted-jiras",
196+
help="Provide possible version strings for not yet targeted jiras",
197+
type=click.STRING,
198+
show_default=True,
199+
default="vfuture",
200+
)
201+
@click.option("--verbose", default=False, is_flag=True)
202+
def get_jira_mismatch(
203+
config_file_path: str,
204+
target_versions: List[str],
205+
url: str,
206+
token: str,
207+
skip_projects: List[str],
208+
resolved_statuses: List[str],
209+
issue_pattern: str,
210+
version_string_not_targeted_jiras: str,
211+
verbose: bool,
212+
) -> None:
213+
LOGGER.setLevel(logging.DEBUG if verbose else logging.INFO)
214+
if not (config_file_path or (token and url)):
215+
LOGGER.error("Config file or token and url are required.")
216+
sys.exit(1)
217+
218+
# Process all the arguments passed from command line or config file or environment variable
219+
jira_config_dict = process_jira_command_line_config_file(
220+
config_file_path=config_file_path,
221+
url=url,
222+
token=token,
223+
resolved_statuses=resolved_statuses,
224+
issue_pattern=issue_pattern,
225+
skip_projects=skip_projects,
226+
version_string_not_targeted_jiras=version_string_not_targeted_jiras,
227+
target_versions=target_versions,
228+
)
229+
230+
jira_obj = JIRA(token_auth=jira_config_dict["token"], options={"server": jira_config_dict["url"]})
231+
jira_error: Dict[str, str] = {}
232+
233+
if jira_id_dict := get_jiras_from_python_files(
234+
issue_pattern=jira_config_dict["issue_pattern"], jira_url=jira_config_dict["url"]
235+
):
236+
with concurrent.futures.ThreadPoolExecutor() as executor:
237+
for file_name, ids in jira_id_dict.items():
238+
for jira_id in ids:
239+
future_to_jiras = {
240+
executor.submit(
241+
get_jira_information,
242+
jira_object=jira_obj,
243+
jira_id=jira_id,
244+
skip_project_ids=jira_config_dict["skip_project_ids"],
245+
resolved_status=jira_config_dict["resolved_status"],
246+
jira_target_versions=jira_config_dict["target_versions"],
247+
target_version_str=jira_config_dict["not_targeted_version_str"],
248+
file_name=file_name,
249+
)
250+
}
251+
252+
for future in concurrent.futures.as_completed(future_to_jiras):
253+
file_name, jira_error_string = future.result()
254+
if jira_error_string:
255+
jira_error[file_name] = jira_error_string
256+
257+
if jira_error:
258+
_jira_error = "\n\t".join([f"{key}: {val}" for key, val in jira_error.items()])
259+
LOGGER.error(f"Following Jira ids failed jira version/statuscheck: \n\t{_jira_error}\n")
260+
sys.exit(1)
261+
262+
263+
if __name__ == "__main__":
264+
get_jira_mismatch()

apps/utils.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,13 @@
55
from simple_logger.logger import get_logger
66
import json
77
import click
8-
from typing import Any, Dict, Iterable
8+
from typing import Any, Dict, Iterable, Optional
99

1010
LOGGER = get_logger(name=__name__)
1111

1212

13-
def get_util_config(util_name: str, config_file_path: str) -> Dict[str, Any]:
14-
if os.path.exists(config_file_path):
13+
def get_util_config(util_name: str, config_file_path: Optional[str] = None) -> Dict[str, Any]:
14+
if config_file_path and os.path.exists(config_file_path):
1515
with open(config_file_path) as _file:
1616
return yaml.safe_load(_file).get(util_name, {})
1717
return {}
@@ -80,7 +80,6 @@ def all_python_files() -> Iterable[str]:
8080
for root, _, files in os.walk(os.path.abspath(os.curdir)):
8181
if [_dir for _dir in exclude_dirs if _dir in root]:
8282
continue
83-
8483
for filename in files:
8584
if filename.endswith(".py"):
8685
yield os.path.join(root, filename)

config.example.yaml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,17 @@ pyutils-polarion-verify-tc-requirements:
99
pyutils-polarion-set-automated:
1010
project_id: "ABCDEF"
1111
branch: "origin/main"
12+
pyutils-jira:
13+
url: <jira url>
14+
token: <jira token>
15+
resolved_statuses:
16+
- verified
17+
- release pending
18+
- closed
19+
issue_pattern: "([A-Z]+-[0-9]+)"
20+
skip_project_ids:
21+
- ABC
22+
- DEF
23+
jira_target_versions:
24+
- 1.0.0
25+
version_string_not_targeted_jiras: "not-targeted"

0 commit comments

Comments
 (0)