Compare commits

...

4 commits

8 changed files with 432 additions and 193 deletions

View file

@ -8,59 +8,70 @@ from . import _BASE_URL, _headers, _with_token
@_with_token @_with_token
def get(token: str) -> List[Dict[str, Any]]: def get(token: str) -> List[Dict[str, Any]]:
r = requests.get(f'{_BASE_URL}/projects', headers=_headers(token)) r = requests.get(f"{_BASE_URL}/projects", headers=_headers(token))
return r.json() return r.json()
@_with_token @_with_token
def get_project(project_id: int, token: str) -> Dict[str, Any]: def get_project(project_id: int, token: str) -> Dict[str, Any]:
r = requests.get(f'{_BASE_URL}/projects/{project_id}', r = requests.get(f"{_BASE_URL}/projects/{project_id}", headers=_headers(token))
headers=_headers(token))
return r.json() return r.json()
@_with_token @_with_token
def get_stories(project_id: int, token: str) -> List[Dict[str, Any]]: def get_stories(project_id: int, token: str) -> List[Dict[str, Any]]:
r = requests.get(f'{_BASE_URL}/projects/{project_id}/stories', r = requests.get(
headers=_headers(token)) f"{_BASE_URL}/projects/{project_id}/stories", headers=_headers(token)
)
return r.json() return r.json()
@_with_token @_with_token
def get_memberships(project_id: int, token: str) -> List[Dict[str, Any]]: def get_memberships(project_id: int, token: str) -> List[Dict[str, Any]]:
r = requests.get(f'{_BASE_URL}/projects/{project_id}/memberships', r = requests.get(
headers=_headers(token)) f"{_BASE_URL}/projects/{project_id}/memberships", headers=_headers(token)
)
return r.json() return r.json()
@_with_token @_with_token
def get_iterations(project_id: int, scope: str, token: str) \ def get_iterations(project_id: int, scope: str, token: str) -> List[Dict[str, Any]]:
-> List[Dict[str, Any]]: r = requests.get(
r = requests.get(f'{_BASE_URL}/projects/{project_id}/iterations', f"{_BASE_URL}/projects/{project_id}/iterations",
headers=_headers(token), params={'scope': scope}) headers=_headers(token),
params={"scope": scope},
)
return r.json() return r.json()
@_with_token @_with_token
def get_story_transitions(str, project_id: int, token: str, def get_story_transitions(
after: Optional[datetime] = None, str,
before: Optional[datetime] = None) \ project_id: int,
-> List[Dict[str, Any]]: token: str,
after: Optional[datetime] = None,
before: Optional[datetime] = None,
) -> List[Dict[str, Any]]:
parameters = {} parameters = {}
if after: if after:
parameters['occurred_after'] = after.isoformat() parameters["occurred_after"] = after.isoformat()
if before: if before:
parameters['occurred_before'] = before.isoformat() parameters["occurred_before"] = before.isoformat()
r = requests.get(f'{_BASE_URL}/projects/{project_id}/story_transitions', r = requests.get(
headers=_headers(token), params=parameters) f"{_BASE_URL}/projects/{project_id}/story_transitions",
headers=_headers(token),
params=parameters,
)
return r.json() return r.json()
@_with_token @_with_token
def get_history_days(project_id: int, start: datetime, token: str) \ def get_history_days(project_id: int, start: datetime, token: str) -> Dict[str, Any]:
-> Dict[str, Any]: r = requests.get(
r = requests.get(f'{_BASE_URL}/projects/{project_id}/history/days', f"{_BASE_URL}/projects/{project_id}/history/days",
headers=_headers(token), headers=_headers(token),
params={'start_date': start.isoformat()}) params={"start_date": start.isoformat()},
)
return r.json() return r.json()

View file

