Skip to content

Commit 748486c

Browse files
committed
ISSUE-4: Add hook: cojira
1 parent 785a545 commit 748486c

File tree

6 files changed

+904
-13
lines changed

6 files changed

+904
-13
lines changed

.pre-commit-config.yaml

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,6 @@
11
default_install_hook_types: [ commit-msg, pre-commit, prepare-commit-msg ]
22
default_stages: [ pre-commit ]
33
repos:
4-
- repo: https://github.com/ShellMagick/shellmagick-commit-hooks
5-
rev: v24.06
6-
hooks:
7-
- id: commiticketing
8-
args: [ -t= ]
9-
- id: no-boms
10-
- id: no-todos
11-
args: [ -e=.pre-commit-hooks.yaml, -e=test_no_todos.py, -e=no_todos.py, -e=README.md ]
12-
- id: lint-commit-message
134
- repo: https://github.com/sirosen/texthooks
145
rev: 0.6.6
156
hooks:
@@ -69,3 +60,12 @@ repos:
6960
rev: 7.1.0
7061
hooks:
7162
- id: flake8
63+
- repo: https://github.com/ShellMagick/shellmagick-commit-hooks
64+
rev: v24.06
65+
hooks:
66+
- id: no-boms
67+
- id: no-todos
68+
args: [ -e=.pre-commit-hooks.yaml, -e=test_no_todos.py, -e=no_todos.py, -e=README.md ]
69+
- id: commiticketing
70+
args: [ -t= ]
71+
- id: lint-commit-message

.pre-commit-hooks.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
- id: cojira
2+
name: 'cross-check with JIRA when commiting'
3+
description: 'Cross check referenced ticket status in JIRA'
4+
entry: cojira
5+
language: python
6+
stages: [ prepare-commit-msg, manual ]
17
- id: commiticketing
28
name: 'prepend commit message with ticket reference'
39
description: 'Auto-prepend commit message with ticketing system reference based on current branch name.'

README.md

