itw-python-builder 0.2.12__py3-none-any.whl → 0.2.14__py3-none-any.whl

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