project-auto-wizard 0.12.2 → 0.13.0
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.
- package/README.ja.md +165 -0
- package/README.ko.md +165 -0
- package/README.md +107 -286
- package/README.zh-CN.md +165 -0
- package/bin/project-auto-wizard.js +1 -2
- package/package.json +7 -3
- package/payload/config/breaking-changes.json +24 -0
- package/payload/config/wizard-prompts.yml +45 -0
- package/payload/scripts/changelog_manager.py +141 -46
- package/payload/scripts/issue_helper.py +34 -5
- package/payload/scripts/version_manager.py +397 -97
- package/payload/version.yml.template +4 -2
- package/payload/workflows/common/PROJECT-COMMON-AI-PR-SUMMARY.yaml +39 -9
- package/payload/workflows/common/PROJECT-COMMON-AUTO-CHANGELOG-CONTROL.yaml +165 -38
- package/payload/workflows/common/PROJECT-COMMON-README-VERSION-UPDATE.yaml +14 -5
- package/payload/workflows/common/PROJECT-COMMON-RELEASE-PUBLISH.yaml +220 -6
- package/payload/workflows/common/PROJECT-COMMON-VERSION-CONTROL.yaml +89 -3
- package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-FIREBASE-CICD.yaml +78 -16
- package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-PLAYSTORE-CICD.yaml +72 -16
- package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-SELFHOSTED-CICD.yaml +62 -20
- package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-TEST-APK.yaml +51 -13
- package/payload/workflows/flutter/PROJECT-FLUTTER-CI.yaml +75 -11
- package/payload/workflows/flutter/PROJECT-FLUTTER-IOS-TEST-TESTFLIGHT.yaml +52 -3
- package/payload/workflows/flutter/PROJECT-FLUTTER-IOS-TESTFLIGHT.yaml +60 -11
- package/payload/workflows/go/PROJECT-GO-CI.yaml +10 -1
- package/payload/workflows/go/PROJECT-GO-PR-PREVIEW.yaml +148 -28
- package/payload/workflows/go/PROJECT-GO-SIMPLE-CICD.yaml +45 -4
- package/payload/workflows/next/PROJECT-NEXT-CI.yaml +25 -3
- package/payload/workflows/next/PROJECT-NEXT-CICD.yaml +83 -9
- package/payload/workflows/python/PROJECT-PYTHON-CI.yaml +70 -6
- package/payload/workflows/python/PROJECT-PYTHON-PR-PREVIEW.yaml +148 -28
- package/payload/workflows/python/PROJECT-PYTHON-SIMPLE-CICD.yaml +45 -4
- package/payload/workflows/react/PROJECT-REACT-CI.yaml +25 -3
- package/payload/workflows/react/PROJECT-REACT-CICD.yaml +84 -10
- package/payload/workflows/spring/PROJECT-SPRING-CI.yml +13 -3
- package/payload/workflows/spring/server-deploy/PROJECT-SPRING-NONSTOP-NGINX-CICD.yaml +47 -5
- package/payload/workflows/spring/server-deploy/PROJECT-SPRING-NONSTOP-TRAEFIK-CICD.yaml +47 -5
- package/payload/workflows/spring/server-deploy/PROJECT-SPRING-PR-PREVIEW.yaml +153 -30
- package/payload/workflows/spring/server-deploy/PROJECT-SPRING-SIMPLE-CICD.yaml +47 -5
- package/src/cli/args.js +33 -24
- package/src/cli/help.js +16 -4
- package/src/commands/doctor.js +17 -10
- package/src/commands/dry-run.js +54 -1
- package/src/commands/full.js +127 -50
- package/src/commands/interactive-flutter.js +1 -1
- package/src/commands/interactive.js +124 -56
- package/src/commands/purge.js +59 -17
- package/src/commands/status.js +16 -1
- package/src/commands/uninstall.js +58 -13
- package/src/context.js +5 -5
- package/src/core/baseline.js +20 -5
- package/src/core/branches.js +19 -1
- package/src/core/branding.js +1 -1
- package/src/core/breaking-check.js +14 -19
- package/src/core/breaking.js +7 -3
- package/src/core/copy/gitignore.js +20 -12
- package/src/core/copy/readme.js +24 -5
- package/src/core/copy/simple.js +52 -13
- package/src/core/copy/workflows.js +90 -47
- package/src/core/deploy-style.js +83 -11
- package/src/core/detect-fs.js +127 -24
- package/src/core/detect.js +131 -47
- package/src/core/errors.js +3 -0
- package/src/core/flutter-options.js +4 -3
- package/src/core/fsutil.js +23 -6
- package/src/core/logger.js +55 -19
- package/src/core/paths-resolve.js +50 -47
- package/src/core/paths.js +26 -1
- package/src/core/release-options.js +23 -0
- package/src/core/removal-plan.js +75 -19
- package/src/core/types.js +113 -0
- package/src/core/verify.js +85 -19
- package/src/core/version-yml.js +42 -8
- package/src/core/wizard-env.js +21 -14
- package/src/core/wizard-labels.js +34 -25
- package/src/index.js +122 -49
- package/src/ui/ansi.js +3 -1
- package/src/ui/banner.js +12 -8
- package/src/ui/env-plan.js +68 -44
- package/src/ui/prompts.js +22 -12
- package/src/ui/readline-engine.js +109 -22
- package/src/ui/status-cards.js +10 -5
- package/src/ui/summary.js +71 -33
- package/payload/scripts/__pycache__/changelog_manager.cpython-314.pyc +0 -0
- package/payload/scripts/__pycache__/issue_helper.cpython-314.pyc +0 -0
- package/payload/scripts/__pycache__/version_manager.cpython-314.pyc +0 -0
|
@@ -68,6 +68,20 @@ def _clean_summary_noise(text: str) -> str:
|
|
|
68
68
|
return text.strip()
|
|
69
69
|
|
|
70
70
|
|
|
71
|
+
def _strip_version_headings(text: str) -> str:
|
|
72
|
+
"""요약 본문의 `## [1.2.3]` 버전 헤더 줄을 뺀다 — CHANGELOG.md는 릴리스마다
|
|
73
|
+
자체 헤더를 쓰므로, 남겨 두면 커밋이 없는 릴리스에서 헤더가 두 번 찍힌다."""
|
|
74
|
+
if not text:
|
|
75
|
+
return text
|
|
76
|
+
kept = []
|
|
77
|
+
for line in text.split('\n'):
|
|
78
|
+
heading = _HEADING_RE.match(line)
|
|
79
|
+
if heading and _VERSION_HEADING_RE.match(heading.group(2).strip()):
|
|
80
|
+
continue
|
|
81
|
+
kept.append(line)
|
|
82
|
+
return '\n'.join(kept).strip()
|
|
83
|
+
|
|
84
|
+
|
|
71
85
|
def _make_safe_key(title: str, idx: int) -> str:
|
|
72
86
|
"""카테고리 제목을 안전한 키로 변환."""
|
|
73
87
|
safe_key = re.sub(r'[^a-zA-Z0-9가-힣]', '_', title.lower()).strip('_')
|
|
@@ -248,10 +262,13 @@ def _parse_markdown_heuristic(md_content: str) -> dict:
|
|
|
248
262
|
# 1단계 패턴은 제목도 같은 정규식에서 캡처한다 — " : type : " 마커(타입 앞 콜론에
|
|
249
263
|
# 반드시 공백 선행)가 유일한 구분자이므로, 제목 안의 맨몸 콜론("v1:2" 등)에서
|
|
250
264
|
# 잘리지 않는다. 별도 split 재수행 금지.
|
|
251
|
-
|
|
265
|
+
# 타입은 대소문자를 가리지 않는다(Conventional Commits 스펙). `!`는 breaking 표시.
|
|
266
|
+
_TIER1_RE = re.compile(r'^(.+?)\s:\s*(feat|fix|chore|docs|refactor|test)\s*(!)?\s*:\s*(.+)$', re.IGNORECASE)
|
|
252
267
|
_TRAILING_URL_RE = re.compile(r'\s*https?://\S+$')
|
|
268
|
+
# `feat : 내용`처럼 콜론 앞 공백도 흔한 표기라 허용한다.
|
|
253
269
|
_TIER2_RE = re.compile(
|
|
254
|
-
r'^(feat|fix|chore|docs|refactor|test|perf|style|build|ci)(\([^)]*\))
|
|
270
|
+
r'^(feat|fix|chore|docs|refactor|test|perf|style|build|ci)(\([^)]*\))?\s*(!)?\s*:\s*(.+)$',
|
|
271
|
+
re.IGNORECASE,
|
|
255
272
|
)
|
|
256
273
|
_TIER2_BUCKET_MAP = {
|
|
257
274
|
'feat': 'feat',
|
|
@@ -260,19 +277,33 @@ _TIER2_BUCKET_MAP = {
|
|
|
260
277
|
'docs': 'docs',
|
|
261
278
|
'refactor': 'refactor',
|
|
262
279
|
'test': 'test',
|
|
263
|
-
'perf': '
|
|
280
|
+
'perf': 'perf',
|
|
264
281
|
'style': 'chore',
|
|
265
282
|
'build': 'chore',
|
|
266
283
|
'ci': 'chore',
|
|
267
284
|
}
|
|
268
285
|
|
|
269
|
-
_FALLBACK_BUCKET_KEYS = ('feat', 'fix', 'chore', 'docs', 'refactor', 'test', 'changes')
|
|
286
|
+
_FALLBACK_BUCKET_KEYS = ('breaking', 'feat', 'fix', 'perf', 'chore', 'docs', 'refactor', 'test', 'deps', 'changes', 'wip')
|
|
287
|
+
|
|
288
|
+
# 의존성 갱신(Dependabot·Renovate 등)과 작업 중 커밋은 일반 변경사항과 섞이면 노트가 흐려진다.
|
|
289
|
+
_DEPS_SCOPE_RE = re.compile(r'^\(deps(?:-dev)?\)$', re.IGNORECASE)
|
|
290
|
+
_DEPS_FREEFORM_RE = re.compile(r'^Bump \S+ from \S+ to \S+', re.IGNORECASE)
|
|
291
|
+
_WIP_RE = re.compile(r'^\[?wip\b', re.IGNORECASE)
|
|
270
292
|
|
|
271
|
-
# 승격 폭 판단 전용 — 타입 뒤 `!`
|
|
272
|
-
#
|
|
273
|
-
#
|
|
274
|
-
|
|
275
|
-
|
|
293
|
+
# 승격 폭 판단 전용 — 표준 타입 뒤 `!` 마커와 본문 푸터 `BREAKING CHANGE:`가 breaking 신호.
|
|
294
|
+
# `hotfix!:`·`WIP!:`처럼 표준 타입이 아닌 단어의 `!`는 되돌리기 어려운 major를 만들므로 인정하지 않는다.
|
|
295
|
+
# 푸터는 커밋 목록에 본문 줄이 함께 들어올 때만 보인다(제목만 수집하면 `!` 마커만 판정된다).
|
|
296
|
+
_BREAKING_FOOTER_RE = re.compile(r'^BREAKING[ -]CHANGE\s*:\s*(.*)$')
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _is_breaking(line: str) -> bool:
|
|
300
|
+
if _BREAKING_FOOTER_RE.match(line):
|
|
301
|
+
return True
|
|
302
|
+
tier1 = _TIER1_RE.match(line)
|
|
303
|
+
if tier1:
|
|
304
|
+
return bool(tier1.group(3))
|
|
305
|
+
tier2 = _TIER2_RE.match(line)
|
|
306
|
+
return bool(tier2 and tier2.group(3))
|
|
276
307
|
|
|
277
308
|
|
|
278
309
|
def classify_commits(lines: list[str]) -> dict:
|
|
@@ -281,8 +312,9 @@ def classify_commits(lines: list[str]) -> dict:
|
|
|
281
312
|
|
|
282
313
|
1단계: 제목 컨벤션 — "제목 : type : 내용 [URL]"
|
|
283
314
|
2단계: Conventional Commits — "type(scope)!: 내용"
|
|
284
|
-
(
|
|
285
|
-
3단계: 위 두 형식에 매칭되지 않으면 "changes" 버킷 (자유
|
|
315
|
+
(style/build/ci → chore, chore(deps)/build(deps) → deps 버킷)
|
|
316
|
+
3단계: 위 두 형식에 매칭되지 않으면 "changes" 버킷 (자유 형식, Bump… → deps, WIP → wip)
|
|
317
|
+
`!` 마커·BREAKING CHANGE 푸터는 "breaking" 버킷으로 모은다.
|
|
286
318
|
|
|
287
319
|
제외 대상 (매칭 전에 걸러냄): [skip ci] 포함 줄, "Merge "로 시작하는 줄, 빈 줄.
|
|
288
320
|
"""
|
|
@@ -296,6 +328,11 @@ def classify_commits(lines: list[str]) -> dict:
|
|
|
296
328
|
continue
|
|
297
329
|
if line.startswith('Merge '):
|
|
298
330
|
continue
|
|
331
|
+
footer = _BREAKING_FOOTER_RE.match(line)
|
|
332
|
+
if footer:
|
|
333
|
+
if footer.group(1).strip():
|
|
334
|
+
classified['breaking'].append(footer.group(1).strip())
|
|
335
|
+
continue
|
|
299
336
|
|
|
300
337
|
# 1단계가 2단계보다 먼저다 — 트레이드오프: "제목 : feat : 내용" 형식은
|
|
301
338
|
# "feat: ..." Conventional Commits와 겹칠 수 없지만(타입 앞에 제목 필수),
|
|
@@ -304,31 +341,47 @@ def classify_commits(lines: list[str]) -> dict:
|
|
|
304
341
|
tier1 = _TIER1_RE.match(line)
|
|
305
342
|
if tier1:
|
|
306
343
|
title = tier1.group(1).strip()
|
|
307
|
-
commit_type = tier1.group(2)
|
|
308
|
-
desc = tier1.group(
|
|
344
|
+
commit_type = tier1.group(2).lower()
|
|
345
|
+
desc = tier1.group(4).strip()
|
|
309
346
|
# 커밋 말미의 이슈 URL은 릴리즈 노트 렌더링에서 노이즈 — 제거.
|
|
310
347
|
desc = _TRAILING_URL_RE.sub('', desc).strip()
|
|
311
|
-
|
|
348
|
+
# breaking 커밋은 major 승격의 근거라 별도 섹션에 드러낸다.
|
|
349
|
+
bucket = 'breaking' if tier1.group(3) else commit_type
|
|
350
|
+
classified[bucket].append(f"{title} — {desc}")
|
|
312
351
|
continue
|
|
313
352
|
|
|
314
353
|
tier2 = _TIER2_RE.match(line)
|
|
315
354
|
if tier2:
|
|
316
|
-
commit_type,
|
|
317
|
-
|
|
355
|
+
commit_type, scope, desc = tier2.group(1).lower(), tier2.group(2), tier2.group(4)
|
|
356
|
+
if tier2.group(3):
|
|
357
|
+
bucket = 'breaking'
|
|
358
|
+
elif commit_type in ('chore', 'build') and scope and _DEPS_SCOPE_RE.match(scope):
|
|
359
|
+
bucket = 'deps'
|
|
360
|
+
else:
|
|
361
|
+
bucket = _TIER2_BUCKET_MAP[commit_type]
|
|
318
362
|
classified[bucket].append(desc.strip())
|
|
319
363
|
continue
|
|
320
364
|
|
|
321
|
-
|
|
365
|
+
if _DEPS_FREEFORM_RE.match(line):
|
|
366
|
+
classified['deps'].append(line)
|
|
367
|
+
elif _WIP_RE.match(line):
|
|
368
|
+
classified['wip'].append(line)
|
|
369
|
+
else:
|
|
370
|
+
classified['changes'].append(line)
|
|
322
371
|
|
|
323
372
|
return classified
|
|
324
373
|
|
|
325
374
|
|
|
326
375
|
_FALLBACK_SECTION_TITLES = {
|
|
376
|
+
'breaking': '### ⚠️ 호환성 깨짐',
|
|
327
377
|
'feat': '### ✨ 기능',
|
|
328
378
|
'fix': '### 🐛 수정',
|
|
379
|
+
'perf': '### ⚡ 성능',
|
|
329
380
|
'docs': '### 📝 문서',
|
|
330
381
|
'refactor': '### ♻️ 리팩토링',
|
|
331
382
|
'test': '### ✅ 테스트',
|
|
383
|
+
'deps': '### 📦 의존성',
|
|
384
|
+
'wip': '### 🚧 작업 중',
|
|
332
385
|
}
|
|
333
386
|
|
|
334
387
|
|
|
@@ -336,15 +389,18 @@ def render_fallback_md(classified: dict, version: str) -> str:
|
|
|
336
389
|
"""분류된 커밋 딕셔너리를 마크다운 릴리즈 노트로 렌더링."""
|
|
337
390
|
lines: list[str] = [f"## [{version}]", ""]
|
|
338
391
|
|
|
339
|
-
|
|
392
|
+
def add_section(bucket_key):
|
|
340
393
|
items = classified.get(bucket_key) or []
|
|
341
394
|
if not items:
|
|
342
|
-
|
|
395
|
+
return
|
|
343
396
|
lines.append(_FALLBACK_SECTION_TITLES[bucket_key])
|
|
344
397
|
for item in items:
|
|
345
398
|
lines.append(f"- {item}")
|
|
346
399
|
lines.append("")
|
|
347
400
|
|
|
401
|
+
for bucket_key in ('breaking', 'feat', 'fix', 'perf', 'docs', 'refactor', 'test', 'deps'):
|
|
402
|
+
add_section(bucket_key)
|
|
403
|
+
|
|
348
404
|
chore_items = list(classified.get('chore') or [])
|
|
349
405
|
changes_items = list(classified.get('changes') or [])
|
|
350
406
|
merged = chore_items + changes_items
|
|
@@ -354,13 +410,15 @@ def render_fallback_md(classified: dict, version: str) -> str:
|
|
|
354
410
|
lines.append(f"- {item}")
|
|
355
411
|
lines.append("")
|
|
356
412
|
|
|
413
|
+
add_section('wip')
|
|
414
|
+
|
|
357
415
|
return "\n".join(lines).rstrip() + "\n"
|
|
358
416
|
|
|
359
417
|
|
|
360
418
|
def classify_bump_level(lines: list[str]) -> str:
|
|
361
419
|
"""커밋 제목 목록에서 semver 승격 폭을 규칙 기반으로 판단.
|
|
362
420
|
|
|
363
|
-
- 타입 뒤 `!` 마커 포함 -> major
|
|
421
|
+
- 표준 타입 뒤 `!` 마커 또는 `BREAKING CHANGE:` 푸터 포함 -> major
|
|
364
422
|
- `feat:`(classify_commits의 feat 버킷과 동일 판정 기준) 포함 -> minor
|
|
365
423
|
- 그 외(매칭 실패 포함) -> patch
|
|
366
424
|
"""
|
|
@@ -368,7 +426,7 @@ def classify_bump_level(lines: list[str]) -> str:
|
|
|
368
426
|
line = raw_line.strip()
|
|
369
427
|
if not line or '[skip ci]' in line or line.startswith('Merge '):
|
|
370
428
|
continue
|
|
371
|
-
if
|
|
429
|
+
if _is_breaking(line):
|
|
372
430
|
return 'major'
|
|
373
431
|
classified = classify_commits(lines)
|
|
374
432
|
return 'minor' if classified.get('feat') else 'patch'
|
|
@@ -397,13 +455,13 @@ def _ai_assisted_minor_upgrade(unclassified_lines: list[str]) -> bool:
|
|
|
397
455
|
try:
|
|
398
456
|
return call_openai_compatible(base_url, api_key, model, prompt).strip() == 'MINOR'
|
|
399
457
|
except Exception as e:
|
|
400
|
-
|
|
458
|
+
_warn_engine_failure(f"[warn] bump AI assist failed: {e}")
|
|
401
459
|
|
|
402
460
|
if _copilot_enabled():
|
|
403
461
|
try:
|
|
404
462
|
return call_copilot_cli(prompt).strip() == 'MINOR'
|
|
405
463
|
except Exception as e:
|
|
406
|
-
|
|
464
|
+
_warn_engine_failure(f"[warn] bump AI assist (copilot) failed: {e}")
|
|
407
465
|
return False
|
|
408
466
|
|
|
409
467
|
|
|
@@ -505,7 +563,7 @@ def cmd_update_from_summary() -> int:
|
|
|
505
563
|
print("⚠️ 파싱 실패, raw_summary만 저장")
|
|
506
564
|
|
|
507
565
|
# raw_summary 생성 (노이즈 제거)
|
|
508
|
-
raw_summary = _clean_summary_noise(content)
|
|
566
|
+
raw_summary = _strip_version_headings(_clean_summary_noise(content))
|
|
509
567
|
|
|
510
568
|
# 릴리즈 데이터 생성
|
|
511
569
|
new_release = {
|
|
@@ -532,7 +590,12 @@ def cmd_update_from_summary() -> int:
|
|
|
532
590
|
try:
|
|
533
591
|
with open('CHANGELOG.json', 'r', encoding='utf-8') as f:
|
|
534
592
|
changelog_data = json.load(f)
|
|
535
|
-
except
|
|
593
|
+
except json.JSONDecodeError as e:
|
|
594
|
+
# 머지 충돌 마커 등으로 깨진 파일을 새 구조로 덮으면 기존 이력이 전부 사라진다.
|
|
595
|
+
print(f"❌ CHANGELOG.json을 해석할 수 없어 갱신을 중단합니다 (기존 이력 보호): {e}")
|
|
596
|
+
print(f"::error::CHANGELOG.json이 올바른 JSON이 아닙니다: {e}", file=sys.stderr)
|
|
597
|
+
return 1
|
|
598
|
+
except FileNotFoundError:
|
|
536
599
|
changelog_data = {
|
|
537
600
|
"metadata": {
|
|
538
601
|
"lastUpdated": timestamp,
|
|
@@ -552,8 +615,14 @@ def cmd_update_from_summary() -> int:
|
|
|
552
615
|
changelog_data["metadata"]["lastUpdated"] = timestamp
|
|
553
616
|
changelog_data["metadata"]["currentVersion"] = version
|
|
554
617
|
changelog_data["metadata"]["projectTypes"] = project_types
|
|
555
|
-
|
|
556
|
-
|
|
618
|
+
# 같은 버전은 교체한다 — 워크플로우 재실행 시 항목이 중복으로 쌓이지 않게.
|
|
619
|
+
releases = [
|
|
620
|
+
r for r in (changelog_data.get("releases") or [])
|
|
621
|
+
if not (isinstance(r, dict) and str(r.get("version")) == str(version))
|
|
622
|
+
]
|
|
623
|
+
releases.insert(0, new_release)
|
|
624
|
+
changelog_data["releases"] = releases
|
|
625
|
+
changelog_data["metadata"]["totalReleases"] = len(releases)
|
|
557
626
|
|
|
558
627
|
with open('CHANGELOG.json', 'w', encoding='utf-8') as f:
|
|
559
628
|
json.dump(changelog_data, f, indent=2, ensure_ascii=False)
|
|
@@ -616,7 +685,7 @@ def cmd_generate_md() -> int:
|
|
|
616
685
|
# 파싱 실패 시 raw_summary 출력
|
|
617
686
|
raw_summary = release.get('raw_summary', '').strip()
|
|
618
687
|
if raw_summary:
|
|
619
|
-
raw_summary = _clean_summary_noise(raw_summary)
|
|
688
|
+
raw_summary = _strip_version_headings(_clean_summary_noise(raw_summary))
|
|
620
689
|
if raw_summary:
|
|
621
690
|
f.write(raw_summary + "\n\n")
|
|
622
691
|
else:
|
|
@@ -657,12 +726,12 @@ def cmd_export_release_notes(version: str, output_path: str | None) -> int:
|
|
|
657
726
|
if title and items:
|
|
658
727
|
block = "**" + title + "**\n" + "\n".join("- " + it for it in items)
|
|
659
728
|
category_blocks.append(block)
|
|
660
|
-
body = "\n\n".join(category_blocks) if category_blocks else (matched.get('raw_summary') or '').strip()
|
|
729
|
+
body = "\n\n".join(category_blocks) if category_blocks else _strip_version_headings((matched.get('raw_summary') or '').strip())
|
|
661
730
|
else:
|
|
662
|
-
body = (matched.get('raw_summary') or '').strip()
|
|
731
|
+
body = _strip_version_headings((matched.get('raw_summary') or '').strip())
|
|
663
732
|
notes_text = (header + (body or "")).strip()
|
|
664
|
-
except Exception:
|
|
665
|
-
|
|
733
|
+
except Exception as e:
|
|
734
|
+
print(f"::warning::CHANGELOG.json에서 {version} 노트를 읽지 못했습니다: {e}", file=sys.stderr)
|
|
666
735
|
|
|
667
736
|
# 2) CHANGELOG.md 폴백
|
|
668
737
|
if not notes_text and os.path.isfile('CHANGELOG.md'):
|
|
@@ -673,12 +742,14 @@ def cmd_export_release_notes(version: str, output_path: str | None) -> int:
|
|
|
673
742
|
m = pattern.search(md)
|
|
674
743
|
if m:
|
|
675
744
|
start = m.end()
|
|
676
|
-
next_m = re.search(r"^##
|
|
745
|
+
next_m = re.search(r"^## \[", md[start:], re.MULTILINE)
|
|
677
746
|
section = md[start: start + next_m.start()] if next_m else md[start:]
|
|
678
|
-
|
|
747
|
+
# generate-md가 릴리스 사이에 넣는 구분선은 노트 내용이 아니다
|
|
748
|
+
body = re.sub(r'(?:\n\s*-{3,}\s*)+$', '', '\n' + section.strip()).strip()
|
|
679
749
|
notes_text = (f"버전 {version} 업데이트\n\n" + body).strip()
|
|
680
|
-
except Exception:
|
|
681
|
-
|
|
750
|
+
except Exception as e:
|
|
751
|
+
# 조용히 삼키면 고정 문구 폴백이 정상 출력처럼 보여 원인을 알 수 없다.
|
|
752
|
+
print(f"::warning::CHANGELOG.md에서 {version} 노트를 읽지 못했습니다: {e}", file=sys.stderr)
|
|
682
753
|
|
|
683
754
|
# 3) 최종 폴백
|
|
684
755
|
if not notes_text:
|
|
@@ -694,21 +765,37 @@ def cmd_export_release_notes(version: str, output_path: str | None) -> int:
|
|
|
694
765
|
|
|
695
766
|
# ------------------------ ai-summary 엔진 체인 ------------------------
|
|
696
767
|
|
|
697
|
-
# Copilot Free/Student 계정은 모델명 지정이 거부되고 auto 모델 선택만
|
|
768
|
+
# Copilot Free/Student 계정은 모델명 지정이 거부되고 auto 모델 선택만 허용된다.
|
|
698
769
|
_COPILOT_MODEL = "auto"
|
|
699
770
|
_COPILOT_TIMEOUT_SECONDS = 90
|
|
700
771
|
|
|
701
772
|
|
|
773
|
+
def _warn_engine_failure(message: str) -> str:
|
|
774
|
+
"""엔진 실패를 Actions 실행 요약(Annotations)에 경고로 띄운다.
|
|
775
|
+
평문 로그만 남기면 잡 로그를 열기 전에는 fallback 사유를 알 수 없다.
|
|
776
|
+
stdout은 결과 JSON 계약용이라 stderr로 쓴다. 반환값은 한 줄로 줄인 사유."""
|
|
777
|
+
reason = " ".join(str(message).split())[:200]
|
|
778
|
+
escaped = reason.replace('%', '%25').replace('\r', '%0D').replace('\n', '%0A')
|
|
779
|
+
print(f"::warning::{escaped}", file=sys.stderr)
|
|
780
|
+
return reason
|
|
781
|
+
|
|
782
|
+
|
|
702
783
|
def _user_api_settings() -> tuple[str, str, str] | None:
|
|
703
784
|
"""사용자 지정 AI 티어 설정. AI_API_KEY와 AI_API_BASE_URL, AI_MODEL이 모두 있어야 한다.
|
|
704
785
|
|
|
705
786
|
키만 있고 URL·모델이 비어 있으면 그 키를 어디로도 보내지 않고 경고 후 건너뛴다
|
|
706
787
|
(종료된 기본 엔드포인트으로 사용자 키가 흘러가던 문제 방지)."""
|
|
707
788
|
api_key = os.environ.get('AI_API_KEY')
|
|
708
|
-
if not api_key:
|
|
709
|
-
return None
|
|
710
789
|
base_url = os.environ.get('AI_API_BASE_URL')
|
|
711
790
|
model = os.environ.get('AI_MODEL')
|
|
791
|
+
if not api_key:
|
|
792
|
+
# 변수만 등록하고 secret을 빠뜨리면 아무 표시 없이 규칙 요약으로 넘어가 설정이 먹은 줄 안다
|
|
793
|
+
if base_url or model:
|
|
794
|
+
print(
|
|
795
|
+
"::warning::AI_API_BASE_URL/AI_MODEL이 설정됐지만 AI_API_KEY secret이 없어 사용자 API 티어를 건너뜁니다",
|
|
796
|
+
file=sys.stderr,
|
|
797
|
+
)
|
|
798
|
+
return None
|
|
712
799
|
if not base_url or not model:
|
|
713
800
|
print(
|
|
714
801
|
"::warning::AI_API_KEY가 설정됐지만 AI_API_BASE_URL/AI_MODEL이 없어 사용자 API 티어를 건너뜁니다",
|
|
@@ -768,7 +855,9 @@ def _build_ai_prompt(commit_lines: list[str], pr_title: str | None, version: str
|
|
|
768
855
|
"아래 커밋 목록을 바탕으로 한국어 릴리즈 요약을 작성해줘.",
|
|
769
856
|
f"출력 형식: 첫 줄은 '## [{version}]' 헤더로 시작하고,",
|
|
770
857
|
"해당 항목이 있는 섹션만 다음 이름으로 작성해줘:",
|
|
771
|
-
"'###
|
|
858
|
+
"'### ⚠️ 호환성 깨짐', '### ✨ 기능', '### 🐛 수정', '### ⚡ 성능', '### 📝 문서', '### ♻️ 리팩토링',",
|
|
859
|
+
"'### ✅ 테스트', '### 📦 의존성', '### 🔧 변경사항', '### 🚧 작업 중'.",
|
|
860
|
+
"타입 뒤에 '!'가 붙었거나 BREAKING CHANGE인 커밋은 반드시 '### ⚠️ 호환성 깨짐'에 넣어줘.",
|
|
772
861
|
"각 항목은 '- '로 시작하는 불릿으로 작성해줘.",
|
|
773
862
|
]
|
|
774
863
|
if pr_title:
|
|
@@ -808,7 +897,8 @@ def cmd_ai_summary(commits_file: str, version: str, output_path: str, pr_title:
|
|
|
808
897
|
try:
|
|
809
898
|
with open(commits_file, 'r', encoding='utf-8') as f:
|
|
810
899
|
commit_lines = [line.rstrip('\n').rstrip('\r') for line in f]
|
|
811
|
-
except Exception:
|
|
900
|
+
except Exception as e:
|
|
901
|
+
print(f"::warning::커밋 목록 파일을 읽지 못해 빈 목록으로 요약합니다: {e}", file=sys.stderr)
|
|
812
902
|
commit_lines = []
|
|
813
903
|
|
|
814
904
|
diff_stat = None
|
|
@@ -821,6 +911,7 @@ def cmd_ai_summary(commits_file: str, version: str, output_path: str, pr_title:
|
|
|
821
911
|
|
|
822
912
|
engine = None
|
|
823
913
|
summary_text = None
|
|
914
|
+
failures: list[str] = []
|
|
824
915
|
prompt = _build_ai_prompt(commit_lines, pr_title, version, diff_stat)
|
|
825
916
|
|
|
826
917
|
settings = _user_api_settings()
|
|
@@ -832,9 +923,9 @@ def cmd_ai_summary(commits_file: str, version: str, output_path: str, pr_title:
|
|
|
832
923
|
summary_text = candidate
|
|
833
924
|
engine = "user-api"
|
|
834
925
|
else:
|
|
835
|
-
|
|
926
|
+
failures.append(_warn_engine_failure("[warn] user-api failed: empty content in response"))
|
|
836
927
|
except Exception as e:
|
|
837
|
-
|
|
928
|
+
failures.append(_warn_engine_failure(f"[warn] user-api failed: {e}"))
|
|
838
929
|
|
|
839
930
|
if summary_text is None and _copilot_enabled():
|
|
840
931
|
try:
|
|
@@ -843,9 +934,9 @@ def cmd_ai_summary(commits_file: str, version: str, output_path: str, pr_title:
|
|
|
843
934
|
summary_text = candidate
|
|
844
935
|
engine = "copilot"
|
|
845
936
|
else:
|
|
846
|
-
|
|
937
|
+
failures.append(_warn_engine_failure("[warn] copilot failed: empty or not in the requested Markdown format"))
|
|
847
938
|
except Exception as e:
|
|
848
|
-
|
|
939
|
+
failures.append(_warn_engine_failure(f"[warn] copilot failed: {e}"))
|
|
849
940
|
|
|
850
941
|
if summary_text is None:
|
|
851
942
|
classified = classify_commits(commit_lines)
|
|
@@ -864,7 +955,11 @@ def cmd_ai_summary(commits_file: str, version: str, output_path: str, pr_title:
|
|
|
864
955
|
print(f"[warn] output write failed: {e}", file=sys.stderr)
|
|
865
956
|
print(summary_text, file=sys.stderr)
|
|
866
957
|
|
|
867
|
-
|
|
958
|
+
result = {"ok": write_ok, "engine": engine, "output": output_path}
|
|
959
|
+
if engine == "fallback" and failures:
|
|
960
|
+
# 워크플로우가 PR 댓글의 engine 줄에 사유를 붙일 수 있도록 함께 넘긴다.
|
|
961
|
+
result["fallback_reason"] = "; ".join(r.replace("[warn] ", "", 1) for r in failures)
|
|
962
|
+
print(json.dumps(result))
|
|
868
963
|
return 0
|
|
869
964
|
|
|
870
965
|
|
|
@@ -99,20 +99,25 @@ def format_date_yyyymmdd(dt):
|
|
|
99
99
|
return dt.strftime("%Y%m%d")
|
|
100
100
|
|
|
101
101
|
|
|
102
|
-
|
|
102
|
+
# 유니코드 문자·숫자는 모두 살린다. 한글·영문만 남기면 일본어 등 다른 언어 제목이
|
|
103
|
+
# 통째로 사라져 브랜치명이 `_`로 끝나고 커밋 제목이 비었다.
|
|
104
|
+
_NON_WORD_RE = re.compile(r"[\W_]+")
|
|
103
105
|
_MULTI_UNDERSCORE_RE = re.compile(r"_+")
|
|
106
|
+
# 이모지·기호만 있는 제목처럼 정규화 결과가 비었을 때 쓰는 이름
|
|
107
|
+
FALLBACK_TITLE = "issue"
|
|
104
108
|
|
|
105
109
|
|
|
106
110
|
def normalize_title(title):
|
|
107
|
-
normalized =
|
|
111
|
+
normalized = _NON_WORD_RE.sub("_", unicodedata.normalize("NFC", title))
|
|
108
112
|
normalized = _MULTI_UNDERSCORE_RE.sub("_", normalized)
|
|
109
113
|
return normalized.strip("_")
|
|
110
114
|
|
|
111
115
|
|
|
112
116
|
def create_branch_name(issue_title, issue_number, date_yyyymmdd, branch_prefix, max_branch_length):
|
|
113
|
-
normalized_title = normalize_title(issue_title)
|
|
117
|
+
normalized_title = normalize_title(issue_title) or FALLBACK_TITLE
|
|
114
118
|
base = f"{date_yyyymmdd}_#{issue_number}_{normalized_title}"
|
|
115
|
-
|
|
119
|
+
# 절단 지점이 구분자면 `_`로 끝나지 않게 정리한다.
|
|
120
|
+
limited_base = base[:max_branch_length].rstrip("_") if max_branch_length > 0 else base
|
|
116
121
|
return f"{branch_prefix}{limited_base}"
|
|
117
122
|
|
|
118
123
|
|
|
@@ -127,7 +132,7 @@ def render_commit_message(template, issue_title, issue_url, issue_number, branch
|
|
|
127
132
|
|
|
128
133
|
|
|
129
134
|
def normalize_all(title, issue_url, issue_number, date_yyyymmdd, branch_prefix, max_branch_length, commit_template):
|
|
130
|
-
normalized_title = normalize_title(title)
|
|
135
|
+
normalized_title = normalize_title(title) or FALLBACK_TITLE
|
|
131
136
|
branch_name = create_branch_name(title, issue_number, date_yyyymmdd, branch_prefix, max_branch_length)
|
|
132
137
|
commit_message = render_commit_message(
|
|
133
138
|
commit_template, normalized_title, issue_url, issue_number, branch_name, date_yyyymmdd,
|
|
@@ -272,7 +277,31 @@ def create_branch_if_needed(owner, repo, branch_name, base_branch, create_branch
|
|
|
272
277
|
log(f"브랜치 생성됨: {branch_name}")
|
|
273
278
|
|
|
274
279
|
|
|
280
|
+
def filter_existing_issues(owner, repo, issue_numbers, token):
|
|
281
|
+
"""레포에 실제로 있는 이슈 번호만 남긴다. 브랜치명의 번호가 오타이거나 다른 레포
|
|
282
|
+
기준이면 없는 이슈를 Closes로 걸고 "연결 완료"로 기록하게 된다.
|
|
283
|
+
조회 자체가 실패(권한·일시 오류)하면 확인할 수 없으므로 그대로 둔다."""
|
|
284
|
+
kept = []
|
|
285
|
+
for n in issue_numbers:
|
|
286
|
+
status, data, _ = _api_request("GET", f"{API_BASE}/repos/{owner}/{repo}/issues/{n}", token)
|
|
287
|
+
if status in (404, 410):
|
|
288
|
+
print(f"::warning::이슈 #{n}이 레포에 없어 PR 본문 Closes 연결에서 제외합니다", file=sys.stderr)
|
|
289
|
+
continue
|
|
290
|
+
if status < 400 and isinstance(data, dict) and data.get("pull_request"):
|
|
291
|
+
print(f"::warning::#{n}은 이슈가 아니라 PR이라 Closes 연결에서 제외합니다", file=sys.stderr)
|
|
292
|
+
continue
|
|
293
|
+
if status >= 400:
|
|
294
|
+
log(f"이슈 #{n} 확인 실패({status}) — 확인하지 못해 그대로 연결합니다")
|
|
295
|
+
kept.append(n)
|
|
296
|
+
return kept
|
|
297
|
+
|
|
298
|
+
|
|
275
299
|
def link_pr_issues(owner, repo, pr_number, issue_numbers, token, replace_existing):
|
|
300
|
+
issue_numbers = filter_existing_issues(owner, repo, issue_numbers, token)
|
|
301
|
+
if not issue_numbers:
|
|
302
|
+
log(f"연결할 이슈가 없음 — 건너뜀 (PR #{pr_number})")
|
|
303
|
+
return
|
|
304
|
+
|
|
276
305
|
status, pr_data, _ = _api_request("GET", f"{API_BASE}/repos/{owner}/{repo}/pulls/{pr_number}", token)
|
|
277
306
|
if status >= 400:
|
|
278
307
|
raise RuntimeError(f"PR 조회 실패({status}): {pr_number}")
|