itw-python-builder 0.2.5__tar.gz → 0.2.6__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 (24) hide show
  1. {itw_python_builder-0.2.5 → itw_python_builder-0.2.6}/PKG-INFO +2 -1
  2. itw_python_builder-0.2.6/itw_python_builder/_pyruntime/sitecustomize.py +27 -0
  3. {itw_python_builder-0.2.5 → itw_python_builder-0.2.6}/itw_python_builder/notify.py +26 -26
  4. itw_python_builder-0.2.6/itw_python_builder/task_utils.py +266 -0
  5. {itw_python_builder-0.2.5 → itw_python_builder-0.2.6}/itw_python_builder/tasks.py +136 -38
  6. {itw_python_builder-0.2.5 → itw_python_builder-0.2.6}/itw_python_builder/utils.py +56 -69
  7. {itw_python_builder-0.2.5 → itw_python_builder-0.2.6}/itw_python_builder.egg-info/PKG-INFO +2 -1
  8. {itw_python_builder-0.2.5 → itw_python_builder-0.2.6}/itw_python_builder.egg-info/SOURCES.txt +2 -0
  9. {itw_python_builder-0.2.5 → itw_python_builder-0.2.6}/itw_python_builder.egg-info/requires.txt +1 -0
  10. {itw_python_builder-0.2.5 → itw_python_builder-0.2.6}/pyproject.toml +3 -2
  11. {itw_python_builder-0.2.5 → itw_python_builder-0.2.6}/LICENSE +0 -0
  12. {itw_python_builder-0.2.5 → itw_python_builder-0.2.6}/README.md +0 -0
  13. {itw_python_builder-0.2.5 → itw_python_builder-0.2.6}/itw_python_builder/.pylintrc +0 -0
  14. {itw_python_builder-0.2.5 → itw_python_builder-0.2.6}/itw_python_builder/__init__.py +0 -0
  15. {itw_python_builder-0.2.5 → itw_python_builder-0.2.6}/itw_python_builder/cli.py +0 -0
  16. {itw_python_builder-0.2.5 → itw_python_builder-0.2.6}/itw_python_builder/ssr_tasks.py +0 -0
  17. {itw_python_builder-0.2.5 → itw_python_builder-0.2.6}/itw_python_builder/templates/new_version_email.html +0 -0
  18. {itw_python_builder-0.2.5 → itw_python_builder-0.2.6}/itw_python_builder/templates/server.sitemap.snippet.ts +0 -0
  19. {itw_python_builder-0.2.5 → itw_python_builder-0.2.6}/itw_python_builder/templates/sitemap.routes.ts +0 -0
  20. {itw_python_builder-0.2.5 → itw_python_builder-0.2.6}/itw_python_builder/version.py +0 -0
  21. {itw_python_builder-0.2.5 → itw_python_builder-0.2.6}/itw_python_builder.egg-info/dependency_links.txt +0 -0
  22. {itw_python_builder-0.2.5 → itw_python_builder-0.2.6}/itw_python_builder.egg-info/entry_points.txt +0 -0
  23. {itw_python_builder-0.2.5 → itw_python_builder-0.2.6}/itw_python_builder.egg-info/top_level.txt +0 -0
  24. {itw_python_builder-0.2.5 → itw_python_builder-0.2.6}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: itw_python_builder
3
- Version: 0.2.5
3
+ Version: 0.2.6
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
@@ -24,6 +24,7 @@ Requires-Dist: invoke>=2.0.0
24
24
  Requires-Dist: pylint>=3.0.0
25
25
  Requires-Dist: pylint-django>=2.5.0
26
26
  Requires-Dist: python-decouple>=3.8
27
+ Requires-Dist: requests>=2.28.0
27
28
  Dynamic: license-file
28
29
 
29
30
  # ITW Python Builder
@@ -0,0 +1,27 @@
1
+ def _install():
2
+ try:
3
+ from django.db.backends.postgresql import creation as _c
4
+ except Exception:
5
+ return
6
+ cls = _c.DatabaseCreation
7
+ if getattr(cls, '_itw_terminate_patched', False):
8
+ return
9
+ _orig = cls._destroy_test_db
10
+
11
+ def _patched(self, test_database_name, verbosity):
12
+ try:
13
+ with self._nodb_cursor() as cursor:
14
+ cursor.execute(
15
+ "SELECT pg_terminate_backend(pid) FROM pg_stat_activity "
16
+ "WHERE datname = %s AND pid <> pg_backend_pid()",
17
+ [test_database_name],
18
+ )
19
+ except Exception:
20
+ pass
21
+ return _orig(self, test_database_name, verbosity)
22
+
23
+ cls._destroy_test_db = _patched
24
+ cls._itw_terminate_patched = True
25
+
26
+
27
+ _install()
@@ -1,11 +1,9 @@
1
1
  import os
2
- import smtplib
3
- import ssl
4
2
  import sys