@ -6,37 +6,57 @@ from . import _BASE_URL, _headers, _with_token
@_with_token @_with_token
def get(token: str, story_id: int = None) -> Dict[str, Any]: def get(token: str, story_id: int = None) -> Dict[str, Any]:
r = requests.get(f'{_BASE_URL}/stories/{story_id}', r = requests.get(f"{_BASE_URL}/stories/{story_id}", headers=_headers(token))
headers=_headers(token))
return r.json() return r.json()
@_with_token @_with_token
def put_story(story_id: int, token: str, **kwargs: Any) -> Dict[str, Any]: def put_story(story_id: int, token: str, **kwargs: Any) -> Dict[str, Any]:
r = requests.put(f'{_BASE_URL}/stories/{story_id}', r = requests.put(
headers=_headers(token), json=kwargs) f"{_BASE_URL}/stories/{story_id}", headers=_headers(token), json=kwargs
)
return r.json() return r.json()
@_with_token @_with_token
def get_tasks(project_id: int, story_id: int, token: str) \ def get_tasks(project_id: int, story_id: int, token: str) -> List[Dict[str, Any]]:
-> List[Dict[str, Any]]: url = f"{_BASE_URL}/projects/{project_id}/stories/{story_id}/tasks"
url = f'{_BASE_URL}/projects/{project_id}/stories/{story_id}/tasks'
r = requests.get(url, headers=_headers(token)) r = requests.get(url, headers=_headers(token))
return r.json() return r.json()
@_with_token @_with_token
def get_comments(project_id: int, story_id: int, token: str) \ def get_comments(project_id: int, story_id: int, token: str) -> List[Dict[str, Any]]:
-> List[Dict[str, Any]]: url = f"{_BASE_URL}/projects/{project_id}/stories/{story_id}/comments"
url = f'{_BASE_URL}/projects/{project_id}/stories/{story_id}/comments'
r = requests.get(url, headers=_headers(token)) r = requests.get(url, headers=_headers(token))
return r.json() return r.json()
@_with_token @_with_token
def get_blockers(project_id: int, story_id: int, token: str) \ def get_blockers(project_id: int, story_id: int, token: str) -> List[Dict[str, Any]]:
-> List[Dict[str, Any]]: url = f"{_BASE_URL}/projects/{project_id}/stories/{story_id}/blockers"
url = f'{_BASE_URL}/projects/{project_id}/stories/{story_id}/blockers'
r = requests.get(url, headers=_headers(token)) r = requests.get(url, headers=_headers(token))
@_with_token
def post(project_id: int, token: str, **kwargs: Any) -> Dict[str, Any]:
r = requests.post(
f"{_BASE_URL}/projects/{project_id}/stories",
headers={"X-TrackerToken": token},
json=kwargs,
)
r.raise_for_status()
return r.json()
@_with_token
def post_blocker(
token: str, project_id: int, story_id: int, token: str, **kwargs
) -> Dict[str, Any]:
r = requests.post(
f"{_BASE_URL}/projects/{project_id}/stories/{story_id}/blockers",
headers={"X-TrackerToken": token},
json=kwargs,
)
r.raise_for_status()
return r.json() return r.json()

View file

@ -20,26 +20,34 @@ class Color:
BRIGHT_CYAN = 14 BRIGHT_CYAN = 14
BRIGHT_WHITE = 15 BRIGHT_WHITE = 15
def __init__(self, foreground: Optional[int], def __init__(
background: Optional[int] = None) -> None: self, foreground: Optional[int], background: Optional[int] = None
) -> None:
self.foreground = foreground self.foreground = foreground
self.background = background self.background = background
def print(self, *args: Any, end: str = '\n') -> None: def print(self, *args: Any, end: str = "\n") -> None:
print(self.format(*args), end=end) print(self.format(*args), end=end)
def format(self, *args: Any) -> str: def format(self, *args: Any) -> str:
colors = ['0'] # Create the color string.
colors = []
if self.foreground is not None: if self.foreground is not None:
colors.append(f"38;5;{self.foreground}") colors.append(f"38;5;{self.foreground}")
if self.background is not None: if self.background is not None:
colors.append(f"48;5;{self.background}") colors.append(f"48;5;{self.background}")
color_str = ";".join(colors)
color_str = ';'.join(colors) text = " ".join(str(a) for a in args)
text = ' '.join(str(a) for a in args)
if not text: if not text:
return '' return ""
return f'\033[{color_str}m{text}\033[0m' # Reset the color as necessary.
reset = []
if self.foreground is not None:
reset.append("39")
if self.background is not None:
reset.append("49")
reset_str = ";".join(reset)
return f"\033[{color_str}m{text}\033[{reset_str}m"

