commit 1c3e3f0a28cfe00133f0c85666278b8fb5bf5221 Author: serhei_t Date: Tue Jun 16 00:03:34 2026 +0300 Initial commit diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e52d2aa --- /dev/null +++ b/.env.example @@ -0,0 +1,4 @@ +JIRA_BASE_URL=https://jira.a1.by +JIRA_PROJECT=VDEV +JIRA_USER=your-user-name +JIRA_TOKEN=l72382b4ruo8mdp1c6mvcsg8h4kbf9ij497i2vtdqa0sfbaduoeqkcs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e3d9e70 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.env +.DS_Store +*.csv +*.json +__pycache__/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..a32695f --- /dev/null +++ b/README.md @@ -0,0 +1,108 @@ +# Jira Ticket Analysis + +Исполняемый скрипт `jira_analyze.py` анализирует тикеты Jira по проекту `VDEV` на `https://jira.a1.by`. + +Токен не хранится в файлах проекта. Перед запуском задайте его через переменную окружения: + +```bash +export JIRA_TOKEN='your-token-here' +export JIRA_USER='Serhei_T' +./jira_analyze.py +``` + +Шаблон переменных окружения есть в `.env.example`. Файл `.env` добавлен в `.gitignore`. + +Можно также создать локальный `.env`, скрипт загрузит его автоматически: + +```bash +cp .env.example .env +``` + +После этого откройте `.env` и замените `JIRA_TOKEN=your-token-here` на реальный токен. Для этой Jira используется Basic Auth, поэтому также должен быть заполнен `JIRA_USER`. + +Примеры: + +```bash +./jira_analyze.py --project VDEV +./jira_analyze.py --project VDEV --stale-days 14 +./jira_analyze.py --jql 'project = VDEV AND statusCategory != Done ORDER BY updated DESC' +./jira_analyze.py --csv-out jira-dev.csv --json-out jira-dev.json +./jira_analyze.py --html-quarter-out dev-q1-q2-2026.html +./jira_analyze.py --html-sprint-quarter-out vdev-sprints-quarters.html +``` + +Для вашей задачи по завершенным спринтам 2026 используйте готовую команду: + +```bash +./vdev_sprint_report_2026.sh +``` + +Она создаст файл `vdev-sprints-2026.html` и не будет сканировать все тикеты проекта. + +Что считает скрипт: + +- общее количество тикетов; +- количество resolved и unresolved; +- долю закрытых тикетов; +- распределение по статусам, типам, приоритетам, исполнителям, компонентам и меткам; +- созданные и закрытые тикеты по месяцам; +- незакрытые тикеты без обновлений дольше `--stale-days`; +- незакрытые тикеты с просроченным `duedate`. + +HTML-отчет по кварталам: + +```bash +./jira_analyze.py --html-quarter-out dev-q1-q2-2026.html --report-year 2026 --quarters 1,2 --closed-status 'Закрыт' +``` + +Для HTML-отчета без `--jql` скрипт сам использует запрос: + +```jql +project = VDEV AND status = "Закрыт" AND resolutiondate >= "2026-01-01" AND resolutiondate < "2026-07-01" ORDER BY resolutiondate ASC +``` + +В HTML попадает: + +- выполненные задачи по Q1 и Q2 2026; +- фактические и плановые часы из Jira time tracking; +- отклонение факт-план; +- средний цикл выполнения от `created` до `resolutiondate`; +- разбивка по командам через `components`, если компонент не заполнен - через `assignee`; +- проблемные сигналы: нет часов, нет оценки, закрыто после срока, длинный цикл, высокий приоритет, блокирующие метки, нет компонента; +- рекомендации по корректировкам для команд. + +HTML-отчет по спринтам с января по сегодня: + +```bash +./jira_analyze.py --html-sprint-quarter-out vdev-sprints-quarters.html --sprints-from 2026-01-01 --sprints-to 2026-06-15 +``` + +Скрипт использует Jira Agile API: + +- находит boards проекта `VDEV`; +- выгружает только завершенные `closed` спринты, пересекающиеся с периодом; +- выгружает задачи каждого спринта; +- оставляет закрытые задачи через `statusCategory = Done`, `resolutiondate` или статус из `--closed-status`; +- раскладывает спринты по кварталам по `completeDate`, затем `endDate`, затем `startDate`; +- считает закрытые фичи, задачи и баги в каждом квартале; +- дедуплицирует задачу внутри квартала, если она встретилась в нескольких спринтах/boards. + +Категории типов: + +- `Фичи`: Jira-типы с `Feature`, `Story`, `фич`, `истор`; +- `Задачи`: Jira-типы с `Task`, `задач`; +- `Баги`: Jira-типы с `Bug`, `Defect`, `дефект`, `ошиб`; +- остальное попадает в `Прочее`. + +Если нужно ограничить отчет конкретной Agile board: + +```bash +./jira_analyze.py --html-sprint-quarter-out vdev-sprints-quarters.html --board-id 123 +``` + +Параметры по умолчанию: + +- `--base-url https://jira.a1.by`; +- `--project VDEV`; +- `--page-size 100`; +- `--stale-days 30`. diff --git a/jira_analyze.py b/jira_analyze.py new file mode 100755 index 0000000..2f0f202 --- /dev/null +++ b/jira_analyze.py @@ -0,0 +1,1280 @@ +#!/usr/bin/env python3 +"""Analyze Jira issues for a project. + +The Jira token is intentionally read from JIRA_TOKEN instead of being stored in +this file. Example: + + JIRA_TOKEN='...' ./jira_analyze.py --project VDEV +""" + +from __future__ import annotations + +import argparse +import base64 +import csv +import datetime as dt +import html +import json +import os +import sys +import time +import urllib.error +import urllib.parse +import urllib.request +from collections import Counter, defaultdict +from typing import Any + + +DEFAULT_BASE_URL = "https://jira.a1.by" +DEFAULT_PROJECT = "VDEV" +DEFAULT_PAGE_SIZE = 100 + + +def jql_string(value: str) -> str: + escaped = value.replace('\\', '\\\\').replace('"', '\\"') + return f'"{escaped}"' + + +def jql_project(value: str) -> str: + if value.replace("_", "").isalnum() and " " not in value: + return value + return jql_string(value) + + +def load_dotenv(path: str) -> None: + if not os.path.exists(path): + return + with open(path, encoding="utf-8") as file: + for line in file: + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + key = key.strip() + value = value.strip().strip('"').strip("'") + if key and key not in os.environ: + os.environ[key] = value + + +def parse_args() -> argparse.Namespace: + script_dir = os.path.dirname(os.path.abspath(__file__)) + load_dotenv(os.path.join(script_dir, ".env")) + load_dotenv(os.path.join(os.getcwd(), ".env")) + parser = argparse.ArgumentParser( + description="Analyze Jira tickets for a project via Jira REST API." + ) + parser.add_argument( + "--base-url", + default=os.environ.get("JIRA_BASE_URL", DEFAULT_BASE_URL), + help=f"Jira base URL. Default: {DEFAULT_BASE_URL}", + ) + parser.add_argument( + "--project", + default=os.environ.get("JIRA_PROJECT", DEFAULT_PROJECT), + help=f"Jira project key. Default: {DEFAULT_PROJECT}", + ) + parser.add_argument( + "--token", + default=os.environ.get("JIRA_TOKEN"), + help="Jira token. Prefer JIRA_TOKEN environment variable.", + ) + parser.add_argument( + "--user", + default=os.environ.get("JIRA_USER"), + help="Jira user name for Basic Auth. Prefer JIRA_USER environment variable.", + ) + parser.add_argument( + "--jql", + help="Custom JQL. If set, --project is ignored for the search query.", + ) + parser.add_argument( + "--page-size", + type=int, + default=DEFAULT_PAGE_SIZE, + help=f"Jira page size. Default: {DEFAULT_PAGE_SIZE}", + ) + parser.add_argument( + "--stale-days", + type=int, + default=30, + help="Consider unresolved issues stale after this many days without updates.", + ) + parser.add_argument( + "--json-out", + help="Write raw fetched issues to this JSON file.", + ) + parser.add_argument( + "--csv-out", + help="Write a flat issue list to this CSV file.", + ) + parser.add_argument( + "--html-quarter-out", + help="Write Q1/Q2 closed issue analytics to this HTML file.", + ) + parser.add_argument( + "--html-sprint-quarter-out", + help="Write sprint-based quarterly statistics to this HTML file.", + ) + parser.add_argument( + "--sprints-from", + default=f"{dt.date.today().year}-01-01", + help="Start date for sprint report. Default: Jan 1 of current year.", + ) + parser.add_argument( + "--sprints-to", + default=dt.date.today().isoformat(), + help="End date for sprint report. Default: today.", + ) + parser.add_argument( + "--board-id", + type=int, + help="Limit sprint report to one Jira Agile board ID.", + ) + parser.add_argument( + "--report-year", + type=int, + default=2026, + help="Year for --html-quarter-out. Default: 2026", + ) + parser.add_argument( + "--quarters", + default="1,2", + help="Comma-separated quarters for --html-quarter-out. Default: 1,2", + ) + parser.add_argument( + "--closed-status", + default="Закрыт", + help='Closed Jira status name for report filtering. Default: "Закрыт"', + ) + parser.add_argument( + "--list-projects", + action="store_true", + help="List Jira projects visible to the token and exit.", + ) + parser.add_argument( + "--project-search", + help="Filter --list-projects by project key or name substring.", + ) + parser.add_argument( + "--top", + type=int, + default=15, + help="Number of rows to show in top lists. Default: 15", + ) + return parser.parse_args() + + +def auth_header(token: str, user: str | None) -> str: + if user: + raw = f"{user}:{token}".encode("utf-8") + return "Basic " + base64.b64encode(raw).decode("ascii") + return f"Bearer {token}" + + +def jira_get(base_url: str, token: str, path: str, params: dict[str, Any], user: str | None = None) -> Any: + query = urllib.parse.urlencode(params, doseq=True) + url = f"{base_url.rstrip('/')}{path}?{query}" + request = urllib.request.Request( + url, + headers={ + "Accept": "application/json", + "Authorization": auth_header(token, user), + }, + ) + + for attempt in range(4): + try: + with urllib.request.urlopen(request, timeout=60) as response: + return json.loads(response.read().decode("utf-8")) + except urllib.error.HTTPError as exc: + body = exc.read().decode("utf-8", errors="replace") + if exc.code == 429 and attempt < 3: + retry_after = exc.headers.get("Retry-After") + delay = int(retry_after) if retry_after and retry_after.isdigit() else 30 * (attempt + 1) + print(f"Rate limit exceeded. Retrying in {delay} seconds...", file=sys.stderr) + time.sleep(delay) + continue + raise RuntimeError(f"Jira API returned HTTP {exc.code}: {body}") from exc + except urllib.error.URLError as exc: + raise RuntimeError(f"Failed to connect to Jira: {exc}") from exc + + raise RuntimeError("Jira request failed after retries") + + +def fetch_issues( + base_url: str, + token: str, + user: str | None, + jql: str, + page_size: int, +) -> list[dict[str, Any]]: + fields = [ + "summary", + "issuetype", + "status", + "priority", + "assignee", + "reporter", + "created", + "updated", + "resolutiondate", + "components", + "labels", + "fixVersions", + "versions", + "duedate", + "timespent", + "timeoriginalestimate", + "aggregatetimespent", + "aggregatetimeoriginalestimate", + ] + issues: list[dict[str, Any]] = [] + start_at = 0 + + while True: + data = jira_get( + base_url, + token, + "/rest/api/2/search", + { + "jql": jql, + "startAt": start_at, + "maxResults": page_size, + "fields": ",".join(fields), + }, + user, + ) + batch = data.get("issues", []) + issues.extend(batch) + + total = int(data.get("total", len(issues))) + print(f"Fetched {len(issues)}/{total} issues...", file=sys.stderr) + if not batch or len(issues) >= total: + break + start_at += len(batch) + + return issues + + +def field_name(issue: dict[str, Any], field: str, default: str = "Unspecified") -> str: + value = issue.get("fields", {}).get(field) + if isinstance(value, dict): + return value.get("displayName") or value.get("name") or default + if value: + return str(value) + return default + + +def parse_jira_datetime(value: str | None) -> dt.datetime | None: + if not value: + return None + normalized = value + if normalized.endswith("Z"): + normalized = normalized[:-1] + "+00:00" + if len(value) >= 5 and value[-5] in {"+", "-"} and value[-3] != ":": + normalized = f"{value[:-2]}:{value[-2:]}" + try: + return dt.datetime.fromisoformat(normalized) + except ValueError: + return None + + +def month_bucket(value: str | None) -> str: + parsed = parse_jira_datetime(value) + if not parsed: + return "Unknown" + return parsed.strftime("%Y-%m") + + +def is_unresolved(issue: dict[str, Any]) -> bool: + return not issue.get("fields", {}).get("resolutiondate") + + +def count_list_field(issues: list[dict[str, Any]], field: str) -> Counter[str]: + counter: Counter[str] = Counter() + for issue in issues: + values = issue.get("fields", {}).get(field) or [] + if not values: + counter["None"] += 1 + continue + for value in values: + if isinstance(value, dict): + counter[value.get("name") or "Unknown"] += 1 + else: + counter[str(value)] += 1 + return counter + + +def print_counter(title: str, counter: Counter[str], top: int) -> None: + print(f"\n{title}") + print("-" * len(title)) + if not counter: + print("No data") + return + width = max(len(name) for name, _ in counter.most_common(top)) + for name, count in counter.most_common(top): + print(f"{name:<{width}} {count}") + + +def parse_quarters(value: str) -> list[int]: + quarters: list[int] = [] + for raw in value.split(","): + raw = raw.strip() + if not raw: + continue + quarter = int(raw) + if quarter < 1 or quarter > 4: + raise ValueError("quarters must be between 1 and 4") + quarters.append(quarter) + return sorted(set(quarters)) + + +def quarter_bounds(year: int, quarter: int) -> tuple[dt.date, dt.date]: + start_month = (quarter - 1) * 3 + 1 + start = dt.date(year, start_month, 1) + if quarter == 4: + end = dt.date(year + 1, 1, 1) + else: + end = dt.date(year, start_month + 3, 1) + return start, end + + +def quarter_for_date(value: dt.date) -> int: + return ((value.month - 1) // 3) + 1 + + +def jira_date(value: str | None) -> dt.date | None: + parsed = parse_jira_datetime(value) + if parsed: + return parsed.date() + if value: + try: + return dt.date.fromisoformat(value) + except ValueError: + return None + return None + + +def parse_date_arg(value: str, name: str) -> dt.date: + try: + return dt.date.fromisoformat(value) + except ValueError as exc: + raise ValueError(f"{name} must be YYYY-MM-DD") from exc + + +def seconds_value(issue: dict[str, Any], *fields: str) -> int: + issue_fields = issue.get("fields", {}) + for field in fields: + value = issue_fields.get(field) + if isinstance(value, int) and value > 0: + return value + return 0 + + +def hours(seconds: int) -> float: + return round(seconds / 3600, 1) + + +def issue_components(issue: dict[str, Any]) -> list[str]: + components = issue.get("fields", {}).get("components") or [] + names = [component.get("name") for component in components if isinstance(component, dict)] + return [name for name in names if name] + + +def team_name(issue: dict[str, Any]) -> str: + components = issue_components(issue) + if components: + return ", ".join(components) + return field_name(issue, "assignee", "No team/component") + + +def issue_cycle_days(issue: dict[str, Any]) -> int | None: + fields = issue.get("fields", {}) + created = jira_date(fields.get("created")) + resolved = jira_date(fields.get("resolutiondate")) + if not created or not resolved: + return None + return max((resolved - created).days, 0) + + +def html_escape(value: Any) -> str: + return html.escape(str(value if value is not None else "")) + + +def html_table(headers: list[str], rows: list[list[Any]], empty: str = "Нет данных") -> str: + if not rows: + return f"

