2018-07-21 12:36:02 +00:00
|
|
|
import click
|
2018-08-17 12:55:27 +00:00
|
|
|
import math
|
2018-04-03 10:56:07 +00:00
|
|
|
from collections import defaultdict
|
|
|
|
from datetime import datetime, timedelta
|
2018-07-21 12:36:02 +00:00
|
|
|
from typing import Any, DefaultDict, Dict, List, Sequence, Tuple, Optional
|
2018-04-03 10:56:07 +00:00
|
|
|
|
2018-07-22 09:45:15 +00:00
|
|
|
import base32_crockford as base32
|
2018-04-03 10:56:07 +00:00
|
|
|
import tabulate
|
|
|
|
|
|
|
|
import api.projects
|
|
|
|
import api.stories
|
|
|
|
from config import Config
|
2018-08-17 13:51:40 +00:00
|
|
|
from util import require_login
|
2018-04-03 10:56:07 +00:00
|
|
|
|
2018-08-17 13:51:40 +00:00
|
|
|
from . import COLOR_HEADER, COLOR_PLANNED, COLOR_STARTED, COLOR_FINISHED, \
|
2019-02-05 13:29:33 +00:00
|
|
|
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
|
|
|
|
2018-08-17 13:51:40 +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
|
|
|
|
2019-02-05 13:29:33 +00:00
|
|
|
def __burndown(color, letter, points):
|
|
|
|
return color.format(letter * int(math.ceil(points)))
|
2018-08-17 12:55:27 +00:00
|
|
|
|
2018-04-03 10:56:07 +00:00
|
|
|
|
2018-07-22 09:45:15 +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
|
|
|
|
name = persons[owner_id]['name']
|
|
|
|
|
|
|
|
estimates = [points[state] for state in STATES]
|
|
|
|
|
2019-02-05 13:29:33 +00:00
|
|
|
progress = '['
|
2018-07-22 09:45:15 +00:00
|
|
|
if show_accepted:
|
2019-02-05 13:29:33 +00:00
|
|
|
progress += __burndown(COLOR_ACCEPTED, 'A', points['accepted'])
|
2018-08-17 13:51:40 +00:00
|
|
|
|
2019-02-05 13:29:33 +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']) + \
|
|
|
|
']'
|
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
|
|
|
|
|
|
|
|
|
|
|
def __format_story(story: Dict[str, Any], persons: Persons, totals: Totals) \
|
|
|
|
-> Tuple[str, str, str, str, str, int]:
|
2018-07-22 09:45:15 +00:00
|
|
|
code = base32.encode(story['id'])
|
2018-04-03 10:56:07 +00:00
|
|
|
|
|
|
|
is_owner = False
|
|
|
|
initials = []
|
|
|
|
owner_ids = story.get('owner_ids', [])
|
|
|
|
for owner_id in owner_ids:
|
|
|
|
value = persons[owner_id]['initials']
|
|
|
|
if value == Config['user']['initials']:
|
|
|
|
is_owner = True
|
|
|
|
value = f'\033[96m{value}\033[97m'
|
|
|
|
initials.append(value)
|
|
|
|
|
2018-04-24 00:32:22 +00:00
|
|
|
type_ = story['story_type']
|
|
|
|
|
2018-04-03 10:56:07 +00:00
|
|
|
estimate = story.get('estimate')
|
|
|
|
if estimate:
|
|
|
|
for owner_id in owner_ids:
|
|
|
|
state = story['current_state']
|
|
|
|
totals[owner_id][state] += estimate / len(owner_ids)
|
|
|
|
|
|
|
|
if is_owner:
|
|
|
|
code = f'\033[1;97m{code}'
|
|
|
|
estimate = f'\033[97m{estimate}\033[0m' if estimate else None
|
|
|
|
|
|
|
|
owners = ', '.join(initials)
|
|
|
|
|
|
|
|
state = _format_state(story['current_state'])
|
2018-04-24 00:32:22 +00:00
|
|
|
return code, type_, story['name'], owners, state, estimate
|
2018-04-03 10:56:07 +00:00
|
|
|
|
|
|
|
|
2018-07-22 09:45:15 +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')
|
2018-04-03 10:56:07 +00:00
|
|
|
headers = 'Code', 'Type', 'Story name', 'Owners', 'State', 'Pts'
|
|
|
|
print(tabulate.tabulate(table, headers=headers), end='\n\n')
|
|
|
|
|
|
|
|
|
2018-07-22 09:45:15 +00:00
|
|
|
def __print_totals(totals: Totals, persons: Persons, show_accepted: bool) \
|
|
|
|
-> None:
|
2018-04-03 10:56:07 +00:00
|
|
|
COLOR_HEADER.print('Point totals:', end='\n\n')
|
|
|
|
|
2019-02-05 13:29:33 +00:00
|
|
|
state_headers = [_format_state(state, header=True) for state in STATES]
|
2018-04-03 10:56:07 +00:00
|
|
|
headers = ('Owner', *state_headers, 'Total', 'Progress')
|
|
|
|
|
2018-07-22 09:45:15 +00:00
|
|
|
data = sorted((__get_row(item, persons, show_accepted)
|
|
|
|
for item in totals.items()),
|
|
|
|
key=lambda row: row[-2], reverse=True)
|
2018-04-03 10:56:07 +00:00
|
|
|
print(tabulate.tabulate(data, headers), end='\n\n')
|
|
|
|
|
|
|
|
|
2018-07-22 09:45:15 +00:00
|
|
|
def __print_burndown(token: str, iteration: Dict[str, Any],
|
|
|
|
show_accepted: bool) -> None:
|
2018-04-03 10:56:07 +00:00
|
|
|
COLOR_HEADER.print('Burndown:', end='\n\n')
|
|
|
|
|
|
|
|
start = datetime.strptime(iteration['start'], '%Y-%m-%dT%H:%M:%SZ')
|
|
|
|
history = api.projects.get_history_days(
|
|
|
|
token, iteration['project_id'], start - timedelta(days=1))
|
|
|
|
|
|
|
|
accepted_points = history['data'][0][1]
|
|
|
|
|
2018-04-05 08:11:48 +00:00
|
|
|
last_counts: List[int] = []
|
2018-04-03 10:56:07 +00:00
|
|
|
for date, *counts in history['data'][1:]:
|
2018-04-05 08:11:48 +00:00
|
|
|
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
|
|
|
|
|
2018-04-03 10:56:07 +00:00
|
|
|
progress = ''
|
2018-07-22 09:45:15 +00:00
|
|
|
if show_accepted:
|
2019-02-05 13:29:33 +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
|
|
|
|
|
|
|
print(f'{date}: {progress}')
|
|
|
|
|
|
|
|
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:
|
2018-07-21 12:36:02 +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
|
|
|
|
|
|
|
token = Config['user']['api_token']
|
|
|
|
|
|
|
|
iterations = api.projects.get_iterations(
|
2018-07-21 12:36:02 +00:00
|
|
|
token, project_id, scope=scope)
|
2018-04-03 10:56:07 +00:00
|
|
|
if not iterations:
|
2018-08-17 13:51:40 +00:00
|
|
|
print('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(token, project_id=project_id)
|
|
|
|
totals: DefaultDict[int, Dict[str, int]] = \
|
|
|
|
defaultdict(lambda: dict((state, 0) for state in STATES))
|
|
|
|
|
2018-07-22 09:45:15 +00:00
|
|
|
__print_stories(iteration['stories'], persons, totals, show_accepted)
|
|
|
|
__print_totals(totals, persons, show_accepted)
|
2018-08-17 13:51:40 +00:00
|
|
|
if scope == 'current':
|
|
|
|
__print_burndown(token, iteration, show_accepted)
|
2018-04-03 10:56:07 +00:00
|
|
|
|
|
|
|
|
2018-07-21 12:36:02 +00:00
|
|
|
def _set_story_state(story: str, state: str) -> None:
|
|
|
|
token = Config['user']['api_token']
|
2018-07-22 09:45:15 +00:00
|
|
|
story_id = base32.decode(story)
|
2018-07-21 12:36:02 +00:00
|
|
|
api.stories.put_story(token, story_id, current_state=state)
|
|
|
|
|
|
|
|
|
|
|
|
@cli.command('stories')
|
2019-02-05 13:29:33 +00:00
|
|
|
@click.argument('project', type=click.STRING)
|
2018-07-21 12:36:02 +00:00
|
|
|
@click.argument('story', required=False)
|
2018-11-03 15:10:45 +00:00
|
|
|
@click.argument('action', required=False)
|
2018-07-21 12:36:02 +00:00
|
|
|
@click.option('--scope', default='current')
|
|
|
|
@click.option('--show-accepted/--hide-accepted', default=True)
|
2018-11-03 15:10:45 +00:00
|
|
|
@click.option('--set-state', type=click.Choice(STATES))
|
2018-04-03 10:56:07 +00:00
|
|
|
@require_login
|
2018-11-03 15:10:45 +00:00
|
|
|
def stories(project: str, story: Optional[str], action: Optional[str],
|
|
|
|
scope: str, show_accepted: bool, set_state: str) -> None:
|
2018-07-21 12:36:02 +00:00
|
|
|
if story is not None:
|
2018-11-03 15:10:45 +00:00
|
|
|
state_actions = 'start', 'finish', 'deliver', 'accept', 'reject'
|
2018-07-21 12:36:02 +00:00
|
|
|
if set_state is not None:
|
|
|
|
_set_story_state(story, set_state)
|
2018-11-03 15:10:45 +00:00
|
|
|
elif action is not None:
|
|
|
|
if action in state_actions:
|
|
|
|
_set_story_state(story, f"{action}ed")
|
|
|
|
if action == "comment":
|
|
|
|
# todo
|
|
|
|
pass
|
2018-10-17 13:15:56 +00:00
|
|
|
|
|
|
|
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)
|