project-auto-wizard 0.12.2 → 0.13.1

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 (91) hide show
  1. package/README.ja.md +165 -0
  2. package/README.ko.md +165 -0
  3. package/README.md +107 -286
  4. package/README.zh-CN.md +165 -0
  5. package/bin/project-auto-wizard.js +1 -2
  6. package/package.json +9 -3
  7. package/payload/config/breaking-changes.json +24 -0
  8. package/payload/config/wizard-prompts.yml +45 -0
  9. package/payload/scripts/changelog_manager.py +141 -46
  10. package/payload/scripts/issue_helper.py +34 -5
  11. package/payload/scripts/version_manager.py +482 -107
  12. package/payload/version.yml.template +5 -2
  13. package/payload/workflows/common/PROJECT-COMMON-AI-PR-SUMMARY.yaml +39 -9
  14. package/payload/workflows/common/PROJECT-COMMON-AUTO-CHANGELOG-CONTROL.yaml +165 -38
  15. package/payload/workflows/common/PROJECT-COMMON-README-VERSION-UPDATE.yaml +14 -5
  16. package/payload/workflows/common/PROJECT-COMMON-RELEASE-PUBLISH.yaml +220 -6
  17. package/payload/workflows/common/PROJECT-COMMON-VERSION-CONTROL.yaml +89 -3
  18. package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-FIREBASE-CICD.yaml +78 -16
  19. package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-PLAYSTORE-CICD.yaml +72 -16
  20. package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-SELFHOSTED-CICD.yaml +62 -20
  21. package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-TEST-APK.yaml +51 -13
  22. package/payload/workflows/flutter/PROJECT-FLUTTER-CI.yaml +75 -11
  23. package/payload/workflows/flutter/PROJECT-FLUTTER-IOS-TEST-TESTFLIGHT.yaml +52 -3
  24. package/payload/workflows/flutter/PROJECT-FLUTTER-IOS-TESTFLIGHT.yaml +60 -11
  25. package/payload/workflows/go/PROJECT-GO-CI.yaml +10 -1
  26. package/payload/workflows/go/PROJECT-GO-PR-PREVIEW.yaml +148 -28
  27. package/payload/workflows/go/PROJECT-GO-SIMPLE-CICD.yaml +45 -4
  28. package/payload/workflows/next/PROJECT-NEXT-CI.yaml +25 -3
  29. package/payload/workflows/next/PROJECT-NEXT-CICD.yaml +83 -9
  30. package/payload/workflows/python/PROJECT-PYTHON-CI.yaml +70 -6
  31. package/payload/workflows/python/PROJECT-PYTHON-PR-PREVIEW.yaml +148 -28
  32. package/payload/workflows/python/PROJECT-PYTHON-SIMPLE-CICD.yaml +45 -4
  33. package/payload/workflows/react/PROJECT-REACT-CI.yaml +25 -3
  34. package/payload/workflows/react/PROJECT-REACT-CICD.yaml +84 -10
  35. package/payload/workflows/spring/PROJECT-SPRING-CI.yml +13 -3
  36. package/payload/workflows/spring/server-deploy/PROJECT-SPRING-NONSTOP-NGINX-CICD.yaml +47 -5
  37. package/payload/workflows/spring/server-deploy/PROJECT-SPRING-NONSTOP-TRAEFIK-CICD.yaml +47 -5
  38. package/payload/workflows/spring/server-deploy/PROJECT-SPRING-PR-PREVIEW.yaml +153 -30
  39. package/payload/workflows/spring/server-deploy/PROJECT-SPRING-SIMPLE-CICD.yaml +47 -5
  40. package/src/cli/args.js +33 -24
  41. package/src/cli/help.js +16 -4
  42. package/src/commands/doctor.js +26 -60
  43. package/src/commands/dry-run.js +59 -5
  44. package/src/commands/full.js +135 -55
  45. package/src/commands/install-settings.js +51 -0
  46. package/src/commands/interactive-flutter.js +1 -1
  47. package/src/commands/interactive.js +135 -69
  48. package/src/commands/purge.js +50 -21
  49. package/src/commands/status.js +18 -12
  50. package/src/commands/uninstall.js +22 -22
  51. package/src/context.js +5 -5
  52. package/src/core/baseline.js +20 -5
  53. package/src/core/branches.js +19 -1
  54. package/src/core/branding.js +1 -1
  55. package/src/core/breaking-check.js +14 -19
  56. package/src/core/breaking.js +7 -3
  57. package/src/core/copy/app-files.js +28 -0
  58. package/src/core/copy/gitignore.js +20 -12
  59. package/src/core/copy/readme.js +24 -5
  60. package/src/core/copy/simple.js +52 -13
  61. package/src/core/copy/workflows.js +133 -111
  62. package/src/core/deploy-style.js +83 -11
  63. package/src/core/detect-fs.js +127 -24
  64. package/src/core/detect.js +177 -47
  65. package/src/core/errors.js +3 -0
  66. package/src/core/flutter-doctor.js +49 -0
  67. package/src/core/flutter-hooks.js +29 -0
  68. package/src/core/flutter-options.js +4 -3
  69. package/src/core/fsutil.js +23 -6
  70. package/src/core/logger.js +55 -19
  71. package/src/core/paths-resolve.js +50 -47
  72. package/src/core/paths.js +26 -1
  73. package/src/core/release-options.js +23 -0
  74. package/src/core/removal-exec.js +67 -0
  75. package/src/core/removal-plan.js +75 -19
  76. package/src/core/types.js +136 -0
  77. package/src/core/verify.js +85 -19
  78. package/src/core/version-yml.js +42 -8
  79. package/src/core/wizard-env.js +21 -14
  80. package/src/core/wizard-labels.js +34 -25
  81. package/src/index.js +128 -60
  82. package/src/ui/ansi.js +3 -1
  83. package/src/ui/banner.js +12 -8
  84. package/src/ui/env-plan.js +68 -44
  85. package/src/ui/prompts.js +22 -12
  86. package/src/ui/readline-engine.js +109 -22
  87. package/src/ui/status-cards.js +10 -5
  88. package/src/ui/summary.js +71 -33
  89. package/payload/scripts/__pycache__/changelog_manager.cpython-314.pyc +0 -0
  90. package/payload/scripts/__pycache__/issue_helper.cpython-314.pyc +0 -0
  91. 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
