From d3113335cc0c95b3a30fcb17b97554c9f1a24fcd Mon Sep 17 00:00:00 2001 From: Laurent Sorber Date: Wed, 1 Jan 2020 15:36:19 +0100 Subject: [PATCH] Add pre-commit support --- .gitignore | 1 - .pre-commit-hooks.yaml | 6 ++ README.md | 34 ++++++--- auto-smart-commit.py | 119 +++++++++++++++++++++++++++++ githooks/prepare-commit-msg | 147 ------------------------------------ 5 files changed, 150 insertions(+), 157 deletions(-) delete mode 100644 .gitignore create mode 100644 .pre-commit-hooks.yaml create mode 100755 auto-smart-commit.py delete mode 100755 githooks/prepare-commit-msg diff --git a/.gitignore b/.gitignore deleted file mode 100644 index 8f5fd87..0000000 --- a/.gitignore +++ /dev/null @@ -1 +0,0 @@ -.history diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml new file mode 100644 index 0000000..7b1bf27 --- /dev/null +++ b/.pre-commit-hooks.yaml @@ -0,0 +1,6 @@ +- id: auto-smart-commit + name: Auto Jira smart commit + description: Automatically transform your Git commit messages into Jira smart commits + entry: auto-smart-commit.py + language: script + always_run: true diff --git a/README.md b/README.md index a8f1a8e..6b59a94 100644 --- a/README.md +++ b/README.md @@ -1,28 +1,44 @@ -## Automated Jira smart commits +## Auto Jira smart commit -This [`prepare-commit-msg`](https://git-scm.com/docs/githooks#_prepare_commit_msg) Git hook transforms your Git commit messages into [Jira smart commits](https://confluence.atlassian.com/fisheye/using-smart-commits-960155400.html). +This [pre-commit](https://pre-commit.com/) hook transforms your Git commit messages into [Jira smart commits](https://confluence.atlassian.com/fisheye/using-smart-commits-960155400.html). -After naming your branch after a [Jira issue key](https://confluence.atlassian.com/adminjiraserver073/changing-the-project-key-format-861253229.html) such as `ML-42`, the hook will automatically format your commit message into a Jira smart commit: +If your branch name contains a [Jira issue key](https://confluence.atlassian.com/adminjiraserver073/changing-the-project-key-format-861253229.html) such as `ABC-123`, the hook will automatically format your commit message into a Jira smart commit: | Command | Log entry | | ------- | --------- | -| git commit -m "open the pod bay doors." | ML-42 Open the pod bay doors

Jira #time 0w 0d 2h 8m Open the pod bay doors

_Effect:_ Logs the time since your last commit on any branch in the Work Log tab. | -| git commit -m "Open the pod bay doors

I should get back inside, so I must open the pod bay doors." | ML-42 Open the pod bay doors

Jira #comment I should get back inside, so I must open the pod bay doors.

Jira #time 0w 0d 2h 8m Open the pod bay doors

_Effect:_ Posts a comment to the Jira issue and logs the time since your last commit in the Work Log tab. | -| git commit | ML-42 d$:

Jira #comment d$:

Jira #time 0w 0d 2h 8m Open the pod bay doors

_Effect:_ Edit the smart commit with your favourite editor before publishing it. Since the default is usually Vim, we remind the user how to delete a line starting from the cursor with `d$`. | +| git commit -m "release the kraken." | ABC-123 Release the kraken

ABC-123 #time 0w 0d 2h 8m Release the kraken

_Effect:_ Logs the time since your last commit on any branch in the Work Log tab. | +| git commit -m "Release the kraken

A kraken lives in dark depths, usually a sunken rift or a cavern filled with detritus, treasure, and wrecked ships." | ABC-123 Release the kraken

ABC-123 #comment A kraken lives in dark depths, usually a sunken rift or a cavern filled with detritus, treasure, and wrecked ships.

ABC-123 #time 0w 0d 2h 8m Release the kraken