5
- from email.mime.multipart import MIMEMultipart
6
- from email.mime.text import MIMEText
7
3
  from pathlib import Path
8
4
 
5
+ import requests
6
+
9
7
  TEMPLATE_PATH = Path(__file__).parent / 'templates' / 'new_version_email.html'
10
8
 
11
9
 
@@ -21,14 +19,12 @@ def _env_getter():
21
19
  return lambda key, default=None: os.environ[key] if default is None else os.environ.get(key, default)
22
20
 
23
21
 
24
- def _read_smtp_config() -> dict:
22
+ def _read_mailgun_config() -> dict:
25
23
  get = _env_getter()
26
24
  return {
27
- 'host': get('EMAIL_HOST', 'smtp.gmail.com'),
28
- 'port': int(get('EMAIL_PORT', '587')),
29
- 'user': get('EMAIL_HOST_USER'),
30
- 'password': get('EMAIL_HOST_PASSWORD'),
31
- 'use_tls': str(get('EMAIL_USE_TLS', 'True')).lower() == 'true',
25
+ 'from_email': get('FROM_EMAIL'),
26
+ 'base_url': get('MAILGUN_BASE_URL'),
27
+ 'api_key': get('MAILGUN_API_KEY'),
32
28
  }
33
29
 
34
30
 
@@ -56,24 +52,28 @@ def send_new_version_email(version: str, commit_message: str = '') -> int:
56
52
  print('[notify] EMAIL_RECIPIENTS is empty — skipping email notification.')
57
53
  return 0
58
54
 
59
- cfg = _read_smtp_config()
55
+ cfg = _read_mailgun_config()
60
56
  html_body = _render_template(version, commit_message)
61
57
 
62
- msg = MIMEMultipart('alternative')
63
- msg['Subject'] = f'itw-python-builder — Version i ri ({version})'
64
- msg['From'] = cfg['user']
65
- msg['To'] = ', '.join(recipients)
66
- msg.attach(MIMEText(html_body, 'html', 'utf-8'))
67
-
68
- context = ssl.create_default_context()
69
- with smtplib.SMTP(cfg['host'], cfg['port']) as smtp:
70
- if cfg['use_tls']:
71
- smtp.starttls(context=context)
72
- smtp.login(cfg['user'], cfg['password'])
73
- smtp.sendmail(cfg['user'], recipients, msg.as_string())
74
-
75
- print(f'[notify] Sent release email for {version} to {len(recipients)} recipient(s).')
76
- return len(recipients)
58
+ try:
59
+ data = {
60
+ 'from': cfg['from_email'],
61
+ 'to': recipients,
62
+ 'subject': f'itw-python-builder — Version i ri ({version})',
63
+ 'html': html_body,
64
+ }
65
+ response = requests.post(
66
+ cfg['base_url'],
67
+ auth=("api", cfg['api_key']),
68
+ data=data,
69
+ )
70
+ if not response.ok:
71
+ raise RuntimeError(f'Mailgun API error {response.status_code}: {response.text}')
72
+ print(f'[notify] Sent release email for {version} to {len(recipients)} recipient(s).')
73
+ return len(recipients)
74
+ except Exception as e:
75
+ print(f'FAILED TO SEND EMAIL TO {str(recipients)}: {str(e)}')
76
+ return 0
77
77
 
78
78
 
79
79
  def _main() -> int:
