Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59af7ce464 |
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "redmine-reporter"
|
name = "redmine-reporter"
|
||||||
version = "1.7.0"
|
version = "1.8.0"
|
||||||
description = "Redmine time-entry based issue reporter for internal use"
|
description = "Redmine time-entry based issue reporter for internal use"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
authors = [{ name = "Artem Kokos", email = "artem-kokos@mail.ru" }]
|
authors = [{ name = "Artem Kokos", email = "artem-kokos@mail.ru" }]
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
__version__ = "1.7.0"
|
__version__ = "1.8.0"
|
||||||
|
|||||||
@@ -88,6 +88,11 @@ def main(argv: Optional[List[str]] = None) -> int:
|
|||||||
"--user-name",
|
"--user-name",
|
||||||
help="Redmine user full name for the report (alternative to --user-id; ambiguous names are rejected)",
|
help="Redmine user full name for the report (alternative to --user-id; ambiguous names are rejected)",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--by-activity",
|
||||||
|
action="store_true",
|
||||||
|
help="Break down spent time by activity type",
|
||||||
|
)
|
||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
# Валидация взаимоисключающих флагов пользователя
|
# Валидация взаимоисключающих флагов пользователя
|
||||||
@@ -130,6 +135,7 @@ def main(argv: Optional[List[str]] = None) -> int:
|
|||||||
from_date,
|
from_date,
|
||||||
to_date,
|
to_date,
|
||||||
user_id=args.user_id or args.user_login or args.user_name,
|
user_id=args.user_id or args.user_login or args.user_name,
|
||||||
|
by_activity=args.by_activity,
|
||||||
)
|
)
|
||||||
except RedmineAPIError as e:
|
except RedmineAPIError as e:
|
||||||
print(f"❌ {e.message}", file=sys.stderr)
|
print(f"❌ {e.message}", file=sys.stderr)
|
||||||
@@ -150,15 +156,25 @@ def main(argv: Optional[List[str]] = None) -> int:
|
|||||||
|
|
||||||
print(f"✅ Total issues: {len(issue_hours)} [{args.date}]", file=sys.stderr)
|
print(f"✅ Total issues: {len(issue_hours)} [{args.date}]", file=sys.stderr)
|
||||||
|
|
||||||
rows = build_grouped_report(issue_hours, fill_time=not args.no_time)
|
rows = build_grouped_report(
|
||||||
|
issue_hours,
|
||||||
|
fill_time=not args.no_time,
|
||||||
|
by_activity=args.by_activity,
|
||||||
|
)
|
||||||
|
|
||||||
if args.summary:
|
if args.summary:
|
||||||
summary = calculate_summary(rows)
|
summary = calculate_summary(rows, by_activity=args.by_activity)
|
||||||
print(f"⏱️ Total time: {summary['total']}h", file=sys.stderr)
|
print(f"⏱️ Total time: {summary['total']}h", file=sys.stderr)
|
||||||
for key, value in summary.items():
|
project_keys = [k for k in sorted(summary) if k.startswith("project:")]
|
||||||
|
activity_keys = [k for k in sorted(summary) if k.startswith("activity:")]
|
||||||
|
for key in project_keys + activity_keys:
|
||||||
|
value = summary[key]
|
||||||
if key.startswith("project:"):
|
if key.startswith("project:"):
|
||||||
project = key.split(":", 1)[1]
|
project = key.split(":", 1)[1]
|
||||||
print(f" {project}: {value}h", file=sys.stderr)
|
print(f" {project}: {value}h", file=sys.stderr)
|
||||||
|
elif key.startswith("activity:"):
|
||||||
|
activity = key.split(":", 1)[1]
|
||||||
|
print(f" [{activity}]: {value}h", file=sys.stderr)
|
||||||
|
|
||||||
if args.output:
|
if args.output:
|
||||||
output_ext = os.path.splitext(args.output)[1].lower()
|
output_ext = os.path.splitext(args.output)[1].lower()
|
||||||
|
|||||||
@@ -111,6 +111,39 @@ def _format_redmine_error(exc: Exception) -> str:
|
|||||||
return str(exc)
|
return str(exc)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_time_entry_activities(redmine: Redmine) -> Dict[int, str]:
|
||||||
|
"""Загружает справочник типов активности time entries.
|
||||||
|
|
||||||
|
Возвращает словарь id -> name. Если справочник недоступен,
|
||||||
|
возвращает пустой словарь — тогда будем использовать данные из самих entries.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
activities = redmine.enumeration.filter(resource="time_entry_activities")
|
||||||
|
return {int(a.id): str(a.name) for a in activities}
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_activity_name(entry, activities: Dict[int, str]) -> str:
|
||||||
|
"""Определяет название активности для time entry."""
|
||||||
|
activity = getattr(entry, "activity", None)
|
||||||
|
if activity is None:
|
||||||
|
return "<N/A>"
|
||||||
|
|
||||||
|
# activity может быть объектом с id/name или просто значением
|
||||||
|
activity_id = getattr(activity, "id", None)
|
||||||
|
if activity_id is not None:
|
||||||
|
name = activities.get(int(activity_id))
|
||||||
|
if name:
|
||||||
|
return name
|
||||||
|
activity_name = getattr(activity, "name", None)
|
||||||
|
if activity_name:
|
||||||
|
return str(activity_name)
|
||||||
|
return str(activity_id)
|
||||||
|
|
||||||
|
return str(activity)
|
||||||
|
|
||||||
|
|
||||||
def _fetch_issues_chunked(redmine: Redmine, issue_ids: List[int]) -> List[Issue]:
|
def _fetch_issues_chunked(redmine: Redmine, issue_ids: List[int]) -> List[Issue]:
|
||||||
"""Загружает задачи чанками, чтобы не превышать лимит длины URL (#21)."""
|
"""Загружает задачи чанками, чтобы не превышать лимит длины URL (#21)."""
|
||||||
all_issues: List[Issue] = []
|
all_issues: List[Issue] = []
|
||||||
@@ -186,12 +219,14 @@ def fetch_issues_with_spent_time(
|
|||||||
from_date: str,
|
from_date: str,
|
||||||
to_date: str,
|
to_date: str,
|
||||||
user_id: Optional[Union[int, str]] = None,
|
user_id: Optional[Union[int, str]] = None,
|
||||||
) -> Optional[List[Tuple[Issue, float]]]:
|
by_activity: bool = False,
|
||||||
|
) -> Optional[List[Tuple[Issue, float, Optional[Dict[str, float]]]]]:
|
||||||
"""
|
"""
|
||||||
Fetch unique issues linked to time entries of the given user in date range,
|
Fetch unique issues linked to time entries of the given user in date range,
|
||||||
along with total spent hours per issue.
|
along with total spent hours per issue.
|
||||||
If user_id is None, uses current user.
|
If user_id is None, uses current user.
|
||||||
Returns list of (issue, total_hours) tuples.
|
If by_activity is True, returns per-activity breakdown as third tuple element.
|
||||||
|
Returns list of (issue, total_hours, activities) tuples.
|
||||||
Raises RedmineAPIError on API/auth/network failures.
|
Raises RedmineAPIError on API/auth/network failures.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -202,6 +237,7 @@ def fetch_issues_with_spent_time(
|
|||||||
if user_id is not None
|
if user_id is not None
|
||||||
else _get_current_user_id(redmine)
|
else _get_current_user_id(redmine)
|
||||||
)
|
)
|
||||||
|
activities_lookup = _load_time_entry_activities(redmine) if by_activity else {}
|
||||||
time_entries = redmine.time_entry.filter(
|
time_entries = redmine.time_entry.filter(
|
||||||
user_id=target_user_id, from_date=from_date, to_date=to_date
|
user_id=target_user_id, from_date=from_date, to_date=to_date
|
||||||
)
|
)
|
||||||
@@ -210,14 +246,21 @@ def fetch_issues_with_spent_time(
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise RedmineAPIError(_format_redmine_error(exc), original=exc) from exc
|
raise RedmineAPIError(_format_redmine_error(exc), original=exc) from exc
|
||||||
|
|
||||||
# Агрегируем часы по issue.id
|
# Агрегируем часы по issue.id (и активности, если требуется)
|
||||||
spent_time: Dict[int, float] = {}
|
spent_time: Dict[int, float] = {}
|
||||||
|
spent_by_activity: Dict[int, Dict[str, float]] = {}
|
||||||
issue_ids = set()
|
issue_ids = set()
|
||||||
for entry in time_entries:
|
for entry in time_entries:
|
||||||
if hasattr(entry, "issue") and entry.issue and hasattr(entry, "hours"):
|
if hasattr(entry, "issue") and entry.issue and hasattr(entry, "hours"):
|
||||||
iid = entry.issue.id
|
iid = entry.issue.id
|
||||||
|
hours = float(entry.hours)
|
||||||
issue_ids.add(iid)
|
issue_ids.add(iid)
|
||||||
spent_time[iid] = spent_time.get(iid, 0.0) + float(entry.hours)
|
spent_time[iid] = spent_time.get(iid, 0.0) + hours
|
||||||
|
|
||||||
|
if by_activity:
|
||||||
|
activity_name = _get_activity_name(entry, activities_lookup)
|
||||||
|
by_act = spent_by_activity.setdefault(iid, {})
|
||||||
|
by_act[activity_name] = by_act.get(activity_name, 0.0) + hours
|
||||||
|
|
||||||
if not issue_ids:
|
if not issue_ids:
|
||||||
return None
|
return None
|
||||||
@@ -234,7 +277,9 @@ def fetch_issues_with_spent_time(
|
|||||||
# здесь оставляем порядок API как есть.
|
# здесь оставляем порядок API как есть.
|
||||||
result = []
|
result = []
|
||||||
for issue in issues:
|
for issue in issues:
|
||||||
total_hours = spent_time.get(issue.id, 0.0)
|
iid = issue.id
|
||||||
result.append((issue, total_hours))
|
total_hours = spent_time.get(iid, 0.0)
|
||||||
|
activity_breakdown = spent_by_activity.get(iid) if by_activity else None
|
||||||
|
result.append((issue, total_hours, activity_breakdown))
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -12,13 +12,14 @@ class TableFormatter(Formatter):
|
|||||||
def format(self, rows: List[ReportRow]) -> str:
|
def format(self, rows: List[ReportRow]) -> str:
|
||||||
table_rows = [["Проект", "Версия", "Задача", "Статус", "Затрачено"]]
|
table_rows = [["Проект", "Версия", "Задача", "Статус", "Затрачено"]]
|
||||||
for r in rows:
|
for r in rows:
|
||||||
|
time_text = r["time_text"].replace("\n", " / ")
|
||||||
table_rows.append(
|
table_rows.append(
|
||||||
[
|
[
|
||||||
r["display_project"],
|
r["display_project"],
|
||||||
r["display_version"],
|
r["display_version"],
|
||||||
f"{r['issue_id']}. {r['subject']}",
|
f"{r['issue_id']}. {r['subject']}",
|
||||||
r["status_ru"],
|
r["status_ru"],
|
||||||
r["time_text"],
|
time_text,
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
return tabulate(table_rows, headers="firstrow", tablefmt="fancy_grid")
|
return tabulate(table_rows, headers="firstrow", tablefmt="fancy_grid")
|
||||||
@@ -35,9 +36,10 @@ class CompactFormatter(Formatter):
|
|||||||
def format(self, rows: List[ReportRow]) -> str:
|
def format(self, rows: List[ReportRow]) -> str:
|
||||||
lines = []
|
lines = []
|
||||||
for r in rows:
|
for r in rows:
|
||||||
|
time_text = r["time_text"].replace("\n", " / ")
|
||||||
lines.append(
|
lines.append(
|
||||||
f"{r['display_project']} | {r['display_version']} | "
|
f"{r['display_project']} | {r['display_version']} | "
|
||||||
f"{r['issue_id']}. {r['subject']} | {r['status_ru']} | {r['time_text']}"
|
f"{r['issue_id']}. {r['subject']} | {r['status_ru']} | {time_text}"
|
||||||
)
|
)
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ class CSVFormatter(Formatter):
|
|||||||
headers.append("Spent Time")
|
headers.append("Spent Time")
|
||||||
writer.writerow(headers)
|
writer.writerow(headers)
|
||||||
for r in rows:
|
for r in rows:
|
||||||
|
time_text = r["time_text"].replace("\n", " / ")
|
||||||
data = [
|
data = [
|
||||||
r["project"],
|
r["project"],
|
||||||
r["version"],
|
r["version"],
|
||||||
@@ -35,7 +36,7 @@ class CSVFormatter(Formatter):
|
|||||||
r["status_ru"],
|
r["status_ru"],
|
||||||
]
|
]
|
||||||
if not self.no_time:
|
if not self.no_time:
|
||||||
data.append(r["time_text"])
|
data.append(time_text)
|
||||||
writer.writerow(data)
|
writer.writerow(data)
|
||||||
return output.getvalue()
|
return output.getvalue()
|
||||||
|
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ class HTMLFormatter(Formatter):
|
|||||||
for r in task_rows:
|
for r in task_rows:
|
||||||
task_cell = escape(f"{r['issue_id']}. {r['subject']}")
|
task_cell = escape(f"{r['issue_id']}. {r['subject']}")
|
||||||
status_text = escape(r["status_ru"])
|
status_text = escape(r["status_ru"])
|
||||||
time_text = escape(r["time_text"])
|
time_text = escape(r["time_text"]).replace("\n", "<br>")
|
||||||
lines.append(" <tr>")
|
lines.append(" <tr>")
|
||||||
|
|
||||||
# Ячейка "Проект" - только в первой строке проекта
|
# Ячейка "Проект" - только в первой строке проекта
|
||||||
|
|||||||
@@ -12,8 +12,9 @@ class JSONFormatter(Formatter):
|
|||||||
super().__init__()
|
super().__init__()
|
||||||
|
|
||||||
def format(self, rows: List[ReportRow]) -> str:
|
def format(self, rows: List[ReportRow]) -> str:
|
||||||
data = [
|
data = []
|
||||||
{
|
for r in rows:
|
||||||
|
item = {
|
||||||
"project": r["project"],
|
"project": r["project"],
|
||||||
"version": r["version"],
|
"version": r["version"],
|
||||||
"issue_id": r["issue_id"],
|
"issue_id": r["issue_id"],
|
||||||
@@ -21,8 +22,10 @@ class JSONFormatter(Formatter):
|
|||||||
"status": r["status_ru"],
|
"status": r["status_ru"],
|
||||||
"time": r["time_text"],
|
"time": r["time_text"],
|
||||||
}
|
}
|
||||||
for r in rows
|
activities = r.get("activities")
|
||||||
]
|
if activities:
|
||||||
|
item["activities"] = activities
|
||||||
|
data.append(item)
|
||||||
return json.dumps(data, ensure_ascii=False, indent=2)
|
return json.dumps(data, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
def save(self, rows: List[ReportRow], output_path: str) -> None:
|
def save(self, rows: List[ReportRow], output_path: str) -> None:
|
||||||
|
|||||||
@@ -125,8 +125,12 @@ class ODTFormatter(Formatter):
|
|||||||
row.addElement(status_cell)
|
row.addElement(status_cell)
|
||||||
|
|
||||||
time_cell = TableCell(stylename=cell_style_name)
|
time_cell = TableCell(stylename=cell_style_name)
|
||||||
p = P(stylename=para_style_name, text=r["time_text"])
|
time_lines = r["time_text"].split("\n")
|
||||||
|
for i, line in enumerate(time_lines):
|
||||||
|
p = P(stylename=para_style_name, text=line)
|
||||||
time_cell.addElement(p)
|
time_cell.addElement(p)
|
||||||
|
if i < len(time_lines) - 1:
|
||||||
|
time_cell.addElement(P(stylename=para_style_name, text=""))
|
||||||
row.addElement(time_cell)
|
row.addElement(time_cell)
|
||||||
|
|
||||||
table.addElement(row)
|
table.addElement(row)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from typing import Dict, List, Tuple, cast
|
from typing import Dict, List, Optional, Tuple, cast
|
||||||
|
|
||||||
from redminelib.resources import Issue
|
from redminelib.resources import Issue
|
||||||
|
|
||||||
@@ -22,9 +22,19 @@ STATUS_TRANSLATION = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _format_activities(activities: Dict[str, float]) -> str:
|
||||||
|
"""Форматирует разбивку по активностям в многострочный текст."""
|
||||||
|
if not activities:
|
||||||
|
return ""
|
||||||
|
# Сортируем по убыванию часов, затем по алфавиту для стабильности
|
||||||
|
items = sorted(activities.items(), key=lambda x: (-x[1], x[0]))
|
||||||
|
return "\n".join(f"{hours_to_human(hours)} {name}" for name, hours in items)
|
||||||
|
|
||||||
|
|
||||||
def build_grouped_report(
|
def build_grouped_report(
|
||||||
issue_hours: List[Tuple[Issue, float]],
|
issue_hours: List[Tuple[Issue, float, Optional[Dict[str, float]]]],
|
||||||
fill_time: bool = True,
|
fill_time: bool = True,
|
||||||
|
by_activity: bool = False,
|
||||||
) -> List[ReportRow]:
|
) -> List[ReportRow]:
|
||||||
"""
|
"""
|
||||||
Преобразует список задач с затраченным временем в плоский список строк отчёта,
|
Преобразует список задач с затраченным временем в плоский список строк отчёта,
|
||||||
@@ -41,12 +51,20 @@ def build_grouped_report(
|
|||||||
prev_project: str = ""
|
prev_project: str = ""
|
||||||
prev_version: str = ""
|
prev_version: str = ""
|
||||||
|
|
||||||
for issue, hours in issue_hours:
|
for issue, hours, *rest in issue_hours:
|
||||||
|
activities: Optional[Dict[str, float]] = rest[0] if rest else None
|
||||||
project = str(issue.project)
|
project = str(issue.project)
|
||||||
version = get_version(issue)
|
version = get_version(issue)
|
||||||
status_en = str(issue.status)
|
status_en = str(issue.status)
|
||||||
status_ru = STATUS_TRANSLATION.get(status_en, status_en)
|
status_ru = STATUS_TRANSLATION.get(status_en, status_en)
|
||||||
time_text = hours_to_human(hours) if fill_time else ""
|
|
||||||
|
if fill_time:
|
||||||
|
if by_activity and activities:
|
||||||
|
time_text = _format_activities(activities)
|
||||||
|
else:
|
||||||
|
time_text = hours_to_human(hours)
|
||||||
|
else:
|
||||||
|
time_text = ""
|
||||||
|
|
||||||
display_project = project if project != prev_project else ""
|
display_project = project if project != prev_project else ""
|
||||||
display_version = version if (project != prev_project or version != prev_version) else ""
|
display_version = version if (project != prev_project or version != prev_version) else ""
|
||||||
@@ -64,6 +82,7 @@ def build_grouped_report(
|
|||||||
"status_ru": status_ru,
|
"status_ru": status_ru,
|
||||||
"time_text": time_text,
|
"time_text": time_text,
|
||||||
"hours": round(hours, 2),
|
"hours": round(hours, 2),
|
||||||
|
"activities": activities,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -74,11 +93,15 @@ def build_grouped_report(
|
|||||||
return rows
|
return rows
|
||||||
|
|
||||||
|
|
||||||
def calculate_summary(rows: List[ReportRow]) -> Dict[str, float]:
|
def calculate_summary(
|
||||||
"""Возвращает сводку: общее время, время по проектам и версиям."""
|
rows: List[ReportRow],
|
||||||
|
by_activity: bool = False,
|
||||||
|
) -> Dict[str, float]:
|
||||||
|
"""Возвращает сводку: общее время, время по проектам, версиям и активностям."""
|
||||||
total = 0.0
|
total = 0.0
|
||||||
by_project: Dict[str, float] = {}
|
by_project: Dict[str, float] = {}
|
||||||
by_project_version: Dict[str, float] = {}
|
by_project_version: Dict[str, float] = {}
|
||||||
|
by_activity_name: Dict[str, float] = {}
|
||||||
|
|
||||||
for r in rows:
|
for r in rows:
|
||||||
hours = r.get("hours", 0.0)
|
hours = r.get("hours", 0.0)
|
||||||
@@ -87,11 +110,21 @@ def calculate_summary(rows: List[ReportRow]) -> Dict[str, float]:
|
|||||||
key = f"{r['project']}::{r['version']}"
|
key = f"{r['project']}::{r['version']}"
|
||||||
by_project_version[key] = by_project_version.get(key, 0.0) + hours
|
by_project_version[key] = by_project_version.get(key, 0.0) + hours
|
||||||
|
|
||||||
return {
|
if by_activity:
|
||||||
|
activities = r.get("activities")
|
||||||
|
if activities:
|
||||||
|
for name, act_hours in activities.items():
|
||||||
|
by_activity_name[name] = by_activity_name.get(name, 0.0) + act_hours
|
||||||
|
|
||||||
|
result: Dict[str, float] = {
|
||||||
"total": round(total, 2),
|
"total": round(total, 2),
|
||||||
**{f"project:{k}": round(v, 2) for k, v in by_project.items()},
|
**{f"project:{k}": round(v, 2) for k, v in by_project.items()},
|
||||||
**{f"version:{k}": round(v, 2) for k, v in by_project_version.items()},
|
**{f"version:{k}": round(v, 2) for k, v in by_project_version.items()},
|
||||||
}
|
}
|
||||||
|
if by_activity:
|
||||||
|
result.update({f"activity:{k}": round(v, 2) for k, v in by_activity_name.items()})
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def group_rows_by_project_and_version(
|
def group_rows_by_project_and_version(
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from typing import TypedDict
|
from typing import Dict, Optional, TypedDict
|
||||||
|
|
||||||
|
|
||||||
class ReportRow(TypedDict):
|
class ReportRowBase(TypedDict):
|
||||||
"""Строка итогового отчёта."""
|
"""Строка итогового отчёта."""
|
||||||
|
|
||||||
project: str
|
project: str
|
||||||
@@ -13,3 +13,9 @@ class ReportRow(TypedDict):
|
|||||||
status_ru: str
|
status_ru: str
|
||||||
time_text: str
|
time_text: str
|
||||||
hours: float
|
hours: float
|
||||||
|
|
||||||
|
|
||||||
|
class ReportRow(ReportRowBase, total=False):
|
||||||
|
"""Строка итогового отчёта с опциональной разбивкой по активностям."""
|
||||||
|
|
||||||
|
activities: Optional[Dict[str, float]]
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ def test_fetch_aggregates_hours_per_issue(mock_redmine_class):
|
|||||||
|
|
||||||
assert result is not None
|
assert result is not None
|
||||||
assert len(result) == 1
|
assert len(result) == 1
|
||||||
issue, total_hours = result[0]
|
issue, total_hours, _activities = result[0]
|
||||||
assert total_hours == 3.5
|
assert total_hours == 3.5
|
||||||
|
|
||||||
|
|
||||||
@@ -109,7 +109,7 @@ def test_fetch_multiple_issues(mock_redmine_class):
|
|||||||
assert result is not None
|
assert result is not None
|
||||||
assert len(result) == 2
|
assert len(result) == 2
|
||||||
|
|
||||||
hours_by_id = {issue.id: hours for issue, hours in result}
|
hours_by_id = {issue.id: hours for issue, hours, _ in result}
|
||||||
assert hours_by_id[1] == 1.5
|
assert hours_by_id[1] == 1.5
|
||||||
assert hours_by_id[2] == 2.0
|
assert hours_by_id[2] == 2.0
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user