From 4575db1c384a171ebac365b8c5891d1cfc4280a0 Mon Sep 17 00:00:00 2001 From: arzzen Date: Thu, 19 Jun 2025 19:11:43 +0200 Subject: [PATCH 1/2] activity calendar by author --- git_py_stats/arg_parser.py | 9 ++ git_py_stats/config.py | 1 + git_py_stats/generate_cmds.py | 89 +++++++++++++++++++ git_py_stats/interactive_mode.py | 3 + git_py_stats/menu.py | 2 + git_py_stats/non_interactive_mode.py | 3 + git_py_stats/tests/test_generate_cmds.py | 21 +++++ .../tests/test_non_interactive_mode.py | 9 ++ 8 files changed, 137 insertions(+) diff --git a/git_py_stats/arg_parser.py b/git_py_stats/arg_parser.py index a59dd85..a68cf43 100644 --- a/git_py_stats/arg_parser.py +++ b/git_py_stats/arg_parser.py @@ -166,6 +166,15 @@ def parse_arguments(argv: Optional[List[str]] = None) -> Namespace: help="Displays a list of commits per timezone by author", ) + # Calendar Options + parser.add_argument( + "-k", + "--commits-calendar-by-author", + metavar='"AUTHOR NAME"', + type=str, + help="Show a calendar of commits by author", + ) + # Suggest Options parser.add_argument( "-r", diff --git a/git_py_stats/config.py b/git_py_stats/config.py index 8c05c73..05363e2 100644 --- a/git_py_stats/config.py +++ b/git_py_stats/config.py @@ -39,6 +39,7 @@ def get_config() -> Dict[str, Union[str, int]]: _GIT_LOG_OPTIONS (str): Additional git log options. Default is empty. _MENU_THEME (str): Toggles between the default theme and legacy theme. - 'legacy' to set the legacy theme + - 'none' to disable the menu theme - Empty to set the default theme Args: diff --git a/git_py_stats/generate_cmds.py b/git_py_stats/generate_cmds.py index 6813139..d1fa183 100644 --- a/git_py_stats/generate_cmds.py +++ b/git_py_stats/generate_cmds.py @@ -7,6 +7,7 @@ import json from typing import Optional, Dict, Any, List, Union from datetime import datetime, timedelta +from collections import defaultdict from git_py_stats.git_operations import run_git_command @@ -305,6 +306,94 @@ def changelogs(config: Dict[str, Union[str, int]], author: Optional[str] = None) next_date = date # Update next_date for the next iteration +def commits_calendar_by_author(config: Dict[str, Union[str, int]], author: Optional[str]) -> None: + """ + Displays a calendar of commits by author + + Args: + config: Dict[str, Union[str, int]]: Config dictionary holding env vars. + author: Optional[str]: The author's name to filter commits by. + + Returns: + None + """ + + # Initialize variables similar to the Bash version + author_option = f"--author={author}" if author else "" + + # Grab the config options from our config.py. + # config.py should give fallbacks for these, but for sanity, + # lets also provide some defaults just in case. + merges = config.get("merges", "--no-merges") + since = config.get("since", "") + until = config.get("until", "") + log_options = config.get("log_options", "") + pathspec = config.get("pathspec", "") + + # Original git command: + # git -c log.showSignature=false log --use-mailmap $_merges \ + # --date=iso --author="$author" "$_since" "$_until" $_log_options \ + # --pretty='%ad' $_pathspec + cmd = [ + "git", + "-c", + "log.showSignature=false", + "log", + "--use-mailmap", + "--date=iso", + f"--author={author}", + "--pretty=%ad", + ] + + if author_option: + cmd.append(author_option) + + cmd.extend([since, until, log_options, merges, pathspec]) + + # Remove any empty space from the cmd + cmd = [arg for arg in cmd if arg] + + print(f"Commit Activity Calendar for '{author}'") + + # Get commit dates + output = run_git_command(cmd) + if not output: + print("No commits found.") + return + + print("\n Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec") + + count = defaultdict(lambda: defaultdict(int)) + for line in output.strip().split("\n"): + try: + date_str = line.strip().split(" ")[0] + date_obj = datetime.strptime(date_str, "%Y-%m-%d") + weekday = date_obj.isoweekday() # 1=Mon, ..., 7=Sun + month = date_obj.month + count[weekday][month] += 1 + except ValueError: + continue + + # Print the calendar + weekdays = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] + for d in range(1, 8): + row = f"{weekdays[d-1]:<5} " + for m in range(1, 13): + c = count[d][m] + if c == 0: + out = "..." + elif c <= 9: + out = "░░░" + elif c <= 19: + out = "▒▒▒" + else: + out = "▓▓▓" + row += out + (" " if m < 12 else "") + print(row) + + print("\nLegend: ... = 0 ░░░ = 1–9 ▒▒▒ = 10–19 ▓▓▓ = 20+ commits") + + def my_daily_status(config: Dict[str, Union[str, int]]) -> None: """ Displays the user's commits from the last day. diff --git a/git_py_stats/interactive_mode.py b/git_py_stats/interactive_mode.py index 77b8234..853176e 100644 --- a/git_py_stats/interactive_mode.py +++ b/git_py_stats/interactive_mode.py @@ -44,6 +44,9 @@ def handle_interactive_mode(config: Dict[str, Union[str, int]]) -> None: "20": lambda: list_cmds.git_commits_per_timezone(config), "21": lambda: list_cmds.git_commits_per_timezone(config, input("Enter author name: ")), "22": lambda: suggest_cmds.suggest_reviewers(config), + "23": lambda: generate_cmds.commits_calendar_by_author( + config, input("Enter author name: ") + ), } while True: diff --git a/git_py_stats/menu.py b/git_py_stats/menu.py index 30e50e8..4f76b17 100644 --- a/git_py_stats/menu.py +++ b/git_py_stats/menu.py @@ -72,6 +72,8 @@ def interactive_menu(config: Dict[str, Union[str, int]]) -> str: print(f"{NUMS} 21){TEXT} Git commits per timezone by author") print(f"\n{TITLES} Suggest:{NORMAL}") print(f"{NUMS} 22){TEXT} Code reviewers (based on git history)") + print(f"\n{TITLES} Calendar:{NORMAL}") + print(f"{NUMS} 23){TEXT} Activity calendar by author") print(f"\n{HELP_TXT}Please enter a menu option or {EXIT_TXT}press Enter to exit.{NORMAL}") choice = input(f"{TEXT}> {NORMAL}") diff --git a/git_py_stats/non_interactive_mode.py b/git_py_stats/non_interactive_mode.py index f4cfa47..bf046c4 100644 --- a/git_py_stats/non_interactive_mode.py +++ b/git_py_stats/non_interactive_mode.py @@ -50,6 +50,9 @@ def handle_non_interactive_mode(args: Namespace, config: Dict[str, Union[str, in config, args.commits_by_author_by_timezone ), "suggest_reviewers": lambda: suggest_cmds.suggest_reviewers(config), + "commits_calendar_by_author": lambda: generate_cmds.commits_calendar_by_author( + config, args.commits_calendar_by_author + ), } # Call the appropriate function based on the command-line argument diff --git a/git_py_stats/tests/test_generate_cmds.py b/git_py_stats/tests/test_generate_cmds.py index f51ae2a..8b7fc97 100644 --- a/git_py_stats/tests/test_generate_cmds.py +++ b/git_py_stats/tests/test_generate_cmds.py @@ -105,6 +105,27 @@ def test_changelogs_with_author(self, mock_print, mock_run_git_command): self.assertTrue(mock_print.called) + @patch("git_py_stats.generate_cmds.run_git_command") + @patch("builtins.print") + def test_commits_calendar_by_author(self, mock_print, mock_run_git_command): + """ + Test commits_calendar_by_author function with an author specified. + """ + # Mock git command outputs + mock_run_git_command.side_effect = [ + "", # git diff output (no changes) + "John Doe", # git config user.name + "", # git log output (no commits) + ] + + generate_cmds.commits_calendar_by_author(self.mock_config, author="John Doe") + + # Verify that the author option was included in the command + called_cmd = mock_run_git_command.call_args_list[0][0][0] + self.assertIn("--author=John Doe", called_cmd) + + self.assertTrue(mock_print.called) + @patch("git_py_stats.generate_cmds.run_git_command") @patch("builtins.print") def test_my_daily_status(self, mock_print, mock_run_git_command): diff --git a/git_py_stats/tests/test_non_interactive_mode.py b/git_py_stats/tests/test_non_interactive_mode.py index ddc4954..ecf8178 100644 --- a/git_py_stats/tests/test_non_interactive_mode.py +++ b/git_py_stats/tests/test_non_interactive_mode.py @@ -46,6 +46,7 @@ def setUp(self): "commits_by_timezone": False, "commits_by_author_by_timezone": None, "suggest_reviewers": False, + "commits_calendar_by_author": None, } @patch("git_py_stats.non_interactive_mode.generate_cmds.detailed_git_stats") @@ -104,6 +105,14 @@ def test_json_output(self, mock_save_json): non_interactive_mode.handle_non_interactive_mode(args, self.mock_config) mock_save_json.assert_called_once_with(self.mock_config) + @patch("git_py_stats.non_interactive_mode.generate_cmds.commits_calendar_by_author") + def test_commits_calendar_by_author(self, mock_commits_calendar_by_author): + args_dict = self.all_args.copy() + args_dict["commits_calendar_by_author"] = "John Doe" + args = Namespace(**args_dict) + non_interactive_mode.handle_non_interactive_mode(args, self.mock_config) + mock_commits_calendar_by_author.assert_called_once_with(self.mock_config, "John Doe") + @patch("git_py_stats.non_interactive_mode.list_cmds.branch_tree") def test_branch_tree(self, mock_branch_tree): args_dict = self.all_args.copy() From 276dd53324da61cb971fa7f4fc376abbd2a83ea8 Mon Sep 17 00:00:00 2001 From: arzzen Date: Thu, 19 Jun 2025 19:13:50 +0200 Subject: [PATCH 2/2] manpage --- man/git-py-stats.1 | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/man/git-py-stats.1 b/man/git-py-stats.1 index 72d71b0..d13961e 100644 --- a/man/git-py-stats.1 +++ b/man/git-py-stats.1 @@ -98,6 +98,10 @@ Display a list of commits by timezone by author. .B \-r, \--suggest-reviewers Suggest code reviewers based on contribution history. +.TP +.B \-k, \--commits-calendar-by-author "AUTHOR NAME" +Display a calendar of commits by author. + .TP .B \-h, \--help Show this help message and exit.