pivotalcli/commands/stories.py

385 lines
11 KiB
Python
Raw Permalink Normal View History

2018-08-17 12:55:27 +00:00
import math
2019-09-04 14:42:23 +00:00
import inquirer
import re
import sys
2018-04-03 10:56:07 +00:00
from collections import defaultdict
from datetime import datetime, timedelta
2019-09-04 14:42:23 +00:00
from typing import Any, DefaultDict, Dict, List, Sequence, Tuple, Optional
from tqdm import tqdm
2018-04-03 10:56:07 +00:00
2018-07-22 09:45:15 +00:00
import base32_crockford as base32
import click
2018-04-03 10:56:07 +00:00
import tabulate
import api.projects
import api.stories
from config import Config
from util import require_login
2019-09-04 14:42:23 +00:00
from color import Color
from . import (
COLOR_HEADER,
COLOR_PLANNED,
COLOR_STARTED,
COLOR_FINISHED,
COLOR_DELIVERED,
COLOR_ACCEPTED,
COLOR_REJECTED,
_format_state,
_get_persons,
)
2018-07-22 09:45:15 +00:00
from ._stories_info import stories_info
2018-07-21 12:36:02 +00:00
from .cli import cli
2018-04-03 10:56:07 +00:00
2019-09-04 14:42:23 +00:00
STATES = (
"unstarted",
"planned",
"started",
"finished",
"delivered",
"accepted",
"rejected",
)
2018-04-03 10:56:07 +00:00
Persons = Dict[int, Dict[str, Any]]
Totals = DefaultDict[int, Dict[str, int]]
2018-08-17 12:55:27 +00:00
def __burndown(color: Color, letter: str, points: float) -> str:
2019-02-05 13:29:33 +00:00
return color.format(letter * int(math.ceil(points)))
2018-08-17 12:55:27 +00:00
2018-04-03 10:56:07 +00:00
2019-09-04 14:42:23 +00:00
def __get_row(
item: Tuple[int, Dict[str, int]], persons: Persons, show_accepted: bool
) -> Sequence:
2018-04-03 10:56:07 +00:00
owner_id, points = item
2019-09-04 14:42:23 +00:00
name = persons[owner_id]["name"]
2018-04-03 10:56:07 +00:00
2019-10-14 15:38:09 +00:00
estimates = [round(points[state], 1) for state in STATES]
2018-04-03 10:56:07 +00:00
2019-09-04 14:42:23 +00:00
progress = "["
2018-07-22 09:45:15 +00:00
if show_accepted:
2019-09-04 14:42:23 +00:00
progress += __burndown(COLOR_ACCEPTED, "A", points["accepted"])
2019-09-04 14:42:23 +00:00
progress += (
__burndown(COLOR_DELIVERED, "D", points["delivered"])
+ __burndown(COLOR_FINISHED, "F", points["finished"])
+ __burndown(COLOR_STARTED, "S", points["started"])
+ __burndown(COLOR_REJECTED, "R", points["rejected"])
+ __burndown(COLOR_PLANNED, "P", points["planned"])
2019-10-14 15:38:09 +00:00
+ __burndown(COLOR_PLANNED, "U", points["unstarted"])
2019-09-04 14:42:23 +00:00
+ "]"
)
2018-04-03 10:56:07 +00:00
2018-07-22 09:45:15 +00:00
return name, (*estimates), sum(estimates), progress
2018-04-03 10:56:07 +00:00
2019-09-04 14:42:23 +00:00
def __format_story(
story: Dict[str, Any], persons: Persons, totals: Totals
) -> Tuple[str, str, str, str, str, int]:
code = base32.encode(story["id"])
2018-04-03 10:56:07 +00:00
is_owner = False
initials = []
2019-09-04 14:42:23 +00:00
owner_ids = story.get("owner_ids", [])
2018-04-03 10:56:07 +00:00
for owner_id in owner_ids:
2019-09-04 14:42:23 +00:00
email = persons[owner_id]["email"]
value = persons[owner_id]["initials"]
if email == Config["user"]["email"]:
2018-04-03 10:56:07 +00:00
is_owner = True
2019-09-04 14:42:23 +00:00
value = Color(Color.CYAN).format(value)
2018-04-03 10:56:07 +00:00
initials.append(value)
2019-09-04 14:42:23 +00:00
type_ = story["story_type"]
if type_ == "release":
code = f"\033[44m{code}"
2019-09-04 14:42:23 +00:00
estimate = story.get("estimate")
2018-04-03 10:56:07 +00:00
if estimate:
for owner_id in owner_ids:
2019-09-04 14:42:23 +00:00
state = story["current_state"]
2018-04-03 10:56:07 +00:00
totals[owner_id][state] += estimate / len(owner_ids)
2019-09-04 14:42:37 +00:00
estimate = f"{estimate}\033[0m"
2018-04-03 10:56:07 +00:00
if is_owner:
2019-09-04 14:42:23 +00:00
code = f"\033[1;97m{code}"
estimate = f"\033[97m{estimate}\033[0m" if estimate else None
2018-04-03 10:56:07 +00:00
2019-09-04 14:42:23 +00:00
owners = ", ".join(initials)
2018-04-03 10:56:07 +00:00
2019-09-04 14:42:23 +00:00
state = _format_state(story["current_state"])
2019-09-04 14:42:37 +00:00
return code, type_, story["name"], owners, state, estimate
2018-04-03 10:56:07 +00:00
2019-09-04 14:42:23 +00:00
def __print_stories(
stories_: List[Dict[str, Any]],
persons: Persons,
totals: Totals,
show_accepted: bool,
) -> None:
table = (
__format_story(story, persons, totals)
for story in stories_
if show_accepted or story["current_state"] != "accepted"
)
headers = "Code", "Type", "Story name", "Owners", "State", "Pts"
print(tabulate.tabulate(table, headers=headers), end="\n\n")
2018-04-03 10:56:07 +00:00
2019-09-04 14:42:23 +00:00
def __print_totals(totals: Totals, persons: Persons, show_accepted: bool) -> None:
COLOR_HEADER.print("Point totals:", end="\n\n")
2018-04-03 10:56:07 +00:00
2019-09-04 14:42:23 +00:00
state_headers = [_format_state(state) for state in STATES]
2019-10-14 15:38:09 +00:00
headers = ("Owner", *state_headers, "Total", "Progress")
2018-04-03 10:56:07 +00:00
2019-09-04 14:42:23 +00:00
data = sorted(
(__get_row(item, persons, show_accepted) for item in totals.items()),
key=lambda row: row[-2],
reverse=True,
)
print(tabulate.tabulate(data, headers), end="\n\n")
2018-04-03 10:56:07 +00:00
def __print_burndown(iteration: Dict[str, Any], show_accepted: bool) -> None:
2019-09-04 14:42:23 +00:00
COLOR_HEADER.print("Burndown:", end="\n\n")
2018-04-03 10:56:07 +00:00
2019-09-04 14:42:23 +00:00
start = datetime.strptime(iteration["start"], "%Y-%m-%dT%H:%M:%SZ")
2018-04-03 10:56:07 +00:00
history = api.projects.get_history_days(
2019-09-04 14:42:23 +00:00
iteration["project_id"], start - timedelta(days=1)
)
2018-04-03 10:56:07 +00:00
2019-09-04 14:42:23 +00:00
accepted_points = history["data"][0][1]
2018-04-03 10:56:07 +00:00
last_counts: List[int] = []
2019-09-04 14:42:23 +00:00
for date, *counts in history["data"][1:]:
if len(counts) > 0:
# Update the last_counts variable if an update is available.
last_counts = counts
elif len(last_counts) > 0:
# If no counts are available, use those of the previous day.
counts = last_counts
else:
# If there are no last_counts either, just skip the day
continue
2019-09-04 14:42:23 +00:00
progress = ""
2018-07-22 09:45:15 +00:00
if show_accepted:
2019-09-04 14:42:23 +00:00
progress += __burndown(COLOR_ACCEPTED, "A", counts[0] - accepted_points)
progress += (
__burndown(COLOR_DELIVERED, "D", counts[1])
+ __burndown(COLOR_FINISHED, "F", counts[2])
+ __burndown(COLOR_STARTED, "S", counts[3])
+ __burndown(COLOR_PLANNED, "P", counts[5])
+ __burndown(COLOR_PLANNED, "U", counts[6])
)
2018-04-03 10:56:07 +00:00
2019-09-04 14:42:23 +00:00
print(f"{date}: {progress}")
2018-04-03 10:56:07 +00:00
print()
2018-07-21 12:36:02 +00:00
def _stories_current(project: str, scope: str, show_accepted: bool) -> None:
2018-04-03 10:56:07 +00:00
try:
2019-09-04 14:42:23 +00:00
project_id = int(Config["project_aliases"][project])
2018-04-03 10:56:07 +00:00
except KeyError:
2018-07-22 09:45:15 +00:00
project_id = base32.decode(project)
2018-04-03 10:56:07 +00:00
iterations = api.projects.get_iterations(project_id, scope=scope)
2018-04-03 10:56:07 +00:00
if not iterations:
2019-09-04 14:42:23 +00:00
print(f"No stories in {scope}")
2018-04-03 10:56:07 +00:00
return
2018-07-21 12:36:02 +00:00
iteration = iterations[0]
2018-04-03 10:56:07 +00:00
persons = _get_persons(project_id=project_id)
2019-09-04 14:42:23 +00:00
totals: DefaultDict[int, Dict[str, int]] = defaultdict(
lambda: dict((state, 0) for state in STATES)
)
2018-04-03 10:56:07 +00:00
2019-09-04 14:42:23 +00:00
__print_stories(iteration["stories"], persons, totals, show_accepted)
2018-07-22 09:45:15 +00:00
__print_totals(totals, persons, show_accepted)
2019-09-04 14:42:23 +00:00
if scope == "current":
__print_burndown(iteration, show_accepted)
2018-04-03 10:56:07 +00:00
2019-09-04 14:42:23 +00:00
def _complete_projects(
ctx: click.Context, args: List[str], incomplete: str
) -> List[str]:
return [
alias for alias in Config["project_aliases"] if alias.startswith(incomplete)
]
@cli.command("stories")
@click.argument("project", type=click.STRING, autocompletion=_complete_projects)
@click.argument("story", required=False)
@click.argument("action", required=False)
@click.option("--scope", default="current")
@click.option("--show-accepted/--hide-accepted", default=True)
2018-04-03 10:56:07 +00:00
@require_login
2019-09-04 14:42:23 +00:00
def stories(
project: str,
story: Optional[str],
action: Optional[str],
scope: str,
show_accepted: bool,
) -> None:
2019-10-14 15:32:48 +00:00
project_id = int(Config["project_aliases"][project])
2018-07-21 12:36:02 +00:00
if story is not None:
2019-10-14 15:32:48 +00:00
story_id = base32.decode(story)
2019-09-04 14:42:23 +00:00
state_actions = "start", "finish", "deliver", "accept", "reject"
2019-10-14 15:32:48 +00:00
if action is not None:
2018-11-03 15:10:45 +00:00
if action in state_actions:
2019-10-14 15:32:48 +00:00
api.stories.put_story(story_id, current_state=action + "ed")
elif action == "comment":
print("Enter the comment, and press Ctrl+D to confirm.")
comment = sys.stdin.read()
print()
api.stories.post_comment(project_id, story_id, text=comment)
stories_info(story)
2018-04-03 10:56:07 +00:00
else:
2018-07-21 12:36:02 +00:00
_stories_current(project, scope, show_accepted)
2019-09-04 14:42:37 +00:00
def __calculate_stories(all_owners, hours, point_scale, allow_split=False):
owners = all_owners.copy()
stories = []
original_hours = hours
while owners:
best, done = None, False
for n in range(min(len(owners), 3), 0, -1):
if done:
break
for points in point_scale:
error = hours * n - points
if error in (point_scale if allow_split else (0,)):
done = True
best = points, n
break
if best is None:
if not allow_split:
return __calculate_stories(all_owners, hours, point_scale, True)
else:
return None
points, n = best
last_points = points / n
story_owners = owners[:n]
del owners[:n]
stories.append((story_owners, points))
if error != 0:
stories.append((story_owners, error))
return stories
@cli.command("meeting")
@click.argument("project", type=click.STRING)
@require_login
def meeting(project: str):
2019-10-14 15:32:48 +00:00
project_id = int(Config["project_aliases"][project])
2019-09-04 14:42:37 +00:00
2019-10-14 15:32:48 +00:00
members = api.projects.get_memberships(project_id)
2019-09-04 14:42:37 +00:00
members = {m["person"]["name"]: m["person"]["id"] for m in members}
2019-10-14 15:32:48 +00:00
labels = api.projects.get_labels(project_id)
2019-09-04 14:42:37 +00:00
labels = {l["name"]: l["id"] for l in labels}
2019-10-14 15:32:48 +00:00
project_info = api.projects.get_project(project_id)
2019-09-04 14:42:37 +00:00
point_scale = [
1 if p == "0" else int(float(p) * 2)
for p in project_info["point_scale"].split(",")
][::-1] + [0]
answers = inquirer.prompt(
[
inquirer.Text("name", message="Meeting name"),
inquirer.Text(
"hours",
message="Meeting length (hours, multiple of 0.5)",
2019-10-14 15:32:48 +00:00
validate=lambda _, x: re.match(r"^[0-9]+(\.[05])?$", x),
2019-09-04 14:42:37 +00:00
),
inquirer.Checkbox(
"owners",
message="Attendees (select with space, confirm with enter)",
choices=list(members.keys()),
),
]
)
if not answers:
return
name = answers["name"]
hours = round(float(answers["hours"]) * 2)
owners = answers["owners"]
total_hours = hours * len(owners)
print(
f"{len(owners)} attendees on a {hours / 2} hour meeting, for a total "
f"of {total_hours / 2} points."
)
stories = __calculate_stories(owners, hours, point_scale)
if stories is None:
print("Could not find a solution")
return
print("\nGoing to create:")
for story_owners, points in stories:
print(
f" - A {points / 2} point story for {', '.join(story_owners)} "
f"({points / 2 / len(story_owners)} each)"
)
print()
answers = inquirer.prompt(
[
inquirer.List("state", message="State of the story", choices=STATES),
inquirer.Checkbox(
"labels",
message="Labels to add (select with space, confirm with enter)",
choices=list(labels.keys()),
),
inquirer.List(
"requester", message="Story requester", choices=list(members.keys())
),
]
)
if not answers:
return
print("Enter the story description, and press Ctrl+D to confirm.")
description = sys.stdin.read()
print()
primary_story = None
for i, (story_owners, points) in enumerate(tqdm(stories, "Creating stories...")):
s_name = f"{name} ({i + 1}/{len(stories)})"
s_requester = members[answers["requester"]]
s_label_ids = [labels[name] for name in answers["labels"]]
s_owner_ids = [members[name] for name in story_owners]
s_description = description.strip()
story = api.stories.post(
token,
project_id,
name=s_name,
requested_by_id=s_requester,
label_ids=s_label_ids,
owner_ids=s_owner_ids,
description=s_description,
estimate=points // 2,
)
s_id = int(story["id"])
if primary_story is None:
primary_story = s_id