Unverified Commit 0f8da77e authored by Leon Morten Richter's avatar Leon Morten Richter
Browse files

Adds simple time tracking

parent f513ab88
lab.egg-info/
*.pyc
.idea
\ No newline at end of file
.idea
build
venv
......@@ -24,6 +24,7 @@ from lab import (
pipelines,
fork,
issues,
issue,
snippet,
workflow,
rewrite_remote,
......@@ -50,6 +51,7 @@ class Parser: # pylint: disable=R0903
search,
pipelines,
fork,
issue,
issues,
snippet,
workflow,
......
"""
Module with functionality around single issues.
"""
import argparse
import sys
from typing import Dict, Any, Callable
from gitlab import GitlabGetError
from gitlab.v4.objects import ProjectIssue
from lab.repositoryconnection import RepositoryConnection
from lab.utils import Utils, LogType, TextFormatting, is_valid_time_str
def parser(
subparsers: argparse._SubParsersAction, # pylint: disable=protected-access
) -> argparse.ArgumentParser:
"""
Subparser for issue command
:param subparsers: subparsers object from global parser
:return: issues subparser
"""
issue_parser: argparse.ArgumentParser = subparsers.add_parser(
"issue", help="Gitlab issue commands."
)
issue_parser.add_argument("issue_id", help="Issue ID", metavar="issue_id", type=int)
issue_subparsers = issue_parser.add_subparsers(
dest="command", required=True, help="Issue sub command"
)
estimate_parser = issue_subparsers.add_parser("estimate")
spend_parser = issue_subparsers.add_parser("spend")
estimate_parser.add_argument(
"--update",
help="Update estimated time (override). E.g. '2d4h'",
metavar="time_str",
type=str,
)
spend_parser.add_argument(
"--update",
help="Add new time entry (time spent). E.g. '5h30m'",
metavar="time_str",
type=str,
)
return issue_parser
def run(args: argparse.Namespace) -> None:
"""
Run issue command.
:param args: parsed arguments
"""
issue = IssueConnection(args.issue_id)
if args.command == "estimate":
if args.update:
issue.update_estimated(args.update)
print(TextFormatting.green(f"Set estimate to {args.update}"))
else:
issue.print_estimated()
elif args.command == "spend":
if args.update:
issue.update_spent(args.update)
print(TextFormatting.green(f"Added time entry of {args.update}"))
else:
issue.print_spent()
class IssueConnection(RepositoryConnection):
def __init__(self, issue_id: int):
"""
Creates a new issue connection. Requires a valid issue ID for the current project.
"""
RepositoryConnection.__init__(self)
try:
self.issue: ProjectIssue = self._remote_project.issues.get(issue_id, lazy=False)
except GitlabGetError:
Utils.log(LogType.WARNING, f"No issue with ID {issue_id}")
sys.exit(1)
@property
def title_bold(self) -> str:
"""Get the title formatted as f´bold text."""
return f"{TextFormatting.BOLD}{self.issue.title}{TextFormatting.END}"
@property
def overdue(self) -> bool:
"""True if the issue has more time spent than was originally estimated."""
ts: Dict[str, Any] = self.issue.attributes["time_stats"]
return bool(ts["time_estimate"] <= ts["total_time_spent"])
def print_estimated(self) -> None:
"""Print short info about the estimated time for the issue."""
# API endpoint https://python-gitlab.readthedocs.io/en/stable/gl_objects/issues.html
ts: Dict[str, Any] = self.issue.attributes["time_stats"]
color: Callable[[str], str] = TextFormatting.red if self.overdue else TextFormatting.green
estimated: str = ts["human_time_estimate"] or "0h"
spent: str = color(ts["human_total_time_spent"] or "0h")
text = f"{self.title_bold} is estimated at {estimated} (spent: {spent})"
print(text)
def update_estimated(self, time_str: str) -> None:
"""Updates the estimated time for the issue. Overrides the old value."""
if not is_valid_time_str(time_str):
Utils.log(LogType.WARNING, f"{time_str} is an invalid time string.")
sys.exit(1)
self.issue.time_estimate(time_str)
self.issue.save()
def print_spent(self) -> None:
"""Prints a short info about the total time spent on this issue."""
# API endpoint https://python-gitlab.readthedocs.io/en/stable/gl_objects/issues.html
ts: Dict[str, Any] = self.issue.attributes["time_stats"]
color: Callable[[str], str] = TextFormatting.red if self.overdue else TextFormatting.green
estimated: str = color(ts["human_time_estimate"] or "0h")
spent: str = ts["human_total_time_spent"] or "0h"
text = f"{self.title_bold} has {spent} tracked (estimated: {estimated})"
print(text)
def update_spent(self, time_str: str) -> None:
"""Adds time spent to the already existing time spent."""
if not is_valid_time_str(time_str):
Utils.log(LogType.WARNING, f"{time_str} is an invalid time string.")
sys.exit(1)
self.issue.add_spent_time(time_str)
self.issue.save()
......@@ -3,6 +3,7 @@ Module containing classes for common tasks
"""
import os
import re
import shlex
# SPDX-FileCopyrightText: 2020 Jonah Brüchert <jbb@kaidan.im>
......@@ -19,6 +20,16 @@ from urllib.parse import ParseResult, urlparse
from git import Repo
from git.exc import InvalidGitRepositoryError
TIME_STR_REGEX = r"^([0-9]+mo)?([0-9]+w)?([0-9]+d)?([0-9]+h)?([0-9]+m)?$"
def is_valid_time_str(time_str: str) -> bool:
"""
Returns True if a given string matches the time tracking convention of Gitlab.
See: https://docs.gitlab.com/ee/user/project/time_tracking.html
"""
return bool(re.match(TIME_STR_REGEX, time_str))
def removesuffix(string: str, suffix: str) -> str:
"""
......@@ -60,6 +71,14 @@ class TextFormatting: # pylint: disable=too-few-public-methods
UNDERLINE: Final[str] = "\033[4m"
END: Final[str] = "\033[0m"
@staticmethod
def red(s: str) -> str:
return f"{TextFormatting.RED}{s}{TextFormatting.END}"
@staticmethod
def green(s: str) -> str:
return f"{TextFormatting.GREEN}{s}{TextFormatting.END}"
class Utils:
"""
......
import unittest
from io import StringIO
from unittest.mock import MagicMock, patch
from lab.issue import IssueConnection
class MockIssueConnection(IssueConnection):
"""
Subclass the original class to be able to override __init__.
Otherwise IssueConnection would try to talk to the API.
"""
def __init__(self, issue):
self.issue = issue
class IssueTestCase(unittest.TestCase):
def test_print_estimate(self):
"""
Tests that the output for some sample data matches the expectation.
"""
with patch("sys.stdout", new=StringIO()) as mock_stdout:
mock_issue = MagicMock()
mock_issue.title = "Fancy Title"
mock_issue.attributes = {'time_stats': {
'time_estimate': 520,
'total_time_spent': 600,
'human_time_estimate': '9m',
'human_total_time_spent': '10m',
}}
issue = MockIssueConnection(mock_issue)
issue.print_spent()
mock_stdout.seek(0)
self.assertEqual(
mock_stdout.read(),
'\x1b[1mFancy Title\x1b[0m has 10m tracked (estimated: \x1b[0;31m9m\x1b[0m)\n'
)
def test_print_spent(self):
"""
Tests that the output for some sample data matches the expectation.
"""
with patch("sys.stdout", new=StringIO()) as mock_stdout:
mock_issue = MagicMock()
mock_issue.title = "Fancy Title"
mock_issue.attributes = {'time_stats': {
'time_estimate': 28800,
'total_time_spent': 25200,
'human_time_estimate': '8h',
'human_total_time_spent': '7h',
}}
issue = MockIssueConnection(mock_issue)
issue.print_spent()
mock_stdout.seek(0)
self.assertEqual(
mock_stdout.read(),
'\x1b[1mFancy Title\x1b[0m has 7h tracked (estimated: \x1b[0;32m8h\x1b[0m)\n'
)
......@@ -9,6 +9,7 @@ sys.path.append(os.path.dirname(os.path.abspath(__file__)) + "/../")
from lab.utils import Utils
from lab.pipelines import PipelineStatus
from lab.issue import is_valid_time_str
class UtilsTest(unittest.TestCase):
......@@ -149,5 +150,30 @@ class PipelineTest(unittest.TestCase):
self.assertTrue(PipelineStatus.SKIPPED.finished)
class TestTimeTracking(unittest.TestCase):
def test_regex(self):
self.assertTrue(is_valid_time_str("1mo2w4d10h2m"))
self.assertTrue(is_valid_time_str("2w4d10h2m"))
self.assertTrue(is_valid_time_str("4d10h2m"))
self.assertTrue(is_valid_time_str("10h2m"))
self.assertTrue(is_valid_time_str("2m"))
self.assertTrue(is_valid_time_str("10m"))
self.assertTrue(is_valid_time_str("12h"))
self.assertTrue(is_valid_time_str("5d"))
self.assertTrue(is_valid_time_str("10w"))
self.assertTrue(is_valid_time_str("8m"))
self.assertFalse(is_valid_time_str("w"))
self.assertFalse(is_valid_time_str("mo"))
self.assertFalse(is_valid_time_str("d"))
self.assertFalse(is_valid_time_str("h"))
self.assertFalse(is_valid_time_str("m"))
self.assertFalse(is_valid_time_str("100"))
self.assertFalse(is_valid_time_str("100p"))
self.assertFalse(is_valid_time_str("100wof"))
self.assertFalse(is_valid_time_str("bar1foo"))
if __name__ == "__main__":
unittest.main()
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment