#!/usr/bin/env python3
# -*- coding: utf-8 -*-

# SPDX-License-Identifier: MIT
# Copyright (C) 2020-2021 Mobica Limited

"""Command line script creating sprint delivery report file"""

# Standard library imports
import copy
import decimal
import json
import re
import sys
import datetime

# Third party imports
import jsonschema
import tabulate
import dateutil.parser

# Project imports
import cjm
import cjm.capacity
import cjm.cfg
import cjm.codes
import cjm.commitment
import cjm.data
import cjm.delivery
import cjm.issue
import cjm.presentation
import cjm.run
import cjm.schema
import cjm.sprint
import cjm.team


def parse_options(args):
    """Parse command line options"""
    defaults = cjm.cfg.load_defaults()
    parser = cjm.cfg.make_common_parser(defaults)

    parser.add_argument(
        "sprint_file", action="store",
        help=(
            "Path to the json sprint data file as generated by the {0:s} script and described by"
            " the {1:s} schema"
            "".format(cjm.SM_CREATE_SPRINT_FILE, cjm.schema.make_subpath("sprint.json"))))

    parser.add_argument(
        "--delivered", action="store", dest="delivered_filter", choices=("all", "yes", "no"),
        default="all",
        help="Filter the issues by the delivered flag")
    parser.add_argument(
        "--consider-delivery-comment", action="store_true", dest="delivery_comment",
        help="Add to raport ticket closed after sprint but with comment /Delivered")
    parser.add_argument(
        "-s", "--summary", action="store_true", dest="show_summary", default=False,
        help="Show the delivery summary instead of the detailed issue table")
    return parser.parse_args(args)


def _make_augment_issue_cb(cfg, extended, sprint_data, warnings):
    if extended:
        ext_comment_re = re.compile(
            r"{0:s}/Extended \((?P<committed>[0-9]+)/(?P<deliverable>[0-9]+)\)"
            "".format(re.escape(sprint_data["comment prefix"]))) if extended else None
        assert sprint_data is not None

    def __retrieve_ext_committed_sps(issue):
        comments = cjm.issue.request_issue_comments_regexp(cfg, issue["key"], ext_comment_re)

        if not comments:
            return 0
        else:
            if len(set(comments)) > 1:
                cjm.data.add_warning(
                    warnings, issue["key"],
                    "Issue has more than one ({0:s}) sprint extension comments. Only the first"
                    " meaningful comment will be used. Delete all erroneous extension comments"
                    " prefixed by '{1:s}'"
                    "".format(
                        cjm.presentation.color_emph("{0:d}".format(len(set(comments)))),
                        cjm.presentation.color_issue_comment(sprint_data["comment prefix"])))
            sp_committed = int(comments[0].group("committed"))
            sp_deliverable = int(comments[0].group("deliverable"))
            if sp_deliverable != issue["story points"]:
                cjm.data.add_warning(
                    warnings, issue["key"],
                    "Story point value inconsistency between the story points field ({0:s}) and"
                    " the sprint extension comment ({1:s}). The value taken from the story points"
                    " field will be used for reporting purposes and the committed value will be"
                    " assumed to be 0"
                    "".format(
                        cjm.presentation.color_emph("{0:d}".format(issue["story points"])),
                        cjm.presentation.color_emph(
                            "{0:d}/{1:d}".format(sp_committed, sp_deliverable))))
                return 0
            elif sp_committed > sp_deliverable:
                cjm.data.add_warning(
                    warnings, issue["key"],
                    "According to the sprint extension comment, the number of committed story"
                    " points ({0:s}) is greater than the number of deliverable story points"
                    " ({1:s}). The deliverable number of story points will be used in both cases"
                    "".format(
                        cjm.presentation.color_emph("{0:d}".format(sp_committed)),
                        cjm.presentation.color_emph("{0:d}".format(sp_deliverable))))
                return sp_deliverable
            else:
                return sp_committed

    def __augment_issue_cb(issue):
        issue = copy.copy(issue)
        points = issue.get("story points")
        points = 0 if points is None else int(points)
        issue["story points"] = points
        issue["dropped"] = False
        issue["extended"] = extended
        issue["committed story points"] = (
            __retrieve_ext_committed_sps(issue) if extended else points)
        issue["delivered story points"] = -1
        return issue

    return __augment_issue_cb


def _retrieve_issues(cfg, issue_keys, warnings):
    issues = cjm.issue.request_issues_by_keys(cfg, issue_keys)

    response_keys = {i["key"] for i in issues}
    request_keys = set(issue_keys)

    if response_keys != request_keys:
        for key in sorted(request_keys-response_keys):
            cjm.data.add_warning(
                warnings, key,
                "Issue details were requested from Jira server but not included in its response")

    augment_cb = _make_augment_issue_cb(cfg, False, None, warnings)
    return [augment_cb(i) for i in issues]