View file

@ -2,15 +2,14 @@ import re
import sys import sys
from datetime import datetime from datetime import datetime
from typing import Any, Dict from typing import Any, Dict
from consolemd import Renderer
import base32_crockford as base32 import base32_crockford as base32
import api.stories import api.stories
from config import Config from config import Config
from util import print_wrap from util import print_wrap
from . import COLOR_HEADER, COLOR_TITLE, COLOR_WHITE, _format_state, _get_persons
from . import (COLOR_HEADER, COLOR_TITLE, COLOR_WHITE, _format_state,
_get_persons)
def __print_story(story: Dict[str, Any]) -> None: def __print_story(story: Dict[str, Any]) -> None:
@ -20,62 +19,63 @@ def __print_story(story: Dict[str, Any]) -> None:
TODO: Split up in functions. TODO: Split up in functions.
""" """
COLOR_TITLE.print(story['name'], end='\n\n') COLOR_TITLE.print(story["name"])
print(story["url"], end="\n\n")
if 'current_state' in story: if "current_state" in story:
state = _format_state(story['current_state']) state = _format_state(story["current_state"])
COLOR_HEADER.print('State:', state, end='') COLOR_HEADER.print("State:", state, end="")
if story['current_state'] == 'accepted': if story["current_state"] == "accepted":
print(f" (at {story['accepted_at']})", end='') print(f" (at {story['accepted_at']})", end="")
print(end='\n\n') print(end="\n\n")
if 'estimate' in story: if "estimate" in story:
COLOR_HEADER.print('Estimate: ', end='') COLOR_HEADER.print("Estimate: ", end="")
print(story['estimate'], 'points', end='') print(story["estimate"], "points", end="")
if len(story.get('owner_ids', [])) > 1: if len(story.get("owner_ids", [])) > 1:
points = story['estimate'] / len(story['owner_ids']) points = story["estimate"] / len(story["owner_ids"])
print(f' ({points} each)', end='') print(f" ({points} each)", end="")
print(end='\n\n') print(end="\n\n")
def __print_owners(story: Dict[str, Any], persons: Dict[int, Any]) -> None: def __print_owners(story: Dict[str, Any], persons: Dict[int, Any]) -> None:
"""Prints the owners of the story, if available.""" """Prints the owners of the story, if available."""
if story.get('owner_ids'): if story.get("owner_ids"):
COLOR_HEADER.print('Owners:') COLOR_HEADER.print("Owners:")
owners = [] owners = []
for owner_id in story['owner_ids']: for owner_id in story["owner_ids"]:
name = persons[owner_id]['name'] name = persons[owner_id]["name"]
initials = persons[owner_id]['initials'] initials = persons[owner_id]["initials"]
owners.append(f' - {name} ({initials})') owners.append(f" - {name} ({initials})")
print('\n'.join(owners), end='\n\n') print("\n".join(owners), end="\n\n")
def __print_description(story: Dict[str, Any]) -> None: def __print_description(story: Dict[str, Any]) -> None:
"""Prints the description of the story, if available.""" """Prints the description of the story, if available."""
COLOR_HEADER.print('Description:') COLOR_HEADER.print("Description:")
if 'description' in story: if "description" in story:
description = story['description'].strip() description = story["description"].strip()
print_wrap(description, indent=' ', end='\n\n') Renderer().render(description, width=80)
print()
else: else:
print(' (No description)', end='\n\n') print(" (No description)", end="\n\n")
def __print_labels(story: Dict[str, Any]) -> None: def __print_labels(story: Dict[str, Any]) -> None:
"""Prints the labels of the story, if available.""" """Prints the labels of the story, if available."""
if not story.get('labels'): if not story.get("labels"):
return return
COLOR_HEADER.print('Labels:') COLOR_HEADER.print("Labels:")
template = '\033[97;48;5;22m {} \033[0m' template = "\033[97;48;5;22m {} \033[0m"
labels = ' '.join(template.format(label['name']) labels = " ".join(template.format(label["name"]) for label in story["labels"])
for label in story['labels'])
print(f' {labels}', end='\n\n') print(f" {labels}", end="\n\n")
def __print_tasks(project_id: int, story_id: int) -> None: def __print_tasks(project_id: int, story_id: int) -> None:
@ -83,36 +83,37 @@ def __print_tasks(project_id: int, story_id: int) -> None:
tasks = api.stories.get_tasks(project_id, story_id) tasks = api.stories.get_tasks(project_id, story_id)
if tasks: if tasks:
COLOR_HEADER.print('Tasks:') COLOR_HEADER.print("Tasks:")
for task in tasks: for task in tasks:
print(end=' ') print(end=" ")
print('[X]' if task['complete'] else '[ ]', end=' \033[34m') print("[X]" if task["complete"] else "[ ]", end=" \033[34m")
print(base32.encode(task['id']), end=':\033[0m ') print(base32.encode(task["id"]), end=":\033[0m ")
print(task['description']) print(task["description"])
print() print()
def __print_comments(project_id: int, story_id: int, def __print_comments(
persons: Dict[int, Dict[str, Any]]) -> None: project_id: int, story_id: int, persons: Dict[int, Dict[str, Any]]
) -> None:
"""Prints the comments on the story, if available.""" """Prints the comments on the story, if available."""
comments = api.stories.get_comments(project_id, story_id) comments = api.stories.get_comments(project_id, story_id)
if comments: if comments:
COLOR_HEADER.print('Comments:') COLOR_HEADER.print("Comments:")
for comment in comments: for comment in comments:
text = comment['text'].strip() text = comment.get("text", "[Empty comment]").strip()
print_wrap(text, indent=' ') print_wrap(text, indent=" ")
person_id = comment['person_id'] person_id = comment["person_id"]
name = persons[person_id]['name'] name = persons[person_id]["name"]
COLOR_WHITE.print(' -', name, end=' ') COLOR_WHITE.print(" -", name, end=" ")
date = datetime.strptime(comment['created_at'], '%Y-%m-%dT%H:%M:%SZ') date = datetime.strptime(comment["created_at"], "%Y-%m-%dT%H:%M:%SZ")
date_str = date.strftime('on %a %Y-%m-%d at %H:%M') date_str = date.strftime("on %a %Y-%m-%d at %H:%M")
print(date_str, end='\n\n') print(date_str, end="\n\n")
def __print_blockers(project_id: int, story_id: int) -> None: def __print_blockers(project_id: int, story_id: int) -> None:
@ -120,25 +121,25 @@ def __print_blockers(project_id: int, story_id: int) -> None:
blockers = api.stories.get_blockers(project_id, story_id) blockers = api.stories.get_blockers(project_id, story_id)
if blockers: if blockers:
COLOR_HEADER.print('Blockers:') COLOR_HEADER.print("Blockers:")
def blocker_repl(matchgroup: Any) -> str: def blocker_repl(matchgroup: Any) -> str:
id = int(matchgroup.group(1)) id = int(matchgroup.group(1))
code = base32.encode(id) code = base32.encode(id)
return COLOR_HEADER.format(code) return COLOR_HEADER.format(code)
pattern = re.compile(r'#(\d+)') pattern = re.compile(r"#(\d+)")
for blocker in blockers: for blocker in blockers:
resolved = 'X' if blocker['resolved'] else ' ' resolved = "X" if blocker["resolved"] else " "
desc = pattern.sub(blocker_repl, blocker['description']) desc = pattern.sub(blocker_repl, blocker["description"])
print(f' [{resolved}] {desc}') print(f" [{resolved}] {desc}")
def stories_info(story_b32: str) -> None: def stories_info(story_b32: str) -> None:
story_id = base32.decode(story_b32) story_id = base32.decode(story_b32)
story = api.stories.get(story_id) story = api.stories.get(story_id)
project_id = story['project_id'] project_id = story["project_id"]
persons = _get_persons(project_id) persons = _get_persons(project_id)
__print_story(story) __print_story(story)