- _TIER1_RE = re.compile(r'^(.+?)\s:\s*(feat|fix|chore|docs|refactor|test)\s*:\s*(.+)$')
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)(\([^)]*\))?!?:\s*(.+)$'
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': 'chore',
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
- # 승격 폭 판단 전용 — 타입 뒤 `!` 마커(어떤 타입이든)는 breaking 신호.
272
- # BREAKING CHANGE: 본문 푸터는 지원하지 않는다(커밋 수집이 제목 한 줄만
273
- # 가져오는 구조라 본문에 접근 불가 — Conventional Commits 스펙 조항 13에
274
- # 따르면 `!` 마커 단독으로도 표준을 만족하므로 이는 표준이 허용하는 부분집합).
275
- _BREAKING_MARKER_RE = re.compile(r'^[a-zA-Z]+(\([^)]*\))?!:')
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
- (perf/style/build/ci → chore 버킷으로 매핑)
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(3).strip()
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
- classified[commit_type].append(f"{title} — {desc}")
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, _scope, desc = tier2.group(1), tier2.group(2), tier2.group(3)
317
- bucket = _TIER2_BUCKET_MAP[commit_type]
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
- classified['changes'].append(line)
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
- for bucket_key in ('feat', 'fix', 'docs', 'refactor', 'test'):
392
+ def add_section(bucket_key):
340
393
  items = classified.get(bucket_key) or []
341
394
  if not items:
342
- continue
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 _BREAKING_MARKER_RE.match(line):
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
- print(f"[warn] bump AI assist failed: {e}", file=sys.stderr)
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
- print(f"[warn] bump AI assist (copilot) failed: {e}", file=sys.stderr)
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 (FileNotFoundError, json.JSONDecodeError):
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
- changelog_data["metadata"]["totalReleases"] = len(changelog_data.get("releases", [])) + 1
556
- changelog_data.setdefault("releases", []).insert(0, new_release)
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
- pass
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"^## \\[", md[start:], re.MULTILINE)
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
- body = section.strip()
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
- pass
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 모델 선택만 허용된다 (#153).
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
- print("[warn] user-api failed: empty content in response", file=sys.stderr)
926
+ failures.append(_warn_engine_failure("[warn] user-api failed: empty content in response"))
836
927
  except Exception as e:
837
- print(f"[warn] user-api failed: {e}", file=sys.stderr)
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
- print("[warn] copilot failed: empty or not in the requested Markdown format", file=sys.stderr)
937
+ failures.append(_warn_engine_failure("[warn] copilot failed: empty or not in the requested Markdown format"))
847
938
  except Exception as e:
848
- print(f"[warn] copilot failed: {e}", file=sys.stderr)
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
- print(json.dumps({"ok": write_ok, "engine": engine, "output": output_path}))
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
- _NON_ALNUM_KO_RE = re.compile(r"[^a-zA-Z0-9가-힣]")
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 = _NON_ALNUM_KO_RE.sub("_", title)
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
- limited_base = base[:max_branch_length] if max_branch_length > 0 else base
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}")