def _verify_committed_issues(cfg, sprint_data, issues_com, commitment_data, warnings):
    commitment_lut = dict((i["key"], i) for i in commitment_data["issues"])

    confirm_postfix = "ConfirmSp"
    confirm_comment = "{0:s}/{1:s}".format(sprint_data["comment prefix"], confirm_postfix)
    confirm_re = re.compile(
        r"{0:s}/{1:s}"
        "".format(re.escape(sprint_data["comment prefix"]), re.escape(confirm_postfix)))

    for issue in issues_com:
        issue_key = issue["key"]
        curr_sp = issue["story points"]
        prev_sp = (lambda p: 0 if p is None else p)(commitment_lut[issue_key]["story points"])

        if curr_sp == 0:
            cjm.data.add_warning(
                warnings, issue_key, "Issue story points not specified or equal to 0")

        if curr_sp != prev_sp:
            comments = cjm.issue.request_issue_comments_regexp(cfg, issue_key, confirm_re)

            if not comments:
                cjm.data.add_warning(
                    warnings, issue_key,
                    "Issue story points value changed from the committed {0:s} to the current"
                    " {1:s}. To confirm this change add '{2:s}' comment to the issue"
                    "".format(
                        cjm.presentation.color_emph("{0:d}".format(prev_sp)),
                        cjm.presentation.color_emph("{0:d}".format(curr_sp)),
                        cjm.presentation.color_issue_comment(confirm_comment)))


def _verify_assignees(issues, person_lut, warnings):
    for issue in issues:
        if issue["assignee id"] is None:
            cjm.data.add_warning(
                warnings, issue["key"],
                "Issue unassigned")
        elif issue["assignee id"] not in person_lut:
            cjm.data.add_warning(
                warnings, issue["key"],
                "Issue assigned to an unknown account ({0:s})"
                "".format(cjm.presentation.color_emph(issue["assignee id"])))


def _retrieve_extension_issues(cfg, sprint_data, team_data, warnings):
    issues = cjm.sprint.request_issues_by_comment(
        cfg, "{0:s}/Extended".format(sprint_data["comment prefix"]))
    augment_cb = _make_augment_issue_cb(cfg, True, sprint_data, warnings)
    return [augment_cb(i) for i in cjm.team.filter_team_issues(cfg, issues, team_data)]


def _join_issue_lists(issues_com, issues_ext, warnings):
    com_keys = [i["key"] for i in issues_com]

    def __ext_issue_uniq(issue):
        issue_key = issue["key"]

        if issue_key in com_keys:
            cjm.data.add_warning(
                warnings, issue_key,
                "Issue was found in the commitment data file and at the same time it was found out"
                " to be marked with sprint extension comment. The comment will be ignored")
            return False
        else:
            return True

    return issues_com + [i for i in issues_ext if __ext_issue_uniq(i)]


def _process_dropped_issues(cfg, sprint_data, all_issues, warnings):
    """Determine dropped status of given issues"""
    issues_drp = cjm.sprint.request_issues_by_comment(
        cfg, "{0:s}/Dropped".format(sprint_data["comment prefix"]))

    issue_lut = {i["id"]: i for i in all_issues}

    for dropped_issue in issues_drp:
        issue = issue_lut.get(dropped_issue["id"])

        if issue is None:
            cjm.data.add_warning(
                warnings, dropped_issue["key"],
                "Issue has the dropped comment but no corresponding committed or extended comment")
        else:
            issue["dropped"] = True

    return all_issues


def _process_delivered_issues(cfg, sprint_data, all_issues):
    """Determine delivery status of given issues.

    Optionally, allow late delivery issues, i.e. issues that were delivered after the sprint end
    but still contain (manually added) `/Delivered` comment
    """
    if cfg["issue"]["allow late delivery"]:
        issues_delivered = cjm.sprint.request_issues_by_comment(
            cfg, "{0:s}/Delivered".format(sprint_data["comment prefix"]))

        delivered_ids = [i["id"] for i in issues_delivered]
    else:
        delivered_ids = []

    sprint_end_date = (
        dateutil.parser.parse(sprint_data["end date"]).date() + datetime.timedelta(days=1))

    def __issue_done(issue):
        if issue["id"] in delivered_ids:
            return True

        if issue["status"] != "Done" or issue["resolution date"] is None:
            return False

        issue_resolve_date = dateutil.parser.parse(issue["resolution date"]).date()
        return issue_resolve_date < sprint_end_date

    return [{**i, "delivered": __issue_done(i)} for i in all_issues]


