itw-python-builder 0.2.12__py3-none-any.whl → 0.2.14__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- itw_python_builder/task_utils.py +154 -44
- itw_python_builder/tasks.py +71 -13
- itw_python_builder/templates/task_template.md +9 -8
- itw_python_builder/utils.py +45 -0
- itw_python_builder-0.2.14.dist-info/METADATA +338 -0
- {itw_python_builder-0.2.12.dist-info → itw_python_builder-0.2.14.dist-info}/RECORD +10 -10
- {itw_python_builder-0.2.12.dist-info → itw_python_builder-0.2.14.dist-info}/WHEEL +1 -1
- itw_python_builder-0.2.12.dist-info/METADATA +0 -166
- {itw_python_builder-0.2.12.dist-info → itw_python_builder-0.2.14.dist-info}/entry_points.txt +0 -0
- {itw_python_builder-0.2.12.dist-info → itw_python_builder-0.2.14.dist-info}/licenses/LICENSE +0 -0
- {itw_python_builder-0.2.12.dist-info → itw_python_builder-0.2.14.dist-info}/top_level.txt +0 -0
itw_python_builder/task_utils.py
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import re
|
|
2
|
+
from difflib import SequenceMatcher
|
|
2
3
|
|
|
3
4
|
from itw_python_builder.utils import _gitlab_api_call
|
|
4
5
|
|
|
6
|
+
SIMILARITY_THRESHOLD = 0.95
|
|
7
|
+
|
|
8
|
+
DATE_PLACEHOLDER = 'YYYY-MM-DD'
|
|
9
|
+
|
|
10
|
+
DEFAULT_LABEL_COLOR = '#428BCA'
|
|
5
11
|
|
|
6
12
|
DEFAULT_ACCEPTANCE_CRITERIA = (
|
|
7
13
|
'- [ ] U kryen testet manuale në ndërfaqe\n'
|
|
@@ -10,7 +16,6 @@ DEFAULT_ACCEPTANCE_CRITERIA = (
|
|
|
10
16
|
'- [ ] U kontrollua cilësia e kodit'
|
|
11
17
|
)
|
|
12
18
|
|
|
13
|
-
|
|
14
19
|
_RE_AC_HEADER = re.compile(r'(?im)^##\s*acceptance\s+criteria\s*$')
|
|
15
20
|
_RE_COMMENT_HEADER = re.compile(r'(?im)^##\s*comment\s*$')
|
|
16
21
|
_RE_AC_BLOCK = re.compile(
|
|
@@ -20,9 +25,9 @@ _RE_COMMENT_BLOCK = re.compile(
|
|
|
20
25
|
r'(?ims)^##\s*comment\s*$(.*?)^##\s*comment\s*$'
|
|
21
26
|
)
|
|
22
27
|
|
|
23
|
-
|
|
24
28
|
_COLOR_GREEN = '\033[92m'
|
|
25
29
|
_COLOR_RED = '\033[91m'
|
|
30
|
+
_COLOR_WHITE = '\033[97m'
|
|
26
31
|
_COLOR_RESET = '\033[0m'
|
|
27
32
|
|
|
28
33
|
|
|
@@ -34,10 +39,37 @@ def log_red(msg: str) -> None:
|
|
|
34
39
|
print(f'{_COLOR_RED}{msg}{_COLOR_RESET}')
|
|
35
40
|
|
|
36
41
|
|
|
42
|
+
def log_white(msg: str) -> None:
|
|
43
|
+
print(f'{_COLOR_WHITE}{msg}{_COLOR_RESET}')
|
|
44
|
+
|
|
45
|
+
|
|
37
46
|
def log_info(msg: str) -> None:
|
|
38
47
|
print(msg)
|
|
39
48
|
|
|
40
49
|
|
|
50
|
+
def _normalize_title(value: str) -> str:
|
|
51
|
+
return ' '.join((value or '').lower().split())
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def is_similar(left: str, right: str, threshold: float = SIMILARITY_THRESHOLD) -> bool:
|
|
55
|
+
"""True when two titles are at least `threshold` similar (default 95%)."""
|
|
56
|
+
a = _normalize_title(left)
|
|
57
|
+
b = _normalize_title(right)
|
|
58
|
+
if not a or not b:
|
|
59
|
+
return False
|
|
60
|
+
if a == b:
|
|
61
|
+
return True
|
|
62
|
+
return SequenceMatcher(None, a, b).ratio() >= threshold
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def find_similar(value: str, items: list, key: str = 'title', threshold: float = SIMILARITY_THRESHOLD):
|
|
66
|
+
"""Return the first item whose `key` is ~threshold similar to `value`, else None."""
|
|
67
|
+
for item in items:
|
|
68
|
+
if is_similar(value, item.get(key, ''), threshold):
|
|
69
|
+
return item
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
|
|
41
73
|
def _list_group_projects(api_host: str, group_path: str, token: str) -> list:
|
|
42
74
|
from urllib.parse import quote
|
|
43
75
|
encoded = quote(group_path, safe='')
|
|
@@ -87,26 +119,30 @@ def get_project_by_path(api_host: str, project_path: str, token: str) -> dict:
|
|
|
87
119
|
|
|
88
120
|
|
|
89
121
|
def find_project_milestone(api_host: str, project_id: int, title: str, token: str):
|
|
90
|
-
from urllib.parse import quote
|
|
91
|
-
encoded = quote(title, safe='')
|
|
92
122
|
results = _gitlab_api_call(
|
|
93
123
|
api_host,
|
|
94
|
-
f'/projects/{project_id}/milestones?
|
|
124
|
+
f'/projects/{project_id}/milestones?per_page=100',
|
|
95
125
|
token,
|
|
96
126
|
)
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
127
|
+
return find_similar(title, results)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def find_project_issue(api_host: str, project_id: int, title: str, token: str, milestone: str = None):
|
|
131
|
+
"""Look for a ~matching title within this milestone ('None' = the no-milestone bucket)."""
|
|
132
|
+
from urllib.parse import quote
|
|
133
|
+
endpoint = f'/projects/{project_id}/issues?per_page=100'
|
|
134
|
+
endpoint += f'&milestone={quote(milestone, safe="") if milestone else "None"}'
|
|
135
|
+
results = _gitlab_api_call(api_host, endpoint, token)
|
|
136
|
+
return find_similar(title, results)
|
|
101
137
|
|
|
102
138
|
|
|
103
139
|
def create_project_milestone(
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
140
|
+
api_host: str,
|
|
141
|
+
project_id: int,
|
|
142
|
+
title: str,
|
|
143
|
+
start_date: str = None,
|
|
144
|
+
due_date: str = None,
|
|
145
|
+
token: str = None,
|
|
110
146
|
) -> dict:
|
|
111
147
|
payload = {'title': title}
|
|
112
148
|
if start_date:
|
|
@@ -122,6 +158,20 @@ def create_project_milestone(
|
|
|
122
158
|
)
|
|
123
159
|
|
|
124
160
|
|
|
161
|
+
def list_project_labels(api_host: str, project_id: int, token: str) -> list:
|
|
162
|
+
return _gitlab_api_call(api_host, f'/projects/{project_id}/labels?per_page=100', token)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def create_project_label(api_host: str, project_id: int, name: str, token: str) -> dict:
|
|
166
|
+
return _gitlab_api_call(
|
|
167
|
+
api_host,
|
|
168
|
+
f'/projects/{project_id}/labels',
|
|
169
|
+
token,
|
|
170
|
+
method='POST',
|
|
171
|
+
payload={'name': name, 'color': DEFAULT_LABEL_COLOR},
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
125
175
|
def create_issue_note(api_host: str, project_id: int, issue_iid: int, body: str, token: str) -> dict:
|
|
126
176
|
return _gitlab_api_call(
|
|
127
177
|
api_host,
|
|
@@ -132,6 +182,21 @@ def create_issue_note(api_host: str, project_id: int, issue_iid: int, body: str,
|
|
|
132
182
|
)
|
|
133
183
|
|
|
134
184
|
|
|
185
|
+
def _normalize_repo(raw: str) -> str:
|
|
186
|
+
value = (raw or '').strip()
|
|
187
|
+
if not value:
|
|
188
|
+
return ''
|
|
189
|
+
m = re.match(r'^[a-z][a-z0-9+.-]*://[^/]+/(.*)$', value, re.IGNORECASE)
|
|
190
|
+
if m:
|
|
191
|
+
value = m.group(1)
|
|
192
|
+
else:
|
|
193
|
+
m = re.match(r'^[^/\s]+@[^:/\s]+:(.*)$', value)
|
|
194
|
+
if m:
|
|
195
|
+
value = m.group(1)
|
|
196
|
+
value = re.sub(r'\.git$', '', value.strip(), flags=re.IGNORECASE)
|
|
197
|
+
return value.strip('/')
|
|
198
|
+
|
|
199
|
+
|
|
135
200
|
def _strip_milestone_wrap(raw: str) -> str:
|
|
136
201
|
raw = raw.strip()
|
|
137
202
|
if raw.startswith('%"') and raw.endswith('"'):
|
|
@@ -143,6 +208,30 @@ def _strip_milestone_wrap(raw: str) -> str:
|
|
|
143
208
|
|
|
144
209
|
_CRED_KEYS = ('glab_host', 'glab_username', 'glab_token')
|
|
145
210
|
|
|
211
|
+
_RE_MARKER_COMMENT = re.compile(r'^<!--.*-->$')
|
|
212
|
+
_RE_EMPTY_CHECKBOX = re.compile(r'^[-*]\s*\[[ xX]?\]\s*$')
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _block_has_content(text: str) -> bool:
|
|
216
|
+
"""False when a section holds only blank lines or empty checklist placeholders."""
|
|
217
|
+
for line in (text or '').splitlines():
|
|
218
|
+
stripped = line.strip()
|
|
219
|
+
if not stripped or _RE_EMPTY_CHECKBOX.match(stripped):
|
|
220
|
+
continue
|
|
221
|
+
return True
|
|
222
|
+
return False
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _match_directive(line: str, name: str):
|
|
226
|
+
"""None when the line is not this directive, '' when it carries no value."""
|
|
227
|
+
m = re.match(rf'^/{name}(?![\w-])\s*(.*)$', line, re.IGNORECASE)
|
|
228
|
+
if not m:
|
|
229
|
+
return None
|
|
230
|
+
value = m.group(1).strip()
|
|
231
|
+
if value.upper() == DATE_PLACEHOLDER:
|
|
232
|
+
return ''
|
|
233
|
+
return value
|
|
234
|
+
|
|
146
235
|
|
|
147
236
|
def extract_glab_credentials(file_path: str) -> dict:
|
|
148
237
|
"""Scan md file for /glab_host, /glab_username, /glab_token top-level directives."""
|
|
@@ -152,9 +241,9 @@ def extract_glab_credentials(file_path: str) -> dict:
|
|
|
152
241
|
for line in content.splitlines():
|
|
153
242
|
stripped = line.strip()
|
|
154
243
|
for key in _CRED_KEYS:
|
|
155
|
-
|
|
156
|
-
if
|
|
157
|
-
creds[key] =
|
|
244
|
+
value = _match_directive(stripped, key)
|
|
245
|
+
if value is not None:
|
|
246
|
+
creds[key] = value or None
|
|
158
247
|
break
|
|
159
248
|
return creds
|
|
160
249
|
|
|
@@ -162,7 +251,7 @@ def extract_glab_credentials(file_path: str) -> dict:
|
|
|
162
251
|
def strip_glab_credentials_lines(content: str) -> str:
|
|
163
252
|
"""Remove /glab_* lines so they don't leak into issue bodies."""
|
|
164
253
|
pattern = re.compile(
|
|
165
|
-
rf'(?im)
|
|
254
|
+
rf'(?im)^[ \t]*/(?:{"|".join(_CRED_KEYS)})(?![\w-])[^\n]*(?:\n|$)'
|
|
166
255
|
)
|
|
167
256
|
return pattern.sub('', content)
|
|
168
257
|
|
|
@@ -204,14 +293,16 @@ def _parse_single_issue(block: str, idx: int, file_path: str) -> dict:
|
|
|
204
293
|
if len(cm_headers) >= 2:
|
|
205
294
|
m = _RE_COMMENT_BLOCK.search(remaining)
|
|
206
295
|
if m:
|
|
207
|
-
|
|
296
|
+
if _block_has_content(m.group(1)):
|
|
297
|
+
comment_text = m.group(1).strip()
|
|
208
298
|
remaining = _RE_COMMENT_BLOCK.sub('', remaining, count=1)
|
|
209
299
|
|
|
210
300
|
acceptance_text = None
|
|
211
301
|
if len(ac_headers) >= 2:
|
|
212
302
|
m = _RE_AC_BLOCK.search(remaining)
|
|
213
303
|
if m:
|
|
214
|
-
|
|
304
|
+
if _block_has_content(m.group(1)):
|
|
305
|
+
acceptance_text = m.group(1).strip()
|
|
215
306
|
remaining = _RE_AC_BLOCK.sub('', remaining, count=1)
|
|
216
307
|
|
|
217
308
|
title = None
|
|
@@ -220,6 +311,7 @@ def _parse_single_issue(block: str, idx: int, file_path: str) -> dict:
|
|
|
220
311
|
milestone_start = None
|
|
221
312
|
milestone_end = None
|
|
222
313
|
assignee = None
|
|
314
|
+
labels = []
|
|
223
315
|
has_estimate = False
|
|
224
316
|
has_due = False
|
|
225
317
|
body_lines = []
|
|
@@ -227,35 +319,52 @@ def _parse_single_issue(block: str, idx: int, file_path: str) -> dict:
|
|
|
227
319
|
for line in remaining.splitlines():
|
|
228
320
|
stripped = line.strip()
|
|
229
321
|
|
|
230
|
-
|
|
231
|
-
if m:
|
|
232
|
-
title = m.group(1).strip()
|
|
322
|
+
if _RE_MARKER_COMMENT.match(stripped):
|
|
233
323
|
continue
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
324
|
+
|
|
325
|
+
value = _match_directive(stripped, 'title')
|
|
326
|
+
if value is not None:
|
|
327
|
+
title = value or None
|
|
237
328
|
continue
|
|
238
|
-
|
|
239
|
-
if
|
|
240
|
-
|
|
329
|
+
value = _match_directive(stripped, 'repo')
|
|
330
|
+
if value is not None:
|
|
331
|
+
repo = _normalize_repo(value) or None
|
|
241
332
|
continue
|
|
242
|
-
|
|
243
|
-
if
|
|
244
|
-
|
|
333
|
+
value = _match_directive(stripped, 'milestone-start')
|
|
334
|
+
if value is not None:
|
|
335
|
+
milestone_start = value or None
|
|
245
336
|
continue
|
|
246
|
-
|
|
247
|
-
if
|
|
248
|
-
|
|
337
|
+
value = _match_directive(stripped, 'milestone-end')
|
|
338
|
+
if value is not None:
|
|
339
|
+
milestone_end = value or None
|
|
249
340
|
continue
|
|
250
|
-
|
|
251
|
-
if
|
|
252
|
-
|
|
253
|
-
|
|
341
|
+
value = _match_directive(stripped, 'milestone')
|
|
342
|
+
if value is not None:
|
|
343
|
+
milestone = _strip_milestone_wrap(value) or None
|
|
344
|
+
continue
|
|
345
|
+
value = _match_directive(stripped, 'label')
|
|
346
|
+
if value is not None:
|
|
347
|
+
labels = [p.strip().lstrip('~').strip('"') for p in value.split(',') if p.strip()]
|
|
348
|
+
labels = [p for p in labels if p]
|
|
349
|
+
continue
|
|
350
|
+
value = _match_directive(stripped, 'assignee')
|
|
351
|
+
if value is not None:
|
|
352
|
+
if value:
|
|
353
|
+
assignee = value
|
|
354
|
+
body_lines.append(f'/assign {assignee}')
|
|
355
|
+
continue
|
|
356
|
+
value = _match_directive(stripped, 'estimate')
|
|
357
|
+
if value is not None:
|
|
358
|
+
if value:
|
|
359
|
+
has_estimate = True
|
|
360
|
+
body_lines.append(f'/estimate {value}')
|
|
361
|
+
continue
|
|
362
|
+
value = _match_directive(stripped, 'due')
|
|
363
|
+
if value is not None:
|
|
364
|
+
if value:
|
|
365
|
+
has_due = True
|
|
366
|
+
body_lines.append(f'/due {value}')
|
|
254
367
|
continue
|
|
255
|
-
if re.match(r'^/estimate\s+\S+', stripped, re.IGNORECASE):
|
|
256
|
-
has_estimate = True
|
|
257
|
-
if re.match(r'^/due\s+\S+', stripped, re.IGNORECASE):
|
|
258
|
-
has_due = True
|
|
259
368
|
|
|
260
369
|
body_lines.append(line)
|
|
261
370
|
|
|
@@ -273,6 +382,7 @@ def _parse_single_issue(block: str, idx: int, file_path: str) -> dict:
|
|
|
273
382
|
'milestone_start': milestone_start,
|
|
274
383
|
'milestone_end': milestone_end,
|
|
275
384
|
'assignee': assignee,
|
|
385
|
+
'labels': labels,
|
|
276
386
|
'has_estimate': has_estimate,
|
|
277
387
|
'has_due': has_due,
|
|
278
388
|
'has_acceptance_criteria': acceptance_text is not None,
|
itw_python_builder/tasks.py
CHANGED
|
@@ -29,6 +29,8 @@ from itw_python_builder.utils import (
|
|
|
29
29
|
TOKEN_CACHE_PATH,
|
|
30
30
|
maybe_override_release_version,
|
|
31
31
|
ensure_gitlab_token,
|
|
32
|
+
tag_exists,
|
|
33
|
+
latest_staging_rc,
|
|
32
34
|
)
|
|
33
35
|
from itw_python_builder.task_utils import (
|
|
34
36
|
resolve_pm_repo,
|
|
@@ -38,11 +40,16 @@ from itw_python_builder.task_utils import (
|
|
|
38
40
|
build_issue_description,
|
|
39
41
|
get_project_by_path,
|
|
40
42
|
find_project_milestone,
|
|
43
|
+
find_project_issue,
|
|
41
44
|
create_project_milestone,
|
|
45
|
+
list_project_labels,
|
|
46
|
+
create_project_label,
|
|
42
47
|
create_issue_note,
|
|
43
48
|
log_green,
|
|
44
49
|
log_red,
|
|
45
50
|
log_info,
|
|
51
|
+
log_white,
|
|
52
|
+
find_similar,
|
|
46
53
|
)
|
|
47
54
|
from itw_python_builder.version import Version
|
|
48
55
|
|
|
@@ -201,12 +208,27 @@ def create_task(ctx: Context, file=None):
|
|
|
201
208
|
if not issue['has_due']:
|
|
202
209
|
log_red('No due date is defined for this issue. Use /due in you md file to define a due date')
|
|
203
210
|
|
|
204
|
-
milestone_id = _resolve_milestone(api_host, project['id'], issue, token)
|
|
211
|
+
milestone_id, milestone_title = _resolve_milestone(api_host, project['id'], issue, token)
|
|
212
|
+
|
|
213
|
+
duplicate = find_project_issue(
|
|
214
|
+
api_host, project['id'], issue['title'], token, milestone_title
|
|
215
|
+
)
|
|
216
|
+
if duplicate:
|
|
217
|
+
scope = f'milestone {milestone_title}' if milestone_title else 'issues without a milestone'
|
|
218
|
+
log_white(
|
|
219
|
+
f'Issue "{issue["title"]}" was not created because it already exists '
|
|
220
|
+
f'in this project for {scope}: {duplicate["web_url"]}'
|
|
221
|
+
)
|
|
222
|
+
continue
|
|
223
|
+
|
|
224
|
+
labels = _resolve_labels(api_host, project['id'], issue, token)
|
|
205
225
|
|
|
206
226
|
description = build_issue_description(issue)
|
|
207
227
|
payload = {'title': issue['title'], 'description': description}
|
|
208
228
|
if milestone_id is not None:
|
|
209
229
|
payload['milestone_id'] = milestone_id
|
|
230
|
+
if labels:
|
|
231
|
+
payload['labels'] = ','.join(labels)
|
|
210
232
|
|
|
211
233
|
result = create_gitlab_issue(api_host, project['id'], payload, token)
|
|
212
234
|
print(f'[itw] Created: {result["web_url"]}')
|
|
@@ -264,6 +286,24 @@ def _resolve_target_project(api_host: str, project_path: str, issue: dict, token
|
|
|
264
286
|
print('Invalid choice, try again.')
|
|
265
287
|
|
|
266
288
|
|
|
289
|
+
def _resolve_labels(api_host: str, project_id: int, issue: dict, token: str) -> list:
|
|
290
|
+
if not issue['labels']:
|
|
291
|
+
return []
|
|
292
|
+
existing = list_project_labels(api_host, project_id, token)
|
|
293
|
+
resolved = []
|
|
294
|
+
for name in issue['labels']:
|
|
295
|
+
match = find_similar(name, existing, key='name')
|
|
296
|
+
if match:
|
|
297
|
+
log_white(f'Using existing label {match["name"]}')
|
|
298
|
+
resolved.append(match['name'])
|
|
299
|
+
continue
|
|
300
|
+
created = create_project_label(api_host, project_id, name, token)
|
|
301
|
+
log_green(f'Created new label {created["name"]}')
|
|
302
|
+
resolved.append(created['name'])
|
|
303
|
+
existing.append(created)
|
|
304
|
+
return resolved
|
|
305
|
+
|
|
306
|
+
|
|
267
307
|
def _resolve_milestone(api_host: str, project_id: int, issue: dict, token: str):
|
|
268
308
|
title = issue['milestone']
|
|
269
309
|
if not title:
|
|
@@ -271,12 +311,12 @@ def _resolve_milestone(api_host: str, project_id: int, issue: dict, token: str):
|
|
|
271
311
|
'There is no milestone defined for this issue. '
|
|
272
312
|
'Use /milestone in your md file to define a milestone'
|
|
273
313
|
)
|
|
274
|
-
return None
|
|
314
|
+
return None, None
|
|
275
315
|
|
|
276
316
|
existing = find_project_milestone(api_host, project_id, title, token)
|
|
277
317
|
if existing:
|
|
278
|
-
log_info(f'Using existing milestone {title}')
|
|
279
|
-
return existing['id']
|
|
318
|
+
log_info(f'Using existing milestone {existing["title"]}')
|
|
319
|
+
return existing['id'], existing['title']
|
|
280
320
|
|
|
281
321
|
start = issue['milestone_start']
|
|
282
322
|
end = issue['milestone_end']
|
|
@@ -289,7 +329,7 @@ def _resolve_milestone(api_host: str, project_id: int, issue: dict, token: str):
|
|
|
289
329
|
f'Created new milestone {title} | No start date or end date is defined for this '
|
|
290
330
|
'new milestone. Use /milestone-start and /milestone-end in your md file to define them '
|
|
291
331
|
)
|
|
292
|
-
return created['id']
|
|
332
|
+
return created['id'], created['title']
|
|
293
333
|
|
|
294
334
|
|
|
295
335
|
def build_frontend(ctx: Context, branch: str, ssr: bool = False) -> None:
|
|
@@ -415,9 +455,11 @@ def push(ctx: Context):
|
|
|
415
455
|
|
|
416
456
|
|
|
417
457
|
def buildimage(ctx: Context):
|
|
458
|
+
token = load_cached_token()
|
|
459
|
+
username = get_gitlab_username(ctx)
|
|
418
460
|
current_branch = get_current_branch(ctx)
|
|
419
461
|
container_registry_path = generate_image_path(ctx, current_branch)
|
|
420
|
-
ctx.run(f'podman build
|
|
462
|
+
ctx.run(f'podman build --build-arg GITLAB_USERNAME={username} --build-arg GITLAB_TOKEN={token} --tag={container_registry_path} .')
|
|
421
463
|
|
|
422
464
|
|
|
423
465
|
def pushimage(ctx: Context):
|
|
@@ -461,16 +503,24 @@ def tag_build_push(ctx: Context, version: Version, skip_pipeline: bool = False,
|
|
|
461
503
|
|
|
462
504
|
@task(name='tag-init')
|
|
463
505
|
def taginit(ctx: Context) -> None:
|
|
464
|
-
"""Initialize version tagging"""
|
|
465
|
-
check_branch(ctx)
|
|
466
|
-
|
|
506
|
+
"""Initialize version tagging — release candidate on staging, release on master."""
|
|
507
|
+
production = check_branch(ctx)
|
|
508
|
+
if production:
|
|
509
|
+
version = latest_staging_rc(ctx)
|
|
510
|
+
log_info(f'Latest release candidate on staging: {version}')
|
|
511
|
+
version.reset_release_candidate(release=True)
|
|
512
|
+
else:
|
|
513
|
+
version = Version(0, 0, 1, 1)
|
|
514
|
+
if tag_exists(ctx, version):
|
|
515
|
+
raise RuntimeError(f'Tag {version} already exists — nothing to initialize.')
|
|
467
516
|
tag(ctx, version)
|
|
468
517
|
save_version(version)
|
|
518
|
+
log_green(f'Initialized {version} on {get_current_branch(ctx)}')
|
|
469
519
|
|
|
470
520
|
|
|
471
521
|
def _ensure_unique_tag(ctx: Context, version: Version) -> None:
|
|
472
522
|
"""If the tag already exists, auto-increment RC (RC tags) or patch (release tags)."""
|
|
473
|
-
while ctx
|
|
523
|
+
while tag_exists(ctx, version):
|
|
474
524
|
if version.release_candidate == 0:
|
|
475
525
|
print(f"[itw] Tag {version} already exists, auto-incrementing patch...")
|
|
476
526
|
version.increment_patch(release=True)
|
|
@@ -743,10 +793,20 @@ def pipelinelocal(ctx: Context, settings='backend.test_settings', pylintrc=None)
|
|
|
743
793
|
print("=" * 60)
|
|
744
794
|
|
|
745
795
|
|
|
796
|
+
def _ensure_changelog() -> None:
|
|
797
|
+
if os.path.exists('CHANGELOG.md'):
|
|
798
|
+
return
|
|
799
|
+
with open('CHANGELOG.md', 'w') as f:
|
|
800
|
+
f.write('# Changelog\n')
|
|
801
|
+
log_green('Created CHANGELOG.md')
|
|
802
|
+
|
|
803
|
+
|
|
746
804
|
@task
|
|
747
805
|
def changelog(ctx: Context, version: Version = None):
|
|
748
806
|
"""Generate changelog from commits with Changelog trailer"""
|
|
749
807
|
|
|
808
|
+
_ensure_changelog()
|
|
809
|
+
|
|
750
810
|
exclude = f'--exclude={version}' if version else ''
|
|
751
811
|
last_tag_result = ctx.run(f'git describe --tags --abbrev=0 {exclude}', hide=True, warn=True)
|
|
752
812
|
if last_tag_result.ok and last_tag_result.stdout.strip():
|
|
@@ -831,9 +891,7 @@ def changelog(ctx: Context, version: Version = None):
|
|
|
831
891
|
changelog_entry += f"{commit}\n"
|
|
832
892
|
changelog_entry += "\n"
|
|
833
893
|
|
|
834
|
-
|
|
835
|
-
with open('CHANGELOG.md', 'w') as f:
|
|
836
|
-
f.write('# Changelog\n')
|
|
894
|
+
_ensure_changelog()
|
|
837
895
|
|
|
838
896
|
with open('CHANGELOG.md', 'r') as f:
|
|
839
897
|
existing = f.read()
|
|
@@ -1,18 +1,19 @@
|
|
|
1
1
|
<!-- OPTIONAL -->
|
|
2
|
-
/glab_host
|
|
3
|
-
/glab_username
|
|
4
|
-
/glab_token
|
|
2
|
+
/glab_host
|
|
3
|
+
/glab_username
|
|
4
|
+
/glab_token
|
|
5
5
|
|
|
6
6
|
<!-- REQUIRED -->
|
|
7
|
-
/title
|
|
7
|
+
/title
|
|
8
8
|
|
|
9
9
|
<!-- OPTIONAL -->
|
|
10
|
-
/repo
|
|
11
|
-
/milestone
|
|
10
|
+
/repo
|
|
11
|
+
/milestone
|
|
12
12
|
/milestone-start YYYY-MM-DD
|
|
13
13
|
/milestone-end YYYY-MM-DD
|
|
14
|
-
/assignee
|
|
15
|
-
/
|
|
14
|
+
/assignee
|
|
15
|
+
/label
|
|
16
|
+
/estimate
|
|
16
17
|
/due YYYY-MM-DD
|
|
17
18
|
|
|
18
19
|
<!-- OPTIONAL -->
|
itw_python_builder/utils.py
CHANGED
|
@@ -254,6 +254,51 @@ def get_latest_tag(ctx: Context) -> Version:
|
|
|
254
254
|
return Version.parse(tags[0])
|
|
255
255
|
|
|
256
256
|
|
|
257
|
+
STAGING_REFS = ('staging', 'origin/staging')
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def ref_exists(ctx: Context, ref: str) -> bool:
|
|
261
|
+
return ctx.run(f'git rev-parse -q --verify "{ref}"', warn=True, hide=True).ok
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def tag_exists(ctx: Context, version: Version) -> bool:
|
|
265
|
+
return ctx.run(f'git rev-parse -q --verify "refs/tags/{version}"', warn=True, hide=True).ok
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def list_rc_tags(ctx: Context, ref: str) -> list:
|
|
269
|
+
result = ctx.run(f'git tag --list "v.*-rc*" --merged {ref}', hide=True)
|
|
270
|
+
versions = []
|
|
271
|
+
for line in result.stdout.splitlines():
|
|
272
|
+
line = line.strip()
|
|
273
|
+
if not line:
|
|
274
|
+
continue
|
|
275
|
+
try:
|
|
276
|
+
versions.append(Version.parse(line))
|
|
277
|
+
except ValueError:
|
|
278
|
+
continue
|
|
279
|
+
return versions
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def latest_staging_rc(ctx: Context) -> Version:
|
|
283
|
+
"""Highest release candidate reachable from staging."""
|
|
284
|
+
ctx.run('git fetch origin --tags', warn=True, hide=True)
|
|
285
|
+
refs = [ref for ref in STAGING_REFS if ref_exists(ctx, ref)]
|
|
286
|
+
if not refs:
|
|
287
|
+
raise RuntimeError(
|
|
288
|
+
'No staging branch found locally or on origin, so there is no release '
|
|
289
|
+
'candidate to promote. Run `itw tag-init` on staging first.'
|
|
290
|
+
)
|
|
291
|
+
versions = []
|
|
292
|
+
for ref in refs:
|
|
293
|
+
versions.extend(list_rc_tags(ctx, ref))
|
|
294
|
+
if not versions:
|
|
295
|
+
raise RuntimeError(
|
|
296
|
+
f'No release candidate tag found on {" or ".join(refs)}. '
|
|
297
|
+
'Run `itw tag-init` on staging first.'
|
|
298
|
+
)
|
|
299
|
+
return max(versions, key=lambda v: (v.major, v.minor, v.patch, v.release_candidate))
|
|
300
|
+
|
|
301
|
+
|
|
257
302
|
def _update_package_json_version(version: Version) -> None:
|
|
258
303
|
"""Update the top-level `version` in package.json (preserves key order)."""
|
|
259
304
|
pkg_path = os.path.join(os.getcwd(), 'package.json')
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: itw_python_builder
|
|
3
|
+
Version: 0.2.14
|
|
4
|
+
Summary: Standardized Django deployment pipeline with Docker, testing, and SonarQube integration
|
|
5
|
+
Author-email: IT-Works <contact@it-works.io>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://git.it-works.io/
|
|
8
|
+
Project-URL: Repository, https://git.it-works.io/
|
|
9
|
+
Project-URL: Issues, https://git.it-works.io/
|
|
10
|
+
Keywords: django,deployment,docker,ci-cd,sonarqube
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Software Development :: Build Tools
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Requires-Dist: invoke>=2.0.0
|
|
24
|
+
Requires-Dist: pylint>=3.0.0
|
|
25
|
+
Requires-Dist: pylint-django>=2.5.0
|
|
26
|
+
Requires-Dist: python-decouple>=3.8
|
|
27
|
+
Requires-Dist: requests>=2.28.0
|
|
28
|
+
Dynamic: license-file
|
|
29
|
+
|
|
30
|
+
# ITW Python Builder
|
|
31
|
+
|
|
32
|
+
Standardized Django and Angular deployment pipeline with Docker, testing, SonarQube integration, automatic changelog generation, GitLab issue creation, and code quality enforcement.
|
|
33
|
+
|
|
34
|
+
## Features
|
|
35
|
+
|
|
36
|
+
- Automated deployment with semantic versioning
|
|
37
|
+
- Docker/Podman-based build and push pipeline (backend)
|
|
38
|
+
- Angular build, packaging and upload to the GitLab Package Registry (frontend)
|
|
39
|
+
- Angular SSR scaffolding and SSR builds
|
|
40
|
+
- Automated testing with coverage
|
|
41
|
+
- SonarQube static code analysis
|
|
42
|
+
- Pylint linting with SonarQube integration
|
|
43
|
+
- Quality gates - deploy only when tests pass
|
|
44
|
+
- Automatic changelog generation from commit trailers
|
|
45
|
+
- Automatic `CHANGELOG.md` creation and propagation to `staging`/`develop`
|
|
46
|
+
- GitLab issue creation from a markdown file
|
|
47
|
+
- Cached GitLab authentication
|
|
48
|
+
- Support for local and production pipelines
|
|
49
|
+
- Global CLI tool
|
|
50
|
+
- Automatic venv detection per project
|
|
51
|
+
|
|
52
|
+
## Installation
|
|
53
|
+
```bash
|
|
54
|
+
pip install itw-python-builder
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Install globally (outside any project venv). The `itw` command becomes available system-wide.
|
|
58
|
+
|
|
59
|
+
Check the installed version:
|
|
60
|
+
```bash
|
|
61
|
+
itw --version
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Quick Start
|
|
65
|
+
|
|
66
|
+
1. Navigate to your project directory (Django projects must have a `.venv` or `venv`):
|
|
67
|
+
|
|
68
|
+
2. Authenticate with GitLab:
|
|
69
|
+
```bash
|
|
70
|
+
itw login
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
3. Initialize versioning — run it on `staging` first, then on `master`:
|
|
74
|
+
```bash
|
|
75
|
+
git checkout staging && itw tag-init # creates v.0.0.1-rc1
|
|
76
|
+
git checkout master && itw tag-init # promotes the latest staging RC to -release
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
4. Run local pipeline:
|
|
80
|
+
```bash
|
|
81
|
+
itw pipelinelocal
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
5. Deploy to staging:
|
|
85
|
+
```bash
|
|
86
|
+
itw incrementrc
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
6. Deploy to production:
|
|
90
|
+
```bash
|
|
91
|
+
itw incrementpatch
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Available Commands
|
|
95
|
+
|
|
96
|
+
### Deployment Commands
|
|
97
|
+
|
|
98
|
+
- `itw incrementpatch` — Increment patch version and deploy
|
|
99
|
+
- `itw incrementminor` — Increment minor version and deploy
|
|
100
|
+
- `itw incrementmajor` — Increment major version and deploy
|
|
101
|
+
- `itw incrementrc` — Increment release candidate (staging)
|
|
102
|
+
- `itw release` — Promote RC to stable release (master)
|
|
103
|
+
|
|
104
|
+
### Authentication Commands
|
|
105
|
+
|
|
106
|
+
- `itw login` — Capture and cache a GitLab token (backend also logs into the container registry)
|
|
107
|
+
- `itw logout` — Forget the cached token and log out of the container registry
|
|
108
|
+
|
|
109
|
+
### GitLab Issue Commands
|
|
110
|
+
|
|
111
|
+
- `itw task-init` — Create a `TASK.md` template in the current directory
|
|
112
|
+
- `itw task --file=TASK.md` — Create GitLab issues from a markdown file
|
|
113
|
+
|
|
114
|
+
### Local Development Commands
|
|
115
|
+
|
|
116
|
+
- `itw pipelinelocal` — Run full local pipeline (lint → test → analyze → build)
|
|
117
|
+
- `itw lintlocal` — Run pylint with human-readable output
|
|
118
|
+
- `itw lint` — Run pylint and generate SonarQube report files
|
|
119
|
+
- `itw buildlocal` — Build Docker image locally
|
|
120
|
+
- `itw test` — Run tests with coverage
|
|
121
|
+
- `itw analyze` — Run SonarQube analysis
|
|
122
|
+
- `itw changelog` — Generate changelog manually
|
|
123
|
+
- `itw tag-init` — Initialize version tagging (see below)
|
|
124
|
+
- `itw ssr-init` — Scaffold Angular SSR (one-time, frontend only)
|
|
125
|
+
|
|
126
|
+
### Version Initialization
|
|
127
|
+
|
|
128
|
+
`itw tag-init` is branch-aware and must be run from `staging` or `master`:
|
|
129
|
+
|
|
130
|
+
- On **staging** it creates the first release candidate, `v.0.0.1-rc1`.
|
|
131
|
+
- On **master** it reads the latest `-rc` tag reachable from `staging` (local or `origin/staging`, after fetching tags) and creates that same version as a release. For example `v.0.0.2-rc10` on staging becomes `v.0.0.2-release` on master.
|
|
132
|
+
|
|
133
|
+
The `VERSION` file is written with the tag that was created. The command stops with a clear error when there is no staging branch, no release candidate to promote, or when the tag already exists.
|
|
134
|
+
|
|
135
|
+
### Skip Pipeline
|
|
136
|
+
```bash
|
|
137
|
+
itw incrementpatch --skip-pipeline
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
### Run a Subset of Tests
|
|
141
|
+
```bash
|
|
142
|
+
# Django: an app or a single test module
|
|
143
|
+
itw test --target=users
|
|
144
|
+
itw test --target=users.test_views
|
|
145
|
+
|
|
146
|
+
# Angular: a directory or a spec name
|
|
147
|
+
itw test --target=core
|
|
148
|
+
itw test --target=core.service
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
### SSR Builds
|
|
152
|
+
|
|
153
|
+
Frontend projects scaffolded with `itw ssr-init` build through the SSR npm scripts when `--ssr` is passed:
|
|
154
|
+
```bash
|
|
155
|
+
itw incrementrc --ssr
|
|
156
|
+
itw incrementpatch --ssr
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
## Virtual Environment Detection
|
|
160
|
+
|
|
161
|
+
`itw` is installed globally but automatically detects the project's virtual environment (`.venv` or `venv`) in the current directory. Tasks that need Python/Django dependencies (test, lint, analyze) activate the venv automatically — no need to manually activate it.
|
|
162
|
+
|
|
163
|
+
If no venv is found, those tasks will error with a clear message. Tasks that only use git (like `changelog`, `tag-init`, `buildlocal`) work without a venv.
|
|
164
|
+
|
|
165
|
+
## Linting
|
|
166
|
+
|
|
167
|
+
The pipeline runs pylint automatically on every deployment and generates report files for SonarQube. The `.pylintrc` configuration is shipped with the package — no config files needed in your project.
|
|
168
|
+
```bash
|
|
169
|
+
# Review issues with human-readable output
|
|
170
|
+
itw lintlocal
|
|
171
|
+
|
|
172
|
+
# Generate report files for SonarQube
|
|
173
|
+
itw lint
|
|
174
|
+
|
|
175
|
+
# Use a custom pylint configuration
|
|
176
|
+
itw lint --pylintrc=.pylintrc
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
Add this to your `sonar-project.properties`:
|
|
180
|
+
```properties
|
|
181
|
+
sonar.python.pylint.reportPaths=pylint-report.txt
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
## Changelog Generation
|
|
185
|
+
|
|
186
|
+
Changelog entries are generated automatically on every deployment based on commit messages. To categorize a commit, add a `Changelog:` trailer to the commit body:
|
|
187
|
+
```
|
|
188
|
+
add user authentication
|
|
189
|
+
|
|
190
|
+
Changelog: added
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
Available categories: `added`, `fixed`, `changed`, `deprecated`, `removed`, `security`, `performance`, `tests`, `docs`, `refactor`
|
|
194
|
+
|
|
195
|
+
Commits without a trailer appear under `Other Changes`. The `CHANGELOG.md` file is committed and pushed automatically with each deployment.
|
|
196
|
+
|
|
197
|
+
If `CHANGELOG.md` does not exist in the current directory it is created automatically — including when there are no new commits since the last tag, so the deployment never fails on a missing file.
|
|
198
|
+
|
|
199
|
+
### Changelog Propagation
|
|
200
|
+
|
|
201
|
+
After a successful deployment the changelog is synced forward through a temporary git worktree:
|
|
202
|
+
|
|
203
|
+
- from `master` → `staging` and `develop`
|
|
204
|
+
- from `staging` → `develop`
|
|
205
|
+
|
|
206
|
+
Propagation is skipped when the working tree is not clean or when there is nothing new to sync.
|
|
207
|
+
|
|
208
|
+
## GitLab Issue Creation
|
|
209
|
+
|
|
210
|
+
Create GitLab issues from a markdown file instead of the web UI.
|
|
211
|
+
|
|
212
|
+
Generate the template:
|
|
213
|
+
```bash
|
|
214
|
+
itw task-init
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
Then fill it in and run:
|
|
218
|
+
```bash
|
|
219
|
+
itw task --file=TASK.md
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
`task-init` refuses to overwrite an existing `TASK.md`.
|
|
223
|
+
|
|
224
|
+
### Template
|
|
225
|
+
|
|
226
|
+
```markdown
|
|
227
|
+
<!-- OPTIONAL -->
|
|
228
|
+
/glab_host
|
|
229
|
+
/glab_username
|
|
230
|
+
/glab_token
|
|
231
|
+
|
|
232
|
+
<!-- REQUIRED -->
|
|
233
|
+
/title
|
|
234
|
+
|
|
235
|
+
<!-- OPTIONAL -->
|
|
236
|
+
/repo
|
|
237
|
+
/milestone
|
|
238
|
+
/milestone-start YYYY-MM-DD
|
|
239
|
+
/milestone-end YYYY-MM-DD
|
|
240
|
+
/assignee
|
|
241
|
+
/label
|
|
242
|
+
/estimate
|
|
243
|
+
/due YYYY-MM-DD
|
|
244
|
+
|
|
245
|
+
<!-- OPTIONAL -->
|
|
246
|
+
## Acceptance Criteria
|
|
247
|
+
- [ ]
|
|
248
|
+
- [ ]
|
|
249
|
+
## Acceptance Criteria
|
|
250
|
+
|
|
251
|
+
<!-- OPTIONAL -->
|
|
252
|
+
## Comment
|
|
253
|
+
|
|
254
|
+
## Comment
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
Every key except `/title` is optional. Leaving a key empty is allowed — empty keys are simply not sent to GitLab. The date keys keep the `YYYY-MM-DD` format in the template and are treated as empty until you replace them.
|
|
258
|
+
|
|
259
|
+
Any text outside the directives becomes the issue description. Create several issues from one file by separating the blocks with a line containing only `===`.
|
|
260
|
+
|
|
261
|
+
### Directives
|
|
262
|
+
|
|
263
|
+
| Directive | Description |
|
|
264
|
+
| --- | --- |
|
|
265
|
+
| `/glab_host` | GitLab host to authenticate against |
|
|
266
|
+
| `/glab_username` | GitLab username |
|
|
267
|
+
| `/glab_token` | GitLab personal access token |
|
|
268
|
+
| `/title` | Issue title (required) |
|
|
269
|
+
| `/repo` | Target project path |
|
|
270
|
+
| `/milestone` | Milestone title |
|
|
271
|
+
| `/milestone-start` | Milestone start date, used only when the milestone is created |
|
|
272
|
+
| `/milestone-end` | Milestone due date, used only when the milestone is created |
|
|
273
|
+
| `/assignee` | Username to assign the issue to |
|
|
274
|
+
| `/label` | Comma-separated labels |
|
|
275
|
+
| `/estimate` | Time estimate (e.g. `3h`) |
|
|
276
|
+
| `/due` | Issue due date |
|
|
277
|
+
|
|
278
|
+
### Authentication
|
|
279
|
+
|
|
280
|
+
Either provide all three of `/glab_host`, `/glab_username` and `/glab_token`, or none of them. Providing only some of them is an error.
|
|
281
|
+
|
|
282
|
+
When no credentials are given, `itw` uses the `.git` directory in the current folder to detect the GitLab host and project, and the token cached by `itw login`.
|
|
283
|
+
|
|
284
|
+
### Target Repository
|
|
285
|
+
|
|
286
|
+
With no `/repo`, the target is resolved by walking the current repository's namespace upward looking for a `_pm` project. If several are found you are prompted to pick one.
|
|
287
|
+
|
|
288
|
+
With `/repo`, leading and trailing slashes are stripped automatically, and full URLs or SSH remotes are accepted:
|
|
289
|
+
|
|
290
|
+
```
|
|
291
|
+
/repo /tools/itw_python_builder/ → tools/itw_python_builder
|
|
292
|
+
/repo https://git.it-works.io/tools/x.git → tools/x
|
|
293
|
+
/repo git@git.it-works.io:tools/x.git → tools/x
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
### Milestones, Labels and Duplicates
|
|
297
|
+
|
|
298
|
+
Milestones, labels and issue titles are matched with a shared fuzzy comparison at 95% similarity, so small differences in spacing or casing still count as a match. The threshold is a single constant (`SIMILARITY_THRESHOLD` in `task_utils.py`) if you need to tune it.
|
|
299
|
+
|
|
300
|
+
- **Milestone** — a similar existing milestone is reused; otherwise it is created (with the start/end dates when both are given).
|
|
301
|
+
- **Label** — a similar existing label is linked and logged in white; a new one is created and logged in green. An empty `/label` is never sent to GitLab.
|
|
302
|
+
- **Duplicate issues** — before creating, the target project is checked for a similar title *within the same milestone* (or among issues with no milestone). If one exists the issue is not created and a white log line reports it.
|
|
303
|
+
|
|
304
|
+
### Acceptance Criteria and Comments
|
|
305
|
+
|
|
306
|
+
The `## Acceptance Criteria` section must be opened and closed with the same header. When omitted or left with empty checkboxes, a default checklist is applied.
|
|
307
|
+
|
|
308
|
+
The `## Comment` section, opened and closed the same way, is posted as a note on the issue after it is created.
|
|
309
|
+
|
|
310
|
+
## Requirements
|
|
311
|
+
|
|
312
|
+
- Python 3.10+
|
|
313
|
+
- Podman
|
|
314
|
+
- Git
|
|
315
|
+
- SonarQube server
|
|
316
|
+
- Node.js and npm (frontend projects)
|
|
317
|
+
|
|
318
|
+
## Configuration
|
|
319
|
+
|
|
320
|
+
### Environment Variables
|
|
321
|
+
|
|
322
|
+
Add to your project `.env`:
|
|
323
|
+
```
|
|
324
|
+
SONAR_HOST_URL=https://your-sonar-server
|
|
325
|
+
SONAR_TOKEN=your-token
|
|
326
|
+
GIT_DEPTH=0
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
### Project Files Required
|
|
330
|
+
|
|
331
|
+
- Git repository with `develop`, `staging`, and `master` branches
|
|
332
|
+
- `VERSION` file in project root (backend) or `package.json` (frontend)
|
|
333
|
+
- `Dockerfile` (backend)
|
|
334
|
+
- `sonar-project.properties`
|
|
335
|
+
|
|
336
|
+
## License
|
|
337
|
+
|
|
338
|
+
MIT
|
|
@@ -3,18 +3,18 @@ itw_python_builder/__init__.py,sha256=0HwEqonfK8w1kx80BOX80fvxSL8ov1hmUD2Zi4SU75
|
|
|
3
3
|
itw_python_builder/cli.py,sha256=UKnswr7vzLC6SnB5YgxWu8ZLbOe8WGStdUHCgkzkyqM,939
|
|
4
4
|
itw_python_builder/notify.py,sha256=Rv1RA9QdDo00bvjMMSDq-oQh2pjDmjjj97yzzhgiwpM,3011
|
|
5
5
|
itw_python_builder/ssr_tasks.py,sha256=TqkuGLH9XJqDkTPBuRnFAKJywYqvdf0lSlSMXtVPM5Y,15730
|
|
6
|
-
itw_python_builder/task_utils.py,sha256=
|
|
7
|
-
itw_python_builder/tasks.py,sha256=
|
|
8
|
-
itw_python_builder/utils.py,sha256=
|
|
6
|
+
itw_python_builder/task_utils.py,sha256=qohNLpIaodGmm9-FOOPL697Q3QZrrQkcK63AKhJcKZY,12683
|
|
7
|
+
itw_python_builder/tasks.py,sha256=_I-hbfHzA7JRkleX7MkmxKi3lLzcrxTh8MIqgQzbCpQ,33617
|
|
8
|
+
itw_python_builder/utils.py,sha256=wKNJM64Vl3O3KbyTSNYu6RelhWgmlapGOUGUokap9Pk,21294
|
|
9
9
|
itw_python_builder/version.py,sha256=RCSKNU4eblCP38-PH5hT6qMDKOyQq5YGCoT2mgpfZkE,3165
|
|
10
10
|
itw_python_builder/_pyruntime/sitecustomize.py,sha256=cjTrKErMdAFI-uVuW60BNNF8X41NrH9XDrIHJlNlEtY,805
|
|
11
11
|
itw_python_builder/templates/new_version_email.html,sha256=I0RuZ8UMBavXTaSzQJQP1wNjsDY5TtxrASgeGiX0NGo,2854
|
|
12
12
|
itw_python_builder/templates/server.sitemap.snippet.ts,sha256=qjRCLiAqzDUTqsWxJLeqlr-jOluK0IE2IoN_lbSx0ec,669
|
|
13
13
|
itw_python_builder/templates/sitemap.routes.ts,sha256=yOXZ8TpHq_-QKH7y_u75XpL8paCF4AEv5z5cvWeI7yY,1323
|
|
14
|
-
itw_python_builder/templates/task_template.md,sha256=
|
|
15
|
-
itw_python_builder-0.2.
|
|
16
|
-
itw_python_builder-0.2.
|
|
17
|
-
itw_python_builder-0.2.
|
|
18
|
-
itw_python_builder-0.2.
|
|
19
|
-
itw_python_builder-0.2.
|
|
20
|
-
itw_python_builder-0.2.
|
|
14
|
+
itw_python_builder/templates/task_template.md,sha256=SxoaWOuQVru6xNTLmhICkDoOSuHPM2B5FiVO67i7ciM,345
|
|
15
|
+
itw_python_builder-0.2.14.dist-info/licenses/LICENSE,sha256=WZ75-earCxnov77gsH4vckNf7Ag8RPst-7y9dPaZTCU,1063
|
|
16
|
+
itw_python_builder-0.2.14.dist-info/METADATA,sha256=glERXhS_LroSe7QZ97_kRZ-utfjkSfdtScqtPstZsKw,11297
|
|
17
|
+
itw_python_builder-0.2.14.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
18
|
+
itw_python_builder-0.2.14.dist-info/entry_points.txt,sha256=Rx2zlbaCxl954YX8kF4hey3Y25fxsaxiEPXjBPw3ky0,52
|
|
19
|
+
itw_python_builder-0.2.14.dist-info/top_level.txt,sha256=9djpWUrRa0vuSfdVrHc4-3gvAHbBvzh_lbyzgTHtkJA,19
|
|
20
|
+
itw_python_builder-0.2.14.dist-info/RECORD,,
|
|
@@ -1,166 +0,0 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: itw_python_builder
|
|
3
|
-
Version: 0.2.12
|
|
4
|
-
Summary: Standardized Django deployment pipeline with Docker, testing, and SonarQube integration
|
|
5
|
-
Author-email: IT-Works <contact@it-works.io>
|
|
6
|
-
License: MIT
|
|
7
|
-
Project-URL: Homepage, https://git.it-works.io/
|
|
8
|
-
Project-URL: Repository, https://git.it-works.io/
|
|
9
|
-
Project-URL: Issues, https://git.it-works.io/
|
|
10
|
-
Keywords: django,deployment,docker,ci-cd,sonarqube
|
|
11
|
-
Classifier: Development Status :: 4 - Beta
|
|
12
|
-
Classifier: Intended Audience :: Developers
|
|
13
|
-
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
-
Classifier: Programming Language :: Python :: 3
|
|
15
|
-
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
-
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
-
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
-
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
-
Classifier: Topic :: Software Development :: Build Tools
|
|
20
|
-
Requires-Python: >=3.10
|
|
21
|
-
Description-Content-Type: text/markdown
|
|
22
|
-
License-File: LICENSE
|
|
23
|
-
Requires-Dist: invoke>=2.0.0
|
|
24
|
-
Requires-Dist: pylint>=3.0.0
|
|
25
|
-
Requires-Dist: pylint-django>=2.5.0
|
|
26
|
-
Requires-Dist: python-decouple>=3.8
|
|
27
|
-
Requires-Dist: requests>=2.28.0
|
|
28
|
-
Dynamic: license-file
|
|
29
|
-
|
|
30
|
-
# ITW Python Builder
|
|
31
|
-
|
|
32
|
-
Standardized Django deployment pipeline with Docker, testing, SonarQube integration, automatic changelog generation, and code quality enforcement.
|
|
33
|
-
|
|
34
|
-
## Features
|
|
35
|
-
|
|
36
|
-
- Automated deployment with semantic versioning
|
|
37
|
-
- Docker/Podman-based build and push pipeline
|
|
38
|
-
- Automated testing with coverage
|
|
39
|
-
- SonarQube static code analysis
|
|
40
|
-
- Pylint linting with SonarQube integration
|
|
41
|
-
- Quality gates - deploy only when tests pass
|
|
42
|
-
- Automatic changelog generation from commit trailers
|
|
43
|
-
- Support for local and production pipelines
|
|
44
|
-
- Global CLI tool
|
|
45
|
-
- Automatic venv detection per project
|
|
46
|
-
|
|
47
|
-
## Installation
|
|
48
|
-
```bash
|
|
49
|
-
pip install itw-python-builder
|
|
50
|
-
```
|
|
51
|
-
|
|
52
|
-
Install globally (outside any project venv). The `itw` command becomes available system-wide.
|
|
53
|
-
|
|
54
|
-
## Quick Start
|
|
55
|
-
|
|
56
|
-
1. Navigate to your Django project directory (must have a `.venv` or `venv`):
|
|
57
|
-
|
|
58
|
-
2. Initialize versioning:
|
|
59
|
-
```bash
|
|
60
|
-
itw tag-init
|
|
61
|
-
```
|
|
62
|
-
|
|
63
|
-
3. Run local pipeline:
|
|
64
|
-
```bash
|
|
65
|
-
itw pipelinelocal
|
|
66
|
-
```
|
|
67
|
-
|
|
68
|
-
4. Deploy to staging:
|
|
69
|
-
```bash
|
|
70
|
-
itw incrementrc
|
|
71
|
-
```
|
|
72
|
-
|
|
73
|
-
5. Deploy to production:
|
|
74
|
-
```bash
|
|
75
|
-
itw incrementpatch
|
|
76
|
-
```
|
|
77
|
-
|
|
78
|
-
## Available Commands
|
|
79
|
-
|
|
80
|
-
### Deployment Commands
|
|
81
|
-
|
|
82
|
-
- `itw incrementpatch` — Increment patch version and deploy
|
|
83
|
-
- `itw incrementminor` — Increment minor version and deploy
|
|
84
|
-
- `itw incrementmajor` — Increment major version and deploy
|
|
85
|
-
- `itw incrementrc` — Increment release candidate (staging)
|
|
86
|
-
- `itw release` — Promote RC to stable release (master)
|
|
87
|
-
|
|
88
|
-
### Local Development Commands
|
|
89
|
-
|
|
90
|
-
- `itw pipelinelocal` — Run full local pipeline (lint → test → analyze → build)
|
|
91
|
-
- `itw lintlocal` — Run pylint with human-readable output
|
|
92
|
-
- `itw lint` — Run pylint and generate SonarQube report files
|
|
93
|
-
- `itw buildlocal` — Build Docker image locally
|
|
94
|
-
- `itw testlocal` — Run tests locally
|
|
95
|
-
- `itw analyzelocal` — Run SonarQube analysis locally
|
|
96
|
-
- `itw changelog` — Generate changelog manually
|
|
97
|
-
- `itw tag-init` — Initialize version tagging
|
|
98
|
-
|
|
99
|
-
### Skip Pipeline
|
|
100
|
-
```bash
|
|
101
|
-
itw incrementpatch --skip-pipeline
|
|
102
|
-
```
|
|
103
|
-
|
|
104
|
-
## Virtual Environment Detection
|
|
105
|
-
|
|
106
|
-
`itw` is installed globally but automatically detects the project's virtual environment (`.venv` or `venv`) in the current directory. Tasks that need Python/Django dependencies (test, lint, analyze) activate the venv automatically — no need to manually activate it.
|
|
107
|
-
|
|
108
|
-
If no venv is found, those tasks will error with a clear message. Tasks that only use git (like `changelog`, `tag-init`, `buildlocal`) work without a venv.
|
|
109
|
-
|
|
110
|
-
## Linting
|
|
111
|
-
|
|
112
|
-
The pipeline runs pylint automatically on every deployment and generates report files for SonarQube. The `.pylintrc` configuration is shipped with the package — no config files needed in your project.
|
|
113
|
-
```bash
|
|
114
|
-
# Review issues with human-readable output
|
|
115
|
-
itw lintlocal
|
|
116
|
-
|
|
117
|
-
# Generate report files for SonarQube
|
|
118
|
-
itw lint
|
|
119
|
-
```
|
|
120
|
-
|
|
121
|
-
Add this to your `sonar-project.properties`:
|
|
122
|
-
```properties
|
|
123
|
-
sonar.python.pylint.reportPaths=pylint-report.txt
|
|
124
|
-
```
|
|
125
|
-
|
|
126
|
-
## Changelog Generation
|
|
127
|
-
|
|
128
|
-
Changelog entries are generated automatically on every deployment based on commit messages. To categorize a commit, add a `Changelog:` trailer to the commit body:
|
|
129
|
-
```
|
|
130
|
-
add user authentication
|
|
131
|
-
|
|
132
|
-
Changelog: added
|
|
133
|
-
```
|
|
134
|
-
|
|
135
|
-
Available categories: `added`, `fixed`, `changed`, `deprecated`, `removed`, `security`, `performance`, `tests`, `docs`, `refactor`
|
|
136
|
-
|
|
137
|
-
Commits without a trailer appear under `Other Changes`. The `CHANGELOG.md` file is committed and pushed automatically with each deployment.
|
|
138
|
-
|
|
139
|
-
## Requirements
|
|
140
|
-
|
|
141
|
-
- Python 3.10+
|
|
142
|
-
- Podman
|
|
143
|
-
- Git
|
|
144
|
-
- SonarQube server
|
|
145
|
-
|
|
146
|
-
## Configuration
|
|
147
|
-
|
|
148
|
-
### Environment Variables
|
|
149
|
-
|
|
150
|
-
Add to your project `.env`:
|
|
151
|
-
```
|
|
152
|
-
SONAR_HOST_URL=https://your-sonar-server
|
|
153
|
-
SONAR_TOKEN=your-token
|
|
154
|
-
GIT_DEPTH=0
|
|
155
|
-
```
|
|
156
|
-
|
|
157
|
-
### Project Files Required
|
|
158
|
-
|
|
159
|
-
- Git repository with `develop`, `staging`, and `master` branches
|
|
160
|
-
- `VERSION` file in project root
|
|
161
|
-
- `Dockerfile`
|
|
162
|
-
- `sonar-project.properties`
|
|
163
|
-
|
|
164
|
-
## License
|
|
165
|
-
|
|
166
|
-
MIT
|
{itw_python_builder-0.2.12.dist-info → itw_python_builder-0.2.14.dist-info}/entry_points.txt
RENAMED
|
File without changes
|
{itw_python_builder-0.2.12.dist-info → itw_python_builder-0.2.14.dist-info}/licenses/LICENSE
RENAMED
|
File without changes
|
|
File without changes
|