@@ -0,0 +1,266 @@
1
+ import re
2
+
3
+ from itw_python_builder.utils import _gitlab_api_call
4
+
5
+
6
+ DEFAULT_ACCEPTANCE_CRITERIA = (
7
+ '- [ ] U kryen testet manuale në ndërfaqe\n'
8
+ '- [ ] U kryen testet automatike\n'
9
+ '- [ ] U trajtuan sugjerimet e SonarQube\n'
10
+ '- [ ] U kontrollua cilësia e kodit'
11
+ )
12
+
13
+
14
+ _RE_AC_HEADER = re.compile(r'(?im)^##\s*acceptance\s+criteria\s*$')
15
+ _RE_COMMENT_HEADER = re.compile(r'(?im)^##\s*comment\s*$')
16
+ _RE_AC_BLOCK = re.compile(
17
+ r'(?ims)^##\s*acceptance\s+criteria\s*$(.*?)^##\s*acceptance\s+criteria\s*$'
18
+ )
19
+ _RE_COMMENT_BLOCK = re.compile(
20
+ r'(?ims)^##\s*comment\s*$(.*?)^##\s*comment\s*$'
21
+ )
22
+
23
+
24
+ _COLOR_GREEN = '\033[92m'
25
+ _COLOR_RED = '\033[91m'
26
+ _COLOR_RESET = '\033[0m'
27
+
28
+
29
+ def log_green(msg: str) -> None:
30
+ print(f'{_COLOR_GREEN}{msg}{_COLOR_RESET}')
31
+
32
+
33
+ def log_red(msg: str) -> None:
34
+ print(f'{_COLOR_RED}{msg}{_COLOR_RESET}')
35
+
36
+
37
+ def log_info(msg: str) -> None:
38
+ print(msg)
39
+
40
+
41
+ def _list_group_projects(api_host: str, group_path: str, token: str) -> list:
42
+ from urllib.parse import quote
43
+ encoded = quote(group_path, safe='')
44
+ return _gitlab_api_call(
45
+ api_host,
46
+ f'/groups/{encoded}/projects?per_page=100&search=_pm',
47
+ token,
48
+ )
49
+
50
+
51
+ def resolve_pm_repo(api_host: str, project_path: str, token: str) -> list:
52
+ """Find _pm repos by walking the namespace upward from project_path."""
53
+ parts = project_path.split('/')
54
+ if len(parts) < 2:
55
+ raise RuntimeError(f'Project path {project_path!r} has no parent group.')
56
+ parts.pop()
57
+ while parts:
58
+ namespace = '/'.join(parts)
59
+ try:
60
+ projects = _list_group_projects(api_host, namespace, token)
61
+ except RuntimeError as exc:
62
+ if '404' in str(exc):
63
+ parts.pop()
64
+ continue
65
+ raise
66
+ matches = [p for p in projects if p.get('path', '').endswith('_pm')]
67
+ if matches:
68
+ return matches
69
+ parts.pop()
70
+ return []
71
+
72
+
73
+ def create_gitlab_issue(api_host: str, project_id: int, payload: dict, token: str) -> dict:
74
+ return _gitlab_api_call(
75
+ api_host,
76
+ f'/projects/{project_id}/issues',
77
+ token,
78
+ method='POST',
79
+ payload=payload,
80
+ )
81
+
82
+
83
+ def get_project_by_path(api_host: str, project_path: str, token: str) -> dict:
84
+ from urllib.parse import quote
85
+ encoded = quote(project_path, safe='')
86
+ return _gitlab_api_call(api_host, f'/projects/{encoded}', token)
87
+
88
+
89
+ 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
+ results = _gitlab_api_call(
93
+ api_host,
94
+ f'/projects/{project_id}/milestones?search={encoded}',
95
+ token,
96
+ )
97
+ for m in results:
98
+ if m.get('title') == title:
99
+ return m
100
+ return None
101
+
102
+
103
+ 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,
110
+ ) -> dict:
111
+ payload = {'title': title}
112
+ if start_date:
113
+ payload['start_date'] = start_date
114
+ if due_date:
115
+ payload['due_date'] = due_date
116
+ return _gitlab_api_call(
117
+ api_host,
118
+ f'/projects/{project_id}/milestones',
119
+ token,
120
+ method='POST',
121
+ payload=payload,
122
+ )
123
+
124
+
125
+ def create_issue_note(api_host: str, project_id: int, issue_iid: int, body: str, token: str) -> dict:
126
+ return _gitlab_api_call(
127
+ api_host,
128
+ f'/projects/{project_id}/issues/{issue_iid}/notes',
129
+ token,
130
+ method='POST',
131
+ payload={'body': body},
132
+ )
133
+
134
+
135
+ def _strip_milestone_wrap(raw: str) -> str:
136
+ raw = raw.strip()
137
+ if raw.startswith('%"') and raw.endswith('"'):
138
+ return raw[2:-1]
139
+ if raw.startswith('%'):
140
+ return raw[1:].strip().strip('"')
141
+ return raw
142
+
143
+
144
+ def parse_md_issues(file_path: str) -> list:
145
+ """Parse a markdown file into structured issue dicts separated by '==='."""
146
+ with open(file_path, 'r', encoding='utf-8') as fp:
147
+ content = fp.read().replace('\r\n', '\n')
148
+ blocks = re.split(r'(?m)^\s*===\s*$', content)
149
+ issues = []
150
+ for idx, block in enumerate(blocks, start=1):
151
+ block = block.strip()
152
+ if not block:
153
+ continue
154
+ issues.append(_parse_single_issue(block, idx, file_path))
155
+ if not issues:
156
+ raise RuntimeError(f'No issues found in {file_path}.')
157
+ return issues
158
+
159
+
160
+ def _parse_single_issue(block: str, idx: int, file_path: str) -> dict:
161
+ ac_headers = _RE_AC_HEADER.findall(block)
162
+ if len(ac_headers) == 1:
163
+ raise RuntimeError(
164
+ f'Block {idx} in {file_path}: found opening "## Acceptance Criteria" '
165
+ 'but no closing "## Acceptance Criteria" tag.'
166
+ )
167
+ cm_headers = _RE_COMMENT_HEADER.findall(block)
168
+ if len(cm_headers) == 1:
169
+ raise RuntimeError(
170
+ f'Block {idx} in {file_path}: found opening "## Comment" '
171
+ 'but no closing "## Comment" tag.'
172
+ )
173
+
174
+ lines = block.splitlines()
175
+ title = None
176
+ body_start = 0
177
+ for i, line in enumerate(lines):
178
+ if line.strip():
179
+ title = line.lstrip('#').strip()
180
+ body_start = i + 1
181
+ break
182
+ if not title:
183
+ raise RuntimeError(f'Block {idx} in {file_path}: missing title (first non-empty line).')
184
+
185
+ remaining = '\n'.join(lines[body_start:])
186
+
187
+ comment_text = None
188
+ if len(cm_headers) >= 2:
189
+ m = _RE_COMMENT_BLOCK.search(remaining)
190
+ if m:
191
+ comment_text = m.group(1).strip()
192
+ remaining = _RE_COMMENT_BLOCK.sub('', remaining, count=1)
193
+
194
+ acceptance_text = None
195
+ if len(ac_headers) >= 2:
196
+ m = _RE_AC_BLOCK.search(remaining)
197
+ if m:
198
+ acceptance_text = m.group(1).strip()
199
+ remaining = _RE_AC_BLOCK.sub('', remaining, count=1)
200
+
201
+ repo = None
202
+ milestone = None
203
+ milestone_start = None
204
+ milestone_end = None
205
+ assignee = None
206
+ has_estimate = False
207
+ has_due = False
208
+ body_lines = []
209
+
210
+ for line in remaining.splitlines():
211
+ stripped = line.strip()
212
+
213
+ m = re.match(r'^/repo\s+(\S+)\s*$', stripped, re.IGNORECASE)
214
+ if m:
215
+ repo = m.group(1)
216
+ continue
217
+ m = re.match(r'^/milestone-start\s+(\S+)\s*$', stripped, re.IGNORECASE)
218
+ if m:
219
+ milestone_start = m.group(1)
220
+ continue
221
+ m = re.match(r'^/milestone-end\s+(\S+)\s*$', stripped, re.IGNORECASE)
222
+ if m:
223
+ milestone_end = m.group(1)
224
+ continue
225
+ m = re.match(r'^/milestone\s+(.+)$', stripped, re.IGNORECASE)
226
+ if m:
227
+ milestone = _strip_milestone_wrap(m.group(1))
228
+ continue
229
+ m = re.match(r'^/assignee\s+(.+)$', stripped, re.IGNORECASE)
230
+ if m:
231
+ assignee = m.group(1).strip()
232
+ body_lines.append(f'/assign {assignee}')
233
+ continue
234
+ if re.match(r'^/estimate\s+\S+', stripped, re.IGNORECASE):
235
+ has_estimate = True
236
+ if re.match(r'^/due\s+\S+', stripped, re.IGNORECASE):
237
+ has_due = True
238
+
239
+ body_lines.append(line)
240
+
241
+ body = '\n'.join(body_lines).strip()
242
+ return {
243
+ 'title': title,
244
+ 'body': body,
245
+ 'repo': repo,
246
+ 'milestone': milestone,
247
+ 'milestone_start': milestone_start,
248
+ 'milestone_end': milestone_end,
249
+ 'assignee': assignee,
250
+ 'has_estimate': has_estimate,
251
+ 'has_due': has_due,
252
+ 'has_acceptance_criteria': acceptance_text is not None,
253
+ 'acceptance_criteria': acceptance_text,
254
+ 'has_comment': comment_text is not None,
255
+ 'comment': comment_text,
256
+ }
257
+
258
+
259
+ def build_issue_description(issue: dict) -> str:
260
+ """Compose the final GitLab issue description with acceptance criteria section."""
261
+ parts = []
262
+ if issue['body']:
263
+ parts.append(issue['body'])
264
+ ac = issue['acceptance_criteria'] if issue['has_acceptance_criteria'] else DEFAULT_ACCEPTANCE_CRITERIA
265
+ parts.append(f'## Acceptance Criteria\n{ac}')
266
+ return '\n\n'.join(parts)
@@ -8,6 +8,7 @@ from datetime import datetime
8
8
  from itw_python_builder.ssr_tasks import * # noqa: F401,F403 — exposes ssr-init