def _make_delivery_data(all_issues):
    for issue in all_issues:
        if issue["dropped"]:
            issue["committed story points"] = 0
            issue["delivered story points"] = 0
            issue["outcome"] = "drop"
        elif issue["delivered"]:
            issue["delivered story points"] = issue["story points"]
            issue["outcome"] = "done"
        else:
            issue["delivered story points"] = 0
            issue["outcome"] = "open"

        issue["income"] = "extend" if issue["extended"] else "commit"

    total_committed = sum([i["committed story points"] for i in all_issues])
    total_delivered = sum([i["delivered story points"] for i in all_issues if i["delivered"]])

    if total_committed > 0:
        delivery_ratio = decimal.Decimal(total_delivered) / decimal.Decimal(total_committed)
        delivery_ratio = delivery_ratio.quantize(decimal.Decimal(".0000"), decimal.ROUND_HALF_UP)
        ratio_value = str(delivery_ratio)
    else:
        ratio_value = None

    return {
        "total": {
            "committed": total_committed,
            "delivered": total_delivered
        },
        "ratio": ratio_value,
        "issues": sorted(all_issues, key=lambda i: i["id"])
    }


def main(options):
    """Entry function"""
    cfg = cjm.cfg.apply_options(cjm.cfg.init_defaults(), options)
    cfg["issue"]["include unassigned"] = True
    cfg["issue"]["allow late delivery"] = options.delivery_comment

    # Load sprint data:

    sprint_data = cjm.data.load(cfg, options.sprint_file, "sprint.json")

    cfg["sprint"]["id"] = sprint_data.get("id")
    cfg["project"]["key"] = sprint_data["project"]["key"]

    if cfg["sprint"]["id"] is None:
        sys.stderr.write(
            "ERROR: The sprint id is not specified by the sprint data file ('{0:s}')\n"
            "".format(options.sprint_file))
        return cjm.codes.CONFIGURATION_ERROR

    cjm.sprint.apply_data_file_paths(cfg, sprint_data)

    # Load other data:

    team_data = cjm.data.load(cfg, cfg["path"]["team"], "team.json")
    capacity_data = cjm.data.load(cfg, cfg["path"]["capacity"], "capacity.json")
    commitment_data = cjm.data.load(cfg, cfg["path"]["commitment"], "commitment.json")

    # Determine the story points field id:

    if cfg["jira"]["fields"]["story points"] is None:
        cfg["jira"]["fields"]["story points"] = cjm.issue.detect_story_point_field_id(cfg)

    # Request all committed issues:

    warnings = {}
    issues_com = _retrieve_issues(cfg, [i["key"] for i in commitment_data["issues"]], warnings)
    _verify_committed_issues(cfg, sprint_data, issues_com, commitment_data, warnings)

    # Request all extension issues and determine their commitment story points:

    issues_ext = _retrieve_extension_issues(cfg, sprint_data, team_data, warnings)

    issues = _join_issue_lists(issues_com, issues_ext, warnings)

    person_lut = dict((p["account id"], p) for p in team_data["people"])
    _verify_assignees(issues, person_lut, warnings)

    # Request dropped issues and change story point value to 0

    issues = _process_dropped_issues(cfg, sprint_data, issues, warnings)
    issues = _process_delivered_issues(cfg, sprint_data, issues)
    issues = list(filter(cjm.data.make_flag_filter("delivered", options.delivered_filter), issues))

    # Determine delivered story points

    delivery_data = _make_delivery_data(issues)
    delivery_schema = cjm.schema.load(cfg, "delivery.json")
    jsonschema.validate(delivery_data, delivery_schema)

    if options.json_output:
        print(json.dumps(delivery_data, indent=4, sort_keys=False))
    else:
        if options.show_summary:
            print_summary(delivery_data, team_data, sprint_data, capacity_data)
        else:
            print_issue_list(delivery_data, person_lut)

    cjm.presentation.print_data_warnings(warnings)

    return cjm.codes.NO_ERROR


def print_issue_list(delivery, person_lut):
    """Print the detailed issue status table"""
    def __fmt_assignee(issue):
        if issue["assignee id"] is None:
            return ""
        elif issue["assignee id"] not in person_lut:
            return issue["assignee id"]
        else:
            return "{0:s}, {1:s}".format(
                person_lut[issue["assignee id"]]["last name"],
                person_lut[issue["assignee id"]]["first name"])

    print(tabulate.tabulate(
        [(i["id"], i["key"], i["summary"], __fmt_assignee(i),
          i["committed story points"], i["delivered story points"], i["status"],
          i["income"], i["outcome"], i["extended"], i["delivered"], i["dropped"])
         for i in delivery["issues"]],
        headers=["Id", "Key", "Summary", "Assignee", "Committed", "Delivered",
                 "Current Status", "Income", "Outcome", "Extended", "Delivered", "Dropped"],
        tablefmt="orgtbl"))


def calc_total(issues, field_key):
    """Calculate sum of given fields"""
    return sum([int(i[field_key]) for i in issues])


def print_summary(delivery_data, team_data, sprint_data, capacity_data):
    """Print the report summary table"""
    # pylint: disable=too-many-locals
    person_capacity_list = cjm.capacity.process_person_capacity_list(sprint_data, capacity_data)
    person_capacity_lut = cjm.capacity.make_person_capacity_lut(person_capacity_list)
    total_capacity = sum(p["sprint capacity"] for p in person_capacity_list)
    total_committed = delivery_data["total"]["committed"]
    total_delivered = delivery_data["total"]["delivered"]

    unassigned_issues = cjm.issue.unassigned_issues(delivery_data["issues"])
    assigned_issues = cjm.issue.assigned_issues(delivery_data["issues"])

    assigned_committed = calc_total(assigned_issues, "committed story points")
    unassigned_committed = calc_total(unassigned_issues, "committed story points")

    cells = (
        cjm.presentation.default_cell("caption"),
        cjm.presentation.default_cell("delivery"),
        cjm.presentation.default_cell("commitment"),
        cjm.presentation.default_cell("capacity"),
        cjm.presentation.ratio_cell("commitment ratio"),
        cjm.presentation.status_cell("commitment status"),
        cjm.presentation.ratio_cell("delivery ratio"),
        cjm.presentation.status_cell("delivery status"))

    def __make_person_row(person_data):
        capacity = (
            person_capacity_lut.get(person_data["account id"], {}).get("sprint capacity", 0))
        commitment = calc_total(
            cjm.issue.person_issues(delivery_data["issues"], person_data),
            "committed story points")
        delivery = calc_total(
            cjm.issue.person_issues(delivery_data["issues"], person_data),
            "delivered story points")
        commitment_summary = cjm.capacity.determine_summary(commitment, capacity)
        delivery_summary = cjm.delivery.determine_summary(delivery, commitment)

        if commitment_summary["capacity"] or delivery_summary["commitment"]:
            importance_code = cjm.presentation.IMPORTANCE_CODES.NORMAL
        else:
            importance_code = cjm.presentation.IMPORTANCE_CODES.LOW

        return cjm.presentation.format_row(
            importance_code, cells,
            {**commitment_summary, **delivery_summary,
             "caption": cjm.team.format_full_name(person_data)})

    def __make_unassigned_row():
        capacity = total_capacity - assigned_committed
        commitment_summary = cjm.capacity.determine_summary(unassigned_committed, capacity)
        commitment = unassigned_committed
        delivery = calc_total(unassigned_issues, "delivered story points")
        delivery_summary = cjm.delivery.determine_summary(delivery, commitment)

        if delivery_summary["commitment"]:
            importance_code = cjm.presentation.IMPORTANCE_CODES.NORMAL
        else:
            importance_code = cjm.presentation.IMPORTANCE_CODES.LOW

        return cjm.presentation.format_row(
            importance_code, cells,
            {**commitment_summary, **delivery_summary, "caption": "Unasigned"})

    def __make_total_row():
        commitment_summary = cjm.capacity.determine_summary(total_committed, total_capacity)
        delivery_summary = cjm.delivery.determine_summary(total_delivered, total_committed)

        return cjm.presentation.format_row(
            cjm.presentation.IMPORTANCE_CODES.HIGH, cells,
            {**commitment_summary, **delivery_summary, "caption": "Team Summary"})

    def __sort_cb(person):
        return (person["last name"], person["first name"])

    print(tabulate.tabulate(
        [__make_person_row(p) for p in sorted(team_data["people"], key=__sort_cb)] +
        [__make_unassigned_row()] +
        [__make_total_row()],
        headers=[
            "<NAME>", "Delivery", "Commitment", "Capacity",
            "Com/Cap Ratio", "Com Status", "Del/Com Ratio", "Del Status"],
        tablefmt="orgtbl"))

if __name__ == '__main__':
    cjm.run.run(main, parse_options(sys.argv[1:]))

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

# SPDX-License-Identifier: MIT
# Copyright (C) 2020-2021 Mobica Limited

"""Command line script creating sprint delivery report file"""

# Standard library imports
import copy
import decimal
import json
import re
import sys
import datetime

# Third party imports
import jsonschema
import tabulate
import dateutil.parser