View file

@ -5,15 +5,16 @@ from config import Config
from .cli import cli from .cli import cli
@cli.command('login') @cli.command("login")
@click.option('--email', prompt=True) @click.option("--email", prompt=True)
@click.option('--password', prompt=True, hide_input=True) @click.option("--password", prompt=True, hide_input=True)
def login(email: str, password: str) -> None: def login(email: str, password: str) -> None:
user = api.me.get(email, password) user = api.me.get(email, password)
print() print()
print(f"Logged in successfully as {user['name']} (@{user['username']}).") print(f"Logged in successfully as {user['name']} (@{user['username']}).")
Config['user']['api_token'] = user['api_token'] Config["user"]["api_token"] = user["api_token"]
Config['user']['initials'] = user['initials'] Config["user"]["initials"] = user["initials"]
Config["user"]["email"] = user["email"]
Config.write() Config.write()

View file

@ -1,7 +1,11 @@
import math import math
import inquirer
import re
import sys
from collections import defaultdict from collections import defaultdict
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Any, DefaultDict, Dict, List, Optional, Sequence, Tuple from typing import Any, DefaultDict, Dict, List, Sequence, Tuple, Optional
from tqdm import tqdm
import base32_crockford as base32 import base32_crockford as base32
import click import click
@ -11,15 +15,31 @@ import api.projects
import api.stories import api.stories
from config import Config from config import Config
from util import require_login from util import require_login
from color import Color
from . import (COLOR_ACCEPTED, COLOR_DELIVERED, COLOR_FINISHED, COLOR_HEADER, from . import (
COLOR_PLANNED, COLOR_REJECTED, COLOR_STARTED, Color, COLOR_HEADER,
_format_state, _get_persons) COLOR_PLANNED,
COLOR_STARTED,
COLOR_FINISHED,
COLOR_DELIVERED,
COLOR_ACCEPTED,
COLOR_REJECTED,
_format_state,
_get_persons,
)
from ._stories_info import stories_info from ._stories_info import stories_info
from .cli import cli from .cli import cli
STATES = 'unstarted', 'planned', 'started', 'finished', 'delivered', \ STATES = (
'accepted', 'rejected' "unstarted",
"planned",
"started",
"finished",
"delivered",
"accepted",
"rejected",
)
Persons = Dict[int, Dict[str, Any]] Persons = Dict[int, Dict[str, Any]]
@ -30,92 +50,108 @@ def __burndown(color: Color, letter: str, points: float) -> str:
return color.format(letter * int(math.ceil(points))) return color.format(letter * int(math.ceil(points)))
def __get_row(item: Tuple[int, Dict[str, int]], persons: Persons, def __get_row(
show_accepted: bool) -> Sequence: item: Tuple[int, Dict[str, int]], persons: Persons, show_accepted: bool
) -> Sequence:
owner_id, points = item owner_id, points = item
name = persons[owner_id]['name'] name = persons[owner_id]["name"]
estimates = [points[state] for state in STATES] estimates = [round(points[state], 1) for state in STATES[1:]]
progress = '[' progress = "["
if show_accepted: if show_accepted:
progress += __burndown(COLOR_ACCEPTED, 'A', points['accepted']) progress += __burndown(COLOR_ACCEPTED, "A", points["accepted"])
progress += \ progress += (
__burndown(COLOR_DELIVERED, 'D', points['delivered']) + \ __burndown(COLOR_DELIVERED, "D", points["delivered"])
__burndown(COLOR_FINISHED, 'F', points['finished']) + \ + __burndown(COLOR_FINISHED, "F", points["finished"])
__burndown(COLOR_STARTED, 'S', points['started']) + \ + __burndown(COLOR_STARTED, "S", points["started"])
__burndown(COLOR_REJECTED, 'R', points['rejected']) + \ + __burndown(COLOR_REJECTED, "R", points["rejected"])
__burndown(COLOR_PLANNED, 'P', points['planned']) + \ + __burndown(COLOR_PLANNED, "P", points["planned"])
']' + "]"
)
return name, (*estimates), sum(estimates), progress return name, (*estimates), sum(estimates), progress
def __format_story(story: Dict[str, Any], persons: Persons, totals: Totals) \ def __format_story(
-> Tuple[str, str, str, str, str, int]: story: Dict[str, Any], persons: Persons, totals: Totals
code = base32.encode(story['id']) ) -> Tuple[str, str, str, str, str, int]:
code = base32.encode(story["id"])
is_owner = False is_owner = False
initials = [] initials = []
owner_ids = story.get('owner_ids', []) owner_ids = story.get("owner_ids", [])
for owner_id in owner_ids: for owner_id in owner_ids:
value = persons[owner_id]['initials'] email = persons[owner_id]["email"]
if value == Config['user']['initials']: value = persons[owner_id]["initials"]
if email == Config["user"]["email"]:
is_owner = True is_owner = True
value = f'\033[96m{value}\033[97m' value = Color(Color.CYAN).format(value)
initials.append(value) initials.append(value)
type_ = story['story_type'] type_ = story["story_type"]
if type_ == "release":
code = f"\033[44m{code}"
estimate = story.get('estimate') estimate = story.get("estimate")
if estimate: if estimate:
for owner_id in owner_ids: for owner_id in owner_ids:
state = story['current_state'] state = story["current_state"]
totals[owner_id][state] += estimate / len(owner_ids) totals[owner_id][state] += estimate / len(owner_ids)
estimate = f"{estimate}\033[0m"
if is_owner: if is_owner:
code = f'\033[1;97m{code}' code = f"\033[1;97m{code}"
estimate = f'\033[97m{estimate}\033[0m' if estimate else None estimate = f"\033[97m{estimate}\033[0m" if estimate else None
owners = ', '.join(initials) owners = ", ".join(initials)
state = _format_state(story['current_state']) state = _format_state(story["current_state"])
return code, type_, story['name'], owners, state, estimate return code, type_, story["name"], owners, state, estimate
def __print_stories(stories_: List[Dict[str, Any]], persons: Persons, def __print_stories(
totals: Totals, show_accepted: bool) -> None: stories_: List[Dict[str, Any]],
table = (__format_story(story, persons, totals) for story in stories_ persons: Persons,
if show_accepted or story['current_state'] != 'accepted') totals: Totals,
headers = 'Code', 'Type', 'Story name', 'Owners', 'State', 'Pts' show_accepted: bool,
print(tabulate.tabulate(table, headers=headers), end='\n\n') ) -> 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")
def __print_totals(totals: Totals, persons: Persons, show_accepted: bool) \ def __print_totals(totals: Totals, persons: Persons, show_accepted: bool) -> None:
-> None: COLOR_HEADER.print("Point totals:", end="\n\n")
COLOR_HEADER.print('Point totals:', end='\n\n')
state_headers = [_format_state(state, header=True) for state in STATES] state_headers = [_format_state(state) for state in STATES]
headers = ('Owner', *state_headers, 'Total', 'Progress') headers = ("Owner", *state_headers[1:], "Total", "Progress")
data = sorted((__get_row(item, persons, show_accepted) data = sorted(
for item in totals.items()), (__get_row(item, persons, show_accepted) for item in totals.items()),
key=lambda row: row[-2], reverse=True) key=lambda row: row[-2],
print(tabulate.tabulate(data, headers), end='\n\n') reverse=True,
)
print(tabulate.tabulate(data, headers), end="\n\n")
def __print_burndown(iteration: Dict[str, Any], show_accepted: bool) -> None: def __print_burndown(iteration: Dict[str, Any], show_accepted: bool) -> None:
COLOR_HEADER.print('Burndown:', end='\n\n') COLOR_HEADER.print("Burndown:", end="\n\n")
start = datetime.strptime(iteration['start'], '%Y-%m-%dT%H:%M:%SZ') start = datetime.strptime(iteration["start"], "%Y-%m-%dT%H:%M:%SZ")
history = api.projects.get_history_days( history = api.projects.get_history_days(
iteration['project_id'], start - timedelta(days=1)) iteration["project_id"], start - timedelta(days=1)
)
accepted_points = history['data'][0][1] accepted_points = history["data"][0][1]
last_counts: List[int] = [] last_counts: List[int] = []
for date, *counts in history['data'][1:]: for date, *counts in history["data"][1:]:
if len(counts) > 0: if len(counts) > 0:
# Update the last_counts variable if an update is available. # Update the last_counts variable if an update is available.
last_counts = counts last_counts = counts
@ -126,42 +162,43 @@ def __print_burndown(iteration: Dict[str, Any], show_accepted: bool) -> None:
# If there are no last_counts either, just skip the day # If there are no last_counts either, just skip the day
continue continue
progress = '' progress = ""
if show_accepted: if show_accepted:
progress += __burndown(COLOR_ACCEPTED, 'A', progress += __burndown(COLOR_ACCEPTED, "A", counts[0] - accepted_points)
counts[0] - accepted_points) progress += (
progress += \ __burndown(COLOR_DELIVERED, "D", counts[1])
__burndown(COLOR_DELIVERED, 'D', counts[1]) + \ + __burndown(COLOR_FINISHED, "F", counts[2])
__burndown(COLOR_FINISHED, 'F', counts[2]) + \ + __burndown(COLOR_STARTED, "S", counts[3])
__burndown(COLOR_STARTED, 'S', counts[3]) + \ + __burndown(COLOR_PLANNED, "P", counts[5])
__burndown(COLOR_PLANNED, 'P', counts[5]) + \ + __burndown(COLOR_PLANNED, "U", counts[6])
__burndown(COLOR_PLANNED, 'U', counts[6]) )
print(f'{date}: {progress}') print(f"{date}: {progress}")
print() print()
def _stories_current(project: str, scope: str, show_accepted: bool) -> None: def _stories_current(project: str, scope: str, show_accepted: bool) -> None:
try: try:
project_id = int(Config['project_aliases'][project]) project_id = int(Config["project_aliases"][project])
except KeyError: except KeyError:
project_id = base32.decode(project) project_id = base32.decode(project)
iterations = api.projects.get_iterations(project_id, scope=scope) iterations = api.projects.get_iterations(project_id, scope=scope)
if not iterations: if not iterations:
print(f'No stories in {scope}') print(f"No stories in {scope}")
return return
iteration = iterations[0] iteration = iterations[0]
persons = _get_persons(project_id=project_id) persons = _get_persons(project_id=project_id)
totals: DefaultDict[int, Dict[str, int]] = \ totals: DefaultDict[int, Dict[str, int]] = defaultdict(
defaultdict(lambda: dict((state, 0) for state in STATES)) lambda: dict((state, 0) for state in STATES)
)
__print_stories(iteration['stories'], persons, totals, show_accepted) __print_stories(iteration["stories"], persons, totals, show_accepted)
__print_totals(totals, persons, show_accepted) __print_totals(totals, persons, show_accepted)
if scope == 'current': if scope == "current":
__print_burndown(iteration, show_accepted) __print_burndown(iteration, show_accepted)
@ -170,18 +207,32 @@ def _set_story_state(story: str, state: str) -> None:
api.stories.put_story(story_id, current_state=state) api.stories.put_story(story_id, current_state=state)
@cli.command('stories') def _complete_projects(
@click.argument('project', type=click.STRING) ctx: click.Context, args: List[str], incomplete: str
@click.argument('story', required=False) ) -> List[str]:
@click.argument('action', required=False) return [
@click.option('--scope', default='current') alias for alias in Config["project_aliases"] if alias.startswith(incomplete)
@click.option('--show-accepted/--hide-accepted', default=True) ]
@click.option('--set-state', type=click.Choice(STATES))
@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)
@click.option("--set-state", type=click.Choice(STATES))
@require_login @require_login
def stories(project: str, story: Optional[str], action: Optional[str], def stories(
scope: str, show_accepted: bool, set_state: str) -> None: project: str,
story: Optional[str],
action: Optional[str],
scope: str,
show_accepted: bool,
set_state: str,
) -> None:
if story is not None: if story is not None:
state_actions = 'start', 'finish', 'deliver', 'accept', 'reject' state_actions = "start", "finish", "deliver", "accept", "reject"
if set_state is not None: if set_state is not None:
_set_story_state(story, set_state) _set_story_state(story, set_state)
elif action is not None: elif action is not None:
@ -194,3 +245,147 @@ def stories(project: str, story: Optional[str], action: Optional[str],
stories_info(story) stories_info(story)
else: else:
_stories_current(project, scope, show_accepted) _stories_current(project, scope, show_accepted)
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):
try:
project_id = int(Config["project_aliases"][project])
except KeyError:
project_id = base32.decode(project)
token = Config["user"]["api_token"]
members = api.projects.get_memberships(token, project_id)
members = {m["person"]["name"]: m["person"]["id"] for m in members}
labels = api.projects.get_labels(token, project_id)
labels = {l["name"]: l["id"] for l in labels}
project_info = api.projects.get_project(token, project_id)
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)",
validate=lambda _, x: re.match("^[0-9]+(\.[05])?$", x),
),
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

View file

@ -18,9 +18,9 @@ class Config(object, metaclass=_Config):
@staticmethod @staticmethod
def __get_filename() -> str: def __get_filename() -> str:
data_dir = appdirs.user_data_dir('pivotalcli', 'sijmen') data_dir = appdirs.user_data_dir("pivotalcli", "sijmen")
os.makedirs(data_dir, exist_ok=True) os.makedirs(data_dir, exist_ok=True)
return os.path.join(data_dir, 'config.ini') return os.path.join(data_dir, "config.ini")
@classmethod @classmethod
def read(cls) -> None: def read(cls) -> None:
@ -28,5 +28,5 @@ class Config(object, metaclass=_Config):
@classmethod @classmethod
def write(cls) -> None: def write(cls) -> None:
with open(cls.__get_filename(), mode='w') as config_file: with open(cls.__get_filename(), mode="w") as config_file:
cls.config.write(config_file) cls.config.write(config_file)

View file

@ -6,4 +6,7 @@ click-fish==0.1.0
click==7.0 click==7.0
requests==2.18.4 requests==2.18.4
tabulate==0.8.2 tabulate==0.8.2
appdirs==1.4.3 appdirs==1.4.3
inquirer==2.1.1
tqdm==4.31.1
consolemd==0.5.0