9
9
  from itw_python_builder.utils import (
10
10
  detect_and_activate_venv,
11
+ terminate_stale_test_db_sessions,
11
12
  load_env,
12
13
  get_current_branch,
13
14
  check_branch,
@@ -28,9 +29,19 @@ from itw_python_builder.utils import (
28
29
  TOKEN_CACHE_PATH,
29
30
  maybe_override_release_version,
30
31
  ensure_gitlab_token,
32
+ )
33
+ from itw_python_builder.task_utils import (
31
34
  resolve_pm_repo,
32
- create_gitlab_task,
35
+ create_gitlab_issue,
33
36
  parse_md_issues,
37
+ build_issue_description,
38
+ get_project_by_path,
39
+ find_project_milestone,
40
+ create_project_milestone,
41
+ create_issue_note,
42
+ log_green,
43
+ log_red,
44
+ log_info,
34
45
  )
35
46
  from itw_python_builder.version import Version
36
47
 
@@ -113,7 +124,7 @@ def logout(ctx: Context):
113
124
 
114
125
  @task(name='task')
115
126
  def create_task(ctx: Context, file=None):
116
- """Create GitLab tasks in the group's _pm repo from a markdown file (--file path.md)."""
127
+ """Create GitLab issues in the group's _pm repo from a markdown file (--file path.md)."""
117
128
  if not file:
118
129
  raise RuntimeError('Missing --file path to markdown file.')
119
130
  if not file.lower().endswith('.md'):
@@ -123,51 +134,126 @@ def create_task(ctx: Context, file=None):
123
134
  if not os.path.isdir(os.path.join(os.getcwd(), '.git')):
124
135
  raise RuntimeError('Not a git repository (no .git folder here).')
125
136
 
126
- issues = parse_md_issues(file)
127
- print(f'[itw] Parsed {len(issues)} task(s) from {file}.')
137
+ try:
138
+ issues = parse_md_issues(file)
139
+ except RuntimeError as exc:
140
+ log_red(str(exc))
141
+ raise
142
+ print(f'[itw] Parsed {len(issues)} issue(s) from {file}.')
128
143
 
129
144
  token = ensure_gitlab_token(ctx)
130
145
  host, project_path = _parse_gitlab_remote(ctx)
131
146
  api_host = _GITLAB_API_HOST_MAP.get(host, host)
132
147
 
133
- print(f'[itw] Looking for _pm repos under {project_path!r}...')
148
+ for idx, issue in enumerate(issues, start=1):
149
+ print(f'\n[itw] Issue {idx}/{len(issues)}: {issue["title"]}')
150
+ try:
151
+ project = _resolve_target_project(api_host, project_path, issue, token)
152
+
153
+ if issue['has_acceptance_criteria']:
154
+ log_info('Using inputed acceptance criteria')
155
+ else:
156
+ log_green(
157
+ 'Default acceptance criteria applied. To define a custom acceptance criteria, '
158
+ 'define the section ## Acceptance Criteria in your md file and make sure to end '
159
+ 'it with ## Acceptance Criteria'
160
+ )
161
+
162
+ if issue['assignee']:
163
+ log_info(f'This issue will be assigned to {issue["assignee"]}')
164
+ else:
165
+ log_red('No asignee is defined for this issue.Use /assignee in you md file to define an asignee')
166
+
167
+ if not issue['has_estimate']:
168
+ log_red('There is no estimation for this issue. Use /estimate in you md file to define an estimation')
169
+
170
+ if not issue['has_due']:
171
+ log_red('No due date is defined for this issue. Use /due in you md file to define a due date')
172
+
173
+ milestone_id = _resolve_milestone(api_host, project['id'], issue, token)
174
+
175
+ description = build_issue_description(issue)
176
+ payload = {'title': issue['title'], 'description': description}
177
+ if milestone_id is not None:
178
+ payload['milestone_id'] = milestone_id
179
+
180
+ result = create_gitlab_issue(api_host, project['id'], payload, token)
181
+ print(f'[itw] Created: {result["web_url"]}')
182
+
183
+ if issue['has_comment']:
184
+ create_issue_note(api_host, project['id'], result['iid'], issue['comment'], token)
185
+ log_info('Comments added sucessfully')
186
+ else:
187
+ log_green(
188
+ 'No coments found on this issue. To leave a comment define the section '
189
+ '## Comment section in your md file and make sure to end it with ## Comment'
190
+ )
191
+ except Exception as exc:
192
+ log_red(f'Failed at issue {idx}/{len(issues)} ({issue["title"]!r}): {exc}')
193
+ print(f'[itw] {idx - 1} issue(s) were created successfully before this failure.')
194
+ sys.exit(1)
195
+
196
+
197
+ def _resolve_target_project(api_host: str, project_path: str, issue: dict, token: str) -> dict:
198
+ if issue['repo']:
199
+ log_info(f'Using {issue["repo"]} repo')
200
+ return get_project_by_path(api_host, issue['repo'], token)
201
+
202
+ log_green(f'Using current git repo {project_path}')
134
203
  candidates = resolve_pm_repo(api_host, project_path, token)
135
204
  if not candidates:
136
205
  raise RuntimeError(
137
206
  f'No _pm repo found anywhere in the namespace of {project_path!r}.'
138
207
  )
139
-
140
208
  if len(candidates) == 1:
141
209
  chosen = candidates[0]
142
210
  print(f'[itw] Using _pm repo: {chosen["path_with_namespace"]}')
143
- else:
144
- if not sys.stdin.isatty():
145
- names = ', '.join(p['path_with_namespace'] for p in candidates)
146
- raise RuntimeError(
147
- f'{len(candidates)} _pm repos found ({names}) and stdin is not a TTY — cannot prompt.'
148
- )
149
- print(f'[itw] Found {len(candidates)} _pm repos:')
150
- for i, p in enumerate(candidates, start=1):
151
- print(f' [{i}] {p["path_with_namespace"]}')
152
- while True:
153
- raw = input(f'Pick one (1-{len(candidates)}): ').strip()
154
- try:
155
- idx = int(raw)
156
- if 1 <= idx <= len(candidates):
157
- chosen = candidates[idx - 1]
158
- break
159
- except ValueError:
160
- pass
161
- print('Invalid choice, try again.')
162
-
163
- for idx, (title, body) in enumerate(issues, start=1):
211
+ return chosen
212
+ if not sys.stdin.isatty():
213
+ names = ', '.join(p['path_with_namespace'] for p in candidates)
214
+ raise RuntimeError(
215
+ f'{len(candidates)} _pm repos found ({names}) and stdin is not a TTY — cannot prompt.'
216
+ )
217
+ print(f'[itw] Found {len(candidates)} _pm repos:')
218
+ for i, p in enumerate(candidates, start=1):
219
+ print(f' [{i}] {p["path_with_namespace"]}')
220
+ while True:
221
+ raw = input(f'Pick one (1-{len(candidates)}): ').strip()
164
222
  try:
165
- result = create_gitlab_task(api_host, chosen['id'], title, body, token)
166
- except Exception as exc:
167
- print(f'[itw] Failed at task {idx}/{len(issues)} ({title!r}): {exc}')
168
- print(f'[itw] {idx - 1} task(s) were created successfully before this failure.')
169
- raise
170
- print(f'[itw] Created: {result["web_url"]}')
223
+ idx = int(raw)
224
+ if 1 <= idx <= len(candidates):
225
+ return candidates[idx - 1]
226
+ except ValueError:
227
+ pass
228
+ print('Invalid choice, try again.')
229
+
230
+
231
+ def _resolve_milestone(api_host: str, project_id: int, issue: dict, token: str):
232
+ title = issue['milestone']
233
+ if not title:
234
+ log_red(
235
+ 'There is no milestone defined for this issue. '
236
+ 'Use /milestone in your md file to define a milestone'
237
+ )
238
+ return None
239
+
240
+ existing = find_project_milestone(api_host, project_id, title, token)
241
+ if existing:
242
+ log_info(f'Using existing milestone {title}')
243
+ return existing['id']
244
+
245
+ start = issue['milestone_start']
246
+ end = issue['milestone_end']
247
+ if start and end:
248
+ created = create_project_milestone(api_host, project_id, title, start, end, token)
249
+ log_green(f'Created new milestone {title} | {start}-{end}')
250
+ else:
251
+ created = create_project_milestone(api_host, project_id, title, token=token)
252
+ log_green(
253
+ f'Created new milestone {title} | No start date or end date is defined for this '
254
+ 'new milestone. Use /milestone-start and /milestone-end in your md file to define them '
255
+ )
256
+ return created['id']
171
257
 
172
258
 
173
259
  def build_frontend(ctx: Context, branch: str, ssr: bool = False) -> None:
@@ -308,7 +394,7 @@ def tag_build_push(ctx: Context, version: Version, skip_pipeline: bool = False,
308
394
  install_sigint_guard()
309
395
  project_type = detect_project_type()
310
396
  if not skip_pipeline:
311
- step('tests', test, ctx)
397
+ step('tests', test, ctx, warn=False)
312
398
  step('analyze', analyze, ctx)
313
399
  step('tag', tag, ctx, version)
314
400
  if project_type == 'backend':
@@ -442,14 +528,26 @@ def release(ctx: Context, skip_pipeline=False, ssr=False) -> None:
442
528
 
443
529
 
444
530
  @task
445
- def test(ctx: Context, settings='backend.test_settings', warn=False):
446
- """Run tests with coverage. --warn lets the pipeline continue and print coverage on failure."""
531
+ def test(ctx: Context, settings='backend.test_settings', warn=True):
532
+ """Run tests with coverage."""
447
533
  if detect_project_type() == 'frontend':
448
534
  ctx.run('npx ng test --no-watch --code-coverage --browsers=ChromeHeadlessNoSandbox', warn=warn)
449
535
  print("✓ Tests completed")
450
536
  return
451
537
  detect_and_activate_venv()
452
- ctx.run(f'python -m coverage run manage.py test --settings={settings} --noinput -v 2', warn=warn)
538
+ load_env(ctx)
539
+ terminate_stale_test_db_sessions(ctx, settings)
540
+ patch_dir = str(Path(__file__).parent / '_pyruntime')
541
+ existing_pp = os.environ.get('PYTHONPATH', '')
542
+ injected_pp = f'{patch_dir}:{existing_pp}' if existing_pp else patch_dir
543
+ try:
544
+ ctx.run(
545
+ f'python -m coverage run manage.py test --settings={settings} --noinput -v 2',
546
+ warn=warn,
547
+ env={'PYTHONPATH': injected_pp},
548
+ )
549
+ finally:
550
+ terminate_stale_test_db_sessions(ctx, settings)
453
551
  ctx.run('python -m coverage report -m')
454
552
  ctx.run('python -m coverage xml -o coverage.xml')
455
553
  print("✓ Tests completed")
@@ -547,7 +645,7 @@ def pipelinelocal(ctx: Context, settings='backend.test_settings', pylintrc=None)
547
645
  print("\n" + "=" * 60)
548
646
  print(f"Step {'2' if project_type == 'backend' else '1'}: Running tests...")
549
647
  print("=" * 60)
550
- step('tests', test, ctx, settings, warn=True)
648
+ step('tests', test, ctx, settings, warn=False)
551
649
 
552
650
  print("\n" + "=" * 60)
553
651
  print(f"Step {'3' if project_type == 'backend' else '2'}: Running SonarQube analysis...")
@@ -2,8 +2,10 @@ import io
2
2
  import json
3
3
  import os
4
4
  import re
5
+ import shlex
5
6
  import signal
6
7
  import sys
8
+ import textwrap
7
9
  from pathlib import Path
8
10
 
9
11
  from invoke import Context
@@ -120,6 +122,60 @@ def detect_and_activate_venv():
120
122
  _venv_activated = True
121
123
 
122
124
 
125
+ def terminate_stale_test_db_sessions(ctx: Context, settings_module: str) -> None:
126
+ script = textwrap.dedent(f'''
127
+ import os, sys
128
+ os.environ["DJANGO_SETTINGS_MODULE"] = {settings_module!r}
129
+ try:
130
+ import django
131
+ django.setup()
132
+ from django.conf import settings as _s
133
+ except Exception as _e:
134
+ print(f"[itw] session-cleanup skipped: cannot load Django settings ({{_e}})")
135
+ sys.exit(0)
136
+ try:
137
+ import psycopg2 as _pg
138
+ except ImportError:
139
+ try:
140
+ import psycopg as _pg
141
+ except ImportError:
142
+ print("[itw] session-cleanup skipped: no psycopg installed")
143
+ sys.exit(0)
144
+ for _alias, _cfg in _s.DATABASES.items():
145
+ if "postgresql" not in _cfg.get("ENGINE", ""):
146
+ continue
147
+ _test_name = (_cfg.get("TEST") or {{}}).get("NAME") or f"test_{{_cfg.get('NAME', '')}}"
148
+ if not _test_name or _test_name == "test_":
149
+ continue
150
+ try:
151
+ _conn = _pg.connect(
152
+ host=_cfg.get("HOST") or "localhost",
153
+ port=int(_cfg.get("PORT") or 5432),
154
+ user=_cfg.get("USER") or None,
155
+ password=_cfg.get("PASSWORD") or None,
156
+ dbname="postgres",
157
+ connect_timeout=5,
158
+ )
159
+ _conn.autocommit = True
160
+ _cur = _conn.cursor()
161
+ _cur.execute(
162
+ "SELECT pg_terminate_backend(pid) FROM pg_stat_activity "
163
+ "WHERE datname = %s AND pid <> pg_backend_pid()",
164
+ [_test_name],
165
+ )
166
+ _rows = _cur.fetchall()
167
+ _cur.close()
168
+ _conn.close()
169
+ _killed = len(_rows)
170
+ if _killed > 0:
171
+ print(f"[itw] Terminated {{_killed}} stale session(s) on {{_test_name}}")
172
+ except Exception as _e:
173
+ print(f"[itw] session-cleanup skipped for {{_test_name}}: {{_e}}")
174
+ continue
175
+ ''').strip()
176
+ ctx.run(f'python -c {shlex.quote(script)}', warn=True)
177
+
178
+
123
179
  def _parse_angular_env_file(path: str) -> dict:
124
180
  """Extract string-valued keys from an Angular environment.ts object literal."""
125
181
  with open(path, 'r', encoding='utf-8') as fp:
@@ -494,72 +550,3 @@ def ensure_gitlab_token(ctx: Context) -> str:
494
550
  return token
495
551
 
496
552
 
497
- def _list_group_projects(api_host: str, group_path: str, token: str) -> list:
498
- """List up to 100 projects in a group matching '_pm'."""
499
- from urllib.parse import quote
500
- encoded = quote(group_path, safe='')
501
- return _gitlab_api_call(
502
- api_host,
503
- f'/groups/{encoded}/projects?per_page=100&search=_pm',
504
- token,
505
- )
506
-
507
-
508
- def resolve_pm_repo(api_host: str, project_path: str, token: str) -> list:
509
- """Find _pm repos by walking the namespace upward from project_path."""
510
- parts = project_path.split('/')
511
- if len(parts) < 2:
512
- raise RuntimeError(f'Project path {project_path!r} has no parent group.')
513
- parts.pop()
514
- while parts:
515
- namespace = '/'.join(parts)
516
- try:
517
- projects = _list_group_projects(api_host, namespace, token)
518
- except RuntimeError as exc:
519
- if '404' in str(exc):
520
- parts.pop()
521
- continue
522
- raise
523
- matches = [p for p in projects if p.get('path', '').endswith('_pm')]
524
- if matches:
525
- return matches
526
- parts.pop()
527
- return []
528
-
529
-
530
- def create_gitlab_task(api_host: str, project_id: int, title: str, description: str, token: str) -> dict:
531
- """Create a GitLab task in the given project."""
532
- return _gitlab_api_call(
533
- api_host,
534
- f'/projects/{project_id}/issues',
535
- token,
536
- method='POST',
537
- payload={'title': title, 'description': description, 'issue_type': 'task'},
538
- )
539
-
540
-
541
- def parse_md_issues(file_path: str) -> list:
542
- """Parse a markdown file into (title, body) blocks separated by '==='."""
543
- with open(file_path, 'r', encoding='utf-8') as fp:
544
- content = fp.read().replace('\r\n', '\n')
545
- blocks = re.split(r'(?m)^\s*===\s*$', content)
546
- issues = []
547
- for idx, block in enumerate(blocks, start=1):
548
- block = block.strip()
549
- if not block:
550
- continue
551
- lines = block.splitlines()
552
- title = None
553
- body_start = 0
554
- for i, line in enumerate(lines):
555
- if line.strip():
556
- title = line.lstrip('#').strip()
557
- body_start = i + 1
558
- break
559
- if not title:
560
- raise RuntimeError(f'Block {idx} in {file_path}: missing title (first non-empty line).')
561
- body = '\n'.join(lines[body_start:]).strip()
562
- issues.append((title, body))
563
- if not issues:
564
- raise RuntimeError(f'No issues found in {file_path}.')
565
- return issues
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: itw_python_builder
3
- Version: 0.2.5
3
+ Version: 0.2.6
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
@@ -24,6 +24,7 @@ Requires-Dist: invoke>=2.0.0
24
24
  Requires-Dist: pylint>=3.0.0
25
25
  Requires-Dist: pylint-django>=2.5.0
26
26
  Requires-Dist: python-decouple>=3.8
27
+ Requires-Dist: requests>=2.28.0
27
28
  Dynamic: license-file
28
29
 
29
30
  # ITW Python Builder
@@ -6,6 +6,7 @@ itw_python_builder/__init__.py
6
6
  itw_python_builder/cli.py
7
7
  itw_python_builder/notify.py
8
8
  itw_python_builder/ssr_tasks.py
9
+ itw_python_builder/task_utils.py
9
10
  itw_python_builder/tasks.py
10
11
  itw_python_builder/utils.py
11
12
  itw_python_builder/version.py
@@ -15,6 +16,7 @@ itw_python_builder.egg-info/dependency_links.txt
15
16
  itw_python_builder.egg-info/entry_points.txt
16
17
  itw_python_builder.egg-info/requires.txt
17
18
  itw_python_builder.egg-info/top_level.txt
19
+ itw_python_builder/_pyruntime/sitecustomize.py
18
20
  itw_python_builder/templates/new_version_email.html
19
21
  itw_python_builder/templates/server.sitemap.snippet.ts
20
22
  itw_python_builder/templates/sitemap.routes.ts
@@ -2,3 +2,4 @@ invoke>=2.0.0
2
2
  pylint>=3.0.0
3
3
  pylint-django>=2.5.0
4
4
  python-decouple>=3.8
5
+ requests>=2.28.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "itw_python_builder"
7
- version = "0.2.5"
7
+ version = "0.2.6"
8
8
  description = "Standardized Django deployment pipeline with Docker, testing, and SonarQube integration"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -29,6 +29,7 @@ dependencies = [
29
29
  "pylint>=3.0.0",
30
30
  "pylint-django>=2.5.0",
31
31
  "python-decouple>=3.8",
32
+ "requests>=2.28.0",
32
33
  ]
33
34
 
34
35
  [project.urls]
@@ -40,7 +41,7 @@ Issues = "https://git.it-works.io/"
40
41
  include-package-data = true
41
42
 
42
43
  [tool.setuptools.package-data]
43
- itw_python_builder = [".pylintrc", "templates/*.ts", "templates/*.html"]
44
+ itw_python_builder = [".pylintrc", "templates/*.ts", "templates/*.html", "_pyruntime/*.py"]
44
45
 
45
46
  [project.scripts]
46
47
  itw = "itw_python_builder.cli:main"