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()