2024-07-09 22:15:45 +00:00
|
|
|
"""# Git-based Time Tracker.
|
|
|
|
|
|
|
|
Quick and dirty time tracker on git histories.
|
|
|
|
|
|
|
|
Uses the simple heuristics that each commit takes precisely one hour of work.
|
|
|
|
It will automatically trim commits below one hour if another commit occurred
|
|
|
|
less than an hour ago.
|
|
|
|
|
|
|
|
Usage:
|
|
|
|
|
|
|
|
```
|
|
|
|
python -m git_time_tracker REPO1 REPO2...
|
|
|
|
```
|
|
|
|
|
|
|
|
# Obligatory
|
|
|
|
|
|
|
|
This tool reports:
|
|
|
|
|
|
|
|
```
|
|
|
|
project Jmaa/git-time-tracker.git 3h 33m (2024)
|
|
|
|
```
|
|
|
|
|
|
|
|
And the ([Hamster](https://github.com/projecthamster/hamster)) manual time tracker reports:
|
|
|
|
|
|
|
|
![](docs/obligatory-hamster.png)
|
|
|
|
"""
|
|
|
|
|
2024-06-03 20:42:05 +00:00
|
|
|
import argparse
|
2024-06-03 21:01:45 +00:00
|
|
|
import datetime
|
2024-06-08 12:43:44 +00:00
|
|
|
import logging
|
|
|
|
import sys
|
2024-08-25 22:14:12 +00:00
|
|
|
from collections.abc import Iterator
|
2024-06-03 21:13:16 +00:00
|
|
|
from pathlib import Path
|
2024-06-03 20:42:05 +00:00
|
|
|
|
2024-08-25 22:57:51 +00:00
|
|
|
from .data import (
|
|
|
|
HIDDEN_LABEL_PREFIX,
|
|
|
|
HIDDEN_LABEL_TOTAL,
|
|
|
|
RealizedWorkSample,
|
|
|
|
WorkSample,
|
|
|
|
)
|
2024-08-25 22:26:04 +00:00
|
|
|
from .format import cli, icalendar
|
2024-09-20 22:32:19 +00:00
|
|
|
from .source import csv_file, git_repo
|
2024-06-03 22:15:18 +00:00
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
2024-06-08 12:43:44 +00:00
|
|
|
|
2024-08-25 22:14:12 +00:00
|
|
|
DEFAULT_ESTIMATED_DURATION = datetime.timedelta(hours=1)
|
2024-06-08 12:43:44 +00:00
|
|
|
ZERO_DURATION = datetime.timedelta(seconds=0)
|
|
|
|
HOUR = datetime.timedelta(hours=1)
|
2024-06-08 12:49:26 +00:00
|
|
|
MINUTE = datetime.timedelta(minutes=1)
|
2024-06-08 12:43:44 +00:00
|
|
|
|
2024-06-03 21:49:42 +00:00
|
|
|
|
2024-08-25 22:57:51 +00:00
|
|
|
def filter_samples(
|
2024-09-20 22:32:19 +00:00
|
|
|
samples: list[WorkSample],
|
|
|
|
sample_filter: set[str],
|
2024-08-25 22:57:51 +00:00
|
|
|
) -> list[WorkSample]:
|
2024-08-25 22:14:12 +00:00
|
|
|
assert len(sample_filter) > 0
|
2024-08-25 22:26:04 +00:00
|
|
|
return [s for s in samples if set(s.labels).intersection(sample_filter)]
|
2024-08-25 22:14:12 +00:00
|
|
|
|
2024-08-25 22:57:51 +00:00
|
|
|
|
|
|
|
def heuristically_realize_samples(
|
|
|
|
samples: list[WorkSample],
|
|
|
|
) -> Iterator[RealizedWorkSample]:
|
2024-08-25 22:14:12 +00:00
|
|
|
"""Secret sauce.
|
|
|
|
|
|
|
|
Guarentees that:
|
|
|
|
* No samples overlap.
|
|
|
|
"""
|
|
|
|
|
2024-09-26 22:03:41 +00:00
|
|
|
previous_sample_end = None
|
2024-08-25 22:14:12 +00:00
|
|
|
for sample in samples:
|
|
|
|
end_at = sample.end_at
|
|
|
|
|
2024-09-26 22:03:41 +00:00
|
|
|
if previous_sample_end is None:
|
|
|
|
if end_at.tzinfo:
|
|
|
|
previous_sample_end = datetime.datetime.fromtimestamp(0, datetime.UTC)
|
|
|
|
else:
|
|
|
|
previous_sample_end = datetime.datetime.fromtimestamp(0)
|
|
|
|
|
2024-08-25 22:14:12 +00:00
|
|
|
assert previous_sample_end <= end_at, 'Iterating in incorrect order'
|
|
|
|
|
|
|
|
# TODO: Allow end_at is None
|
|
|
|
|
|
|
|
start_at = sample.start_at
|
|
|
|
if start_at is None:
|
|
|
|
estimated_duration: datetime.timedelta = DEFAULT_ESTIMATED_DURATION
|
|
|
|
start_at = max(previous_sample_end, end_at - estimated_duration)
|
|
|
|
del estimated_duration
|
|
|
|
|
|
|
|
yield RealizedWorkSample(labels=sample.labels, end_at=end_at, start_at=start_at)
|
|
|
|
|
|
|
|
previous_sample_end = sample.end_at
|
|
|
|
del sample
|
|
|
|
|
2024-08-25 22:57:51 +00:00
|
|
|
|
2024-06-08 13:17:04 +00:00
|
|
|
def parse_arguments():
|
|
|
|
parser = argparse.ArgumentParser()
|
2024-08-25 21:41:42 +00:00
|
|
|
parser.add_argument(
|
2024-08-25 21:46:12 +00:00
|
|
|
'--git-repo',
|
|
|
|
action='extend',
|
|
|
|
nargs='+',
|
|
|
|
type=Path,
|
|
|
|
dest='repositories',
|
2024-08-27 19:04:37 +00:00
|
|
|
default=[],
|
|
|
|
)
|
|
|
|
parser.add_argument(
|
|
|
|
'--csv-file',
|
|
|
|
action='extend',
|
|
|
|
nargs='+',
|
|
|
|
type=Path,
|
|
|
|
dest='csv_files',
|
|
|
|
default=[],
|
2024-08-25 21:41:42 +00:00
|
|
|
)
|
|
|
|
parser.add_argument(
|
|
|
|
'--filter',
|
|
|
|
action='extend',
|
|
|
|
nargs='+',
|
|
|
|
type=str,
|
|
|
|
dest='sample_filter',
|
|
|
|
default=[],
|
|
|
|
)
|
2024-08-25 22:26:04 +00:00
|
|
|
parser.add_argument(
|
|
|
|
'--format',
|
|
|
|
action='store',
|
|
|
|
type=str,
|
|
|
|
dest='format_mode',
|
|
|
|
default='cli_report',
|
|
|
|
choices=['cli_report', 'icalendar'],
|
|
|
|
)
|
2024-09-26 22:03:41 +00:00
|
|
|
parser.add_argument(
|
|
|
|
'--out',
|
|
|
|
action='store',
|
|
|
|
type=Path,
|
|
|
|
dest='output_file',
|
|
|
|
default='output/samples.ics',
|
|
|
|
)
|
2024-06-08 13:17:04 +00:00
|
|
|
return parser.parse_args()
|
|
|
|
|
2024-09-20 22:32:19 +00:00
|
|
|
|
2024-09-26 22:03:41 +00:00
|
|
|
def load_samples(args) -> set[WorkSample]:
|
2024-08-27 19:04:37 +00:00
|
|
|
shared_time_stamps_set: set[WorkSample] = set()
|
|
|
|
|
|
|
|
# Git repositories
|
|
|
|
for repo_path in args.repositories:
|
|
|
|
logger.warning('Determine commits from %s', repo_path)
|
|
|
|
shared_time_stamps_set |= set(
|
|
|
|
git_repo.iterate_samples_from_git_repository(repo_path),
|
|
|
|
)
|
|
|
|
del repo_path
|
|
|
|
|
|
|
|
# CSV Files
|
|
|
|
for csv_path in args.csv_files:
|
|
|
|
logger.warning('Load samples from %s', csv_path)
|
|
|
|
shared_time_stamps_set |= set(
|
|
|
|
csv_file.iterate_samples_from_csv_file(csv_path),
|
|
|
|
)
|
|
|
|
del csv_path
|
|
|
|
|
|
|
|
return shared_time_stamps_set
|
2024-06-08 13:17:04 +00:00
|
|
|
|
2024-09-20 22:32:19 +00:00
|
|
|
|
2024-06-03 20:42:05 +00:00
|
|
|
def main():
|
2024-06-03 22:15:18 +00:00
|
|
|
logging.basicConfig()
|
|
|
|
|
2024-06-03 20:42:05 +00:00
|
|
|
args = parse_arguments()
|
|
|
|
|
2024-08-27 19:04:37 +00:00
|
|
|
# Determine samples
|
|
|
|
shared_time_stamps_set = load_samples(args)
|
2024-06-03 21:49:42 +00:00
|
|
|
|
2024-08-27 19:04:37 +00:00
|
|
|
# Sort samples
|
2024-08-25 22:57:51 +00:00
|
|
|
shared_time_stamps = sorted(shared_time_stamps_set, key=lambda s: s.end_at)
|
2024-08-25 22:14:12 +00:00
|
|
|
del shared_time_stamps_set
|
|
|
|
|
2024-08-27 19:04:37 +00:00
|
|
|
# Filter samples
|
2024-08-25 22:14:12 +00:00
|
|
|
sample_filter = args.sample_filter
|
|
|
|
if len(sample_filter) != 0:
|
|
|
|
logger.warning('Filtering %s samples', len(shared_time_stamps))
|
|
|
|
shared_time_stamps = filter_samples(shared_time_stamps, sample_filter)
|
|
|
|
logger.warning('Filtered down to %s samples', len(shared_time_stamps))
|
|
|
|
|
2024-08-27 19:04:37 +00:00
|
|
|
# Heuristic samples
|
2024-08-25 22:14:12 +00:00
|
|
|
logger.warning('Realizing %s samples', len(shared_time_stamps))
|
2024-08-25 22:26:04 +00:00
|
|
|
shared_time_stamps = list(heuristically_realize_samples(shared_time_stamps))
|
2024-06-03 21:49:42 +00:00
|
|
|
|
2024-08-27 19:04:37 +00:00
|
|
|
# Output format
|
2024-08-25 22:26:04 +00:00
|
|
|
if args.format_mode == 'cli_report':
|
|
|
|
for t in cli.generate_report(shared_time_stamps):
|
|
|
|
sys.stdout.write(t)
|
|
|
|
elif args.format_mode == 'icalendar':
|
2024-08-25 22:57:51 +00:00
|
|
|
icalendar.generate_icalendar_file(
|
2024-09-20 22:32:19 +00:00
|
|
|
shared_time_stamps,
|
2024-09-26 22:03:41 +00:00
|
|
|
file=args.output_file,
|
2024-08-25 22:57:51 +00:00
|
|
|
)
|