# Project imports
import cjm
import cjm.capacity
import cjm.cfg
import cjm.codes
import cjm.commitment
import cjm.data
import cjm.delivery
import cjm.issue
import cjm.presentation
import cjm.run
import cjm.schema
import cjm.sprint
import cjm.team


def parse_options(args):
    """Parse command line options"""
    defaults = cjm.cfg.load_defaults()
    parser = cjm.cfg.make_common_parser(defaults)

    parser.add_argument(
        "sprint_file", action="store",
        help=(
            "Path to the json sprint data file as generated by the {0:s} script and described by"
            " the {1:s} schema"
            "".format(cjm.SM_CREATE_SPRINT_FILE, cjm.schema.make_subpath("sprint.json"))))

    parser.add_argument(
        "--delivered", action="store", dest="delivered_filter", choices=("all", "yes", "no"),
        default="all",
        help="Filter the issues by the delivered flag")
    parser.add_argument(
        "--consider-delivery-comment", action="store_true", dest="delivery_comment",
        help="Add to raport ticket closed after sprint but with comment /Delivered")
    parser.add_argument(
        "-s", "--summary", action="store_true", dest="show_summary", default=False,
        help="Show the delivery summary instead of the detailed issue table")
    return parser.parse_args(args)


def _make_augment_issue_cb(cfg, extended, sprint_data, warnings):
    if extended:
        ext_comment_re = re.compile(
            r"{0:s}/Extended \((?P<committed>[0-9]+)/(?P<deliverable>[0-9]+)\)"
            "".format(re.escape(sprint_data["comment prefix"]))) if extended else None
        assert sprint_data is not None

    def __retrieve_ext_committed_sps(issue):
        comments = cjm.issue.request_issue_comments_regexp(cfg, issue["key"], ext_comment_re)

        if not comments:
            return 0
        else:
            if len(set(comments)) > 1:
                cjm.data.add_warning(
                    warnings, issue["key"],
                    "Issue has more than one ({0:s}) sprint extension comments. Only the first"
                    " meaningful comment will be used. Delete all erroneous extension comments"
                    " prefixed by '{1:s}'"
                    "".format(
                        cjm.presentation.color_emph("{0:d}".format(len(set(comments)))),
                        cjm.presentation.color_issue_comment(sprint_data["comment prefix"])))
            sp_committed = int(comments[0].group("committed"))
            sp_deliverable = int(comments[0].group("deliverable"))
            if sp_deliverable != issue["story points"]:
                cjm.data.add_warning(
                    warnings, issue["key"],
                    "Story point value inconsistency between the story points field ({0:s}) and"
                    " the sprint extension comment ({1:s}). The value taken from the story points"
                    " field will be used for reporting purposes and the committed value will be"
                    " assumed to be 0"
                    "".format(
                        cjm.presentation.color_emph("{0:d}".format(issue["story points"])),
                        cjm.presentation.color_emph(
                            "{0:d}/{1:d}".format(sp_committed, sp_deliverable))))
                return 0
            elif sp_committed > sp_deliverable:
                cjm.data.add_warning(
                    warnings, issue["key"],
                    "According to the sprint extension comment, the number of committed story"
                    " points ({0:s}) is greater than the number of deliverable story points"
                    " ({1:s}). The deliverable number of story points will be used in both cases"
                    "".format(
                        cjm.presentation.color_emph("{0:d}".format(sp_committed)),
                        cjm.presentation.color_emph("{0:d}".format(sp_deliverable))))
                return sp_deliverable
            else:
                return sp_committed

    def __augment_issue_cb(issue):
        issue = copy.copy(issue)
        points = issue.get("story points")
        points = 0 if points is None else int(points)
        issue["story points"] = points
        issue["dropped"] = False
        issue["extended"] = extended
        issue["committed story points"] = (
            __retrieve_ext_committed_sps(issue) if extended else points)
        issue["delivered story points"] = -1
        return issue

    return __augment_issue_cb


def _retrieve_issues(cfg, issue_keys, warnings):
    issues = cjm.issue.request_issues_by_keys(cfg, issue_keys)

    response_keys = {i["key"] for i in issues}
    request_keys = set(issue_keys)

    if response_keys != request_keys:
        for key in sorted(request_keys-response_keys):
            cjm.data.add_warning(
                warnings, key,
                "Issue details were requested from Jira server but not included in its response")

    augment_cb = _make_augment_issue_cb(cfg, False, None, warnings)
    return [augment_cb(i) for i in issues]