Lines changed: 68 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,16 @@ Commit hooks based on experience, needs in teams I have worked, and acquired tas
55
This repository uses the [pre-commit](https://pre-commit.com/) framework
66
and is heavily influenced by their [out-of-the-box hooks](https://github.com/pre-commit/pre-commit-hooks/).
77

8-
Notably, hooks in this repository do not support Python 3.8, only 3.9 and above.
9-
This is, because [`str.removeprefix`](https://docs.python.org/3.9/library/stdtypes.html#str.removeprefix) is used,
10-
which has been introduced with Python 3.9.
8+
> [!IMPORTANT]
9+
> Notably, hooks in this repository do not support Python 3.8, only 3.9 and above.
10+
> This is, because [`str.removeprefix`](https://docs.python.org/3.9/library/stdtypes.html#str.removeprefix) is used,
11+
> which has been introduced with Python 3.9.
1112
1213
## Listing of hooks
1314

15+
In order of proposed configuration:
16+
17+
* _**EXPERIMENTAL**_ [`cojira`](#cojira)
1418
* [`commiticketing`](#commiticketing)
1519
* [`lint-commit-message`](#lint-commit-message)
1620
* [`no-boms`](#no-boms)
@@ -39,15 +43,75 @@ repos:
3943
- repo: https://github.com/ShellMagick/commit-hooks
4044
rev: v24.06
4145
hooks:
42-
- id: commiticketing
4346
- id: no-boms
4447
- id: no-todos
48+
- id: commiticketing
49+
- id: cojira
50+
verbose: true
51+
args: [ '-l', '-u=$JIRA_URI', '-p=$JIRA_PAT', '-v=$ALLOWED_VERSIONS' ]
4552
- id: lint-commit-message
4653
...
4754
```
4855

56+
We propose that `cojira` should be configured _after_ `commiticketing`.
57+
This way `commiticketing` can prefix your commit message, if needed, and thus `cojira` can check without problems.
58+
4959
## Available hooks
5060

61+
### `cojira`
62+
63+
> [!WARNING]
64+
> Consider this hook as _**EXPERIMENTAL**_ in the sense of a) expect clumsy UX b) do not be surprised by minor bugs
65+
66+
In case you are working with a ticketing system, and you want to "bind" your commits to tickets (cf. [`commiticketing`](#commiticketing)),
67+
you may also want to make sure that the referenced ticket is in a "desired" state.
68+
69+
This pre-commit hook can look up basic status of a JIRA-ticket based on the arguments given.
70+
71+
> [!IMPORTANT]
72+
> Consider enabling verbose output for this hook, so that you see inline feedback.
73+
74+
#### Arguments
75+
76+
* `-l/--lenient`: The hook is lenient regarding configuration. Enables a "soft onboarding" of this hook in projects.
77+
* In case specified, but no JIRA URI-root given (either parameter missing, or it is resolved as an empty String), then the hook early-exist with "success".
78+
* `-u/--jira-uri`: the URI-root for your JIRA instance.
79+
* In case it starts with `$`, it will be interpreted as an environment variable.
80+
* `-p/--jira-pat`: the PAT (personal access token) to be used for queries against the JIRA REST API (`<-u>/rest/api/latest/issue/<ticket>`).
81+
* In case it starts with `$`, it will be interpreted as an environment variable.
82+
* `-i/--allow-status-category`: Defines an "allowed" ("included") status category. This argument is repeatable.
83+
* These take precendence over "disallowed" ("excluded") categories.
84+
* By default this is an empty list (i.e., a category is implicitly "allowed" if and only if it is not "disallowed").
85+
* JSONPath of status category is: `$.fields.status.statusCategory.key`.
86+
* `-e/--disallow-status-category`: Defines an "disallowed" ("excluded") status category. This argument is repeatable.
87+
* In case none are specified, by default this contains the status category "done".
88+
* In case at least one are specified, only the specified values are considered.
89+
* JSONPath of status category is: `$.fields.status.statusCategory.key`.
90+
* `-v/--allowed-fix-version`: Defines an "allowed" fix version. This argument is repeatable.
91+
* In case it starts with `$`, it will be interpreted as an environment variable.
92+
* In case none are given, no check for fix versions is performed.
93+
* JSONPath of fix version is: `$.fields.fixVersions[0].name`, i.e., only the first fix version of the ticket is checked.
94+
In case multiple fix versions are defined on the ticket, that is considered an error (i.e., as if no fix versions were specified).
95+
96+
#### Possible outputs
97+
98+
Presuming correct configuration of JIRA URI and PAT, some examples of possible results are:
99+
100+
* "Could not reify ticket from commit message" with return code `4` in case the commit message does not start with a ticketing reference (cf. [`commiticketing`](#commiticketing)).
101+
* "Ticket has no fix version, but it is expected" with return code `3` in case the fix version in JIRA is empty (or multiple fix versions are defined), but `-v` is at least once defined for the hook.
102+
* "Fix version of ticket ("{ticket_version}") is not allowed" with return code `2` in case the fix version in JIRA is not empty, but does not correspond to any value given via `-v`.
103+
* "Ticket status category ("{category}") is not allowed" with return code `1` in case the status category is not on the "allowed" list (`-i`) and is on the "disallowed" list (`-e`).
104+
* "Ticket is OK according to COJIRA rules" with return code `0` if everything is according to expectations.
105+
106+
#### Side effect
107+
108+
In case:
109+
* the JIRA REST API could not be queried (incorrect URI/PAT)
110+
* **or** the "allowed" fix version list is not empty **and** the ticket's fix version is not in the "allowed" fix version list
111+
* **or** the ticket's status category is not in the "allowed" list **and** the ticket's status category is on the "disallowed" list
112+
113+
then the pre-commit hook results in a failure. No changes will be done in your repository (files and commits are not touched by this hook).
114+
51115
### `commiticketing`
52116

53117
Many projects working with Git have a pre-defined workflow; let it be

hooks/cojira.py

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
from __future__ import annotations
2+
3+
import argparse
4+
import json
5+
from collections.abc import Sequence
6+
from collections.abc import Set
7+
from os import environ
8+
from re import search
9+
from typing import SupportsIndex
10+
from urllib import request
11+
12+
13+
def get_ticket(commit_msg_filename: str) -> str | None:
14+
with open(commit_msg_filename, encoding='utf-8') as msg:
15+
lines = msg.readlines()
16+
ticketing = search(r'^([A-Z]{2,}-[1-9][0-9]*): .*', lines[0])
17+
return None if not ticketing else ticketing.group(1)
18+
19+
20+
def fetch_jira(
21+
ticket: str,
22+
jira_uri: str,
23+
jira_pat: str,
24+
) -> SupportsIndex | slice | None:
25+
try:
26+
req = request.Request(f'{jira_uri}/rest/api/latest/issue/{ticket}')
27+
req.add_header('Authorization', f'Bearer {jira_pat}')
28+
resp = request.urlopen(req)
29+
return json.loads(
30+
resp.read().decode(resp.info().get_param('charset') or 'utf-8'),
31+
)
32+
except json.decoder.JSONDecodeError: # unexpected response
33+
return None
34+
35+
36+
def get_ticket_version(
37+
ticket: str,
38+
jira_uri: str,
39+
jira_pat: str,
40+
) -> str | None:
41+
body = fetch_jira(ticket, jira_uri, jira_pat)
42+
if not body:
43+
return None
44+
try:
45+
fixVersions = body['fields']['fixVersions'] # type: ignore[index]
46+
return fixVersions[0]['name'] if len(fixVersions) == 1 else None
47+
except (KeyError, TypeError): # no .fields.fixVersions[0].name
48+
return None
49+
50+
51+
def get_ticket_status_category(
52+
ticket: str,
53+
jira_uri: str,
54+
jira_pat: str,
55+
) -> str | None:
56+
body = fetch_jira(ticket, jira_uri, jira_pat)
57+
try:
58+
if body:
59+
return body['fields']['status']['statusCategory']['key'] # type: ignore[index] # noqa: E501
60+
else:
61+
return None
62+
except (KeyError, TypeError): # no .fields.status.statusCategory.key
63+
return None
64+
65+
66+
def check_ticket_status_category(
67+
ticket_status_category: str | None,
68+
allowed: Set[str],
69+
disallowed: Set[str],
70+
) -> bool:
71+
if not ticket_status_category:
72+
return False
73+
if ticket_status_category in allowed \
74+
or ticket_status_category not in disallowed:
75+
return True
76+
return False
77+
78+
79+
def main(argv: Sequence[str] | None = None) -> int:
80+
parser = argparse.ArgumentParser()
81+
parser.add_argument('commit_msg', help='Filename of commit message')
82+
parser.add_argument(
83+
'-l', '--lenient', action='store_true',
84+
help='If set, and no JIRA URI is present,'
85+
' this hook defaults to a NOOP',
86+
)
87+
parser.add_argument(
88+
'-u', '--jira-uri',
89+
help='URI of JIRA instance, may be an environment variable'
90+
' (starting with "$")',
91+
)
92+
parser.add_argument(
93+
'-p', '--jira-pat',
94+
help='Personal access token (PAT) to use for JIRA authentication,'
95+
' may be an environment variable (starting with "$")',
96+
)
97+
parser.add_argument(
98+
'-i', '--allow-status-category', action='append',
99+
help='Ticket status category to be allowed,'
100+
' may be specified multiple times;'
101+
' has priority over disallowed status categories'
102+
' (default: none)',
103+
)
104+
parser.add_argument(
105+
'-e', '--disallow-status-category', action='append',
106+
help='Ticket status category to be disallowed,'
107+
' may be specified multiple times'
108+
' (default: "done")',
109+
)
110+
parser.add_argument(
111+
'-v', '--allowed-fix-version', action='append',
112+
help='Fix version to be allowed;'
113+
' not checked if none specified;'
114+
' may be specified multiple times,'
115+
' may any of them be an environment variable (starting with "$")'
116+
' (default: none)',
117+
)
118+
args = parser.parse_args(argv)
119+
default_value = '' # pragma: no mutate
120+
if args.jira_uri and args.jira_uri.startswith('$'): # pragma: no mutate
121+
args.jira_uri = environ.get(args.jira_uri[1:], default_value)
122+
if args.lenient and not (args.jira_uri and args.jira_uri.strip()):
123+
print('Lenient early exit, because no JIRA URI given')
124+
return 0
125+
126+
if args.jira_pat and args.jira_pat.startswith('$'): # pragma: no mutate
127+
args.jira_pat = environ.get(args.jira_pat[1:], default_value)
128+
if args.allowed_fix_version:
129+
resolved = []
130+
for e in args.allowed_fix_version:
131+
if e.startswith('$'): # pragma: no mutate
132+
resolved.extend(environ.get(e[1:], default_value).split(','))
133+
else:
134+
resolved.extend(e.split(','))
135+
args.allowed_fix_version = filter(None, resolved)
136+
137+
allowed = frozenset(args.allow_status_category or ())
138+
disallowed = frozenset(args.disallow_status_category or ('done',))
139+
version = frozenset(args.allowed_fix_version or ())
140+
141+
ticket = get_ticket(args.commit_msg)
142+
if not ticket:
143+
print('Could not reify ticket from commit message')
144+
return 4
145+
print(f'Checking ticket "{ticket}"')
146+
147+
if len(version) > 0:
148+
ticket_version = \
149+
get_ticket_version(ticket, args.jira_uri, args.jira_pat)
150+
if not ticket_version:
151+
print('Ticket has no fix version, but it is expected')
152+
print(
153+
'\t(allowed versions are:'
154+
f' {str(version).replace("frozenset","")})',
155+
)
156+
return 3
157+
if ticket_version not in version:
158+
print(f'Fix version of ticket ("{ticket_version}") is not allowed')
159+
print(
160+
'\t(allowed versions are:'
161+
f' {str(version).replace("frozenset","")})',
162+
)
163+
return 2
164+
print(f'Ticket fix version ("{ticket_version}") is allowed')
165+
print(
166+
'\t(allowed versions are:'
167+
f' {str(version).replace("frozenset","")})',
168+
)
169+
else:
170+
print('Ticket fix version not checked')
171+
172+
category = get_ticket_status_category(
173+
ticket, args.jira_uri, args.jira_pat,
174+
)
175+
if check_ticket_status_category(category, allowed, disallowed):
176+
print('Ticket is OK according to COJIRA rules')
177+
return 0
178+
print(f'Ticket status category ("{category}") is not allowed')
179+
print(
180+
f'\t(allowed categories are: {str(allowed).replace("frozenset","")},',
181+
)
182+
print(
183+
'\t disallowed categories are:'
184+
f' {str(disallowed).replace("frozenset","")})',
185+
)
186+
return 1
187+
188+
189+
if __name__ == '__main__':
190+
raise SystemExit(main())

setup.cfg

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ exclude =
2727

2828
[options.entry_points]
2929
console_scripts =
30+
cojira = hooks.cojira:main
3031
commiticketing = hooks.commiticketing:main
3132
no-boms = hooks.no_boms:main
3233
no-todos = hooks.no_todos:main

0 commit comments

Comments
 (0)