_Effect:_ Posts a comment to the Jira issue and logs the time since your last commit in the Work Log tab. | + +If the branch name does not contain a Jira issue key, the commit message is not modified. The time logged takes into account non-working hours such as lunch breaks and nights. See [How to Write a Git Commit Message](https://chris.beams.io/posts/git-commit/) for an explanation of the seven rules of a great Git commit message: 1. Separate subject from body with a blank line 2. Limit the subject line to 50 characters -3. Capitalize the subject line -4. Do not end the subject line with a period +3. Capitalize the subject line (automated) +4. Do not end the subject line with a period (automated) 5. Use the imperative mood in the subject line 6. Wrap the body at 72 characters 7. Use the body to explain what and why vs. how ## Installation -To install the git hooks in the directory `githooks`, run the following command from the root of your repository: +### Installation with pre-commit + +Add the following to your `.pre-commit-config.yaml` file: + +```yaml +repos: + - repo: https://github.com/radix-ai/auto-smart-commit + rev: v1.0.0 + hooks: + - id: auto-smart-commit +``` + +### Manual installation + +Copy `auto-smart-commit.py` to a `githooks` directory in your repository, then run the following command from the root of your repository: + ```bash git config --local core.hooksPath githooks ``` diff --git a/auto-smart-commit.py b/auto-smart-commit.py new file mode 100755 index 0000000..b44af92 --- /dev/null +++ b/auto-smart-commit.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python + +import re +import sys +from datetime import datetime +from math import floor +from subprocess import check_output +from typing import NoReturn, Optional + + +def run_command(command: str) -> str: + stdout: str = check_output(command.split()).decode("utf-8").strip() + return stdout + + +def current_git_branch_name() -> str: + return run_command("git symbolic-ref --short HEAD") + + +def extract_jira_issue_key(message: str) -> Optional[str]: + project_key, issue_number = r"[A-Z]{2,}", r"[0-9]+" + match = re.search(f"{project_key}-{issue_number}", message) + if match: + return match.group(0) + return None + + +def last_commit_datetime() -> datetime: + # https://git-scm.com/docs/git-log#_pretty_formats + git_log = "git log -1 --branches --format=%aI" + author = run_command("git config user.email") + last_author_datetime = run_command(f"{git_log} --author={author}") or run_command(git_log) + if "+" in last_author_datetime: + return datetime.strptime(last_author_datetime.split("+")[0], "%Y-%m-%dT%H:%M:%S") + return datetime.now() + + +def num_lunches(start: datetime, end: datetime) -> int: + n = (end.date() - start.date()).days - 1 + if start < start.replace(hour=12, minute=0, second=0): + n += 1 + if end > end.replace(hour=12, minute=45, second=0): + n += 1 + return max(n, 0) + + +def num_nights(start: datetime, end: datetime) -> int: + n = (end.date() - start.date()).days - 1 + if start < start.replace(hour=1, minute=0, second=0): + n += 1 + if end > end.replace(hour=5, minute=0, second=0): + n += 1 + return max(n, 0) + + +def time_worked_on_commit() -> Optional[str]: + now = datetime.now() + last = last_commit_datetime() + # Determine the number of minutes worked on this commit as the number of + # minutes since the last commit minus the lunch breaks and nights. + working_hours_per_day = 8 + working_days_per_week = 5 + minutes = max( + round((now - last).total_seconds() / 60) + - num_nights(last, now) * (24 - working_hours_per_day) * 60 + - num_lunches(last, now) * 45, + 0, + ) + # Convert the number of minutes worked to working weeks, days, hours, + # minutes. + if minutes > 0: + hours = floor(minutes / 60) + minutes -= hours * 60 + days = floor(hours / working_hours_per_day) + hours -= days * working_hours_per_day + weeks = floor(days / working_days_per_week) + days -= weeks * working_days_per_week + return f"{weeks}w {days}d {hours}h {minutes}m" + return None + + +def main() -> NoReturn: + # https://confluence.atlassian.com/fisheye/using-smart-commits-960155400.html + # Exit if the branch name does not contain a Jira issue key. + git_branch_name = current_git_branch_name() + jira_issue_key = extract_jira_issue_key(git_branch_name) + if not jira_issue_key: + sys.exit(0) + # Read the commit message. + commit_msg_filepath = sys.argv[1] + with open(commit_msg_filepath, "r") as f: + commit_msg = f.read() + # Split the commit into a subject and body and apply some light formatting. + commit_elements = commit_msg.split("\n", maxsplit=1) + commit_subject = commit_elements[0].strip() + commit_subject = f"{commit_subject[:1].upper()}{commit_subject[1:]}" + commit_subject = re.sub(r"\.+$", "", commit_subject) + commit_body = None if len(commit_elements) == 1 else commit_elements[1].strip() + # Build the new commit message: + # 1. If there is a body, turn it into a comment on the issue. + if "#comment" not in commit_msg and commit_body: + commit_body = f"{jira_issue_key} #comment {commit_body}" + # 2. Add the time worked to the Work Log in the commit body. + work_time = time_worked_on_commit() + if "#time" not in commit_msg and work_time: + work_log = f"{jira_issue_key} #time {work_time} {commit_subject}" + commit_body = f"{commit_body}\n\n{work_log}" if commit_body else work_log + # 3. Make sure the subject starts with a Jira issue key. + if not extract_jira_issue_key(commit_subject): + commit_subject = f"{jira_issue_key} {commit_subject}" + # Override commit message. + commit_msg = f"{commit_subject}\n\n{commit_body}" if commit_body else commit_subject + with open(commit_msg_filepath, "w") as f: + f.write(commit_msg) + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/githooks/prepare-commit-msg b/githooks/prepare-commit-msg deleted file mode 100755 index 648337b..0000000 --- a/githooks/prepare-commit-msg +++ /dev/null @@ -1,147 +0,0 @@ -#!/usr/bin/env python - -# To enable the git hooks in this directory, run: -# $ git config --local core.hooksPath githooks - -import datetime -import re -import sys -from math import floor -from subprocess import check_output -from typing import Optional - - -def exit_with_error(message: str) -> None: - print(message) - sys.exit(1) - - -def run_command(command: str) -> str: - return check_output(command.split()).decode('utf-8').strip() - - -def current_git_branch_name() -> str: - return run_command('git symbolic-ref --short HEAD') - - -def extract_jira_issue_key(message: str) -> Optional[str]: - project_key, issue_number = r'[A-Z]{2,}', r'[0-9]+' - match = re.search(f'{project_key}-{issue_number}', message) - return match and match.group(0) - - -def last_commit_datetime() -> datetime.datetime: - # https://git-scm.com/docs/git-log#_pretty_formats - author = run_command('git config user.email') - last_author_stamp = run_command( - f'git log -1 --branches --format=%aI --author={author}' - ) - if '+' in last_author_stamp: - last_datetime = datetime.datetime.strptime( - last_author_stamp.split('+')[0], - '%Y-%m-%dT%H:%M:%S' - ) # %z - else: - last_datetime = datetime.datetime.strptime( - run_command( - f'git log -1 --branches --format=%aI' - ).split('+')[0], - '%Y-%m-%dT%H:%M:%S' - ) # %z - return last_datetime - - -def num_lunches(start: datetime.datetime, end: datetime.datetime) -> int: - n = (end.date() - start.date()).days - 1 - if start < start.replace(hour=12, minute=0, second=0): - n += 1 - if end > end.replace(hour=12, minute=45, second=0): - n += 1 - return max(n, 0) - - -def num_nights(start: datetime.datetime, end: datetime.datetime) -> int: - n = (end.date() - start.date()).days - 1 - if start < start.replace(hour=1, minute=0, second=0): - n += 1 - if end > end.replace(hour=5, minute=0, second=0): - n += 1 - return max(n, 0) - - -def time_worked_on_commit() -> str: - now = datetime.datetime.now() - last = last_commit_datetime() - # Determine the number of minutes worked on this commit as the number of - # minutes since the last commit minus the lunch breaks and nights. - working_hours_per_day = 8 - working_days_per_week = 5 - minutes = max( - round((now - last).total_seconds() / 60) - \ - num_nights(last, now) * (24 - working_hours_per_day) * 60 - \ - num_lunches(last, now) * 45, 0) - # Convert the number of minutes worked to working weeks, days, hours, - # minutes. - if minutes >= 0: - hours = floor(minutes / 60) - minutes -= hours * 60 - days = floor(hours / working_hours_per_day) - hours -= days * working_hours_per_day - weeks = floor(days / working_days_per_week) - days -= weeks * working_days_per_week - return f'{weeks}w {days}d {hours}h {minutes}m' - return '' - - -def main(): - # Verify that the branch name is a Jira issue key. - git_branch_name = current_git_branch_name() - jira_issue_key = extract_jira_issue_key(git_branch_name) - if not jira_issue_key: - exit_with_error( - f'Commit aborted! To continue, please rename your branch to a Jira ' - f'issue key with:\n$ git branch -m {git_branch_name} ') - # Read the commit message. - commit_msg_filepath = sys.argv[1] - with open(commit_msg_filepath, 'r') as f: - commit_msg = f.read() - # Replace the default multiline commit message. - default_commit_msg = 'Please enter the commit message for your changes.' in commit_msg - if default_commit_msg: - # https://chris.beams.io/posts/git-commit/ - commit_msg = ''' - d$: - d$: - '''.strip() - # Split the commit into a subject and body and apply some light formatting. - commit_elements = commit_msg.split('\n', maxsplit=1) - commit_subject = commit_elements[0].strip() - if not default_commit_msg: - commit_subject.capitalize() - commit_subject = re.sub(r'\.+$', '', commit_subject) - commit_body = None if len(commit_elements) == 1 else commit_elements[1] - commit_body = commit_body.strip() - # Build the new commit message: - # 1. If there is a body, turn it into a comment on the issue. - if '#comment' not in commit_msg and commit_body: - commit_body = f'{jira_issue_key} #comment {commit_body}' - # 2. Add the time worked to the Work Log in the commit body. - if '#time' not in commit_msg: - if 'Open the pod bay doors' in commit_subject: - log = 'Open the pod bay doors' - else: - log = commit_subject - log = f'{jira_issue_key} #time {time_worked_on_commit()} {log}' - commit_body = f'{commit_body}\n\n{log}' if commit_body else log - # 3. Make sure the subject starts with a Jira issue key. - if not extract_jira_issue_key(commit_subject): - commit_subject = f'{jira_issue_key} {commit_subject}' - # Assemble the commit message as the subject plus body. - commit_msg = f'{commit_subject}\n\n{commit_body}' - # Override commit message. - with open(commit_msg_filepath, 'w') as f: - f.write(commit_msg) - - -if __name__ == '__main__': - main()