def _verify_committed_issues(cfg, sprint_data, issues_com, commitment_data, warnings):
    commitment_lut = dict((i["key"], i) for i in commitment_data["issues"])

    confirm_postfix = "ConfirmSp"
    confirm_comment = "{0:s}/{1:s}".format(sprint_data["comment prefix"], confirm_postfix)
    confirm_re = re.compile(
        r"{0:s}/{1:s}"
        "".format(re.escape(sprint_data["comment prefix"]), re.escape(confirm_postfix)))

    for issue in issues_com:
        issue_key = issue["key"]
        curr_sp = issue["story points"]
        prev_sp = (lambda p: 0 if p is None else p)(commitment_lut[issue_key]["story points"])

        if curr_sp == 0:
            cjm.data.add_warning(
                warnings, issue_key, "Issue story points not specified or equal to 0")

        if curr_sp != prev_sp:
            comments = cjm.issue.request_issue_comments_regexp(cfg, issue_key, confirm_re)

            if not comments:
                cjm.data.add_warning(
                    warnings, issue_key,
                    "Issue story points value changed from the committed {0:s} to the current"
                    " {1:s}. To confirm this change add '{2:s}' comment to the issue"
                    "".format(
                        cjm.presentation.color_emph("{0:d}".format(prev_sp)),
                        cjm.presentation.color_emph("{0:d}".format(curr_sp)),
                        cjm.presentation.color_issue_comment(confirm_comment)))


def _verify_assignees(issues, person_lut, warnings):
    for issue in issues:
        if issue["assignee id"] is None:
            cjm.data.add_warning(
                warnings, issue["key"],
                "Issue unassigned")
        elif issue["assignee id"] not in person_lut:
            cjm.data.add_warning(
                warnings, issue["key"],
                "Issue assigned to an unknown account ({0:s})"
                "".format(cjm.presentation.color_emph(issue["assignee id"])))


def _retrieve_extension_issues(cfg, sprint_data, team_data, warnings):
    issues = cjm.sprint.request_issues_by_comment(
        cfg, "{0:s}/Extended".format(sprint_data["comment prefix"]))
    augment_cb = _make_augment_issue_cb(cfg, True, sprint_data, warnings)
    return [augment_cb(i) for i in cjm.team.filter_team_issues(cfg, issues, team_data)]


def _join_issue_lists(issues_com, issues_ext, warnings):
    com_keys = [i["key"] for i in issues_com]

    def __ext_issue_uniq(issue):
        issue_key = issue["key"]

        if issue_key in com_keys:
            cjm.data.add_warning(
                warnings, issue_key,
                "Issue was found in the commitment data file and at the same time it was found out"
                " to be marked with sprint extension comment. The comment will be ignored")
            return False
        else:
            return True

    return issues_com + [i for i in issues_ext if __ext_issue_uniq(i)]


def _process_dropped_issues(cfg, sprint_data, all_issues, warnings):
    """Determine dropped status of given issues"""
    issues_drp = cjm.sprint.request_issues_by_comment(
        cfg, "{0:s}/Dropped".format(sprint_data["comment prefix"]))

    issue_lut = {i["id"]: i for i in all_issues}

    for dropped_issue in issues_drp:
        issue = issue_lut.get(dropped_issue["id"])

        if issue is None:
            cjm.data.add_warning(
                warnings, dropped_issue["key"],
                "Issue has the dropped comment but no corresponding committed or extended comment")
        else:
            issue["dropped"] = True

    return all_issues


def _process_delivered_issues(cfg, sprint_data, all_issues):
    """Determine delivery status of given issues.

    Optionally, allow late delivery issues, i.e. issues that were delivered after the sprint end
    but still contain (manually added) `/Delivered` comment
    """
    if cfg["issue"]["allow late delivery"]:
        issues_delivered = cjm.sprint.request_issues_by_comment(
            cfg, "{0:s}/Delivered".format(sprint_data["comment prefix"]))

        delivered_ids = [i["id"] for i in issues_delivered]
    else:
        delivered_ids = []

    sprint_end_date = (
        dateutil.parser.parse(sprint_data["end date"]).date() + datetime.timedelta(days=1))

    def __issue_done(issue):
        if issue["id"] in delivered_ids:
            return True

        if issue["status"] != "Done" or issue["resolution date"] is None:
            return False

        issue_resolve_date = dateutil.parser.parse(issue["resolution date"]).date()
        return issue_resolve_date < sprint_end_date

    return [{**i, "delivered": __issue_done(i)} for i in all_issues]


def _make_delivery_data(all_issues):
    for issue in all_issues:
        if issue["dropped"]:
            issue["committed story points"] = 0
            issue["delivered story points"] = 0
            issue["outcome"] = "drop"
        elif issue["delivered"]:
            issue["delivered story points"] = issue["story points"]
            issue["outcome"] = "done"
        else:
            issue["delivered story points"] = 0
            issue["outcome"] = "open"

        issue["income"] = "extend" if issue["extended"] else "commit"

    total_committed = sum([i["committed story points"] for i in all_issues])
    total_delivered = sum([i["delivered story points"] for i in all_issues if i["delivered"]])

    if total_committed > 0:
        delivery_ratio = decimal.Decimal(total_delivered) / decimal.Decimal(total_committed)
        delivery_ratio = delivery_ratio.quantize(decimal.Decimal(".0000"), decimal.ROUND_HALF_UP)
        ratio_value = str(delivery_ratio)
    else:
        ratio_value = None

    return {
        "total": {
            "committed": total_committed,
            "delivered": total_delivered
        },
        "ratio": ratio_value,
        "issues": sorted(all_issues, key=lambda i: i["id"])
    }


def main(options):
    """Entry function"""
    cfg = cjm.cfg.apply_options(cjm.cfg.init_defaults(), options)
    cfg["issue"]["include unassigned"] = True
    cfg["issue"]["allow late delivery"] = options.delivery_comment

    # Load sprint data:

    sprint_data = cjm.data.load(cfg, options.sprint_file, "sprint.json")

    cfg["sprint"]["id"] = sprint_data.get("id")
    cfg["project"]["key"] = sprint_data["project"]["key"]

    if cfg["sprint"]["id"] is None:
        sys.stderr.write(
            "ERROR: The sprint id is not specified by the sprint data file ('{0:s}')\n"
            "".format(options.sprint_file))
        return cjm.codes.CONFIGURATION_ERROR

    cjm.sprint.apply_data_file_paths(cfg, sprint_data)

    # Load other data:

    team_data = cjm.data.load(cfg, cfg["path"]["team"], "team.json")
    capacity_data = cjm.data.load(cfg, cfg["path"]["capacity"], "capacity.json")
    commitment_data = cjm.data.load(cfg, cfg["path"]["commitment"], "commitment.json")

    # Determine the story points field id:

    if cfg["jira"]["fields"]["story points"] is None:
        cfg["jira"]["fields"]["story points"] = cjm.issue.detect_story_point_field_id(cfg)

    # Request all committed issues:

    warnings = {}
    issues_com = _retrieve_issues(cfg, [i["key"] for i in commitment_data["issues"]], warnings)
    _verify_committed_issues(cfg, sprint_data, issues_com, commitment_data, warnings)

    # Request all extension issues and determine their commitment story points:

    issues_ext = _retrieve_extension_issues(cfg, sprint_data, team_data, warnings)

    issues = _join_issue_lists(issues_com, issues_ext, warnings)

    person_lut = dict((p["account id"], p) for p in team_data["people"])
    _verify_assignees(issues, person_lut, warnings)

    # Request dropped issues and change story point value to 0

    issues = _process_dropped_issues(cfg, sprint_data, issues, warnings)
    issues = _process_delivered_issues(cfg, sprint_data, issues)
    issues = list(filter(cjm.data.make_flag_filter("delivered", options.delivered_filter), issues))

    # Determine delivered story points

    delivery_data = _make_delivery_data(issues)
    delivery_schema = cjm.schema.load(cfg, "delivery.json")
    jsonschema.validate(delivery_data, delivery_schema)

    if options.json_output:
        print(json.dumps(delivery_data, indent=4, sort_keys=False))
    else:
        if options.show_summary:
            print_summary(delivery_data, team_data, sprint_data, capacity_data)
        else:
            print_issue_list(delivery_data, person_lut)

    cjm.presentation.print_data_warnings(warnings)

    return cjm.codes.NO_ERROR


def print_issue_list(delivery, person_lut):
    """Print the detailed issue status table"""
    def __fmt_assignee(issue):
        if issue["assignee id"] is None:
            return ""
        elif issue["assignee id"] not in person_lut:
            return issue["assignee id"]
        else:
            return "{0:s}, {1:s}".format(
                person_lut[issue["assignee id"]]["last name"],
                person_lut[issue["assignee id"]]["first name"])

    print(tabulate.tabulate(
        [(i["id"], i["key"], i["summary"], __fmt_assignee(i),
          i["committed story points"], i["delivered story points"], i["status"],
          i["income"], i["outcome"], i["extended"], i["delivered"], i["dropped"])
         for i in delivery["issues"]],
        headers=["Id", "Key", "Summary", "Assignee", "Committed", "Delivered",
                 "Current Status", "Income", "Outcome", "Extended", "Delivered", "Dropped"],
        tablefmt="orgtbl"))


def calc_total(issues, field_key):
    """Calculate sum of given fields"""
    return sum([int(i[field_key]) for i in issues])


