itw-python-builder 0.2.0__tar.gz → 0.2.2__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.0 → itw_python_builder-0.2.2}/PKG-INFO +1 -1
- {itw_python_builder-0.2.0 → itw_python_builder-0.2.2}/itw_python_builder/tasks.py +65 -1
- {itw_python_builder-0.2.0 → itw_python_builder-0.2.2}/itw_python_builder/templates/new_version_email.html +2 -1
- {itw_python_builder-0.2.0 → itw_python_builder-0.2.2}/itw_python_builder/utils.py +135 -0
- {itw_python_builder-0.2.0 → itw_python_builder-0.2.2}/itw_python_builder.egg-info/PKG-INFO +1 -1
- {itw_python_builder-0.2.0 → itw_python_builder-0.2.2}/pyproject.toml +1 -1
- {itw_python_builder-0.2.0 → itw_python_builder-0.2.2}/LICENSE +0 -0
- {itw_python_builder-0.2.0 → itw_python_builder-0.2.2}/README.md +0 -0
- {itw_python_builder-0.2.0 → itw_python_builder-0.2.2}/itw_python_builder/.pylintrc +0 -0
- {itw_python_builder-0.2.0 → itw_python_builder-0.2.2}/itw_python_builder/__init__.py +0 -0
- {itw_python_builder-0.2.0 → itw_python_builder-0.2.2}/itw_python_builder/cli.py +0 -0
- {itw_python_builder-0.2.0 → itw_python_builder-0.2.2}/itw_python_builder/notify.py +0 -0
- {itw_python_builder-0.2.0 → itw_python_builder-0.2.2}/itw_python_builder/ssr_tasks.py +0 -0
- {itw_python_builder-0.2.0 → itw_python_builder-0.2.2}/itw_python_builder/templates/server.sitemap.snippet.ts +0 -0
- {itw_python_builder-0.2.0 → itw_python_builder-0.2.2}/itw_python_builder/templates/sitemap.routes.ts +0 -0
- {itw_python_builder-0.2.0 → itw_python_builder-0.2.2}/itw_python_builder/version.py +0 -0
- {itw_python_builder-0.2.0 → itw_python_builder-0.2.2}/itw_python_builder.egg-info/SOURCES.txt +0 -0
- {itw_python_builder-0.2.0 → itw_python_builder-0.2.2}/itw_python_builder.egg-info/dependency_links.txt +0 -0
- {itw_python_builder-0.2.0 → itw_python_builder-0.2.2}/itw_python_builder.egg-info/entry_points.txt +0 -0
- {itw_python_builder-0.2.0 → itw_python_builder-0.2.2}/itw_python_builder.egg-info/requires.txt +0 -0
- {itw_python_builder-0.2.0 → itw_python_builder-0.2.2}/itw_python_builder.egg-info/top_level.txt +0 -0
- {itw_python_builder-0.2.0 → itw_python_builder-0.2.2}/setup.cfg +0 -0
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import getpass
|
|
2
2
|
import io
|
|
3
3
|
import shutil
|
|
4
|
+
import sys
|
|
4
5
|
import tempfile
|
|
5
6
|
from datetime import datetime
|
|
6
7
|
|
|
@@ -26,6 +27,10 @@ from itw_python_builder.utils import (
|
|
|
26
27
|
probe_gitlab_token,
|
|
27
28
|
TOKEN_CACHE_PATH,
|
|
28
29
|
maybe_override_release_version,
|
|
30
|
+
ensure_gitlab_token,
|
|
31
|
+
resolve_pm_repo,
|
|
32
|
+
create_gitlab_task,
|
|
33
|
+
parse_md_issues,
|
|
29
34
|
)
|
|
30
35
|
from itw_python_builder.version import Version
|
|
31
36
|
|
|
@@ -106,6 +111,65 @@ def logout(ctx: Context):
|
|
|
106
111
|
os.environ.pop('GITLAB_USERNAME', None)
|
|
107
112
|
|
|
108
113
|
|
|
114
|
+
@task(name='task')
|
|
115
|
+
def create_task(ctx: Context, file=None):
|
|
116
|
+
"""Create GitLab tasks in the group's _pm repo from a markdown file (--file path.md)."""
|
|
117
|
+
if not file:
|
|
118
|
+
raise RuntimeError('Missing --file path to markdown file.')
|
|
119
|
+
if not file.lower().endswith('.md'):
|
|
120
|
+
raise RuntimeError(f'File must be .md, got {file!r}.')
|
|
121
|
+
if not os.path.isfile(file):
|
|
122
|
+
raise RuntimeError(f'File not found: {file}')
|
|
123
|
+
if not os.path.isdir(os.path.join(os.getcwd(), '.git')):
|
|
124
|
+
raise RuntimeError('Not a git repository (no .git folder here).')
|
|
125
|
+
|
|
126
|
+
issues = parse_md_issues(file)
|
|
127
|
+
print(f'[itw] Parsed {len(issues)} task(s) from {file}.')
|
|
128
|
+
|
|
129
|
+
token = ensure_gitlab_token(ctx)
|
|
130
|
+
host, project_path = _parse_gitlab_remote(ctx)
|
|
131
|
+
api_host = _GITLAB_API_HOST_MAP.get(host, host)
|
|
132
|
+
|
|
133
|
+
print(f'[itw] Looking for _pm repos under {project_path!r}...')
|
|
134
|
+
candidates = resolve_pm_repo(api_host, project_path, token)
|
|
135
|
+
if not candidates:
|
|
136
|
+
raise RuntimeError(
|
|
137
|
+
f'No _pm repo found anywhere in the namespace of {project_path!r}.'
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
if len(candidates) == 1:
|
|
141
|
+
chosen = candidates[0]
|
|
142
|
+
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):
|
|
164
|
+
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"]}')
|
|
171
|
+
|
|
172
|
+
|
|
109
173
|
def build_frontend(ctx: Context, branch: str, ssr: bool = False) -> None:
|
|
110
174
|
"""Build the Angular app. With ssr=True uses the SSR npm scripts."""
|
|
111
175
|
if ssr:
|
|
@@ -277,7 +341,7 @@ def tag_build_push(ctx: Context, version: Version, skip_pipeline: bool = False,
|
|
|
277
341
|
def taginit(ctx: Context) -> None:
|
|
278
342
|
"""Initialize version tagging"""
|
|
279
343
|
check_branch(ctx)
|
|
280
|
-
version = Version(0,
|
|
344
|
+
version = Version(0, 0, 1, 1)
|
|
281
345
|
tag(ctx, version)
|
|
282
346
|
save_version(version)
|
|
283
347
|
|
|
@@ -26,7 +26,8 @@
|
|
|
26
26
|
{COMMIT_MESSAGE}
|
|
27
27
|
</div>
|
|
28
28
|
<p style="margin:0 0 8px 0;font-size:15px;"><strong>Komanda për të bërë upgrade:</strong></p>
|
|
29
|
-
<
|
|
29
|
+
<p style="margin:0 0 6px 0;font-size:12px;color:#6b7280;">Klikoni mbi komandën për ta zgjedhur, pastaj Ctrl+C (ose ⌘+C).</p>
|
|
30
|
+
<pre style="background:#0f172a;color:#f9fafb;padding:14px 16px;border-radius:6px;font-size:13px;overflow-x:auto;margin:0 0 20px 0;-webkit-user-select:all;-moz-user-select:all;-ms-user-select:all;user-select:all;cursor:text;">pip install itw-python-builder=={VERSION} --no-cache-dir</pre>
|
|
30
31
|
<p style="margin:0;font-size:13px;color:#6b7280;">Faleminderit</p>
|
|
31
32
|
</td>
|
|
32
33
|
</tr>
|
|
@@ -420,3 +420,138 @@ def is_ssr_project() -> bool:
|
|
|
420
420
|
os.path.isfile(os.path.join(cwd, 'server.ts'))
|
|
421
421
|
and os.path.isfile(os.path.join(cwd, 'tsconfig.server.json'))
|
|
422
422
|
)
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _gitlab_api_call(api_host: str, endpoint: str, token: str, method: str = 'GET', payload=None):
|
|
426
|
+
"""Make a JSON call to the GitLab v4 API and return the parsed body."""
|
|
427
|
+
import ssl
|
|
428
|
+
import urllib.error
|
|
429
|
+
import urllib.request
|
|
430
|
+
|
|
431
|
+
url = f'https://{api_host}/api/v4{endpoint}'
|
|
432
|
+
headers = {
|
|
433
|
+
'PRIVATE-TOKEN': token,
|
|
434
|
+
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)',
|
|
435
|
+
}
|
|
436
|
+
data = None
|
|
437
|
+
if payload is not None:
|
|
438
|
+
data = json.dumps(payload).encode('utf-8')
|
|
439
|
+
headers['Content-Type'] = 'application/json'
|
|
440
|
+
|
|
441
|
+
req = urllib.request.Request(url, headers=headers, method=method, data=data)
|
|
442
|
+
ssl_ctx = ssl.create_default_context()
|
|
443
|
+
ssl_ctx.check_hostname = False
|
|
444
|
+
ssl_ctx.verify_mode = ssl.CERT_NONE
|
|
445
|
+
try:
|
|
446
|
+
with urllib.request.urlopen(req, context=ssl_ctx, timeout=15) as resp:
|
|
447
|
+
return json.loads(resp.read().decode('utf-8'))
|
|
448
|
+
except urllib.error.HTTPError as exc:
|
|
449
|
+
body = exc.read().decode('utf-8', errors='replace')[:500]
|
|
450
|
+
raise RuntimeError(f'GitLab API {method} {endpoint} failed ({exc.code}): {body}') from exc
|
|
451
|
+
except (urllib.error.URLError, TimeoutError, OSError) as exc:
|
|
452
|
+
raise RuntimeError(f'GitLab API {method} {endpoint} unreachable: {exc}') from exc
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def ensure_gitlab_token(ctx: Context) -> str:
|
|
456
|
+
"""Resolve a usable GitLab token."""
|
|
457
|
+
import getpass
|
|
458
|
+
|
|
459
|
+
if os.environ.get('GITLAB_TOKEN'):
|
|
460
|
+
return os.environ['GITLAB_TOKEN']
|
|
461
|
+
|
|
462
|
+
host, _ = _parse_gitlab_remote(ctx)
|
|
463
|
+
api_host = {'git.it-works.io': 'gitlab.it-works.io'}.get(host, host)
|
|
464
|
+
|
|
465
|
+
cached = load_cached_token()
|
|
466
|
+
if cached:
|
|
467
|
+
try:
|
|
468
|
+
_gitlab_api_call(api_host, '/user', cached)
|
|
469
|
+
os.environ['GITLAB_TOKEN'] = cached
|
|
470
|
+
print('[itw] Using cached GitLab token.')
|
|
471
|
+
return cached
|
|
472
|
+
except RuntimeError as exc:
|
|
473
|
+
msg = str(exc)
|
|
474
|
+
if '401' in msg or '403' in msg:
|
|
475
|
+
print('[itw] Cached GitLab token rejected. Please re-enter.')
|
|
476
|
+
else:
|
|
477
|
+
print(f'[itw] Could not verify cached token ({exc}); using it anyway.')
|
|
478
|
+
os.environ['GITLAB_TOKEN'] = cached
|
|
479
|
+
return cached
|
|
480
|
+
|
|
481
|
+
token = getpass.getpass('Enter GitLab token: ')
|
|
482
|
+
if not token:
|
|
483
|
+
raise RuntimeError('No token entered; aborting.')
|
|
484
|
+
os.environ['GITLAB_TOKEN'] = token
|
|
485
|
+
save_token_to_cache(token)
|
|
486
|
+
return token
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def _list_group_projects(api_host: str, group_path: str, token: str) -> list:
|
|
490
|
+
"""List up to 100 projects in a group matching '_pm'."""
|
|
491
|
+
from urllib.parse import quote
|
|
492
|
+
encoded = quote(group_path, safe='')
|
|
493
|
+
return _gitlab_api_call(
|
|
494
|
+
api_host,
|
|
495
|
+
f'/groups/{encoded}/projects?per_page=100&search=_pm',
|
|
496
|
+
token,
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def resolve_pm_repo(api_host: str, project_path: str, token: str) -> list:
|
|
501
|
+
"""Find _pm repos by walking the namespace upward from project_path."""
|
|
502
|
+
parts = project_path.split('/')
|
|
503
|
+
if len(parts) < 2:
|
|
504
|
+
raise RuntimeError(f'Project path {project_path!r} has no parent group.')
|
|
505
|
+
parts.pop()
|
|
506
|
+
while parts:
|
|
507
|
+
namespace = '/'.join(parts)
|
|
508
|
+
try:
|
|
509
|
+
projects = _list_group_projects(api_host, namespace, token)
|
|
510
|
+
except RuntimeError as exc:
|
|
511
|
+
if '404' in str(exc):
|
|
512
|
+
parts.pop()
|
|
513
|
+
continue
|
|
514
|
+
raise
|
|
515
|
+
matches = [p for p in projects if p.get('path', '').endswith('_pm')]
|
|
516
|
+
if matches:
|
|
517
|
+
return matches
|
|
518
|
+
parts.pop()
|
|
519
|
+
return []
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def create_gitlab_task(api_host: str, project_id: int, title: str, description: str, token: str) -> dict:
|
|
523
|
+
"""Create a GitLab task in the given project."""
|
|
524
|
+
return _gitlab_api_call(
|
|
525
|
+
api_host,
|
|
526
|
+
f'/projects/{project_id}/issues',
|
|
527
|
+
token,
|
|
528
|
+
method='POST',
|
|
529
|
+
payload={'title': title, 'description': description, 'issue_type': 'task'},
|
|
530
|
+
)
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def parse_md_issues(file_path: str) -> list:
|
|
534
|
+
"""Parse a markdown file into (title, body) blocks separated by '==='."""
|
|
535
|
+
with open(file_path, 'r', encoding='utf-8') as fp:
|
|
536
|
+
content = fp.read().replace('\r\n', '\n')
|
|
537
|
+
blocks = re.split(r'(?m)^\s*===\s*$', content)
|
|
538
|
+
issues = []
|
|
539
|
+
for idx, block in enumerate(blocks, start=1):
|
|
540
|
+
block = block.strip()
|
|
541
|
+
if not block:
|
|
542
|
+
continue
|
|
543
|
+
lines = block.splitlines()
|
|
544
|
+
title = None
|
|
545
|
+
body_start = 0
|
|
546
|
+
for i, line in enumerate(lines):
|
|
547
|
+
if line.strip():
|
|
548
|
+
title = line.lstrip('#').strip()
|
|
549
|
+
body_start = i + 1
|
|
550
|
+
break
|
|
551
|
+
if not title:
|
|
552
|
+
raise RuntimeError(f'Block {idx} in {file_path}: missing title (first non-empty line).')
|
|
553
|
+
body = '\n'.join(lines[body_start:]).strip()
|
|
554
|
+
issues.append((title, body))
|
|
555
|
+
if not issues:
|
|
556
|
+
raise RuntimeError(f'No issues found in {file_path}.')
|
|
557
|
+
return issues
|
|
@@ -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.2"
|
|
8
8
|
description = "Standardized Django deployment pipeline with Docker, testing, and SonarQube integration"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
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.0 → itw_python_builder-0.2.2}/itw_python_builder/templates/sitemap.routes.ts
RENAMED
|
File without changes
|
|
File without changes
|
{itw_python_builder-0.2.0 → itw_python_builder-0.2.2}/itw_python_builder.egg-info/SOURCES.txt
RENAMED
|
File without changes
|
|
File without changes
|
{itw_python_builder-0.2.0 → itw_python_builder-0.2.2}/itw_python_builder.egg-info/entry_points.txt
RENAMED
|
File without changes
|
{itw_python_builder-0.2.0 → itw_python_builder-0.2.2}/itw_python_builder.egg-info/requires.txt
RENAMED
|
File without changes
|
{itw_python_builder-0.2.0 → itw_python_builder-0.2.2}/itw_python_builder.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|