itw-python-builder 0.2.11__tar.gz → 0.2.13__tar.gz
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-0.2.11 → itw_python_builder-0.2.13}/PKG-INFO +1 -1
- {itw_python_builder-0.2.11 → itw_python_builder-0.2.13}/itw_python_builder/task_utils.py +154 -44
- {itw_python_builder-0.2.11 → itw_python_builder-0.2.13}/itw_python_builder/tasks.py +120 -14
- itw_python_builder-0.2.13/itw_python_builder/templates/task_template.md +28 -0
- {itw_python_builder-0.2.11 → itw_python_builder-0.2.13}/itw_python_builder.egg-info/PKG-INFO +1 -1
- {itw_python_builder-0.2.11 → itw_python_builder-0.2.13}/itw_python_builder.egg-info/SOURCES.txt +2 -1
- {itw_python_builder-0.2.11 → itw_python_builder-0.2.13}/pyproject.toml +2 -2
- {itw_python_builder-0.2.11 → itw_python_builder-0.2.13}/LICENSE +0 -0
- {itw_python_builder-0.2.11 → itw_python_builder-0.2.13}/README.md +0 -0
- {itw_python_builder-0.2.11 → itw_python_builder-0.2.13}/itw_python_builder/.pylintrc +0 -0
- {itw_python_builder-0.2.11 → itw_python_builder-0.2.13}/itw_python_builder/__init__.py +0 -0
- {itw_python_builder-0.2.11 → itw_python_builder-0.2.13}/itw_python_builder/_pyruntime/sitecustomize.py +0 -0
- {itw_python_builder-0.2.11 → itw_python_builder-0.2.13}/itw_python_builder/cli.py +0 -0
- {itw_python_builder-0.2.11 → itw_python_builder-0.2.13}/itw_python_builder/notify.py +0 -0
- {itw_python_builder-0.2.11 → itw_python_builder-0.2.13}/itw_python_builder/ssr_tasks.py +0 -0
- {itw_python_builder-0.2.11 → itw_python_builder-0.2.13}/itw_python_builder/templates/new_version_email.html +0 -0
- {itw_python_builder-0.2.11 → itw_python_builder-0.2.13}/itw_python_builder/templates/server.sitemap.snippet.ts +0 -0
- {itw_python_builder-0.2.11 → itw_python_builder-0.2.13}/itw_python_builder/templates/sitemap.routes.ts +0 -0
- {itw_python_builder-0.2.11 → itw_python_builder-0.2.13}/itw_python_builder/utils.py +0 -0
- {itw_python_builder-0.2.11 → itw_python_builder-0.2.13}/itw_python_builder/version.py +0 -0
- {itw_python_builder-0.2.11 → itw_python_builder-0.2.13}/itw_python_builder.egg-info/dependency_links.txt +0 -0
- {itw_python_builder-0.2.11 → itw_python_builder-0.2.13}/itw_python_builder.egg-info/entry_points.txt +0 -0
- {itw_python_builder-0.2.11 → itw_python_builder-0.2.13}/itw_python_builder.egg-info/requires.txt +0 -0
- {itw_python_builder-0.2.11 → itw_python_builder-0.2.13}/itw_python_builder.egg-info/top_level.txt +0 -0
- {itw_python_builder-0.2.11 → itw_python_builder-0.2.13}/setup.cfg +0 -0
|
@@ -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,
|
|
@@ -38,15 +38,21 @@ from itw_python_builder.task_utils import (
|
|
|
38
38
|
build_issue_description,
|
|
39
39
|
get_project_by_path,
|
|
40
40
|
find_project_milestone,
|
|
41
|
+
find_project_issue,
|
|
41
42
|
create_project_milestone,
|
|
43
|
+
list_project_labels,
|
|
44
|
+
create_project_label,
|
|
42
45
|
create_issue_note,
|
|
43
46
|
log_green,
|
|
44
47
|
log_red,
|
|
45
48
|
log_info,
|
|
49
|
+
log_white,
|
|
50
|
+
find_similar,
|
|
46
51
|
)
|
|
47
52
|
from itw_python_builder.version import Version
|
|
48
53
|
|
|
49
54
|
PYLINTRC = Path(__file__).parent / ".pylintrc"
|
|
55
|
+
TASK_TEMPLATE_PATH = Path(__file__).parent / "templates" / "task_template.md"
|
|
50
56
|
|
|
51
57
|
|
|
52
58
|
def _prompt_and_store_token(ctx: Context, username: str = None) -> str:
|
|
@@ -123,6 +129,17 @@ def logout(ctx: Context):
|
|
|
123
129
|
os.environ.pop('GITLAB_USERNAME', None)
|
|
124
130
|
|
|
125
131
|
|
|
132
|
+
@task(name='task-init')
|
|
133
|
+
def task_init(ctx: Context) -> None:
|
|
134
|
+
"""Create a TASK.md template with every /directive and section supported by `itw task`."""
|
|
135
|
+
dest = os.path.join(os.getcwd(), 'TASK.md')
|
|
136
|
+
if os.path.exists(dest):
|
|
137
|
+
log_red('TASK.md already exists in this directory — refusing to overwrite.')
|
|
138
|
+
sys.exit(1)
|
|
139
|
+
shutil.copyfile(TASK_TEMPLATE_PATH, dest)
|
|
140
|
+
log_green(f'Created TASK.md in {os.getcwd()}')
|
|
141
|
+
|
|
142
|
+
|
|
126
143
|
@task(name='task')
|
|
127
144
|
def create_task(ctx: Context, file=None):
|
|
128
145
|
"""Create GitLab issues in the group's _pm repo from a markdown file (--file path.md)."""
|
|
@@ -189,12 +206,27 @@ def create_task(ctx: Context, file=None):
|
|
|
189
206
|
if not issue['has_due']:
|
|
190
207
|
log_red('No due date is defined for this issue. Use /due in you md file to define a due date')
|
|
191
208
|
|
|
192
|
-
milestone_id = _resolve_milestone(api_host, project['id'], issue, token)
|
|
209
|
+
milestone_id, milestone_title = _resolve_milestone(api_host, project['id'], issue, token)
|
|
210
|
+
|
|
211
|
+
duplicate = find_project_issue(
|
|
212
|
+
api_host, project['id'], issue['title'], token, milestone_title
|
|
213
|
+
)
|
|
214
|
+
if duplicate:
|
|
215
|
+
scope = f'milestone {milestone_title}' if milestone_title else 'issues without a milestone'
|
|
216
|
+
log_white(
|
|
217
|
+
f'Issue "{issue["title"]}" was not created because it already exists '
|
|
218
|
+
f'in this project for {scope}: {duplicate["web_url"]}'
|
|
219
|
+
)
|
|
220
|
+
continue
|
|
221
|
+
|
|
222
|
+
labels = _resolve_labels(api_host, project['id'], issue, token)
|
|
193
223
|
|
|
194
224
|
description = build_issue_description(issue)
|
|
195
225
|
payload = {'title': issue['title'], 'description': description}
|
|
196
226
|
if milestone_id is not None:
|
|
197
227
|
payload['milestone_id'] = milestone_id
|
|
228
|
+
if labels:
|
|
229
|
+
payload['labels'] = ','.join(labels)
|
|
198
230
|
|
|
199
231
|
result = create_gitlab_issue(api_host, project['id'], payload, token)
|
|
200
232
|
print(f'[itw] Created: {result["web_url"]}')
|
|
@@ -252,6 +284,24 @@ def _resolve_target_project(api_host: str, project_path: str, issue: dict, token
|
|
|
252
284
|
print('Invalid choice, try again.')
|
|
253
285
|
|
|
254
286
|
|
|
287
|
+
def _resolve_labels(api_host: str, project_id: int, issue: dict, token: str) -> list:
|
|
288
|
+
if not issue['labels']:
|
|
289
|
+
return []
|
|
290
|
+
existing = list_project_labels(api_host, project_id, token)
|
|
291
|
+
resolved = []
|
|
292
|
+
for name in issue['labels']:
|
|
293
|
+
match = find_similar(name, existing, key='name')
|
|
294
|
+
if match:
|
|
295
|
+
log_white(f'Using existing label {match["name"]}')
|
|
296
|
+
resolved.append(match['name'])
|
|
297
|
+
continue
|
|
298
|
+
created = create_project_label(api_host, project_id, name, token)
|
|
299
|
+
log_green(f'Created new label {created["name"]}')
|
|
300
|
+
resolved.append(created['name'])
|
|
301
|
+
existing.append(created)
|
|
302
|
+
return resolved
|
|
303
|
+
|
|
304
|
+
|
|
255
305
|
def _resolve_milestone(api_host: str, project_id: int, issue: dict, token: str):
|
|
256
306
|
title = issue['milestone']
|
|
257
307
|
if not title:
|
|
@@ -259,12 +309,12 @@ def _resolve_milestone(api_host: str, project_id: int, issue: dict, token: str):
|
|
|
259
309
|
'There is no milestone defined for this issue. '
|
|
260
310
|
'Use /milestone in your md file to define a milestone'
|
|
261
311
|
)
|
|
262
|
-
return None
|
|
312
|
+
return None, None
|
|
263
313
|
|
|
264
314
|
existing = find_project_milestone(api_host, project_id, title, token)
|
|
265
315
|
if existing:
|
|
266
|
-
log_info(f'Using existing milestone {title}')
|
|
267
|
-
return existing['id']
|
|
316
|
+
log_info(f'Using existing milestone {existing["title"]}')
|
|
317
|
+
return existing['id'], existing['title']
|
|
268
318
|
|
|
269
319
|
start = issue['milestone_start']
|
|
270
320
|
end = issue['milestone_end']
|
|
@@ -277,7 +327,7 @@ def _resolve_milestone(api_host: str, project_id: int, issue: dict, token: str):
|
|
|
277
327
|
f'Created new milestone {title} | No start date or end date is defined for this '
|
|
278
328
|
'new milestone. Use /milestone-start and /milestone-end in your md file to define them '
|
|
279
329
|
)
|
|
280
|
-
return created['id']
|
|
330
|
+
return created['id'], created['title']
|
|
281
331
|
|
|
282
332
|
|
|
283
333
|
def build_frontend(ctx: Context, branch: str, ssr: bool = False) -> None:
|
|
@@ -553,11 +603,52 @@ def release(ctx: Context, skip_pipeline=False, ssr=False) -> None:
|
|
|
553
603
|
propagate_changelog(ctx)
|
|
554
604
|
|
|
555
605
|
|
|
606
|
+
def _list_spec_files() -> list:
|
|
607
|
+
root = Path(os.getcwd())
|
|
608
|
+
return [
|
|
609
|
+
p.relative_to(root)
|
|
610
|
+
for p in root.glob('**/*.spec.ts')
|
|
611
|
+
if 'node_modules' not in p.parts
|
|
612
|
+
]
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def _resolve_spec_include(target: str) -> str:
|
|
616
|
+
if '/' in target or '*' in target:
|
|
617
|
+
return target
|
|
618
|
+
if target.endswith('.spec.ts'):
|
|
619
|
+
return f'**/{target}'
|
|
620
|
+
|
|
621
|
+
name = target.removesuffix('.ts')
|
|
622
|
+
specs = _list_spec_files()
|
|
623
|
+
if any(name in spec.parts[:-1] for spec in specs):
|
|
624
|
+
return f'**/{name}/**/*.spec.ts'
|
|
625
|
+
if any(spec.name == f'{name}.spec.ts' for spec in specs):
|
|
626
|
+
return f'**/{name}.spec.ts'
|
|
627
|
+
if '.' in name:
|
|
628
|
+
head, rest = name.split('.', 1)
|
|
629
|
+
if any(head in spec.parts[:-1] and spec.name == f'{rest}.spec.ts' for spec in specs):
|
|
630
|
+
return f'**/{head}/**/{rest}.spec.ts'
|
|
631
|
+
|
|
632
|
+
raise RuntimeError(
|
|
633
|
+
f'No spec files match --target={target!r}. Expected a directory name '
|
|
634
|
+
f'(e.g. --target=core), a spec name (e.g. --target=core.service), or a path/glob.'
|
|
635
|
+
)
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def _resolve_django_label(target: str) -> str:
|
|
639
|
+
return target.strip().removesuffix('.py').replace('/', '.').strip('.')
|
|
640
|
+
|
|
641
|
+
|
|
556
642
|
@task
|
|
557
|
-
def test(ctx: Context, settings='backend.test_settings', warn=True):
|
|
558
|
-
"""Run tests with coverage."""
|
|
643
|
+
def test(ctx: Context, settings='backend.test_settings', target=None, warn=True):
|
|
644
|
+
"""Run tests with coverage. --target=app or --target=app.test_file runs a subset."""
|
|
559
645
|
if detect_project_type() == 'frontend':
|
|
560
|
-
|
|
646
|
+
cmd = 'npx ng test --no-watch --code-coverage --browsers=ChromeHeadlessNoSandbox'
|
|
647
|
+
if target:
|
|
648
|
+
include = _resolve_spec_include(target)
|
|
649
|
+
print(f'[itw] Limiting run to specs matching {include}')
|
|
650
|
+
cmd += f' --include="{include}"'
|
|
651
|
+
ctx.run(cmd, warn=warn)
|
|
561
652
|
print("✓ Tests completed")
|
|
562
653
|
return
|
|
563
654
|
detect_and_activate_venv()
|
|
@@ -566,16 +657,23 @@ def test(ctx: Context, settings='backend.test_settings', warn=True):
|
|
|
566
657
|
patch_dir = str(Path(__file__).parent / '_pyruntime')
|
|
567
658
|
existing_pp = os.environ.get('PYTHONPATH', '')
|
|
568
659
|
injected_pp = f'{patch_dir}:{existing_pp}' if existing_pp else patch_dir
|
|
660
|
+
label = ''
|
|
661
|
+
if target:
|
|
662
|
+
label = f' {_resolve_django_label(target)}'
|
|
663
|
+
print(f'[itw] Limiting run to{label}')
|
|
569
664
|
try:
|
|
570
665
|
ctx.run(
|
|
571
|
-
f'python -m coverage run manage.py test --settings={settings} --noinput -v 2',
|
|
666
|
+
f'python -m coverage run manage.py test{label} --settings={settings} --noinput -v 2',
|
|
572
667
|
warn=warn,
|
|
573
668
|
env={'PYTHONPATH': injected_pp},
|
|
574
669
|
)
|
|
575
670
|
finally:
|
|
576
671
|
terminate_stale_test_db_sessions(ctx, settings)
|
|
577
|
-
|
|
578
|
-
|
|
672
|
+
if target:
|
|
673
|
+
ctx.run('python -m coverage report -m --fail-under=0')
|
|
674
|
+
else:
|
|
675
|
+
ctx.run('python -m coverage report -m')
|
|
676
|
+
ctx.run('python -m coverage xml -o coverage.xml')
|
|
579
677
|
print("✓ Tests completed")
|
|
580
678
|
|
|
581
679
|
|
|
@@ -683,10 +781,20 @@ def pipelinelocal(ctx: Context, settings='backend.test_settings', pylintrc=None)
|
|
|
683
781
|
print("=" * 60)
|
|
684
782
|
|
|
685
783
|
|
|
784
|
+
def _ensure_changelog() -> None:
|
|
785
|
+
if os.path.exists('CHANGELOG.md'):
|
|
786
|
+
return
|
|
787
|
+
with open('CHANGELOG.md', 'w') as f:
|
|
788
|
+
f.write('# Changelog\n')
|
|
789
|
+
log_green('Created CHANGELOG.md')
|
|
790
|
+
|
|
791
|
+
|
|
686
792
|
@task
|
|
687
793
|
def changelog(ctx: Context, version: Version = None):
|
|
688
794
|
"""Generate changelog from commits with Changelog trailer"""
|
|
689
795
|
|
|
796
|
+
_ensure_changelog()
|
|
797
|
+
|
|
690
798
|
exclude = f'--exclude={version}' if version else ''
|
|
691
799
|
last_tag_result = ctx.run(f'git describe --tags --abbrev=0 {exclude}', hide=True, warn=True)
|
|
692
800
|
if last_tag_result.ok and last_tag_result.stdout.strip():
|
|
@@ -771,9 +879,7 @@ def changelog(ctx: Context, version: Version = None):
|
|
|
771
879
|
changelog_entry += f"{commit}\n"
|
|
772
880
|
changelog_entry += "\n"
|
|
773
881
|
|
|
774
|
-
|
|
775
|
-
with open('CHANGELOG.md', 'w') as f:
|
|
776
|
-
f.write('# Changelog\n')
|
|
882
|
+
_ensure_changelog()
|
|
777
883
|
|
|
778
884
|
with open('CHANGELOG.md', 'r') as f:
|
|
779
885
|
existing = f.read()
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
<!-- OPTIONAL -->
|
|
2
|
+
/glab_host
|
|
3
|
+
/glab_username
|
|
4
|
+
/glab_token
|
|
5
|
+
|
|
6
|
+
<!-- REQUIRED -->
|
|
7
|
+
/title
|
|
8
|
+
|
|
9
|
+
<!-- OPTIONAL -->
|
|
10
|
+
/repo
|
|
11
|
+
/milestone
|
|
12
|
+
/milestone-start YYYY-MM-DD
|
|
13
|
+
/milestone-end YYYY-MM-DD
|
|
14
|
+
/assignee
|
|
15
|
+
/label
|
|
16
|
+
/estimate
|
|
17
|
+
/due YYYY-MM-DD
|
|
18
|
+
|
|
19
|
+
<!-- OPTIONAL -->
|
|
20
|
+
## Acceptance Criteria
|
|
21
|
+
- [ ]
|
|
22
|
+
- [ ]
|
|
23
|
+
## Acceptance Criteria
|
|
24
|
+
|
|
25
|
+
<!-- OPTIONAL -->
|
|
26
|
+
## Comment
|
|
27
|
+
|
|
28
|
+
## Comment
|
{itw_python_builder-0.2.11 → itw_python_builder-0.2.13}/itw_python_builder.egg-info/SOURCES.txt
RENAMED
|
@@ -19,4 +19,5 @@ itw_python_builder.egg-info/top_level.txt
|
|
|
19
19
|
itw_python_builder/_pyruntime/sitecustomize.py
|
|
20
20
|
itw_python_builder/templates/new_version_email.html
|
|
21
21
|
itw_python_builder/templates/server.sitemap.snippet.ts
|
|
22
|
-
itw_python_builder/templates/sitemap.routes.ts
|
|
22
|
+
itw_python_builder/templates/sitemap.routes.ts
|
|
23
|
+
itw_python_builder/templates/task_template.md
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "itw_python_builder"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.13"
|
|
8
8
|
description = "Standardized Django deployment pipeline with Docker, testing, and SonarQube integration"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -41,7 +41,7 @@ Issues = "https://git.it-works.io/"
|
|
|
41
41
|
include-package-data = true
|
|
42
42
|
|
|
43
43
|
[tool.setuptools.package-data]
|
|
44
|
-
itw_python_builder = [".pylintrc", "templates/*.ts", "templates/*.html", "_pyruntime/*.py"]
|
|
44
|
+
itw_python_builder = [".pylintrc", "templates/*.ts", "templates/*.html", "templates/*.md", "_pyruntime/*.py"]
|
|
45
45
|
|
|
46
46
|
[project.scripts]
|
|
47
47
|
itw = "itw_python_builder.cli:main"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{itw_python_builder-0.2.11 → itw_python_builder-0.2.13}/itw_python_builder.egg-info/entry_points.txt
RENAMED
|
File without changes
|
{itw_python_builder-0.2.11 → itw_python_builder-0.2.13}/itw_python_builder.egg-info/requires.txt
RENAMED
|
File without changes
|
{itw_python_builder-0.2.11 → itw_python_builder-0.2.13}/itw_python_builder.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|