Compare commits
4 commits
f61cb1632f
...
e0f95d0d6b
Author | SHA1 | Date | |
---|---|---|---|
e0f95d0d6b | |||
2311ae4919 | |||
7c98ed05d2 | |||
0d30debeac |
8 changed files with 432 additions and 193 deletions
|
@ -8,59 +8,70 @@ from . import _BASE_URL, _headers, _with_token
|
|||
|
||||
@_with_token
|
||||
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()
|
||||
|
||||
|
||||
@_with_token
|
||||
def get_project(project_id: int, token: str) -> Dict[str, Any]:
|
||||
r = requests.get(f'{_BASE_URL}/projects/{project_id}',
|
||||
headers=_headers(token))
|
||||
r = requests.get(f"{_BASE_URL}/projects/{project_id}", headers=_headers(token))
|
||||
return r.json()
|
||||
|
||||
|
||||
@_with_token
|
||||
def get_stories(project_id: int, token: str) -> List[Dict[str, Any]]:
|
||||
r = requests.get(f'{_BASE_URL}/projects/{project_id}/stories',
|
||||
headers=_headers(token))
|
||||
r = requests.get(
|
||||
f"{_BASE_URL}/projects/{project_id}/stories", headers=_headers(token)
|
||||
)
|
||||
return r.json()
|
||||
|
||||
|
||||
@_with_token
|
||||
def get_memberships(project_id: int, token: str) -> List[Dict[str, Any]]:
|
||||
r = requests.get(f'{_BASE_URL}/projects/{project_id}/memberships',
|
||||
headers=_headers(token))
|
||||
r = requests.get(
|
||||
f"{_BASE_URL}/projects/{project_id}/memberships", headers=_headers(token)
|
||||
)
|
||||
return r.json()
|
||||
|
||||
|
||||
@_with_token
|
||||
def get_iterations(project_id: int, scope: str, token: str) \
|
||||
-> List[Dict[str, Any]]:
|
||||
r = requests.get(f'{_BASE_URL}/projects/{project_id}/iterations',
|
||||
headers=_headers(token), params={'scope': scope})
|
||||
def get_iterations(project_id: int, scope: str, token: str) -> List[Dict[str, Any]]:
|
||||
r = requests.get(
|
||||
f"{_BASE_URL}/projects/{project_id}/iterations",
|
||||
headers=_headers(token),
|
||||
params={"scope": scope},
|
||||
)
|
||||
return r.json()
|
||||
|
||||
|
||||
@_with_token
|
||||
def get_story_transitions(str, project_id: int, token: str,
|
||||
after: Optional[datetime] = None,
|
||||
before: Optional[datetime] = None) \
|
||||
-> List[Dict[str, Any]]:
|
||||
def get_story_transitions(
|
||||
str,
|
||||
project_id: int,
|
||||
token: str,
|
||||
after: Optional[datetime] = None,
|
||||
before: Optional[datetime] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
parameters = {}
|
||||
if after:
|
||||
parameters['occurred_after'] = after.isoformat()
|
||||
parameters["occurred_after"] = after.isoformat()
|
||||
if before:
|
||||
parameters['occurred_before'] = before.isoformat()
|
||||
parameters["occurred_before"] = before.isoformat()
|
||||
|
||||
r = requests.get(f'{_BASE_URL}/projects/{project_id}/story_transitions',
|
||||
headers=_headers(token), params=parameters)
|
||||
r = requests.get(
|
||||
f"{_BASE_URL}/projects/{project_id}/story_transitions",
|
||||
headers=_headers(token),
|
||||
params=parameters,
|
||||
)
|
||||
return r.json()
|
||||
|
||||
|
||||
@_with_token
|
||||
def get_history_days(project_id: int, start: datetime, token: str) \
|
||||
-> Dict[str, Any]:
|
||||
r = requests.get(f'{_BASE_URL}/projects/{project_id}/history/days',
|
||||
headers=_headers(token),
|
||||
params={'start_date': start.isoformat()})
|
||||
def get_history_days(project_id: int, start: datetime, token: str) -> Dict[str, Any]:
|
||||
r = requests.get(
|
||||
f"{_BASE_URL}/projects/{project_id}/history/days",
|
||||
headers=_headers(token),
|
||||
params={"start_date": start.isoformat()},
|
||||
)
|
||||
|
||||
return r.json()
|
||||
|
|
|
@ -6,37 +6,57 @@ from . import _BASE_URL, _headers, _with_token
|
|||
|
||||
@_with_token
|
||||
def get(token: str, story_id: int = None) -> Dict[str, Any]:
|
||||
r = requests.get(f'{_BASE_URL}/stories/{story_id}',
|
||||
headers=_headers(token))
|
||||
r = requests.get(f"{_BASE_URL}/stories/{story_id}", headers=_headers(token))
|
||||
return r.json()
|
||||
|
||||
|
||||
@_with_token
|
||||
def put_story(story_id: int, token: str, **kwargs: Any) -> Dict[str, Any]:
|
||||
r = requests.put(f'{_BASE_URL}/stories/{story_id}',
|
||||
headers=_headers(token), json=kwargs)
|
||||
r = requests.put(
|
||||
f"{_BASE_URL}/stories/{story_id}", headers=_headers(token), json=kwargs
|
||||
)
|
||||
return r.json()
|
||||
|
||||
|
||||
@_with_token
|
||||
def get_tasks(project_id: int, story_id: int, token: str) \
|
||||
-> List[Dict[str, Any]]:
|
||||
url = f'{_BASE_URL}/projects/{project_id}/stories/{story_id}/tasks'
|
||||
def get_tasks(project_id: int, story_id: int, token: str) -> List[Dict[str, Any]]:
|
||||
url = f"{_BASE_URL}/projects/{project_id}/stories/{story_id}/tasks"
|
||||
r = requests.get(url, headers=_headers(token))
|
||||
return r.json()
|
||||
|
||||
|
||||
@_with_token
|
||||
def get_comments(project_id: int, story_id: int, token: str) \
|
||||
-> List[Dict[str, Any]]:
|
||||
url = f'{_BASE_URL}/projects/{project_id}/stories/{story_id}/comments'
|
||||
def get_comments(project_id: int, story_id: int, token: str) -> List[Dict[str, Any]]:
|
||||
url = f"{_BASE_URL}/projects/{project_id}/stories/{story_id}/comments"
|
||||
r = requests.get(url, headers=_headers(token))
|
||||
return r.json()
|
||||
|
||||
|
||||
@_with_token
|
||||
def get_blockers(project_id: int, story_id: int, token: str) \
|
||||
-> List[Dict[str, Any]]:
|
||||
url = f'{_BASE_URL}/projects/{project_id}/stories/{story_id}/blockers'
|
||||
def get_blockers(project_id: int, story_id: int, token: str) -> List[Dict[str, Any]]:
|
||||
url = f"{_BASE_URL}/projects/{project_id}/stories/{story_id}/blockers"
|
||||
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()
|
||||
|
|
28
color.py
28
color.py
|
@ -20,26 +20,34 @@ class Color:
|
|||
BRIGHT_CYAN = 14
|
||||
BRIGHT_WHITE = 15
|
||||
|
||||
def __init__(self, foreground: Optional[int],
|
||||
background: Optional[int] = None) -> None:
|
||||
def __init__(
|
||||
self, foreground: Optional[int], background: Optional[int] = None
|
||||
) -> None:
|
||||
self.foreground = foreground
|
||||
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)
|
||||
|
||||
def format(self, *args: Any) -> str:
|
||||
colors = ['0']
|
||||
|
||||
# Create the color string.
|
||||
colors = []
|
||||
if self.foreground is not None:
|
||||
colors.append(f"38;5;{self.foreground}")
|
||||
|
||||
if self.background is not None:
|
||||
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:
|
||||
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"
|
||||
|
|
|
@ -2,15 +2,14 @@ import re
|
|||
import sys
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict
|
||||
from consolemd import Renderer
|
||||
|
||||
import base32_crockford as base32
|
||||
|
||||
import api.stories
|
||||
from config import Config
|
||||
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:
|
||||
|
@ -20,62 +19,63 @@ def __print_story(story: Dict[str, Any]) -> None:
|
|||
|
||||
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:
|
||||
state = _format_state(story['current_state'])
|
||||
COLOR_HEADER.print('State:', state, end='')
|
||||
if "current_state" in story:
|
||||
state = _format_state(story["current_state"])
|
||||
COLOR_HEADER.print("State:", state, end="")
|
||||
|
||||
if story['current_state'] == 'accepted':
|
||||
print(f" (at {story['accepted_at']})", end='')
|
||||
if story["current_state"] == "accepted":
|
||||
print(f" (at {story['accepted_at']})", end="")
|
||||
|
||||
print(end='\n\n')
|
||||
print(end="\n\n")
|
||||
|
||||
if 'estimate' in story:
|
||||
COLOR_HEADER.print('Estimate: ', end='')
|
||||
print(story['estimate'], 'points', end='')
|
||||
if len(story.get('owner_ids', [])) > 1:
|
||||
points = story['estimate'] / len(story['owner_ids'])
|
||||
print(f' ({points} each)', end='')
|
||||
print(end='\n\n')
|
||||
if "estimate" in story:
|
||||
COLOR_HEADER.print("Estimate: ", end="")
|
||||
print(story["estimate"], "points", end="")
|
||||
if len(story.get("owner_ids", [])) > 1:
|
||||
points = story["estimate"] / len(story["owner_ids"])
|
||||
print(f" ({points} each)", end="")
|
||||
print(end="\n\n")
|
||||
|
||||
|
||||
def __print_owners(story: Dict[str, Any], persons: Dict[int, Any]) -> None:
|
||||
"""Prints the owners of the story, if available."""
|
||||
if story.get('owner_ids'):
|
||||
COLOR_HEADER.print('Owners:')
|
||||
if story.get("owner_ids"):
|
||||
COLOR_HEADER.print("Owners:")
|
||||
|
||||
owners = []
|
||||
for owner_id in story['owner_ids']:
|
||||
name = persons[owner_id]['name']
|
||||
initials = persons[owner_id]['initials']
|
||||
owners.append(f' - {name} ({initials})')
|
||||
for owner_id in story["owner_ids"]:
|
||||
name = persons[owner_id]["name"]
|
||||
initials = persons[owner_id]["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:
|
||||
"""Prints the description of the story, if available."""
|
||||
COLOR_HEADER.print('Description:')
|
||||
if 'description' in story:
|
||||
description = story['description'].strip()
|
||||
print_wrap(description, indent=' ', end='\n\n')
|
||||
COLOR_HEADER.print("Description:")
|
||||
if "description" in story:
|
||||
description = story["description"].strip()
|
||||
Renderer().render(description, width=80)
|
||||
print()
|
||||
else:
|
||||
print(' (No description)', end='\n\n')
|
||||
print(" (No description)", end="\n\n")
|
||||
|
||||
|
||||
def __print_labels(story: Dict[str, Any]) -> None:
|
||||
"""Prints the labels of the story, if available."""
|
||||
if not story.get('labels'):
|
||||
if not story.get("labels"):
|
||||
return
|
||||
|
||||
COLOR_HEADER.print('Labels:')
|
||||
COLOR_HEADER.print("Labels:")
|
||||
|
||||
template = '\033[97;48;5;22m {} \033[0m'
|
||||
labels = ' '.join(template.format(label['name'])
|
||||
for label in story['labels'])
|
||||
template = "\033[97;48;5;22m {} \033[0m"
|
||||
labels = " ".join(template.format(label["name"]) 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:
|
||||
|
@ -83,36 +83,37 @@ def __print_tasks(project_id: int, story_id: int) -> None:
|
|||
tasks = api.stories.get_tasks(project_id, story_id)
|
||||
|
||||
if tasks:
|
||||
COLOR_HEADER.print('Tasks:')
|
||||
COLOR_HEADER.print("Tasks:")
|
||||
|
||||
for task in tasks:
|
||||
print(end=' ')
|
||||
print('[X]' if task['complete'] else '[ ]', end=' \033[34m')
|
||||
print(base32.encode(task['id']), end=':\033[0m ')
|
||||
print(task['description'])
|
||||
print(end=" ")
|
||||
print("[X]" if task["complete"] else "[ ]", end=" \033[34m")
|
||||
print(base32.encode(task["id"]), end=":\033[0m ")
|
||||
print(task["description"])
|
||||
|
||||
print()
|
||||
|
||||
|
||||
def __print_comments(project_id: int, story_id: int,
|
||||
persons: Dict[int, Dict[str, Any]]) -> None:
|
||||
def __print_comments(
|
||||
project_id: int, story_id: int, persons: Dict[int, Dict[str, Any]]
|
||||
) -> None:
|
||||
"""Prints the comments on the story, if available."""
|
||||
comments = api.stories.get_comments(project_id, story_id)
|
||||
|
||||
if comments:
|
||||
COLOR_HEADER.print('Comments:')
|
||||
COLOR_HEADER.print("Comments:")
|
||||
|
||||
for comment in comments:
|
||||
text = comment['text'].strip()
|
||||
print_wrap(text, indent=' ')
|
||||
text = comment.get("text", "[Empty comment]").strip()
|
||||
print_wrap(text, indent=" ")
|
||||
|
||||
person_id = comment['person_id']
|
||||
name = persons[person_id]['name']
|
||||
COLOR_WHITE.print(' -', name, end=' ')
|
||||
person_id = comment["person_id"]
|
||||
name = persons[person_id]["name"]
|
||||
COLOR_WHITE.print(" -", name, end=" ")
|
||||
|
||||
date = datetime.strptime(comment['created_at'], '%Y-%m-%dT%H:%M:%SZ')
|
||||
date_str = date.strftime('on %a %Y-%m-%d at %H:%M')
|
||||
print(date_str, end='\n\n')
|
||||
date = datetime.strptime(comment["created_at"], "%Y-%m-%dT%H:%M:%SZ")
|
||||
date_str = date.strftime("on %a %Y-%m-%d at %H:%M")
|
||||
print(date_str, end="\n\n")
|
||||
|
||||
|
||||
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)
|
||||
|
||||
if blockers:
|
||||
COLOR_HEADER.print('Blockers:')
|
||||
COLOR_HEADER.print("Blockers:")
|
||||
|
||||
def blocker_repl(matchgroup: Any) -> str:
|
||||
id = int(matchgroup.group(1))
|
||||
code = base32.encode(id)
|
||||
return COLOR_HEADER.format(code)
|
||||
|
||||
pattern = re.compile(r'#(\d+)')
|
||||
pattern = re.compile(r"#(\d+)")
|
||||
for blocker in blockers:
|
||||
resolved = 'X' if blocker['resolved'] else ' '
|
||||
desc = pattern.sub(blocker_repl, blocker['description'])
|
||||
print(f' [{resolved}] {desc}')
|
||||
resolved = "X" if blocker["resolved"] else " "
|
||||
desc = pattern.sub(blocker_repl, blocker["description"])
|
||||
print(f" [{resolved}] {desc}")
|
||||
|
||||
|
||||
def stories_info(story_b32: str) -> None:
|
||||
story_id = base32.decode(story_b32)
|
||||
story = api.stories.get(story_id)
|
||||
|
||||
project_id = story['project_id']
|
||||
project_id = story["project_id"]
|
||||
persons = _get_persons(project_id)
|
||||
|
||||
__print_story(story)
|
||||
|
|
|
@ -5,15 +5,16 @@ from config import Config
|
|||
from .cli import cli
|
||||
|
||||
|
||||
@cli.command('login')
|
||||
@click.option('--email', prompt=True)
|
||||
@click.option('--password', prompt=True, hide_input=True)
|
||||
@cli.command("login")
|
||||
@click.option("--email", prompt=True)
|
||||
@click.option("--password", prompt=True, hide_input=True)
|
||||
def login(email: str, password: str) -> None:
|
||||
user = api.me.get(email, password)
|
||||
|
||||
print()
|
||||
print(f"Logged in successfully as {user['name']} (@{user['username']}).")
|
||||
|
||||
Config['user']['api_token'] = user['api_token']
|
||||
Config['user']['initials'] = user['initials']
|
||||
Config["user"]["api_token"] = user["api_token"]
|
||||
Config["user"]["initials"] = user["initials"]
|
||||
Config["user"]["email"] = user["email"]
|
||||
Config.write()
|
||||
|
|
|
@ -1,7 +1,11 @@
|
|||
import math
|
||||
import inquirer
|
||||
import re
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
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 click
|
||||
|
@ -11,15 +15,31 @@ import api.projects
|
|||
import api.stories
|
||||
from config import Config
|
||||
from util import require_login
|
||||
from color import Color
|
||||
|
||||
from . import (COLOR_ACCEPTED, COLOR_DELIVERED, COLOR_FINISHED, COLOR_HEADER,
|
||||
COLOR_PLANNED, COLOR_REJECTED, COLOR_STARTED, Color,
|
||||
_format_state, _get_persons)
|
||||
from . import (
|
||||
COLOR_HEADER,
|
||||
COLOR_PLANNED,
|
||||
COLOR_STARTED,
|
||||
COLOR_FINISHED,
|
||||
COLOR_DELIVERED,
|
||||
COLOR_ACCEPTED,
|
||||
COLOR_REJECTED,
|
||||
_format_state,
|
||||
_get_persons,
|
||||
)
|
||||
from ._stories_info import stories_info
|
||||
from .cli import cli
|
||||
|
||||
STATES = 'unstarted', 'planned', 'started', 'finished', 'delivered', \
|
||||
'accepted', 'rejected'
|
||||
STATES = (
|
||||
"unstarted",
|
||||
"planned",
|
||||
"started",
|
||||
"finished",
|
||||
"delivered",
|
||||
"accepted",
|
||||
"rejected",
|
||||
)
|
||||
|
||||
|
||||
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)))
|
||||
|
||||
|
||||
def __get_row(item: Tuple[int, Dict[str, int]], persons: Persons,
|
||||
show_accepted: bool) -> Sequence:
|
||||
def __get_row(
|
||||
item: Tuple[int, Dict[str, int]], persons: Persons, show_accepted: bool
|
||||
) -> Sequence:
|
||||
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:
|
||||
progress += __burndown(COLOR_ACCEPTED, 'A', points['accepted'])
|
||||
progress += __burndown(COLOR_ACCEPTED, "A", points["accepted"])
|
||||
|
||||
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']) + \
|
||||
']'
|
||||
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"])
|
||||
+ "]"
|
||||
)
|
||||
|
||||
return name, (*estimates), sum(estimates), progress
|
||||
|
||||
|
||||
def __format_story(story: Dict[str, Any], persons: Persons, totals: Totals) \
|
||||
-> Tuple[str, str, str, str, str, int]:
|
||||
code = base32.encode(story['id'])
|
||||
def __format_story(
|
||||
story: Dict[str, Any], persons: Persons, totals: Totals
|
||||
) -> Tuple[str, str, str, str, str, int]:
|
||||
code = base32.encode(story["id"])
|
||||
|
||||
is_owner = False
|
||||
initials = []
|
||||
owner_ids = story.get('owner_ids', [])
|
||||
owner_ids = story.get("owner_ids", [])
|
||||
for owner_id in owner_ids:
|
||||
value = persons[owner_id]['initials']
|
||||
if value == Config['user']['initials']:
|
||||
email = persons[owner_id]["email"]
|
||||
value = persons[owner_id]["initials"]
|
||||
if email == Config["user"]["email"]:
|
||||
is_owner = True
|
||||
value = f'\033[96m{value}\033[97m'
|
||||
value = Color(Color.CYAN).format(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:
|
||||
for owner_id in owner_ids:
|
||||
state = story['current_state']
|
||||
state = story["current_state"]
|
||||
totals[owner_id][state] += estimate / len(owner_ids)
|
||||
estimate = f"{estimate}\033[0m"
|
||||
|
||||
if is_owner:
|
||||
code = f'\033[1;97m{code}'
|
||||
estimate = f'\033[97m{estimate}\033[0m' if estimate else None
|
||||
code = f"\033[1;97m{code}"
|
||||
estimate = f"\033[97m{estimate}\033[0m" if estimate else None
|
||||
|
||||
owners = ', '.join(initials)
|
||||
owners = ", ".join(initials)
|
||||
|
||||
state = _format_state(story['current_state'])
|
||||
return code, type_, story['name'], owners, state, estimate
|
||||
state = _format_state(story["current_state"])
|
||||
return code, type_, story["name"], owners, state, estimate
|
||||
|
||||
|
||||
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')
|
||||
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")
|
||||
|
||||
|
||||
def __print_totals(totals: Totals, persons: Persons, show_accepted: bool) \
|
||||
-> None:
|
||||
COLOR_HEADER.print('Point totals:', end='\n\n')
|
||||
def __print_totals(totals: Totals, persons: Persons, show_accepted: bool) -> None:
|
||||
COLOR_HEADER.print("Point totals:", end="\n\n")
|
||||
|
||||
state_headers = [_format_state(state, header=True) for state in STATES]
|
||||
headers = ('Owner', *state_headers, 'Total', 'Progress')
|
||||
state_headers = [_format_state(state) for state in STATES]
|
||||
headers = ("Owner", *state_headers[1:], "Total", "Progress")
|
||||
|
||||
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')
|
||||
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")
|
||||
|
||||
|
||||
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(
|
||||
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] = []
|
||||
for date, *counts in history['data'][1:]:
|
||||
for date, *counts in history["data"][1:]:
|
||||
if len(counts) > 0:
|
||||
# Update the last_counts variable if an update is available.
|
||||
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
|
||||
continue
|
||||
|
||||
progress = ''
|
||||
progress = ""
|
||||
if show_accepted:
|
||||
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])
|
||||
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])
|
||||
)
|
||||
|
||||
print(f'{date}: {progress}')
|
||||
print(f"{date}: {progress}")
|
||||
|
||||
print()
|
||||
|
||||
|
||||
def _stories_current(project: str, scope: str, show_accepted: bool) -> None:
|
||||
try:
|
||||
project_id = int(Config['project_aliases'][project])
|
||||
project_id = int(Config["project_aliases"][project])
|
||||
except KeyError:
|
||||
project_id = base32.decode(project)
|
||||
|
||||
iterations = api.projects.get_iterations(project_id, scope=scope)
|
||||
if not iterations:
|
||||
print(f'No stories in {scope}')
|
||||
print(f"No stories in {scope}")
|
||||
return
|
||||
|
||||
iteration = iterations[0]
|
||||
|
||||
persons = _get_persons(project_id=project_id)
|
||||
totals: DefaultDict[int, Dict[str, int]] = \
|
||||
defaultdict(lambda: dict((state, 0) for state in STATES))
|
||||
totals: DefaultDict[int, Dict[str, int]] = defaultdict(
|
||||
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)
|
||||
if scope == 'current':
|
||||
if scope == "current":
|
||||
__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)
|
||||
|
||||
|
||||
@cli.command('stories')
|
||||
@click.argument('project', type=click.STRING)
|
||||
@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))
|
||||
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)
|
||||
@click.option("--set-state", type=click.Choice(STATES))
|
||||
@require_login
|
||||
def stories(project: str, story: Optional[str], action: Optional[str],
|
||||
scope: str, show_accepted: bool, set_state: str) -> None:
|
||||
def stories(
|
||||
project: str,
|
||||
story: Optional[str],
|
||||
action: Optional[str],
|
||||
scope: str,
|
||||
show_accepted: bool,
|
||||
set_state: str,
|
||||
) -> 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:
|
||||
_set_story_state(story, set_state)
|
||||
elif action is not None:
|
||||
|
@ -194,3 +245,147 @@ def stories(project: str, story: Optional[str], action: Optional[str],
|
|||
stories_info(story)
|
||||
else:
|
||||
_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
|
||||
|
|
|
@ -18,9 +18,9 @@ class Config(object, metaclass=_Config):
|
|||
|
||||
@staticmethod
|
||||
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)
|
||||
return os.path.join(data_dir, 'config.ini')
|
||||
return os.path.join(data_dir, "config.ini")
|
||||
|
||||
@classmethod
|
||||
def read(cls) -> None:
|
||||
|
@ -28,5 +28,5 @@ class Config(object, metaclass=_Config):
|
|||
|
||||
@classmethod
|
||||
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)
|
||||
|
|
|
@ -6,4 +6,7 @@ click-fish==0.1.0
|
|||
click==7.0
|
||||
requests==2.18.4
|
||||
tabulate==0.8.2
|
||||
appdirs==1.4.3
|
||||
appdirs==1.4.3
|
||||
inquirer==2.1.1
|
||||
tqdm==4.31.1
|
||||
consolemd==0.5.0
|
||||
|
|
Loading…
Reference in a new issue