def print_summary(delivery_data, team_data, sprint_data, capacity_data):
    """Print the report summary table"""
    # pylint: disable=too-many-locals
    person_capacity_list = cjm.capacity.process_person_capacity_list(sprint_data, capacity_data)
    person_capacity_lut = cjm.capacity.make_person_capacity_lut(person_capacity_list)
    total_capacity = sum(p["sprint capacity"] for p in person_capacity_list)
    total_committed = delivery_data["total"]["committed"]
    total_delivered = delivery_data["total"]["delivered"]

    unassigned_issues = cjm.issue.unassigned_issues(delivery_data["issues"])
    assigned_issues = cjm.issue.assigned_issues(delivery_data["issues"])

    assigned_committed = calc_total(assigned_issues, "committed story points")
    unassigned_committed = calc_total(unassigned_issues, "committed story points")

    cells = (
        cjm.presentation.default_cell("caption"),
        cjm.presentation.default_cell("delivery"),
        cjm.presentation.default_cell("commitment"),
        cjm.presentation.default_cell("capacity"),
        cjm.presentation.ratio_cell("commitment ratio"),
        cjm.presentation.status_cell("commitment status"),
        cjm.presentation.ratio_cell("delivery ratio"),
        cjm.presentation.status_cell("delivery status"))

    def __make_person_row(person_data):
        capacity = (
            person_capacity_lut.get(person_data["account id"], {}).get("sprint capacity", 0))
        commitment = calc_total(
            cjm.issue.person_issues(delivery_data["issues"], person_data),
            "committed story points")
        delivery = calc_total(
            cjm.issue.person_issues(delivery_data["issues"], person_data),
            "delivered story points")
        commitment_summary = cjm.capacity.determine_summary(commitment, capacity)
        delivery_summary = cjm.delivery.determine_summary(delivery, commitment)

        if commitment_summary["capacity"] or delivery_summary["commitment"]:
            importance_code = cjm.presentation.IMPORTANCE_CODES.NORMAL
        else:
            importance_code = cjm.presentation.IMPORTANCE_CODES.LOW

        return cjm.presentation.format_row(
            importance_code, cells,
            {**commitment_summary, **delivery_summary,
             "caption": cjm.team.format_full_name(person_data)})

    def __make_unassigned_row():
        capacity = total_capacity - assigned_committed
        commitment_summary = cjm.capacity.determine_summary(unassigned_committed, capacity)
        commitment = unassigned_committed
        delivery = calc_total(unassigned_issues, "delivered story points")
        delivery_summary = cjm.delivery.determine_summary(delivery, commitment)

        if delivery_summary["commitment"]:
            importance_code = cjm.presentation.IMPORTANCE_CODES.NORMAL
        else:
            importance_code = cjm.presentation.IMPORTANCE_CODES.LOW

        return cjm.presentation.format_row(
            importance_code, cells,
            {**commitment_summary, **delivery_summary, "caption": "Unasigned"})

    def __make_total_row():
        commitment_summary = cjm.capacity.determine_summary(total_committed, total_capacity)
        delivery_summary = cjm.delivery.determine_summary(total_delivered, total_committed)

        return cjm.presentation.format_row(
            cjm.presentation.IMPORTANCE_CODES.HIGH, cells,
            {**commitment_summary, **delivery_summary, "caption": "Team Summary"})

    def __sort_cb(person):
        return (person["last name"], person["first name"])

    print(tabulate.tabulate(
        [__make_person_row(p) for p in sorted(team_data["people"], key=__sort_cb)] +
        [__make_unassigned_row()] +
        [__make_total_row()],
        headers=[
            "<NAME>", "Delivery", "Commitment", "Capacity",
            "Com/Cap Ratio", "Com Status", "Del/Com Ratio", "Del Status"],
        tablefmt="orgtbl"))

if __name__ == '__main__':
    cjm.run.run(main, parse_options(sys.argv[1:]))

#!/usr/bin/env python3 # -*- coding: utf-8 -*- # SPDX-License-Identifier: MIT # Copyright (C) 2020-2021 Mobica Limited Command line script creating sprint delivery report file # Standard library imports # Third party imports # Project imports Parse command line options Determine dropped status of given issues Determine delivery status of given issues.      Optionally, allow late delivery issues, i.e. issues that were delivered after the sprint end     but still contain (manually added) `/Delivered` comment Entry function # Load sprint data: # Load other data: # Determine the story points field id: # Request all committed issues: # Request all extension issues and determine their commitment story points: # Request dropped issues and change story point value to 0 # Determine delivered story points Print the detailed issue status table Calculate sum of given fields Print the report summary table # pylint: disable=too-many-locals