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.
Files changed (25) hide show
  1. {itw_python_builder-0.2.11 → itw_python_builder-0.2.13}/PKG-INFO +1 -1
  2. {itw_python_builder-0.2.11 → itw_python_builder-0.2.13}/itw_python_builder/task_utils.py +154 -44
  3. {itw_python_builder-0.2.11 → itw_python_builder-0.2.13}/itw_python_builder/tasks.py +120 -14
  4. itw_python_builder-0.2.13/itw_python_builder/templates/task_template.md +28 -0
  5. {itw_python_builder-0.2.11 → itw_python_builder-0.2.13}/itw_python_builder.egg-info/PKG-INFO +1 -1
  6. {itw_python_builder-0.2.11 → itw_python_builder-0.2.13}/itw_python_builder.egg-info/SOURCES.txt +2 -1
  7. {itw_python_builder-0.2.11 → itw_python_builder-0.2.13}/pyproject.toml +2 -2
  8. {itw_python_builder-0.2.11 → itw_python_builder-0.2.13}/LICENSE +0 -0
  9. {itw_python_builder-0.2.11 → itw_python_builder-0.2.13}/README.md +0 -0
  10. {itw_python_builder-0.2.11 → itw_python_builder-0.2.13}/itw_python_builder/.pylintrc +0 -0
  11. {itw_python_builder-0.2.11 → itw_python_builder-0.2.13}/itw_python_builder/__init__.py +0 -0
  12. {itw_python_builder-0.2.11 → itw_python_builder-0.2.13}/itw_python_builder/_pyruntime/sitecustomize.py +0 -0
  13. {itw_python_builder-0.2.11 → itw_python_builder-0.2.13}/itw_python_builder/cli.py +0 -0
  14. {itw_python_builder-0.2.11 → itw_python_builder-0.2.13}/itw_python_builder/notify.py +0 -0
  15. {itw_python_builder-0.2.11 → itw_python_builder-0.2.13}/itw_python_builder/ssr_tasks.py +0 -0
  16. {itw_python_builder-0.2.11 → itw_python_builder-0.2.13}/itw_python_builder/templates/new_version_email.html +0 -0
  17. {itw_python_builder-0.2.11 → itw_python_builder-0.2.13}/itw_python_builder/templates/server.sitemap.snippet.ts +0 -0
  18. {itw_python_builder-0.2.11 → itw_python_builder-0.2.13}/itw_python_builder/templates/sitemap.routes.ts +0 -0
  19. {itw_python_builder-0.2.11 → itw_python_builder-0.2.13}/itw_python_builder/utils.py +0 -0
  20. {itw_python_builder-0.2.11 → itw_python_builder-0.2.13}/itw_python_builder/version.py +0 -0
  21. {itw_python_builder-0.2.11 → itw_python_builder-0.2.13}/itw_python_builder.egg-info/dependency_links.txt +0 -0
  22. {itw_python_builder-0.2.11 → itw_python_builder-0.2.13}/itw_python_builder.egg-info/entry_points.txt +0 -0
  23. {itw_python_builder-0.2.11 → itw_python_builder-0.2.13}/itw_python_builder.egg-info/requires.txt +0 -0
  24. {itw_python_builder-0.2.11 → itw_python_builder-0.2.13}/itw_python_builder.egg-info/top_level.txt +0 -0
  25. {itw_python_builder-0.2.11 → itw_python_builder-0.2.13}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: itw_python_builder
3
- Version: 0.2.11
3
+ Version: 0.2.13
4
4
  Summary: Standardized Django deployment pipeline with Docker, testing, and SonarQube integration
5
5
  Author-email: IT-Works <contact@it-works.io>
6
6
  License: MIT
@@ -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?search={encoded}',
124
+ f'/projects/{project_id}/milestones?per_page=100',
95
125
  token,
96
126
  )
97
- for m in results:
98
- if m.get('title') == title:
99
- return m
100
- return None
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
- api_host: str,
105
- project_id: int,
106
- title: str,
107
- start_date: str = None,
108
- due_date: str = None,
109
- token: str = None,
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
- m = re.match(rf'^/{key}\s+(\S+)\s*$', stripped, re.IGNORECASE)
156
- if m:
157
- creds[key] = m.group(1)
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)^\s*/(?:{"|".join(_CRED_KEYS)})\s+\S+\s*$\n?'
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
- comment_text = m.group(1).strip()
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
- acceptance_text = m.group(1).strip()
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
- m = re.match(r'^/title\s+(.+)$', stripped, re.IGNORECASE)
231
- if m:
232
- title = m.group(1).strip()
322
+ if _RE_MARKER_COMMENT.match(stripped):
233
323
  continue
234
- m = re.match(r'^/repo\s+(\S+)\s*$', stripped, re.IGNORECASE)
235
- if m:
236
- repo = m.group(1)
324
+
325
+ value = _match_directive(stripped, 'title')
326
+ if value is not None:
327
+ title = value or None
237
328
  continue
238
- m = re.match(r'^/milestone-start\s+(\S+)\s*$', stripped, re.IGNORECASE)
239
- if m:
240
- milestone_start = m.group(1)
329
+ value = _match_directive(stripped, 'repo')
330
+ if value is not None:
331
+ repo = _normalize_repo(value) or None
241
332
  continue
242
- m = re.match(r'^/milestone-end\s+(\S+)\s*$', stripped, re.IGNORECASE)
243
- if m:
244
- milestone_end = m.group(1)
333
+ value = _match_directive(stripped, 'milestone-start')
334
+ if value is not None:
335
+ milestone_start = value or None
245
336
  continue
246
- m = re.match(r'^/milestone\s+(.+)$', stripped, re.IGNORECASE)
247
- if m:
248
- milestone = _strip_milestone_wrap(m.group(1))
337
+ value = _match_directive(stripped, 'milestone-end')
338
+ if value is not None:
339
+ milestone_end = value or None
249
340
  continue
250
- m = re.match(r'^/assignee\s+(.+)$', stripped, re.IGNORECASE)
251
- if m:
252
- assignee = m.group(1).strip()
253
- body_lines.append(f'/assign {assignee}')
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
- ctx.run('npx ng test --no-watch --code-coverage --browsers=ChromeHeadlessNoSandbox', warn=warn)
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
- ctx.run('python -m coverage report -m')
578
- ctx.run('python -m coverage xml -o coverage.xml')
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
- if not os.path.exists('CHANGELOG.md'):
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: itw_python_builder
3
- Version: 0.2.11
3
+ Version: 0.2.13
4
4
  Summary: Standardized Django deployment pipeline with Docker, testing, and SonarQube integration
5
5
  Author-email: IT-Works <contact@it-works.io>
6
6
  License: MIT
@@ -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.11"
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"