pycodetags-issue-tracker 0.1.0__py2.py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pycodetags_issue_tracker/__about__.py +7 -0
- pycodetags_issue_tracker/__init__.py +29 -0
- pycodetags_issue_tracker/_tag_templates.txt.md +21 -0
- pycodetags_issue_tracker/cli.py +73 -0
- pycodetags_issue_tracker/converters.py +161 -0
- pycodetags_issue_tracker/health_o_meter.py +233 -0
- pycodetags_issue_tracker/issue_tracker_config.py +153 -0
- pycodetags_issue_tracker/issue_tracker_schema.py +47 -0
- pycodetags_issue_tracker/main.py +84 -0
- pycodetags_issue_tracker/plugin_manager.py +29 -0
- pycodetags_issue_tracker/plugin_specs.py +92 -0
- pycodetags_issue_tracker/standard_code_tags.py +147 -0
- pycodetags_issue_tracker/templates/__init__.py +3 -0
- pycodetags_issue_tracker/templates/report.html.jinja2 +291 -0
- pycodetags_issue_tracker/todo_object_schema.py +31 -0
- pycodetags_issue_tracker/todo_tag_types.py +349 -0
- pycodetags_issue_tracker/todo_tag_types_aliases.py +535 -0
- pycodetags_issue_tracker/todo_tag_types_generate_aliases.py +122 -0
- pycodetags_issue_tracker/user.py +48 -0
- pycodetags_issue_tracker/users_from_authors.py +95 -0
- pycodetags_issue_tracker/views.py +216 -0
- pycodetags_issue_tracker/views_templated.py +69 -0
- pycodetags_issue_tracker-0.1.0.dist-info/METADATA +5 -0
- pycodetags_issue_tracker-0.1.0.dist-info/RECORD +26 -0
- pycodetags_issue_tracker-0.1.0.dist-info/WHEEL +5 -0
- pycodetags_issue_tracker-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""Metadata for pycodetags_issue_tracker."""
|
|
2
|
+
|
|
3
|
+
__all__ = ["__title__", "__version__", "__description__"]
|
|
4
|
+
|
|
5
|
+
__title__ = "pycodetags-issue-tracker"
|
|
6
|
+
__version__ = "0.1.0"
|
|
7
|
+
__description__ = "Plugin for pycodetags to track issues, e.g. bugs, TODO, etc in your code"
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
__all__ = [
|
|
2
|
+
"TODO",
|
|
3
|
+
"FIXME",
|
|
4
|
+
"REQUIREMENT",
|
|
5
|
+
"STORY",
|
|
6
|
+
"IDEA",
|
|
7
|
+
"BUG",
|
|
8
|
+
"HACK",
|
|
9
|
+
"CLEVER",
|
|
10
|
+
"MAGIC",
|
|
11
|
+
"ALERT",
|
|
12
|
+
"PORT",
|
|
13
|
+
"DOCUMENT",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
from pycodetags_issue_tracker.todo_tag_types import TODO
|
|
17
|
+
from pycodetags_issue_tracker.todo_tag_types_aliases import (
|
|
18
|
+
ALERT,
|
|
19
|
+
BUG,
|
|
20
|
+
CLEVER,
|
|
21
|
+
DOCUMENT,
|
|
22
|
+
FIXME,
|
|
23
|
+
HACK,
|
|
24
|
+
IDEA,
|
|
25
|
+
MAGIC,
|
|
26
|
+
PORT,
|
|
27
|
+
REQUIREMENT,
|
|
28
|
+
STORY,
|
|
29
|
+
)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Templates
|
|
2
|
+
|
|
3
|
+
Copy paste these on creation, update and close
|
|
4
|
+
|
|
5
|
+
## TODO
|
|
6
|
+
```python
|
|
7
|
+
# TODO: ...
|
|
8
|
+
# <matth 2025-07-04 status:development category:parser priority:medium>
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## PLANNING
|
|
12
|
+
```python
|
|
13
|
+
# TODO: ...
|
|
14
|
+
# <matth 2025-07-04 status:planned release:... iteration:1 due:...>
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## DONE
|
|
18
|
+
```python
|
|
19
|
+
# TODO: ...
|
|
20
|
+
# <matth 2025-07-04 status:done release:... >
|
|
21
|
+
```
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
from collections.abc import Sequence
|
|
5
|
+
from typing import cast
|
|
6
|
+
|
|
7
|
+
from pycodetags_issue_tracker import TODO, views, views_templated
|
|
8
|
+
|
|
9
|
+
from pycodetags import DATA
|
|
10
|
+
from pycodetags.config import CodeTagsConfig
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def handle_cli(subparsers: argparse._SubParsersAction):
|
|
14
|
+
report_parser = subparsers.add_parser(
|
|
15
|
+
"issues",
|
|
16
|
+
# parents=[base_parser],
|
|
17
|
+
help="Reports for TODOs and BUGs",
|
|
18
|
+
)
|
|
19
|
+
# report runs collectors, collected things can be validated
|
|
20
|
+
report_parser.add_argument("--module", action="append", help="Python module to inspect (e.g., 'my_project.main')")
|
|
21
|
+
report_parser.add_argument("--src", action="append", help="file or folder of source code")
|
|
22
|
+
|
|
23
|
+
report_parser.add_argument("--output", help="destination file or folder")
|
|
24
|
+
|
|
25
|
+
supported_formats = ["changelog", "validate", "html", "todomd", "donefile", "text"]
|
|
26
|
+
|
|
27
|
+
report_parser.add_argument(
|
|
28
|
+
"--format",
|
|
29
|
+
choices=supported_formats,
|
|
30
|
+
default="text",
|
|
31
|
+
help="Output format for the report.",
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
common_switches(report_parser)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def common_switches(parser) -> None:
|
|
38
|
+
parser.add_argument("--config", help="Path to config file, defaults to current folder pyproject.toml")
|
|
39
|
+
parser.add_argument("--verbose", default=False, action="store_true", help="verbose level logging output")
|
|
40
|
+
parser.add_argument("--info", default=False, action="store_true", help="info level logging output")
|
|
41
|
+
parser.add_argument("--bug-trail", default=False, action="store_true", help="enable bug trail, local logging")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def run_cli_command(
|
|
45
|
+
command_name: str,
|
|
46
|
+
args: argparse.Namespace,
|
|
47
|
+
found_data: Sequence[DATA | TODO],
|
|
48
|
+
# pylint: disable=unused-argument)
|
|
49
|
+
config: CodeTagsConfig,
|
|
50
|
+
) -> bool:
|
|
51
|
+
format_name = args.format
|
|
52
|
+
# args.output
|
|
53
|
+
if command_name == "issues":
|
|
54
|
+
if format_name == "validate":
|
|
55
|
+
views.print_validate(cast(list[TODO], found_data))
|
|
56
|
+
return True
|
|
57
|
+
if format_name == "html":
|
|
58
|
+
views_templated.print_html(cast(list[TODO], found_data))
|
|
59
|
+
return True
|
|
60
|
+
if format_name == "todomd":
|
|
61
|
+
views.print_todo_md(cast(list[TODO], found_data))
|
|
62
|
+
return True
|
|
63
|
+
if format_name == "text":
|
|
64
|
+
views.print_text(cast(list[TODO], found_data))
|
|
65
|
+
return True
|
|
66
|
+
if format_name == "changelog":
|
|
67
|
+
views.print_changelog(cast(list[TODO], found_data))
|
|
68
|
+
return True
|
|
69
|
+
if format_name == "donefile":
|
|
70
|
+
views.print_done_file(cast(list[TODO], found_data))
|
|
71
|
+
return True
|
|
72
|
+
return False
|
|
73
|
+
return False
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Converters for FolkTag and PEP350Tag to TODO
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
from collections.abc import Iterable
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from pycodetags_issue_tracker.todo_object_schema import TODO_KEYWORDS
|
|
12
|
+
from pycodetags_issue_tracker.todo_tag_types import TODO
|
|
13
|
+
|
|
14
|
+
from pycodetags import DATA
|
|
15
|
+
from pycodetags.data_tags_schema import DataTag
|
|
16
|
+
from pycodetags.folk_code_tags import FolkTag
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def blank_to_null(value: str | None) -> str | None:
|
|
22
|
+
"""
|
|
23
|
+
Convert a blank string to None.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
value (str | None): The value to convert.
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
str | None: The converted value.
|
|
30
|
+
"""
|
|
31
|
+
if isinstance(value, list):
|
|
32
|
+
return [_.strip() for _ in value]
|
|
33
|
+
if value is None or value.strip() == "":
|
|
34
|
+
return None
|
|
35
|
+
return value.strip()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def convert_datas_to_TODOs(tags: Iterable[DATA]) -> Iterable[TODO]:
|
|
39
|
+
"""Syntactic sugar to convert many tags"""
|
|
40
|
+
return [convert_data_to_TODO(_) for _ in tags]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def get_from_custom_or_data(name: str, tag: DATA) -> Any:
|
|
44
|
+
value = (tag.data_fields or {}).get(name)
|
|
45
|
+
if value:
|
|
46
|
+
return value
|
|
47
|
+
value = (tag.data_fields or {}).get(name)
|
|
48
|
+
if value:
|
|
49
|
+
return value
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def convert_data_to_TODO(tag: DATA) -> TODO:
|
|
54
|
+
return TODO(
|
|
55
|
+
code_tag=tag.code_tag,
|
|
56
|
+
comment=tag.comment,
|
|
57
|
+
default_fields=tag.default_fields or {},
|
|
58
|
+
data_fields=tag.data_fields or {},
|
|
59
|
+
custom_fields=tag.custom_fields or {},
|
|
60
|
+
unprocessed_defaults=tag.unprocessed_defaults or [],
|
|
61
|
+
assignee=get_from_custom_or_data("assignee", tag),
|
|
62
|
+
originator=get_from_custom_or_data("originator", tag),
|
|
63
|
+
origination_date=get_from_custom_or_data("origination_date", tag),
|
|
64
|
+
due=get_from_custom_or_data("due", tag),
|
|
65
|
+
release_due=get_from_custom_or_data("release_due", tag),
|
|
66
|
+
release=get_from_custom_or_data("release", tag),
|
|
67
|
+
iteration=get_from_custom_or_data("iteration", tag),
|
|
68
|
+
change_type=get_from_custom_or_data("change_type", tag),
|
|
69
|
+
closed_date=get_from_custom_or_data("closed_date", tag),
|
|
70
|
+
closed_comment=get_from_custom_or_data("closed_comment", tag),
|
|
71
|
+
tracker=get_from_custom_or_data("tracker", tag),
|
|
72
|
+
file_path=tag.file_path,
|
|
73
|
+
line_number=tag.line_number,
|
|
74
|
+
original_text=tag.original_text,
|
|
75
|
+
original_schema=tag.original_schema,
|
|
76
|
+
offsets=tag.offsets,
|
|
77
|
+
priority=get_from_custom_or_data("priority", tag),
|
|
78
|
+
status=get_from_custom_or_data("status", tag),
|
|
79
|
+
category=get_from_custom_or_data("category", tag),
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def convert_folk_tag_to_TODO(folk_tag: FolkTag) -> TODO:
|
|
84
|
+
"""
|
|
85
|
+
Convert a FolkTag to a TODO object.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
folk_tag (FolkTag): The FolkTag to convert.
|
|
89
|
+
"""
|
|
90
|
+
kwargs = {
|
|
91
|
+
"code_tag": folk_tag.get("code_tag"),
|
|
92
|
+
"file_path": folk_tag.get("file_path"),
|
|
93
|
+
"line_number": folk_tag.get("line_number"),
|
|
94
|
+
# folk_tag.get("default_field"),
|
|
95
|
+
"custom_fields": folk_tag.get("custom_fields"),
|
|
96
|
+
"comment": folk_tag["comment"], # required
|
|
97
|
+
"tracker": folk_tag.get("tracker"),
|
|
98
|
+
"assignee": blank_to_null(folk_tag.get("assignee")),
|
|
99
|
+
"originator": blank_to_null(folk_tag.get("originator")),
|
|
100
|
+
# person=folk_tag.get("person")
|
|
101
|
+
"original_text": folk_tag.get("original_text"),
|
|
102
|
+
"original_schema": "folk",
|
|
103
|
+
}
|
|
104
|
+
custom_fields = folk_tag.get("custom_fields", {})
|
|
105
|
+
for keyword in TODO_KEYWORDS:
|
|
106
|
+
for field_key, field_value in custom_fields.items():
|
|
107
|
+
# Promote custom fields to kwargs if they match the keyword
|
|
108
|
+
# and the keyword is not already in kwargs
|
|
109
|
+
if keyword == field_key and keyword not in kwargs:
|
|
110
|
+
kwargs[keyword] = field_value
|
|
111
|
+
if keyword == field_key and keyword not in kwargs:
|
|
112
|
+
logger.warning("Duplicate keyword found in custom fields: %s", keyword)
|
|
113
|
+
return TODO(**kwargs) # type: ignore[arg-type]
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def convert_pep350_tag_to_TODO(pep350_tag: DataTag) -> TODO:
|
|
117
|
+
"""
|
|
118
|
+
Convert a PEP350Tag to a TODO object.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
pep350_tag (PEP350Tag): The PEP350Tag to convert.
|
|
122
|
+
"""
|
|
123
|
+
# default fields should have already been promoted to data_fields by now.
|
|
124
|
+
data_fields = pep350_tag["fields"]["data_fields"]
|
|
125
|
+
custom_fields = pep350_tag["fields"]["custom_fields"]
|
|
126
|
+
kwargs = {
|
|
127
|
+
"code_tag": pep350_tag["code_tag"],
|
|
128
|
+
"comment": pep350_tag["comment"],
|
|
129
|
+
"custom_fields": custom_fields,
|
|
130
|
+
# specific fields
|
|
131
|
+
"assignee": blank_to_null(data_fields.get("assignee")),
|
|
132
|
+
"originator": blank_to_null(data_fields.get("originator")),
|
|
133
|
+
# due dates
|
|
134
|
+
"due": data_fields.get("due"),
|
|
135
|
+
"iteration": data_fields.get("iteration"),
|
|
136
|
+
"release": data_fields.get("release"),
|
|
137
|
+
# integrations
|
|
138
|
+
"tracker": data_fields.get("tracker"),
|
|
139
|
+
# idiosyncratic
|
|
140
|
+
"priority": data_fields.get("priority"),
|
|
141
|
+
"status": data_fields.get("status"),
|
|
142
|
+
"category": data_fields.get("category"),
|
|
143
|
+
# Source Mapping
|
|
144
|
+
"file_path": data_fields.get("file_path"),
|
|
145
|
+
"line_number": data_fields.get("line_number"),
|
|
146
|
+
"original_text": pep350_tag.get("original_text"),
|
|
147
|
+
"original_schema": "pep350",
|
|
148
|
+
"offsets": pep350_tag.get("offsets"),
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
custom_fields = pep350_tag["fields"].get("custom_fields", {})
|
|
152
|
+
for keyword in TODO_KEYWORDS:
|
|
153
|
+
for field_key, field_value in custom_fields.items():
|
|
154
|
+
# Promote custom fields to kwargs if they match the keyword
|
|
155
|
+
# and the keyword is not already in kwargs
|
|
156
|
+
if keyword == field_key and keyword not in kwargs:
|
|
157
|
+
kwargs[keyword] = field_value
|
|
158
|
+
if keyword == field_key and keyword not in kwargs:
|
|
159
|
+
logger.warning("Duplicate keyword found in custom fields: %s", keyword)
|
|
160
|
+
|
|
161
|
+
return TODO(**kwargs) # type: ignore[arg-type]
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""
|
|
2
|
+
A module for calculating health metrics based on TODO items.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from collections import defaultdict
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from pycodetags_issue_tracker.todo_tag_types import TODO
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class HealthOMeter:
|
|
14
|
+
"""
|
|
15
|
+
Calculates various health metrics from a list of TODO items.
|
|
16
|
+
|
|
17
|
+
This class operates solely on the provided TODO objects and has no
|
|
18
|
+
dependencies on file systems or reporting mechanisms.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
# Define sentiment scores for specific code tags
|
|
22
|
+
SENTIMENT_SCORES_PER_TAG = {
|
|
23
|
+
"CLEVER": 2, # Very positive
|
|
24
|
+
"MAGIC": 1, # Positive
|
|
25
|
+
"HACK": -1, # Negative
|
|
26
|
+
"FIXME": -2, # More negative
|
|
27
|
+
"BUG": -3, # Most negative
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
def __init__(self, todos: list[TODO]) -> None:
|
|
31
|
+
"""
|
|
32
|
+
Initializes the HealthOMeter with a list of TODO items.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
todos: A list of TODO objects to analyze.
|
|
36
|
+
"""
|
|
37
|
+
self.todos = todos
|
|
38
|
+
# Cache the total number of TODOs to avoid repeated recalculations
|
|
39
|
+
self.total_todos_count = len(todos)
|
|
40
|
+
|
|
41
|
+
def calculate_todos_per_file(self) -> dict[str, int]:
|
|
42
|
+
"""
|
|
43
|
+
Calculates the count of TODOs for each file.
|
|
44
|
+
|
|
45
|
+
TODO: Consider a metric for average TODOs per file.
|
|
46
|
+
TODO: Add a metric for files with no TODOs, but high complexity.
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
A dictionary mapping file paths to the count of TODOs in them.
|
|
50
|
+
"""
|
|
51
|
+
todos_per_file: dict[str, int] = defaultdict(int)
|
|
52
|
+
for todo in self.todos:
|
|
53
|
+
if todo.file_path:
|
|
54
|
+
todos_per_file[todo.file_path] += 1
|
|
55
|
+
return dict(todos_per_file)
|
|
56
|
+
|
|
57
|
+
def calculate_total_dones(self) -> int:
|
|
58
|
+
"""
|
|
59
|
+
Calculates the total count of TODOs marked as "done".
|
|
60
|
+
|
|
61
|
+
TODO: Add a metric for the rate of DONE items over time.
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
The total count of TODOs marked as "done".
|
|
65
|
+
"""
|
|
66
|
+
return sum(1 for todo in self.todos if todo.is_probably_done())
|
|
67
|
+
|
|
68
|
+
def calculate_total_todos(self) -> int:
|
|
69
|
+
"""
|
|
70
|
+
Calculates the total count of all TODO items.
|
|
71
|
+
|
|
72
|
+
TODO: Add a metric for the trend of total TODOs over time (increasing/decreasing).
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
The total count of all TODOs.
|
|
76
|
+
"""
|
|
77
|
+
return self.total_todos_count
|
|
78
|
+
|
|
79
|
+
def calculate_sentiment_score(self) -> float:
|
|
80
|
+
"""
|
|
81
|
+
Calculates a sentiment score based on the presence of specific tags
|
|
82
|
+
(CLEVER, MAGIC, HACK, FIXME, BUG). A higher score indicates better sentiment.
|
|
83
|
+
|
|
84
|
+
TODO: Allow configuration of which tags influence sentiment and their scores.
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
A score normalized between 0.0 and 1.0. Returns 1.0 if no TODOs exist.
|
|
88
|
+
"""
|
|
89
|
+
if self.total_todos_count == 0:
|
|
90
|
+
return 1.0
|
|
91
|
+
|
|
92
|
+
raw_sentiment_sum = 0
|
|
93
|
+
for todo in self.todos:
|
|
94
|
+
# Check if the code_tag exists and is in our sentiment mapping
|
|
95
|
+
if todo.code_tag and todo.code_tag.upper() in self.SENTIMENT_SCORES_PER_TAG:
|
|
96
|
+
raw_sentiment_sum += self.SENTIMENT_SCORES_PER_TAG[todo.code_tag.upper()]
|
|
97
|
+
|
|
98
|
+
# Determine the min and max possible sentiment sums for normalization
|
|
99
|
+
max_score_per_todo = max(self.SENTIMENT_SCORES_PER_TAG.values())
|
|
100
|
+
min_score_per_todo = min(self.SENTIMENT_SCORES_PER_TAG.values())
|
|
101
|
+
|
|
102
|
+
min_possible_total = self.total_todos_count * min_score_per_todo
|
|
103
|
+
max_possible_total = self.total_todos_count * max_score_per_todo
|
|
104
|
+
|
|
105
|
+
# Normalize the raw sum to a 0-1 scale: (actual - min_possible) / (max_possible - min_possible)
|
|
106
|
+
if max_possible_total == min_possible_total:
|
|
107
|
+
# Avoid division by zero if all scores are identical or there are no relevant tags
|
|
108
|
+
return 1.0 # Represents a neutral or ideal state in such cases
|
|
109
|
+
|
|
110
|
+
normalized_score = (raw_sentiment_sum - min_possible_total) / (max_possible_total - min_possible_total)
|
|
111
|
+
|
|
112
|
+
# Ensure the score stays within the [0, 1] range due to potential edge cases or non-linear distribution
|
|
113
|
+
return max(0.0, min(1.0, normalized_score))
|
|
114
|
+
|
|
115
|
+
def calculate_quality_score(self) -> float:
|
|
116
|
+
"""
|
|
117
|
+
Calculates a quality score based on the presence of "BUG" tags.
|
|
118
|
+
A higher score indicates better quality (fewer BUGs).
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
A score normalized between 0.0 and 1.0. Returns 1.0 if no TODOs exist.
|
|
122
|
+
"""
|
|
123
|
+
if self.total_todos_count == 0:
|
|
124
|
+
return 1.0
|
|
125
|
+
|
|
126
|
+
total_bugs = sum(1 for todo in self.todos if todo.code_tag and todo.code_tag.upper() == "BUG")
|
|
127
|
+
# Quality decreases with more bugs. Scale from 1.0 (no bugs) to 0.0 (all todos are bugs, worst case).
|
|
128
|
+
return max(0.0, (self.total_todos_count - total_bugs) / self.total_todos_count)
|
|
129
|
+
|
|
130
|
+
def calculate_bug_density(self) -> float:
|
|
131
|
+
"""
|
|
132
|
+
Calculates the density of BUG tags as the ratio of BUGs to total TODOs.
|
|
133
|
+
This serves as a proxy for 'bugs per line of code/function' given
|
|
134
|
+
the class's constraint of not accessing the file system directly.
|
|
135
|
+
|
|
136
|
+
TODO: Enhance this metric by allowing external provision of actual
|
|
137
|
+
Lines of Code (LoC) or function counts per file to get a true density.
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
The ratio of BUGs to total TODOs (0.0 if no TODOs).
|
|
141
|
+
"""
|
|
142
|
+
if self.total_todos_count == 0:
|
|
143
|
+
return 0.0 # No bugs if no todos
|
|
144
|
+
|
|
145
|
+
total_bugs = sum(1 for todo in self.todos if todo.code_tag and todo.code_tag.upper() == "BUG")
|
|
146
|
+
return total_bugs / self.total_todos_count
|
|
147
|
+
|
|
148
|
+
def get_total_todos_scale(self, total_todos_value: int) -> str:
|
|
149
|
+
"""Provides a descriptive scale for the total number of TODOs."""
|
|
150
|
+
if total_todos_value == 0:
|
|
151
|
+
return "Immaculate Codebase"
|
|
152
|
+
if 1 <= total_todos_value <= 10:
|
|
153
|
+
return "Well-Maintained"
|
|
154
|
+
if 11 <= total_todos_value <= 50:
|
|
155
|
+
return "Active Development"
|
|
156
|
+
if 51 <= total_todos_value <= 100:
|
|
157
|
+
return "Busy Beaver"
|
|
158
|
+
# > 100
|
|
159
|
+
return "Technical Debt Accumulation"
|
|
160
|
+
|
|
161
|
+
def get_sentiment_scale(self, sentiment_score_value: float) -> str:
|
|
162
|
+
"""Provides a descriptive scale for the sentiment score."""
|
|
163
|
+
if 0.8 <= sentiment_score_value <= 1.0:
|
|
164
|
+
return "Positive Outlook"
|
|
165
|
+
if 0.5 <= sentiment_score_value < 0.8:
|
|
166
|
+
return "Mixed Feelings"
|
|
167
|
+
if 0.2 <= sentiment_score_value < 0.5:
|
|
168
|
+
return "Caution Advised"
|
|
169
|
+
# 0.0 <= score < 0.2
|
|
170
|
+
return "Coding Horror"
|
|
171
|
+
|
|
172
|
+
def get_quality_scale(self, quality_score_value: float) -> str:
|
|
173
|
+
"""Provides a descriptive scale for the code quality score."""
|
|
174
|
+
if 0.95 <= quality_score_value <= 1.0:
|
|
175
|
+
return "High Quality"
|
|
176
|
+
if 0.8 <= quality_score_value < 0.95:
|
|
177
|
+
return "Good Quality"
|
|
178
|
+
if 0.5 <= quality_score_value < 0.8:
|
|
179
|
+
return "Average Quality"
|
|
180
|
+
# 0.0 <= score < 0.5
|
|
181
|
+
return "Buggy Waters"
|
|
182
|
+
|
|
183
|
+
def get_bug_density_scale(self, bug_density_value: float) -> str:
|
|
184
|
+
"""Provides a descriptive scale for the bug density."""
|
|
185
|
+
if 0.0 == bug_density_value:
|
|
186
|
+
return "Bug-Free Zone"
|
|
187
|
+
if 0.0 < bug_density_value <= 0.05:
|
|
188
|
+
return "Occasional Pests"
|
|
189
|
+
if 0.05 < bug_density_value <= 0.15:
|
|
190
|
+
return "Frequent Critters"
|
|
191
|
+
# > 0.15
|
|
192
|
+
return "Infestation"
|
|
193
|
+
|
|
194
|
+
def get_total_dones_scale(self, value: int) -> str:
|
|
195
|
+
if value == 0:
|
|
196
|
+
return "No Progress"
|
|
197
|
+
if value <= 5:
|
|
198
|
+
return "Initial Cleanup"
|
|
199
|
+
if value <= 20:
|
|
200
|
+
return "Steady Progress"
|
|
201
|
+
return "High Velocity"
|
|
202
|
+
|
|
203
|
+
def calculate_metrics(self) -> dict[str, Any]:
|
|
204
|
+
"""
|
|
205
|
+
Calculates and returns a dictionary of all health metrics, including their
|
|
206
|
+
descriptive scales.
|
|
207
|
+
|
|
208
|
+
Returns:
|
|
209
|
+
A dictionary containing the calculated metrics and their associated scales.
|
|
210
|
+
"""
|
|
211
|
+
total_todos = self.calculate_total_todos()
|
|
212
|
+
sentiment_score = self.calculate_sentiment_score()
|
|
213
|
+
quality_score = self.calculate_quality_score()
|
|
214
|
+
bug_density = self.calculate_bug_density()
|
|
215
|
+
total_dones = self.calculate_total_dones()
|
|
216
|
+
|
|
217
|
+
metrics = {
|
|
218
|
+
"todos_per_file": self.calculate_todos_per_file(),
|
|
219
|
+
"total_dones": total_dones,
|
|
220
|
+
"total_todos": total_todos,
|
|
221
|
+
"sentiment": sentiment_score,
|
|
222
|
+
"quality": quality_score,
|
|
223
|
+
"bug_density": bug_density,
|
|
224
|
+
"total_todos_scale": self.get_total_todos_scale(total_todos),
|
|
225
|
+
"sentiment_scale": self.get_sentiment_scale(sentiment_score),
|
|
226
|
+
"quality_scale": self.get_quality_scale(quality_score),
|
|
227
|
+
"bug_density_scale": self.get_bug_density_scale(bug_density),
|
|
228
|
+
"total_dones_scale": self.get_total_dones_scale(total_dones),
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
# Add scale descriptions to the metrics
|
|
232
|
+
|
|
233
|
+
return metrics
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pycodetags_issue_tracker.user import get_current_user
|
|
4
|
+
from pycodetags_issue_tracker.users_from_authors import parse_authors_file_simple
|
|
5
|
+
|
|
6
|
+
from pycodetags.config import CodeTagsConfig, careful_to_bool, get_code_tags_config
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class IssueTrackerConfig:
|
|
10
|
+
def __init__(self, parent_config: CodeTagsConfig, set_user: str | None = None):
|
|
11
|
+
|
|
12
|
+
self.parent_config = parent_config
|
|
13
|
+
self.user_override = set_user
|
|
14
|
+
|
|
15
|
+
def user_env_var(self) -> str:
|
|
16
|
+
"""Environment variable with active user."""
|
|
17
|
+
return str(self.parent_config.config.get("user_env_var", ""))
|
|
18
|
+
|
|
19
|
+
def current_user(self) -> str:
|
|
20
|
+
if self.user_override:
|
|
21
|
+
return self.user_override
|
|
22
|
+
return get_current_user(self.user_identification_technique(), self.user_env_var())
|
|
23
|
+
|
|
24
|
+
def user_identification_technique(self) -> str:
|
|
25
|
+
"""Technique for identifying current user. If not set, related features are disabled."""
|
|
26
|
+
field = "user_identification_technique"
|
|
27
|
+
result = self.parent_config.config.get(field, "")
|
|
28
|
+
accepted = ("os", "env", "git", "")
|
|
29
|
+
if result not in accepted:
|
|
30
|
+
raise TypeError(f"Invalid configuration: {field} must be in {accepted}")
|
|
31
|
+
return str(result)
|
|
32
|
+
|
|
33
|
+
def valid_authors_file(self) -> str:
|
|
34
|
+
"""Author list, overrides valid authors if specified. File must exist."""
|
|
35
|
+
field = "valid_authors_file"
|
|
36
|
+
return str(self.parent_config.config.get(field, ""))
|
|
37
|
+
|
|
38
|
+
def valid_authors_schema(self) -> str:
|
|
39
|
+
"""Author schema, must be specified if authors from file is set."""
|
|
40
|
+
field = "valid_authors_schema"
|
|
41
|
+
result = self.parent_config.config.get(field, "")
|
|
42
|
+
accepted = ("gnu_gnits", "single_column", "")
|
|
43
|
+
if result not in accepted:
|
|
44
|
+
raise TypeError(f"Invalid configuration: {field} must be in {accepted}")
|
|
45
|
+
if self.valid_authors_file() and result == "":
|
|
46
|
+
raise TypeError(
|
|
47
|
+
"Invalid configuration: if valid_authors_from_file is set, "
|
|
48
|
+
f"then must be valid_authors_schema must be set to one of {accepted}"
|
|
49
|
+
)
|
|
50
|
+
return str(self.parent_config.config.get("valid_authors_schema", ""))
|
|
51
|
+
|
|
52
|
+
# Property accessors
|
|
53
|
+
def valid_authors(self) -> list[str]:
|
|
54
|
+
"""Author list, if empty or None, all are valid, unless file specified"""
|
|
55
|
+
author_file = self.valid_authors_file()
|
|
56
|
+
schema = self.valid_authors_schema()
|
|
57
|
+
if author_file and schema:
|
|
58
|
+
if schema == "single_column":
|
|
59
|
+
with open(author_file, encoding="utf-8") as file_handle:
|
|
60
|
+
authors = [_ for _ in file_handle.readlines() if _]
|
|
61
|
+
return authors
|
|
62
|
+
if schema == "gnu_gnits":
|
|
63
|
+
authors = parse_authors_file_simple(author_file)
|
|
64
|
+
return authors
|
|
65
|
+
|
|
66
|
+
return [_.lower() for _ in self.parent_config.config.get("valid_authors", [])]
|
|
67
|
+
|
|
68
|
+
def valid_releases(self) -> list[str]:
|
|
69
|
+
"""Releases (Version numbers), if empty or None, all are valid.
|
|
70
|
+
Past releases that do not match current schema are valid.
|
|
71
|
+
"""
|
|
72
|
+
return [str(_).lower() for _ in self.parent_config.config.get("valid_releases", [])]
|
|
73
|
+
|
|
74
|
+
def valid_releases_file(self) -> str:
|
|
75
|
+
"""File name of file with valid releases"""
|
|
76
|
+
valid_releases_from_file = self.parent_config.config.get("valid_releases_file", "")
|
|
77
|
+
return str(valid_releases_from_file).lower() if valid_releases_from_file else str(valid_releases_from_file)
|
|
78
|
+
|
|
79
|
+
def valid_releases_file_schema(self) -> str:
|
|
80
|
+
"""Schema used to read from a known file type the valid versions."""
|
|
81
|
+
field = "valid_releases_file_schema"
|
|
82
|
+
result = self.parent_config.config.get(field, "")
|
|
83
|
+
accepted = ("keepachangelog",)
|
|
84
|
+
if result not in accepted:
|
|
85
|
+
raise TypeError(f"Invalid configuration: {field} must be in {accepted}")
|
|
86
|
+
if self.valid_releases_file() and not result:
|
|
87
|
+
raise TypeError(f"When valid_releases_from_file is set, {field} must be in {accepted}")
|
|
88
|
+
return str(result)
|
|
89
|
+
|
|
90
|
+
def releases_schema(self) -> str:
|
|
91
|
+
"""Schema used to parse, sort release (version) numbers.
|
|
92
|
+
Not used to validate anything
|
|
93
|
+
"""
|
|
94
|
+
field = "releases_schema"
|
|
95
|
+
result = self.parent_config.config.get(field, "")
|
|
96
|
+
accepted = ("semantic", "pep440", "")
|
|
97
|
+
if result not in accepted:
|
|
98
|
+
raise TypeError(f"Invalid configuration: {field} must be in {accepted}")
|
|
99
|
+
return str(result)
|
|
100
|
+
|
|
101
|
+
def valid_priorities(self) -> list[str]:
|
|
102
|
+
"""Priority list, if empty or None, all are valid"""
|
|
103
|
+
return [_.lower() for _ in self.parent_config.config.get("valid_priorities", [])]
|
|
104
|
+
|
|
105
|
+
def valid_iterations(self) -> list[str]:
|
|
106
|
+
"""Iteration list, if empty or None, all are valid."""
|
|
107
|
+
return [_.lower() for _ in self.parent_config.config.get("valid_iterations", [])]
|
|
108
|
+
|
|
109
|
+
def valid_custom_field_names(self) -> list[str]:
|
|
110
|
+
"""Custom field names, if empty or None, all are valid."""
|
|
111
|
+
return [_.lower() for _ in self.parent_config.config.get("valid_custom_field_names", [])]
|
|
112
|
+
|
|
113
|
+
def mandatory_fields(self) -> list[str]:
|
|
114
|
+
"""Mandatory fields, if empty or None, no mandatory fields."""
|
|
115
|
+
return [_.lower() for _ in self.parent_config.config.get("mandatory_fields", [])]
|
|
116
|
+
|
|
117
|
+
def tracker_domain(self) -> str:
|
|
118
|
+
"""Domain of the tracker, used to make ticket links clickable."""
|
|
119
|
+
return str(self.parent_config.config.get("tracker_domain", ""))
|
|
120
|
+
|
|
121
|
+
def tracker_style(self) -> str:
|
|
122
|
+
"""Style of the tracker, used to make ticket links clickable."""
|
|
123
|
+
field = "tracker_style"
|
|
124
|
+
result = self.parent_config.config.get(field, "")
|
|
125
|
+
accepted = ("url", "ticket", "")
|
|
126
|
+
if result not in accepted:
|
|
127
|
+
raise TypeError(f"Invalid configuration: {field} must be in {accepted}")
|
|
128
|
+
return str(result)
|
|
129
|
+
|
|
130
|
+
def valid_status(self) -> list[str]:
|
|
131
|
+
"""Status list, if empty or None, all are valid"""
|
|
132
|
+
return [str(_).lower() for _ in self.parent_config.config.get("valid_status", [])]
|
|
133
|
+
|
|
134
|
+
def valid_categories(self) -> list[str]:
|
|
135
|
+
"""Category list, if empty or None, all are valid"""
|
|
136
|
+
return [str(_).lower() for _ in self.parent_config.config.get("valid_categories", [])]
|
|
137
|
+
|
|
138
|
+
def closed_status(self) -> list[str]:
|
|
139
|
+
"""If status equals this,then it is closed, needed for business rules"""
|
|
140
|
+
closed_status = self.parent_config.config.get("closed_status", [])
|
|
141
|
+
return [str(_).lower() for _ in closed_status]
|
|
142
|
+
|
|
143
|
+
def action_on_past_due(self) -> bool:
|
|
144
|
+
"""Do actions do the default action"""
|
|
145
|
+
return careful_to_bool(self.parent_config.config.get("action_on_past_due", False), False)
|
|
146
|
+
|
|
147
|
+
def action_only_on_responsible_user(self) -> bool:
|
|
148
|
+
"""Do actions do the default action when active user matches"""
|
|
149
|
+
return careful_to_bool(self.parent_config.config.get("action_only_on_responsible_user", False), False)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def get_issue_tracker_config() -> IssueTrackerConfig:
|
|
153
|
+
return IssueTrackerConfig(get_code_tags_config())
|