{html_escape(empty)}

" + header_html = "".join(f"{html_escape(header)}" for header in headers) + row_html = [] + for row in rows: + row_html.append("" + "".join(f"{html_escape(cell)}" for cell in row) + "") + return f"{header_html}{''.join(row_html)}
" + + +def closed_quarter_issues( + issues: list[dict[str, Any]], + year: int, + quarters: list[int], + closed_status: str, +) -> list[dict[str, Any]]: + selected: list[dict[str, Any]] = [] + closed_status_lower = closed_status.casefold() + quarter_set = set(quarters) + for issue in issues: + fields = issue.get("fields", {}) + resolved = jira_date(fields.get("resolutiondate")) + if not resolved or resolved.year != year or quarter_for_date(resolved) not in quarter_set: + continue + if field_name(issue, "status").casefold() != closed_status_lower: + continue + selected.append(issue) + return selected + + +def issue_problem_flags(issue: dict[str, Any]) -> list[str]: + fields = issue.get("fields", {}) + flags: list[str] = [] + spent = seconds_value(issue, "aggregatetimespent", "timespent") + estimate = seconds_value(issue, "aggregatetimeoriginalestimate", "timeoriginalestimate") + cycle = issue_cycle_days(issue) + due = jira_date(fields.get("duedate")) + resolved = jira_date(fields.get("resolutiondate")) + labels = [str(label).casefold() for label in fields.get("labels") or []] + + if spent == 0: + flags.append("нет списанных часов") + if estimate == 0: + flags.append("нет оценки") + if due and resolved and resolved > due: + flags.append("закрыто после срока") + if cycle is not None and cycle > 30: + flags.append("длинный цикл > 30 дней") + high_priorities = {"highest", "high", "blocker", "critical", "критический", "высокий"} + if field_name(issue, "priority").casefold() in high_priorities: + flags.append("высокий приоритет") + if any("block" in label or "блок" in label for label in labels): + flags.append("есть блокирующая метка") + if not issue_components(issue): + flags.append("нет компонента/команды") + return flags + + +def quarter_summary_rows(issues: list[dict[str, Any]], year: int, quarters: list[int]) -> list[list[Any]]: + rows: list[list[Any]] = [] + for quarter in quarters: + start, end = quarter_bounds(year, quarter) + quarter_issues = [] + for issue in issues: + resolved = jira_date(issue.get("fields", {}).get("resolutiondate")) + if resolved and start <= resolved < end: + quarter_issues.append(issue) + + spent = sum(seconds_value(issue, "aggregatetimespent", "timespent") for issue in quarter_issues) + estimate = sum( + seconds_value(issue, "aggregatetimeoriginalestimate", "timeoriginalestimate") + for issue in quarter_issues + ) + cycle_values = [value for issue in quarter_issues if (value := issue_cycle_days(issue)) is not None] + problem_count = sum(1 for issue in quarter_issues if issue_problem_flags(issue)) + rows.append( + [ + f"Q{quarter} {year}", + len(quarter_issues), + hours(spent), + hours(estimate), + hours(spent - estimate), + round(sum(cycle_values) / len(cycle_values), 1) if cycle_values else 0, + problem_count, + ] + ) + return rows + + +def team_summary_rows(issues: list[dict[str, Any]]) -> list[list[Any]]: + teams: dict[str, dict[str, Any]] = defaultdict( + lambda: {"issues": 0, "spent": 0, "estimate": 0, "problems": Counter(), "cycle": []} + ) + for issue in issues: + name = team_name(issue) + bucket = teams[name] + bucket["issues"] += 1 + bucket["spent"] += seconds_value(issue, "aggregatetimespent", "timespent") + bucket["estimate"] += seconds_value(issue, "aggregatetimeoriginalestimate", "timeoriginalestimate") + cycle = issue_cycle_days(issue) + if cycle is not None: + bucket["cycle"].append(cycle) + bucket["problems"].update(issue_problem_flags(issue)) + + rows: list[list[Any]] = [] + for name, data in sorted(teams.items(), key=lambda item: item[1]["issues"], reverse=True): + cycle_values = data["cycle"] + top_problems = ", ".join(problem for problem, _ in data["problems"].most_common(3)) + rows.append( + [ + name, + data["issues"], + hours(data["spent"]), + hours(data["estimate"]), + hours(data["spent"] - data["estimate"]), + round(sum(cycle_values) / len(cycle_values), 1) if cycle_values else 0, + top_problems or "нет явных сигналов", + ] + ) + return rows + + +def recommendations(issues: list[dict[str, Any]]) -> list[str]: + flags = Counter(flag for issue in issues for flag in issue_problem_flags(issue)) + result: list[str] = [] + if flags["нет списанных часов"]: + result.append("Закрепить обязательное списание времени перед закрытием задач; без этого отчет по трудозатратам неполный.") + if flags["нет оценки"]: + result.append("Ввести контроль оценки перед стартом работы, чтобы сравнение план/факт по командам было корректным.") + if flags["закрыто после срока"]: + result.append("Для задач с due date добавить раннюю эскалацию риска за 3-5 рабочих дней до срока.") + if flags["длинный цикл > 30 дней"]: + result.append("Разбивать крупные задачи на меньшие deliverables и отдельно отслеживать зависшие задачи старше 30 дней.") + if flags["нет компонента/команды"]: + result.append("Заполнить components или единое поле команды, иначе корректировки по командам будут неточными.") + if not result: + result.append("Критичных системных проблем по закрытым задачам не найдено; продолжить мониторинг план/факт часов и цикла выполнения.") + return result + + +def write_quarter_html_report( + path: str, + issues: list[dict[str, Any]], + project: str, + year: int, + quarters: list[int], + closed_status: str, +) -> None: + report_issues = closed_quarter_issues(issues, year, quarters, closed_status) + total_spent = sum(seconds_value(issue, "aggregatetimespent", "timespent") for issue in report_issues) + total_estimate = sum( + seconds_value(issue, "aggregatetimeoriginalestimate", "timeoriginalestimate") + for issue in report_issues + ) + problem_rows = [] + for issue in report_issues: + flags = issue_problem_flags(issue) + if not flags: + continue + fields = issue.get("fields", {}) + resolved = jira_date(fields.get("resolutiondate")) + problem_rows.append( + [ + issue.get("key", ""), + fields.get("summary", ""), + f"Q{quarter_for_date(resolved)}" if resolved else "", + team_name(issue), + hours(seconds_value(issue, "aggregatetimespent", "timespent")), + ", ".join(flags), + ] + ) + + quarter_rows = quarter_summary_rows(report_issues, year, quarters) + team_rows = team_summary_rows(report_issues) + type_rows = [[name, count] for name, count in Counter(field_name(issue, "issuetype") for issue in report_issues).most_common()] + priority_rows = [[name, count] for name, count in Counter(field_name(issue, "priority") for issue in report_issues).most_common()] + recommendation_items = "".join(f"
  • {html_escape(item)}
  • " for item in recommendations(report_issues)) + generated_at = dt.datetime.now().strftime("%Y-%m-%d %H:%M") + quarter_label = ", ".join(f"Q{quarter}" for quarter in quarters) + + document = f""" + + + + + Jira {html_escape(project)} Analytics {quarter_label} {year} + + + +
    +

    Аналитика закрытых задач Jira

    +
    Проект {html_escape(project)} · статус {html_escape(closed_status)} · {html_escape(quarter_label)} {year} · сформировано {html_escape(generated_at)}
    +
    +
    +
    +
    Выполнено задач
    {len(report_issues)}
    +
    Фактические часы
    {hours(total_spent)}
    +
    Плановые часы
    {hours(total_estimate)}
    +
    Отклонение факт-план
    {hours(total_spent - total_estimate)}
    +
    +

    Сводка по кварталам

    {html_table(["Квартал", "Задачи", "Факт, ч", "План, ч", "Отклонение, ч", "Средний цикл, дни", "Задачи с сигналами"], quarter_rows)}
    +

    Команды и корректировки

    {html_table(["Команда/компонент", "Задачи", "Факт, ч", "План, ч", "Отклонение, ч", "Средний цикл, дни", "Основные проблемы"], team_rows)}
    +

    Рекомендуемые корректировки

      {recommendation_items}
    +
    +

    Типы задач

    {html_table(["Тип", "Кол-во"], type_rows)}
    +

    Приоритеты

    {html_table(["Приоритет", "Кол-во"], priority_rows)}
    +
    +

    Проблемные сигналы по задачам

    {html_table(["Ключ", "Summary", "Квартал", "Команда", "Факт, ч", "Сигналы"], problem_rows[:100], "Нет проблемных сигналов")}
    +
    + + +""" + with open(path, "w", encoding="utf-8") as file: + file.write(document) + + +def analyze(issues: list[dict[str, Any]], stale_days: int, top: int) -> None: + now = dt.datetime.now(dt.timezone.utc) + unresolved = [issue for issue in issues if is_unresolved(issue)] + resolved = [issue for issue in issues if not is_unresolved(issue)] + stale: list[tuple[str, str, str, int]] = [] + overdue: list[tuple[str, str, str]] = [] + + by_status = Counter(field_name(issue, "status") for issue in issues) + by_type = Counter(field_name(issue, "issuetype") for issue in issues) + by_priority = Counter(field_name(issue, "priority") for issue in issues) + by_assignee = Counter(field_name(issue, "assignee", "Unassigned") for issue in issues) + created_by_month = Counter(month_bucket(issue.get("fields", {}).get("created")) for issue in issues) + resolved_by_month = Counter( + month_bucket(issue.get("fields", {}).get("resolutiondate")) for issue in resolved + ) + + for issue in unresolved: + fields = issue.get("fields", {}) + updated = parse_jira_datetime(fields.get("updated")) + if updated: + age_days = (now - updated.astimezone(dt.timezone.utc)).days + if age_days >= stale_days: + stale.append( + ( + issue.get("key", "Unknown"), + field_name(issue, "status"), + fields.get("summary") or "", + age_days, + ) + ) + + due_date = fields.get("duedate") + if due_date: + try: + due = dt.date.fromisoformat(due_date) + except ValueError: + due = None + if due and due < now.date(): + overdue.append( + ( + issue.get("key", "Unknown"), + due.isoformat(), + fields.get("summary") or "", + ) + ) + + print("Jira Ticket Analysis") + print("====================") + print(f"Total issues: {len(issues)}") + print(f"Resolved issues: {len(resolved)}") + print(f"Unresolved issues: {len(unresolved)}") + if issues: + print(f"Resolution ratio: {len(resolved) / len(issues):.1%}") + print(f"Stale unresolved: {len(stale)} (updated >= {stale_days} days ago)") + print(f"Overdue unresolved: {len(overdue)}") + + print_counter("By Status", by_status, top) + print_counter("By Issue Type", by_type, top) + print_counter("By Priority", by_priority, top) + print_counter("By Assignee", by_assignee, top) + print_counter("By Component", count_list_field(issues, "components"), top) + print_counter("By Label", count_list_field(issues, "labels"), top) + print_counter("Created By Month", Counter(dict(sorted(created_by_month.items()))), top) + print_counter("Resolved By Month", Counter(dict(sorted(resolved_by_month.items()))), top) + + print("\nStale Unresolved Issues") + print("-----------------------") + if not stale: + print("No stale unresolved issues") + else: + for key, status, summary, age_days in sorted(stale, key=lambda item: item[3], reverse=True)[:top]: + print(f"{key:<12} {age_days:>4} days {status:<20} {summary}") + + print("\nOverdue Unresolved Issues") + print("-------------------------") + if not overdue: + print("No overdue unresolved issues") + else: + for key, due_date, summary in sorted(overdue, key=lambda item: item[1])[:top]: + print(f"{key:<12} {due_date} {summary}") + + +def write_json(path: str, issues: list[dict[str, Any]]) -> None: + with open(path, "w", encoding="utf-8") as file: + json.dump(issues, file, ensure_ascii=False, indent=2) + + +def write_csv(path: str, issues: list[dict[str, Any]]) -> None: + with open(path, "w", encoding="utf-8", newline="") as file: + writer = csv.DictWriter( + file, + fieldnames=[ + "key", + "type", + "status", + "priority", + "assignee", + "reporter", + "created", + "updated", + "resolutiondate", + "duedate", + "spent_hours", + "estimate_hours", + "summary", + ], + ) + writer.writeheader() + for issue in issues: + fields = issue.get("fields", {}) + writer.writerow( + { + "key": issue.get("key"), + "type": field_name(issue, "issuetype"), + "status": field_name(issue, "status"), + "priority": field_name(issue, "priority"), + "assignee": field_name(issue, "assignee", "Unassigned"), + "reporter": field_name(issue, "reporter"), + "created": fields.get("created"), + "updated": fields.get("updated"), + "resolutiondate": fields.get("resolutiondate"), + "duedate": fields.get("duedate"), + "spent_hours": hours(seconds_value(issue, "aggregatetimespent", "timespent")), + "estimate_hours": hours( + seconds_value(issue, "aggregatetimeoriginalestimate", "timeoriginalestimate") + ), + "summary": fields.get("summary"), + } + ) + + +def list_projects(base_url: str, token: str, user: str | None, search: str | None) -> None: + projects = jira_get(base_url, token, "/rest/api/2/project", {}, user) + if not isinstance(projects, list): + raise RuntimeError("Unexpected Jira project list response") + + search_lower = search.casefold() if search else None + rows = [] + for project in projects: + key = str(project.get("key", "")) + name = str(project.get("name", "")) + project_type = str(project.get("projectTypeKey", "")) + if search_lower and search_lower not in key.casefold() and search_lower not in name.casefold(): + continue + rows.append((key, name, project_type)) + + if not rows: + print("No projects found") + return + + key_width = max(len(row[0]) for row in rows) + type_width = max(len(row[2]) for row in rows) + print(f"{'KEY':<{key_width}} {'TYPE':<{type_width}} NAME") + print(f"{'-' * key_width} {'-' * type_width} {'-' * 40}") + for key, name, project_type in sorted(rows): + print(f"{key:<{key_width}} {project_type:<{type_width}} {name}") + + +def fetch_agile_pages( + base_url: str, + token: str, + user: str | None, + path: str, + params: dict[str, Any], + page_size: int, +) -> list[dict[str, Any]]: + values: list[dict[str, Any]] = [] + start_at = 0 + while True: + page_params = dict(params) + page_params.update({"startAt": start_at, "maxResults": page_size}) + data = jira_get(base_url, token, path, page_params, user) + batch = data.get("values") or data.get("issues") or [] + values.extend(batch) + total = data.get("total") + is_last = bool(data.get("isLast", False)) + if not batch or is_last: + break + if total is not None and len(values) >= int(total): + break + if total is None and len(batch) < page_size: + break + start_at += len(batch) + return values + + +def fetch_boards( + base_url: str, + token: str, + user: str | None, + project: str, + board_id: int | None, + page_size: int, +) -> list[dict[str, Any]]: + if board_id is not None: + board = jira_get(base_url, token, f"/rest/agile/1.0/board/{board_id}", {}, user) + return [board] + return fetch_agile_pages( + base_url, + token, + user, + "/rest/agile/1.0/board", + {"projectKeyOrId": project}, + page_size, + ) + + +def sprint_reference_date(sprint: dict[str, Any]) -> dt.date | None: + for field in ("completeDate", "endDate", "startDate"): + value = jira_date(sprint.get(field)) + if value: + return value + return None + + +def sprint_overlaps_range(sprint: dict[str, Any], start: dt.date, end: dt.date) -> bool: + sprint_start = jira_date(sprint.get("startDate")) or sprint_reference_date(sprint) + sprint_end = jira_date(sprint.get("completeDate")) or jira_date(sprint.get("endDate")) or sprint_start + if not sprint_start or not sprint_end: + return False + return sprint_start <= end and sprint_end >= start + + +def fetch_sprints_for_boards( + base_url: str, + token: str, + user: str | None, + boards: list[dict[str, Any]], + start: dt.date, + end: dt.date, + page_size: int, +) -> list[dict[str, Any]]: + sprints_by_id: dict[int, dict[str, Any]] = {} + for board in boards: + board_id = board.get("id") + if board_id is None: + continue + if str(board.get("type", "")).casefold() != "scrum": + continue + sprints = fetch_agile_pages( + base_url, + token, + user, + f"/rest/agile/1.0/board/{board_id}/sprint", + {"state": "closed"}, + page_size, + ) + for sprint in sprints: + sprint_id = sprint.get("id") + if isinstance(sprint_id, int) and sprint_overlaps_range(sprint, start, end): + enriched = dict(sprint) + enriched["boardId"] = board_id + enriched["boardName"] = board.get("name", "") + sprints_by_id[sprint_id] = enriched + return sorted(sprints_by_id.values(), key=lambda item: sprint_reference_date(item) or dt.date.min) + + +def fetch_sprint_issues( + base_url: str, + token: str, + user: str | None, + sprint_id: int, + page_size: int, +) -> list[dict[str, Any]]: + fields = [ + "summary", + "issuetype", + "status", + "priority", + "assignee", + "created", + "resolutiondate", + "components", + "labels", + "timespent", + "timeoriginalestimate", + "aggregatetimespent", + "aggregatetimeoriginalestimate", + ] + return fetch_agile_pages( + base_url, + token, + user, + f"/rest/agile/1.0/sprint/{sprint_id}/issue", + {"fields": ",".join(fields)}, + page_size, + ) + + +def is_closed_issue(issue: dict[str, Any], closed_status: str) -> bool: + fields = issue.get("fields", {}) + status = fields.get("status") or {} + category = status.get("statusCategory") if isinstance(status, dict) else None + category_key = category.get("key") if isinstance(category, dict) else None + if str(category_key).casefold() == "done": + return True + if fields.get("resolutiondate"): + return True + return field_name(issue, "status").casefold() == closed_status.casefold() + + +def issue_type_bucket(issue: dict[str, Any]) -> str: + raw = field_name(issue, "issuetype").casefold() + if any(word in raw for word in ("bug", "defect", "дефект", "ошиб")): + return "Баги" + if any(word in raw for word in ("feature", "story", "фич", "истор")): + return "Фичи" + if any(word in raw for word in ("task", "задач")): + return "Задачи" + return "Прочее" + + +def sprint_quarter_label(sprint: dict[str, Any]) -> str: + value = sprint_reference_date(sprint) + if not value: + return "Unknown" + return f"Q{quarter_for_date(value)} {value.year}" + + +def sprint_dates_label(sprint: dict[str, Any]) -> str: + start = jira_date(sprint.get("startDate")) + end = jira_date(sprint.get("completeDate")) or jira_date(sprint.get("endDate")) + if start and end: + return f"{start.isoformat()} - {end.isoformat()}" + if start: + return start.isoformat() + if end: + return end.isoformat() + return "" + + +def build_sprint_quarter_data( + base_url: str, + token: str, + user: str | None, + project: str, + board_id: int | None, + start: dt.date, + end: dt.date, + page_size: int, + closed_status: str, +) -> tuple[list[dict[str, Any]], dict[str, dict[str, Any]], dict[str, dict[str, Any]], list[dict[str, Any]]]: + boards = fetch_boards(base_url, token, user, project, board_id, page_size) + sprints = fetch_sprints_for_boards(base_url, token, user, boards, start, end, page_size) + quarter_stats: dict[str, dict[str, Any]] = defaultdict( + lambda: {"sprints": set(), "issues": set(), "buckets": Counter(), "spent": 0, "estimate": 0} + ) + sprint_stats: dict[str, dict[str, Any]] = {} + issue_rows: list[dict[str, Any]] = [] + seen_issue_quarter: set[tuple[str, str]] = set() + + for index, sprint in enumerate(sprints, start=1): + sprint_id = sprint.get("id") + if not isinstance(sprint_id, int): + continue + print(f"Fetching sprint {index}/{len(sprints)}: {sprint.get('name', sprint_id)}", file=sys.stderr) + quarter = sprint_quarter_label(sprint) + issues = fetch_sprint_issues(base_url, token, user, sprint_id, page_size) + stat = {"closed": 0, "buckets": Counter(), "spent": 0, "estimate": 0} + + for issue in issues: + if not is_closed_issue(issue, closed_status): + continue + key = str(issue.get("key", "")) + if not key: + continue + dedupe_key = (quarter, key) + if dedupe_key in seen_issue_quarter: + continue + seen_issue_quarter.add(dedupe_key) + + bucket = issue_type_bucket(issue) + spent = seconds_value(issue, "aggregatetimespent", "timespent") + estimate = seconds_value(issue, "aggregatetimeoriginalestimate", "timeoriginalestimate") + stat["closed"] += 1 + stat["buckets"][bucket] += 1 + stat["spent"] += spent + stat["estimate"] += estimate + quarter_stats[quarter]["sprints"].add(sprint_id) + quarter_stats[quarter]["issues"].add(key) + quarter_stats[quarter]["buckets"][bucket] += 1 + quarter_stats[quarter]["spent"] += spent + quarter_stats[quarter]["estimate"] += estimate + issue_rows.append( + { + "quarter": quarter, + "sprint": sprint.get("name", ""), + "key": key, + "type": field_name(issue, "issuetype"), + "bucket": bucket, + "status": field_name(issue, "status"), + "assignee": field_name(issue, "assignee", "Unassigned"), + "spent": hours(spent), + "estimate": hours(estimate), + "summary": issue.get("fields", {}).get("summary", ""), + } + ) + + sprint_stats[str(sprint_id)] = { + "name": sprint.get("name", ""), + "state": sprint.get("state", ""), + "board": sprint.get("boardName", ""), + "quarter": quarter, + "dates": sprint_dates_label(sprint), + "closed": stat["closed"], + "buckets": stat["buckets"], + "spent": stat["spent"], + "estimate": stat["estimate"], + } + + return boards, quarter_stats, sprint_stats, issue_rows + + +def write_sprint_quarter_html_report( + path: str, + project: str, + start: dt.date, + end: dt.date, + boards: list[dict[str, Any]], + quarter_stats: dict[str, dict[str, Any]], + sprint_stats: dict[str, dict[str, Any]], + issue_rows: list[dict[str, Any]], +) -> None: + quarter_rows = [] + for quarter in sorted(quarter_stats): + data = quarter_stats[quarter] + buckets = data["buckets"] + quarter_rows.append( + [ + quarter, + len(data["sprints"]), + len(data["issues"]), + buckets.get("Фичи", 0), + buckets.get("Задачи", 0), + buckets.get("Баги", 0), + buckets.get("Прочее", 0), + hours(data["spent"]), + hours(data["estimate"]), + hours(data["spent"] - data["estimate"]), + ] + ) + + sprint_rows = [] + for data in sorted(sprint_stats.values(), key=lambda item: (item["quarter"], item["dates"], item["name"])): + buckets = data["buckets"] + sprint_rows.append( + [ + data["quarter"], + data["name"], + data["state"], + data["dates"], + data["closed"], + buckets.get("Фичи", 0), + buckets.get("Задачи", 0), + buckets.get("Баги", 0), + hours(data["spent"]), + ] + ) + + detail_rows = [ + [ + row["quarter"], + row["sprint"], + row["key"], + row["bucket"], + row["type"], + row["status"], + row["assignee"], + row["spent"], + row["summary"], + ] + for row in sorted(issue_rows, key=lambda item: (item["quarter"], item["sprint"], item["key"])) + ] + + total_issues = sum(len(data["issues"]) for data in quarter_stats.values()) + total_sprints = len(sprint_stats) + total_spent = sum(data["spent"] for data in quarter_stats.values()) + board_names = ", ".join(str(board.get("name", board.get("id", ""))) for board in boards) or "нет boards" + generated_at = dt.datetime.now().strftime("%Y-%m-%d %H:%M") + + document = f""" + + + + + Jira {html_escape(project)} Sprint Quarterly Statistics + + + +
    +

    Статистика закрытых фич, задач и багов по спринтам

    +
    Проект {html_escape(project)} · период {start.isoformat()} - {end.isoformat()} · boards: {html_escape(board_names)} · сформировано {html_escape(generated_at)}
    +
    +
    +
    +
    Спринтов в периоде
    {total_sprints}
    +
    Закрытых задач
    {total_issues}
    +
    Фактические часы
    {hours(total_spent)}
    +
    Кварталов
    {len(quarter_stats)}
    +
    +

    Итоги по кварталам

    {html_table(["Квартал", "Спринты", "Закрыто всего", "Фичи", "Задачи", "Баги", "Прочее", "Факт, ч", "План, ч", "Отклонение, ч"], quarter_rows)}
    +

    Разбивка по спринтам

    {html_table(["Квартал", "Спринт", "Статус", "Даты", "Закрыто", "Фичи", "Задачи", "Баги", "Факт, ч"], sprint_rows)}
    +

    Закрытые задачи

    {html_table(["Квартал", "Спринт", "Ключ", "Категория", "Тип Jira", "Статус", "Исполнитель", "Факт, ч", "Summary"], detail_rows, "Нет закрытых фич/задач/багов")}
    +
    + + +""" + with open(path, "w", encoding="utf-8") as file: + file.write(document) + + +def main() -> int: + args = parse_args() + if not args.token: + print("Error: set JIRA_TOKEN or pass --token.", file=sys.stderr) + return 2 + if args.page_size < 1: + print("Error: --page-size must be greater than 0.", file=sys.stderr) + return 2 + if args.list_projects: + try: + list_projects(args.base_url, args.token, args.user, args.project_search) + except RuntimeError as exc: + print(f"Error: {exc}", file=sys.stderr) + return 1 + return 0 + if args.html_sprint_quarter_out: + try: + sprint_start = parse_date_arg(args.sprints_from, "--sprints-from") + sprint_end = parse_date_arg(args.sprints_to, "--sprints-to") + except ValueError as exc: + print(f"Error: {exc}", file=sys.stderr) + return 2 + if sprint_start > sprint_end: + print("Error: --sprints-from must be earlier than --sprints-to.", file=sys.stderr) + return 2 + try: + boards, quarter_stats, sprint_stats, issue_rows = build_sprint_quarter_data( + args.base_url, + args.token, + args.user, + args.project, + args.board_id, + sprint_start, + sprint_end, + args.page_size, + args.closed_status, + ) + write_sprint_quarter_html_report( + args.html_sprint_quarter_out, + args.project, + sprint_start, + sprint_end, + boards, + quarter_stats, + sprint_stats, + issue_rows, + ) + print(f"Sprint quarter HTML report written to {args.html_sprint_quarter_out}") + print(f"Boards: {len(boards)}; sprints: {len(sprint_stats)}; closed issues: {len(issue_rows)}") + except RuntimeError as exc: + print(f"Error: {exc}", file=sys.stderr) + return 1 + return 0 + try: + quarters = parse_quarters(args.quarters) + except ValueError as exc: + print(f"Error: {exc}", file=sys.stderr) + return 2 + if not quarters: + print("Error: --quarters must contain at least one quarter.", file=sys.stderr) + return 2 + + if args.jql: + jql = args.jql + elif args.html_quarter_out: + start = quarter_bounds(args.report_year, min(quarters))[0] + end = quarter_bounds(args.report_year, max(quarters))[1] + jql = ( + f'project = {jql_project(args.project)} AND status = {jql_string(args.closed_status)} ' + f'AND resolutiondate >= "{start.isoformat()}" ' + f'AND resolutiondate < "{end.isoformat()}" ORDER BY resolutiondate ASC' + ) + else: + jql = f"project = {jql_project(args.project)} ORDER BY updated DESC" + try: + issues = fetch_issues(args.base_url, args.token, args.user, jql, args.page_size) + analyze(issues, args.stale_days, args.top) + if args.html_quarter_out: + write_quarter_html_report( + args.html_quarter_out, + issues, + args.project, + args.report_year, + quarters, + args.closed_status, + ) + print(f"Quarter HTML report written to {args.html_quarter_out}") + if args.json_out: + write_json(args.json_out, issues) + print(f"\nRaw JSON written to {args.json_out}") + if args.csv_out: + write_csv(args.csv_out, issues) + print(f"CSV written to {args.csv_out}") + except RuntimeError as exc: + print(f"Error: {exc}", file=sys.stderr) + message = str(exc) + if "does not exist for the field 'project'" in message: + print( + "Hint: Jira does not recognize this project key for the current token. " + "Check the real project key with --list-projects, or use a token/user with Browse Projects permission.", + file=sys.stderr, + ) + return 1 + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/vdev-sprints-2026.html b/vdev-sprints-2026.html new file mode 100644 index 0000000..3cf231c --- /dev/null +++ b/vdev-sprints-2026.html @@ -0,0 +1,44 @@ + + + + + + Jira VDEV Sprint Quarterly Statistics + + + +
    +

    Статистика закрытых фич, задач и багов по спринтам

    +
    Проект VDEV · период 2026-01-01 - 2026-06-15 · boards: Continuous Improvement Board, my board, Scope Belta Board, Scope VOKA Board, VDEV DevOps Kanban, актуальные тикеты · сформировано 2026-06-15 23:53
    +
    +
    +
    +
    Спринтов в периоде
    0
    +
    Закрытых задач
    0
    +
    Фактические часы
    0.0
    +
    Кварталов
    0
    +
    +

    Итоги по кварталам

    Нет данных

    +

    Разбивка по спринтам

    Нет данных

    +

    Закрытые задачи

    Нет закрытых фич/задач/багов

    +
    + + diff --git a/vdev_sprint_report_2026.sh b/vdev_sprint_report_2026.sh new file mode 100755 index 0000000..76a00a5 --- /dev/null +++ b/vdev_sprint_report_2026.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +"${SCRIPT_DIR}/jira_analyze.py" \ + --html-sprint-quarter-out "${SCRIPT_DIR}/vdev-sprints-2026.html" \ + --sprints-from 2026-01-01 \ + --sprints-to 2026-06-15