Move directory
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
# Skills
|
||||
|
||||
이 폴더는 update_catalog 프로젝트의 Skill 정의(SKill.md) 모음입니다.
|
||||
각 스킬은 OpenClaw Skill 포맷(SKILL.md + YAML frontmatter)을 따릅니다.
|
||||
@@ -0,0 +1,41 @@
|
||||
---
|
||||
name: breaking_change_check
|
||||
description: Determine breaking changes from Structured Diff JSON using deterministic rules.
|
||||
user-invokable: false
|
||||
---
|
||||
|
||||
# breaking_change_check (Skill)
|
||||
|
||||
Rule-engine based breaking change detector.
|
||||
|
||||
## Input schema
|
||||
```json
|
||||
{
|
||||
"diff_json": "object",
|
||||
"custom_values": "object (optional)"
|
||||
}
|
||||
```
|
||||
|
||||
`custom_values`가 제공되면 `values_key_removed` 판단 시 custom-values.yaml에서 실제 사용 중인 key만 breaking으로 분류하고, 나머지는 warnings로 기록한다. 제공되지 않으면 제거된 모든 key를 breaking으로 판단한다.
|
||||
|
||||
## Breaking 판단 기준
|
||||
|
||||
| rule | severity | breaking 조건 |
|
||||
|------|----------|--------------|
|
||||
| `values_key_removed` | high | custom-values.yaml에 해당 key가 존재하는 경우 |
|
||||
| `values_type_changed` | high | 항상 |
|
||||
| `resource_removed` | high | 항상 |
|
||||
| `service_port_changed` | high | 항상 |
|
||||
| `dependency_major_changed` | high | 항상 (sub-chart 내부 breaking 가능) |
|
||||
|
||||
`breaking = severity in ("high", "critical")`
|
||||
|
||||
## Output schema
|
||||
```json
|
||||
{
|
||||
"breaking": "boolean",
|
||||
"severity": "critical|high|medium|warning",
|
||||
"reasons": "array",
|
||||
"warnings": "array"
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,42 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
DEFAULT_REPO = None
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--diff-file", required=True)
|
||||
parser.add_argument("--custom-values", default=None, help="custom-values.yaml 경로 (없으면 전체 key 검사)")
|
||||
args = parser.parse_args()
|
||||
|
||||
repo_root = os.environ.get("UPDATE_CATALOG_ROOT")
|
||||
if repo_root:
|
||||
sys.path.insert(0, str(Path(repo_root) / "src"))
|
||||
else:
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
from update_catalog.breaking_change_check import breaking_change_check # type: ignore
|
||||
|
||||
with open(args.diff_file, "r", encoding="utf-8") as f:
|
||||
diff_json = json.load(f)
|
||||
|
||||
custom_values = None
|
||||
if args.custom_values:
|
||||
try:
|
||||
import yaml # type: ignore
|
||||
with open(args.custom_values, "r", encoding="utf-8") as f:
|
||||
custom_values = yaml.safe_load(f) or {}
|
||||
except Exception as e:
|
||||
print(f"WARNING: custom-values.yaml 로드 실패, 전체 key 검사로 대체: {e}", file=sys.stderr)
|
||||
|
||||
out = breaking_change_check({"diff_json": diff_json, "custom_values": custom_values})
|
||||
print(json.dumps(out, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1 @@
|
||||
__all__ = ["skill_interface"]
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
+143
@@ -0,0 +1,143 @@
|
||||
"""breaking_change_check skill implementation (deterministic rules).
|
||||
|
||||
Breaking 정의: custom-values.yaml을 수정해야 하는 상황.
|
||||
카탈로그는 신규 배포용 차트 보관소이며, 운영 클러스터 직접 변경과 무관하다.
|
||||
|
||||
판단 기준:
|
||||
- values_key_removed / type_changed: custom-values.yaml에 해당 key가 있을 때만 breaking
|
||||
- service_port_changed, resource_removed: warning (custom-values.yaml 수정 불필요)
|
||||
- dependency_major_changed: custom-values.yaml에 subchart prefix key가 있으면 breaking, 없으면 warning
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, Dict, List, Optional, Set
|
||||
|
||||
from .skill_interface import BreakingCheckInput, BreakingCheckOutput, BreakingReason, Severity
|
||||
|
||||
|
||||
def _severity_max(current: Severity, candidate: Severity) -> Severity:
|
||||
order = ["warning", "medium", "high", "critical"]
|
||||
return order[max(order.index(current), order.index(candidate))] # type: ignore
|
||||
|
||||
|
||||
def _flatten_keys(d: Any, prefix: str = "") -> Set[str]:
|
||||
"""YAML dict를 dotted key path 집합으로 평탄화한다.
|
||||
|
||||
예: {"webserver": {"defaultUser": {"enabled": true}}}
|
||||
→ {"webserver", "webserver.defaultUser", "webserver.defaultUser.enabled"}
|
||||
"""
|
||||
keys: Set[str] = set()
|
||||
if not isinstance(d, dict):
|
||||
return keys
|
||||
for k, v in d.items():
|
||||
full_key = f"{prefix}.{k}" if prefix else k
|
||||
keys.add(full_key)
|
||||
if isinstance(v, dict):
|
||||
keys.update(_flatten_keys(v, full_key))
|
||||
return keys
|
||||
|
||||
|
||||
def breaking_change_check(payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
inp = BreakingCheckInput(**payload)
|
||||
diff = inp.diff_json
|
||||
|
||||
# custom-values.yaml key 집합 (없으면 None → 모든 key가 "미사용"으로 처리)
|
||||
custom_keys: Optional[Set[str]] = (
|
||||
_flatten_keys(inp.custom_values) if inp.custom_values else None
|
||||
)
|
||||
|
||||
reasons: List[BreakingReason] = []
|
||||
warnings: List[BreakingReason] = []
|
||||
severity: Severity = "warning"
|
||||
|
||||
# values rules: custom-values.yaml에 있는 key 변경만 breaking
|
||||
values = diff.get("values", {})
|
||||
for k in values.get("removed", []):
|
||||
if custom_keys is not None and k in custom_keys:
|
||||
# 실제 사용 중인 key 삭제 → breaking (custom-values.yaml 수정 필요)
|
||||
reasons.append(BreakingReason(
|
||||
type="values_key_removed",
|
||||
key=k,
|
||||
detail="custom-values.yaml에서 사용 중인 key 삭제 — 수정 필요",
|
||||
))
|
||||
severity = _severity_max(severity, "high")
|
||||
else:
|
||||
# 미사용 key 삭제 → warning (수정 불필요)
|
||||
warnings.append(BreakingReason(
|
||||
type="values_key_removed",
|
||||
key=k,
|
||||
detail="custom-values.yaml에서 사용하지 않는 key 삭제 — 수정 불필요",
|
||||
))
|
||||
|
||||
for item in values.get("type_changed", []):
|
||||
k = item.get("key", "")
|
||||
detail = f"{item.get('old_type')} → {item.get('new_type')}"
|
||||
if custom_keys is not None and k in custom_keys:
|
||||
reasons.append(BreakingReason(
|
||||
type="values_type_changed",
|
||||
key=k,
|
||||
detail=f"custom-values.yaml에서 사용 중인 key 타입 변경 ({detail}) — 수정 필요",
|
||||
))
|
||||
severity = _severity_max(severity, "high")
|
||||
else:
|
||||
warnings.append(BreakingReason(
|
||||
type="values_type_changed",
|
||||
key=k,
|
||||
detail=f"미사용 key 타입 변경 ({detail}) — 수정 불필요",
|
||||
))
|
||||
|
||||
# template rules: 카탈로그 맥락에서 custom-values.yaml 수정 요인 아님 → warning
|
||||
templates = diff.get("templates", {})
|
||||
for rid, info in templates.items():
|
||||
if info.get("removed"):
|
||||
warnings.append(BreakingReason(
|
||||
type="resource_removed",
|
||||
resource=rid,
|
||||
detail="리소스 삭제 — 신규 배포 시 참고",
|
||||
))
|
||||
if rid.startswith("Service/") and info.get("port_changed"):
|
||||
warnings.append(BreakingReason(
|
||||
type="service_port_changed",
|
||||
resource=rid,
|
||||
detail="Service port 변경 — 신규 배포 시 Ingress/LB 설정 확인",
|
||||
))
|
||||
|
||||
# dependency rules: custom-values.yaml에 subchart prefix key가 있으면 breaking
|
||||
deps = diff.get("dependencies", {})
|
||||
for dep, change in deps.get("version_changed", {}).items():
|
||||
old = change.get("old") or ""
|
||||
new = change.get("new") or ""
|
||||
try:
|
||||
old_major = int(str(old).split(".")[0])
|
||||
new_major = int(str(new).split(".")[0])
|
||||
if new_major > old_major:
|
||||
dep_prefix = f"{dep}."
|
||||
dep_in_custom = (
|
||||
custom_keys is not None
|
||||
and any(k.startswith(dep_prefix) for k in custom_keys)
|
||||
)
|
||||
if dep_in_custom:
|
||||
reasons.append(BreakingReason(
|
||||
type="dependency_major_changed",
|
||||
key=dep,
|
||||
detail=f"{old} → {new} — custom-values.yaml에 해당 subchart 설정 있음, 수정 검토 필요",
|
||||
))
|
||||
severity = _severity_max(severity, "medium")
|
||||
else:
|
||||
warnings.append(BreakingReason(
|
||||
type="dependency_major_changed",
|
||||
key=dep,
|
||||
detail=f"{old} → {new} — custom-values.yaml에 해당 subchart 설정 없음",
|
||||
))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
breaking = len(reasons) > 0
|
||||
out = BreakingCheckOutput(
|
||||
breaking=breaking,
|
||||
severity=severity,
|
||||
reasons=reasons,
|
||||
warnings=warnings,
|
||||
)
|
||||
return json.loads(out.model_dump_json())
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
"""Skill Interface v1.0 for Helm upgrade automation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Literal, Optional
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
SKILL_INTERFACE_VERSION = "1.0"
|
||||
|
||||
|
||||
# -------------------------
|
||||
# Common Error Schema
|
||||
# -------------------------
|
||||
class SkillError(BaseModel):
|
||||
code: str
|
||||
message: str
|
||||
retryable: bool = False
|
||||
details: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class ErrorResponse(BaseModel):
|
||||
error: SkillError
|
||||
|
||||
|
||||
# -------------------------
|
||||
# Shared Types
|
||||
# -------------------------
|
||||
Severity = Literal["critical", "high", "medium", "warning"]
|
||||
|
||||
|
||||
class BreakingReason(BaseModel):
|
||||
type: str
|
||||
resource: Optional[str] = None
|
||||
key: Optional[str] = None
|
||||
detail: Optional[str] = None
|
||||
|
||||
|
||||
# -------------------------
|
||||
# helm_diff
|
||||
# -------------------------
|
||||
class HelmDiffInput(BaseModel):
|
||||
chart: str
|
||||
repo: Optional[str] = None
|
||||
chart_path: Optional[str] = None
|
||||
from_version: str
|
||||
to_version: str
|
||||
values_override: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class HelmDiffOutput(BaseModel):
|
||||
chart: str
|
||||
from_version: str
|
||||
to_version: str
|
||||
generated_at: str
|
||||
values: Dict[str, Any]
|
||||
templates: Dict[str, Any]
|
||||
crd: Dict[str, Any]
|
||||
dependencies: Dict[str, Any]
|
||||
errors: List[Dict[str, Any]] = Field(default_factory=list)
|
||||
|
||||
|
||||
# -------------------------
|
||||
# breaking_change_check
|
||||
# -------------------------
|
||||
class BreakingCheckInput(BaseModel):
|
||||
diff_json: Dict[str, Any]
|
||||
custom_values: Optional[Dict[str, Any]] = None # custom-values.yaml 내용 (없으면 전체 key 검사)
|
||||
|
||||
|
||||
class BreakingCheckOutput(BaseModel):
|
||||
breaking: bool
|
||||
severity: Severity
|
||||
reasons: List[BreakingReason] = Field(default_factory=list)
|
||||
warnings: List[BreakingReason] = Field(default_factory=list)
|
||||
|
||||
|
||||
# -------------------------
|
||||
# generate_upgrade_doc
|
||||
# -------------------------
|
||||
class GenerateDocInput(BaseModel):
|
||||
diff_json: Dict[str, Any]
|
||||
breaking_result: Dict[str, Any]
|
||||
docs_context: Optional[Dict[str, str]] = None
|
||||
max_tokens: int = 50000
|
||||
|
||||
|
||||
class GenerateDocOutput(BaseModel):
|
||||
markdown: str
|
||||
truncated: bool = False
|
||||
|
||||
|
||||
# -------------------------
|
||||
# update_docs_file
|
||||
# -------------------------
|
||||
class UpdateDocsInput(BaseModel):
|
||||
repo_path: str
|
||||
docs_file: str = "docs/upgrade.md"
|
||||
version: str
|
||||
content: str
|
||||
overwrite: bool = False
|
||||
|
||||
|
||||
class UpdateDocsOutput(BaseModel):
|
||||
success: bool
|
||||
file_path: str
|
||||
already_existed: bool = False
|
||||
|
||||
|
||||
# -------------------------
|
||||
# create_pr
|
||||
# -------------------------
|
||||
class CreatePRInput(BaseModel):
|
||||
chart: str
|
||||
from_version: str
|
||||
to_version: str
|
||||
repo_path: str
|
||||
breaking: bool
|
||||
severity: Severity
|
||||
reasons: List[BreakingReason] = Field(default_factory=list)
|
||||
warnings: List[BreakingReason] = Field(default_factory=list)
|
||||
|
||||
|
||||
class CreatePROutput(BaseModel):
|
||||
pr_url: str
|
||||
branch_name: str
|
||||
labels: List[str]
|
||||
committed: bool = False
|
||||
|
||||
|
||||
# -------------------------
|
||||
# deploy_validate
|
||||
# -------------------------
|
||||
class DeployValidateInput(BaseModel):
|
||||
chart: str
|
||||
repo: Optional[str] = None
|
||||
version: str
|
||||
values_override: Optional[Dict[str, Any]] = None
|
||||
namespace: str
|
||||
timeout: int = 300
|
||||
|
||||
|
||||
class DeployValidateOutput(BaseModel):
|
||||
success: bool
|
||||
dry_run_passed: bool
|
||||
pod_status: Dict[str, int]
|
||||
events: List[Dict[str, Any]] = Field(default_factory=list)
|
||||
logs: Optional[str] = None
|
||||
@@ -0,0 +1,33 @@
|
||||
---
|
||||
name: chart_updater
|
||||
description: Pull a Helm chart version and extract it into manifests/helm/<chart>/<version>.
|
||||
---
|
||||
|
||||
# chart_updater
|
||||
|
||||
Pull a chart from helm repo and create a versioned directory in dip-catalog.
|
||||
`from_version`이 주어지면 이전 버전 디렉토리에서 아래 파일을 새 버전 디렉토리로 복사한다 (이미 존재하는 파일은 건너뜀):
|
||||
- `BUILD-README.md` — 복사 + `from_version` → `version` 버전 번호 치환
|
||||
- `CUSTOM-README.md` — 그대로 복사
|
||||
- `custom-values.yaml` — 그대로 복사
|
||||
|
||||
## Input schema
|
||||
```json
|
||||
{
|
||||
"catalog_root": "string",
|
||||
"chart": "string",
|
||||
"repo": "string",
|
||||
"version": "string",
|
||||
"from_version": "string (optional)"
|
||||
}
|
||||
```
|
||||
|
||||
## Output schema
|
||||
```json
|
||||
{
|
||||
"success": "boolean",
|
||||
"already_existed": "boolean",
|
||||
"dest_dir": "string",
|
||||
"copied_files": ["string"]
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--catalog-root", required=True)
|
||||
parser.add_argument("--chart", required=True)
|
||||
parser.add_argument("--repo", required=True)
|
||||
parser.add_argument("--version", required=True)
|
||||
parser.add_argument("--from-version", default=None)
|
||||
args = parser.parse_args()
|
||||
|
||||
repo_root = os.environ.get("UPDATE_CATALOG_ROOT")
|
||||
if repo_root:
|
||||
sys.path.insert(0, str(Path(repo_root) / "src"))
|
||||
else:
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
from update_catalog.chart_updater import chart_updater # type: ignore
|
||||
|
||||
out = chart_updater({
|
||||
"catalog_root": args.catalog_root,
|
||||
"chart": args.chart,
|
||||
"repo": args.repo,
|
||||
"version": args.version,
|
||||
"from_version": args.from_version,
|
||||
})
|
||||
print(json.dumps(out, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1 @@
|
||||
__all__ = ["skill_interface"]
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,97 @@
|
||||
"""chart_updater skill implementation.
|
||||
|
||||
Pull latest chart from helm repo and extract into manifests/helm/<chart>/<version>.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
import tarfile
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
|
||||
|
||||
def _run(cmd: list[str], cwd: str | None = None) -> str:
|
||||
res = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True)
|
||||
if res.returncode != 0:
|
||||
raise RuntimeError(res.stderr.strip() or f"command failed: {' '.join(cmd)}")
|
||||
return res.stdout
|
||||
|
||||
|
||||
# Files to carry over from the previous version directory.
|
||||
# (filename, replace_version): replace_version=True → substitute from_version with to_version in content.
|
||||
_CARRY_OVER_FILES = [
|
||||
("BUILD-README.md", True),
|
||||
("CUSTOM-README.md", False),
|
||||
("custom-values.yaml", False),
|
||||
]
|
||||
|
||||
|
||||
def _copy_custom_files(
|
||||
src_dir: Path, dest_dir: Path, from_version: str, to_version: str
|
||||
) -> List[str]:
|
||||
"""Copy carry-over files from src_dir to dest_dir.
|
||||
|
||||
Skips files that already exist in dest_dir (idempotent).
|
||||
Returns list of copied file names.
|
||||
"""
|
||||
copied: List[str] = []
|
||||
for fname, needs_replace in _CARRY_OVER_FILES:
|
||||
src = src_dir / fname
|
||||
dest = dest_dir / fname
|
||||
if not src.exists() or dest.exists():
|
||||
continue
|
||||
content = src.read_text(encoding="utf-8")
|
||||
if needs_replace:
|
||||
content = content.replace(from_version, to_version)
|
||||
dest.write_text(content, encoding="utf-8")
|
||||
copied.append(fname)
|
||||
return copied
|
||||
|
||||
|
||||
def chart_updater(payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
catalog_root = Path(payload["catalog_root"])
|
||||
chart = payload["chart"]
|
||||
repo = payload["repo"]
|
||||
version = payload["version"]
|
||||
from_version: str | None = payload.get("from_version")
|
||||
|
||||
dest_dir = catalog_root / "manifests" / "helm" / chart / version
|
||||
already_existed = dest_dir.exists()
|
||||
|
||||
if not already_existed:
|
||||
work_dir = catalog_root / ".tmp_chart_pull"
|
||||
work_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
_run(["helm", "pull", f"{repo}/{chart}", "--version", version], cwd=str(work_dir))
|
||||
|
||||
tgz = next(work_dir.glob(f"{chart}-*.tgz"), None)
|
||||
if tgz is None:
|
||||
raise FileNotFoundError("pulled chart archive not found")
|
||||
|
||||
with tarfile.open(tgz, "r:gz") as tar:
|
||||
tar.extractall(path=work_dir)
|
||||
|
||||
extracted = work_dir / chart
|
||||
if not extracted.exists():
|
||||
# some charts may use different folder name; fallback to first dir
|
||||
dirs = [p for p in work_dir.iterdir() if p.is_dir() and p.name != "__pycache__"]
|
||||
if dirs:
|
||||
extracted = dirs[0]
|
||||
|
||||
dest_dir.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.move(str(extracted), str(dest_dir))
|
||||
tgz.unlink(missing_ok=True)
|
||||
|
||||
copied_files: List[str] = []
|
||||
if from_version:
|
||||
src_dir = catalog_root / "manifests" / "helm" / chart / from_version
|
||||
if src_dir.exists():
|
||||
copied_files = _copy_custom_files(src_dir, dest_dir, from_version, version)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"already_existed": already_existed,
|
||||
"dest_dir": str(dest_dir),
|
||||
"copied_files": copied_files,
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
"""Skill Interface v1.0 for Helm upgrade automation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Literal, Optional
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
SKILL_INTERFACE_VERSION = "1.0"
|
||||
|
||||
|
||||
# -------------------------
|
||||
# Common Error Schema
|
||||
# -------------------------
|
||||
class SkillError(BaseModel):
|
||||
code: str
|
||||
message: str
|
||||
retryable: bool = False
|
||||
details: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class ErrorResponse(BaseModel):
|
||||
error: SkillError
|
||||
|
||||
|
||||
# -------------------------
|
||||
# Shared Types
|
||||
# -------------------------
|
||||
Severity = Literal["critical", "high", "medium", "warning"]
|
||||
|
||||
|
||||
class BreakingReason(BaseModel):
|
||||
type: str
|
||||
resource: Optional[str] = None
|
||||
key: Optional[str] = None
|
||||
detail: Optional[str] = None
|
||||
|
||||
|
||||
# -------------------------
|
||||
# helm_diff
|
||||
# -------------------------
|
||||
class HelmDiffInput(BaseModel):
|
||||
chart: str
|
||||
repo: Optional[str] = None
|
||||
chart_path: Optional[str] = None
|
||||
from_version: str
|
||||
to_version: str
|
||||
values_override: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class HelmDiffOutput(BaseModel):
|
||||
chart: str
|
||||
from_version: str
|
||||
to_version: str
|
||||
generated_at: str
|
||||
values: Dict[str, Any]
|
||||
templates: Dict[str, Any]
|
||||
crd: Dict[str, Any]
|
||||
dependencies: Dict[str, Any]
|
||||
errors: List[Dict[str, Any]] = Field(default_factory=list)
|
||||
|
||||
|
||||
# -------------------------
|
||||
# breaking_change_check
|
||||
# -------------------------
|
||||
class BreakingCheckInput(BaseModel):
|
||||
diff_json: Dict[str, Any]
|
||||
|
||||
|
||||
class BreakingCheckOutput(BaseModel):
|
||||
breaking: bool
|
||||
severity: Severity
|
||||
reasons: List[BreakingReason] = Field(default_factory=list)
|
||||
warnings: List[BreakingReason] = Field(default_factory=list)
|
||||
|
||||
|
||||
# -------------------------
|
||||
# generate_upgrade_doc
|
||||
# -------------------------
|
||||
class GenerateDocInput(BaseModel):
|
||||
diff_json: Dict[str, Any]
|
||||
breaking_result: Dict[str, Any]
|
||||
docs_context: Optional[Dict[str, str]] = None
|
||||
max_tokens: int = 50000
|
||||
|
||||
|
||||
class GenerateDocOutput(BaseModel):
|
||||
markdown: str
|
||||
truncated: bool = False
|
||||
|
||||
|
||||
# -------------------------
|
||||
# update_docs_file
|
||||
# -------------------------
|
||||
class UpdateDocsInput(BaseModel):
|
||||
repo_path: str
|
||||
docs_file: str = "docs/upgrade.md"
|
||||
version: str
|
||||
content: str
|
||||
overwrite: bool = False
|
||||
|
||||
|
||||
class UpdateDocsOutput(BaseModel):
|
||||
success: bool
|
||||
file_path: str
|
||||
already_existed: bool = False
|
||||
|
||||
|
||||
# -------------------------
|
||||
# create_pr
|
||||
# -------------------------
|
||||
class CreatePRInput(BaseModel):
|
||||
chart: str
|
||||
from_version: str
|
||||
to_version: str
|
||||
repo_path: str
|
||||
doc_content: str
|
||||
breaking: bool
|
||||
severity: Severity
|
||||
|
||||
|
||||
class CreatePROutput(BaseModel):
|
||||
pr_url: str
|
||||
branch_name: str
|
||||
labels: List[str]
|
||||
|
||||
|
||||
# -------------------------
|
||||
# deploy_validate
|
||||
# -------------------------
|
||||
class DeployValidateInput(BaseModel):
|
||||
chart: str
|
||||
repo: Optional[str] = None
|
||||
version: str
|
||||
values_override: Optional[Dict[str, Any]] = None
|
||||
namespace: str
|
||||
timeout: int = 300
|
||||
|
||||
|
||||
class DeployValidateOutput(BaseModel):
|
||||
success: bool
|
||||
dry_run_passed: bool
|
||||
pod_status: Dict[str, int]
|
||||
events: List[Dict[str, Any]] = Field(default_factory=list)
|
||||
logs: Optional[str] = None
|
||||
@@ -0,0 +1,29 @@
|
||||
---
|
||||
name: chart_version_detector
|
||||
description: Detect current chart version from dip-catalog and resolve latest version from helm repo.
|
||||
---
|
||||
|
||||
# chart_version_detector
|
||||
|
||||
Determine current_version from manifests/helm/<chart>/ directories and parse repo info from BUILD-README.md.
|
||||
Then resolve latest_version from helm repo.
|
||||
|
||||
## Input schema
|
||||
```json
|
||||
{
|
||||
"catalog_root": "string",
|
||||
"chart": "string"
|
||||
}
|
||||
```
|
||||
|
||||
## Output schema
|
||||
```json
|
||||
{
|
||||
"chart": "string",
|
||||
"catalog_root": "string",
|
||||
"current_version": "string",
|
||||
"repo": "string | null",
|
||||
"repo_url": "string | null",
|
||||
"latest_version": "string | null"
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--catalog-root", required=True)
|
||||
parser.add_argument("--chart", required=True)
|
||||
args = parser.parse_args()
|
||||
|
||||
repo_root = os.environ.get("UPDATE_CATALOG_ROOT")
|
||||
if repo_root:
|
||||
sys.path.insert(0, str(Path(repo_root) / "src"))
|
||||
else:
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
from update_catalog.chart_version_detector import chart_version_detector # type: ignore
|
||||
|
||||
out = chart_version_detector({
|
||||
"catalog_root": args.catalog_root,
|
||||
"chart": args.chart,
|
||||
})
|
||||
print(json.dumps(out, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1 @@
|
||||
__all__ = ["skill_interface"]
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
+131
@@ -0,0 +1,131 @@
|
||||
"""chart_version_detector skill implementation.
|
||||
|
||||
Determine current_version from dip-catalog directory, parse repo info from BUILD-README,
|
||||
then resolve latest_version from helm repo.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
def _run(cmd: List[str]) -> str:
|
||||
res = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if res.returncode != 0:
|
||||
raise RuntimeError(res.stderr.strip() or f"command failed: {' '.join(cmd)}")
|
||||
return res.stdout
|
||||
|
||||
|
||||
def _parse_version(v: str) -> Tuple[int, ...]:
|
||||
# basic semver compare: take numeric parts only
|
||||
v = v.strip()
|
||||
v = re.split(r"[+-]", v)[0]
|
||||
parts = v.split(".")
|
||||
out = []
|
||||
for p in parts:
|
||||
try:
|
||||
out.append(int(p))
|
||||
except ValueError:
|
||||
out.append(0)
|
||||
return tuple(out)
|
||||
|
||||
|
||||
def _current_version_from_dirs(chart_dir: Path) -> Optional[str]:
|
||||
versions = [p.name for p in chart_dir.iterdir() if p.is_dir()]
|
||||
if not versions:
|
||||
return None
|
||||
versions_sorted = sorted(versions, key=_parse_version)
|
||||
return versions_sorted[-1]
|
||||
|
||||
|
||||
def _current_version_from_chart_yaml(chart_dir: Path) -> Optional[str]:
|
||||
chart_yaml = chart_dir / "Chart.yaml"
|
||||
if not chart_yaml.exists():
|
||||
return None
|
||||
with chart_yaml.open("r", encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
return data.get("version")
|
||||
|
||||
|
||||
def _parse_repo_from_build_readme(path: Path) -> Tuple[Optional[str], Optional[str]]:
|
||||
if not path.exists():
|
||||
return None, None
|
||||
text = path.read_text(encoding="utf-8")
|
||||
m = re.search(r"helm\s+repo\s+add\s+(\S+)\s+(\S+)", text)
|
||||
if not m:
|
||||
return None, None
|
||||
return m.group(1), m.group(2)
|
||||
|
||||
|
||||
def _ensure_repo(repo: str, url: str) -> None:
|
||||
try:
|
||||
out = _run(["helm", "repo", "list"])
|
||||
if repo in out:
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
_run(["helm", "repo", "add", repo, url])
|
||||
_run(["helm", "repo", "update"])
|
||||
|
||||
|
||||
def _latest_version(repo: str, chart: str) -> Optional[str]:
|
||||
out = _run(["helm", "search", "repo", f"{repo}/{chart}", "--versions"])
|
||||
lines = [l for l in out.splitlines() if l.strip()]
|
||||
if len(lines) < 2:
|
||||
return None
|
||||
# header at line 0; next line is latest
|
||||
parts = re.split(r"\s+", lines[1].strip())
|
||||
if len(parts) >= 2:
|
||||
return parts[1]
|
||||
return None
|
||||
|
||||
|
||||
def chart_version_detector(payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
catalog_root = Path(payload["catalog_root"])
|
||||
chart = payload["chart"]
|
||||
chart_dir = catalog_root / "manifests" / "helm" / chart
|
||||
|
||||
if not chart_dir.exists():
|
||||
return {
|
||||
"chart": chart,
|
||||
"catalog_root": str(catalog_root),
|
||||
"current_version": None,
|
||||
"repo": None,
|
||||
"repo_url": None,
|
||||
"latest_version": None,
|
||||
"error": {"code": "CHART_NOT_FOUND", "message": f"chart path not found: {chart_dir}"},
|
||||
}
|
||||
|
||||
current = _current_version_from_dirs(chart_dir)
|
||||
if current is None:
|
||||
return {
|
||||
"chart": chart,
|
||||
"catalog_root": str(catalog_root),
|
||||
"current_version": None,
|
||||
"repo": None,
|
||||
"repo_url": None,
|
||||
"latest_version": None,
|
||||
"error": {"code": "VERSION_NOT_FOUND", "message": f"no version directories under {chart_dir}"},
|
||||
}
|
||||
|
||||
build_readme = chart_dir / current / "BUILD-README.md"
|
||||
repo, url = _parse_repo_from_build_readme(build_readme)
|
||||
|
||||
latest = None
|
||||
if repo and url:
|
||||
_ensure_repo(repo, url)
|
||||
latest = _latest_version(repo, chart)
|
||||
|
||||
return {
|
||||
"chart": chart,
|
||||
"catalog_root": str(catalog_root),
|
||||
"current_version": current,
|
||||
"repo": repo,
|
||||
"repo_url": url,
|
||||
"latest_version": latest,
|
||||
}
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
"""Skill Interface v1.0 for Helm upgrade automation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Literal, Optional
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
SKILL_INTERFACE_VERSION = "1.0"
|
||||
|
||||
|
||||
# -------------------------
|
||||
# Common Error Schema
|
||||
# -------------------------
|
||||
class SkillError(BaseModel):
|
||||
code: str
|
||||
message: str
|
||||
retryable: bool = False
|
||||
details: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class ErrorResponse(BaseModel):
|
||||
error: SkillError
|
||||
|
||||
|
||||
# -------------------------
|
||||
# Shared Types
|
||||
# -------------------------
|
||||
Severity = Literal["critical", "high", "medium", "warning"]
|
||||
|
||||
|
||||
class BreakingReason(BaseModel):
|
||||
type: str
|
||||
resource: Optional[str] = None
|
||||
key: Optional[str] = None
|
||||
detail: Optional[str] = None
|
||||
|
||||
|
||||
# -------------------------
|
||||
# helm_diff
|
||||
# -------------------------
|
||||
class HelmDiffInput(BaseModel):
|
||||
chart: str
|
||||
repo: Optional[str] = None
|
||||
chart_path: Optional[str] = None
|
||||
from_version: str
|
||||
to_version: str
|
||||
values_override: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class HelmDiffOutput(BaseModel):
|
||||
chart: str
|
||||
from_version: str
|
||||
to_version: str
|
||||
generated_at: str
|
||||
values: Dict[str, Any]
|
||||
templates: Dict[str, Any]
|
||||
crd: Dict[str, Any]
|
||||
dependencies: Dict[str, Any]
|
||||
errors: List[Dict[str, Any]] = Field(default_factory=list)
|
||||
|
||||
|
||||
# -------------------------
|
||||
# breaking_change_check
|
||||
# -------------------------
|
||||
class BreakingCheckInput(BaseModel):
|
||||
diff_json: Dict[str, Any]
|
||||
|
||||
|
||||
class BreakingCheckOutput(BaseModel):
|
||||
breaking: bool
|
||||
severity: Severity
|
||||
reasons: List[BreakingReason] = Field(default_factory=list)
|
||||
warnings: List[BreakingReason] = Field(default_factory=list)
|
||||
|
||||
|
||||
# -------------------------
|
||||
# generate_upgrade_doc
|
||||
# -------------------------
|
||||
class GenerateDocInput(BaseModel):
|
||||
diff_json: Dict[str, Any]
|
||||
breaking_result: Dict[str, Any]
|
||||
docs_context: Optional[Dict[str, str]] = None
|
||||
max_tokens: int = 50000
|
||||
|
||||
|
||||
class GenerateDocOutput(BaseModel):
|
||||
markdown: str
|
||||
truncated: bool = False
|
||||
|
||||
|
||||
# -------------------------
|
||||
# update_docs_file
|
||||
# -------------------------
|
||||
class UpdateDocsInput(BaseModel):
|
||||
repo_path: str
|
||||
docs_file: str = "docs/upgrade.md"
|
||||
version: str
|
||||
content: str
|
||||
overwrite: bool = False
|
||||
|
||||
|
||||
class UpdateDocsOutput(BaseModel):
|
||||
success: bool
|
||||
file_path: str
|
||||
already_existed: bool = False
|
||||
|
||||
|
||||
# -------------------------
|
||||
# create_pr
|
||||
# -------------------------
|
||||
class CreatePRInput(BaseModel):
|
||||
chart: str
|
||||
from_version: str
|
||||
to_version: str
|
||||
repo_path: str
|
||||
doc_content: str
|
||||
breaking: bool
|
||||
severity: Severity
|
||||
|
||||
|
||||
class CreatePROutput(BaseModel):
|
||||
pr_url: str
|
||||
branch_name: str
|
||||
labels: List[str]
|
||||
|
||||
|
||||
# -------------------------
|
||||
# deploy_validate
|
||||
# -------------------------
|
||||
class DeployValidateInput(BaseModel):
|
||||
chart: str
|
||||
repo: Optional[str] = None
|
||||
version: str
|
||||
values_override: Optional[Dict[str, Any]] = None
|
||||
namespace: str
|
||||
timeout: int = 300
|
||||
|
||||
|
||||
class DeployValidateOutput(BaseModel):
|
||||
success: bool
|
||||
dry_run_passed: bool
|
||||
pod_status: Dict[str, int]
|
||||
events: List[Dict[str, Any]] = Field(default_factory=list)
|
||||
logs: Optional[str] = None
|
||||
@@ -0,0 +1,49 @@
|
||||
---
|
||||
name: create_pr
|
||||
description: Create a GitHub PR for the chart upgrade.
|
||||
user-invokable: false
|
||||
---
|
||||
|
||||
# create_pr (Skill)
|
||||
|
||||
브랜치 생성 → 커밋 → 푸시 → GitHub PR 생성을 수행한다.
|
||||
breaking=true이면 `needs-review` 레이블, 아니면 `auto-update` 레이블을 붙인다.
|
||||
실행 후 `main` 브랜치로 복귀하여 다음 cronjob 실행을 위한 클린 상태를 유지한다.
|
||||
|
||||
## 브랜치 명명 규칙
|
||||
|
||||
`update-{chart}/{to_version}` — 예: `update-airflow/1.19.0`
|
||||
|
||||
## 재실행 안전성 (Idempotent)
|
||||
|
||||
- 브랜치가 이미 존재하면 체크아웃 후 추가 커밋
|
||||
- PR이 이미 존재하면 URL만 반환 (중복 PR 생성 안 함)
|
||||
|
||||
## Input schema
|
||||
```json
|
||||
{
|
||||
"repo_path": "string",
|
||||
"chart": "string",
|
||||
"from_version": "string",
|
||||
"to_version": "string",
|
||||
"breaking": "boolean",
|
||||
"severity": "critical|high|medium|warning",
|
||||
"reasons": "array (breaking_change_check.reasons)",
|
||||
"warnings": "array (breaking_change_check.warnings)"
|
||||
}
|
||||
```
|
||||
|
||||
## Output schema
|
||||
```json
|
||||
{
|
||||
"pr_url": "string",
|
||||
"branch_name": "string",
|
||||
"labels": ["string"],
|
||||
"committed": "boolean"
|
||||
}
|
||||
```
|
||||
|
||||
## 의존 도구
|
||||
|
||||
- `git` — 브랜치/커밋/푸시
|
||||
- `gh` (GitHub CLI) — PR 생성 및 레이블 관리. `gh auth login` 완료 필요.
|
||||
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--repo-path", required=True, help="dip-catalog git repo 경로")
|
||||
parser.add_argument("--chart", required=True)
|
||||
parser.add_argument("--from-version", required=True)
|
||||
parser.add_argument("--to-version", required=True)
|
||||
parser.add_argument("--breaking-file", required=True, help="breaking_change_check 출력 JSON 경로")
|
||||
args = parser.parse_args()
|
||||
|
||||
repo_root = os.environ.get("UPDATE_CATALOG_ROOT")
|
||||
if repo_root:
|
||||
sys.path.insert(0, str(Path(repo_root) / "src"))
|
||||
else:
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
from update_catalog.create_pr import create_pr # type: ignore
|
||||
|
||||
with open(args.breaking_file, "r", encoding="utf-8") as f:
|
||||
breaking_data = json.load(f)
|
||||
|
||||
out = create_pr({
|
||||
"repo_path": args.repo_path,
|
||||
"chart": args.chart,
|
||||
"from_version": args.from_version,
|
||||
"to_version": args.to_version,
|
||||
"breaking": breaking_data.get("breaking", False),
|
||||
"severity": breaking_data.get("severity", "warning"),
|
||||
"reasons": breaking_data.get("reasons", []),
|
||||
"warnings": breaking_data.get("warnings", []),
|
||||
})
|
||||
print(json.dumps(out, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,164 @@
|
||||
"""create_pr skill implementation.
|
||||
|
||||
Branch → Commit → Push → GitHub PR 생성.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from .skill_interface import CreatePRInput, CreatePROutput, BreakingReason # type: ignore
|
||||
|
||||
|
||||
def _git(args: List[str], cwd: Path) -> str:
|
||||
res = subprocess.run(["git"] + args, cwd=cwd, capture_output=True, text=True)
|
||||
if res.returncode != 0:
|
||||
raise RuntimeError(f"git {' '.join(args)} failed: {res.stderr.strip()}")
|
||||
return res.stdout.strip()
|
||||
|
||||
|
||||
def _gh(args: List[str], cwd: Path) -> str:
|
||||
res = subprocess.run(["gh"] + args, cwd=cwd, capture_output=True, text=True)
|
||||
if res.returncode != 0:
|
||||
raise RuntimeError(f"gh {' '.join(args)} failed: {res.stderr.strip()}")
|
||||
return res.stdout.strip()
|
||||
|
||||
|
||||
def _get_existing_pr_url(branch_name: str, cwd: Path) -> Optional[str]:
|
||||
try:
|
||||
url = _gh(
|
||||
["pr", "list", "--head", branch_name, "--json", "url", "--jq", ".[0].url"],
|
||||
cwd=cwd,
|
||||
)
|
||||
return url.strip() or None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _ensure_label(label: str, cwd: Path) -> None:
|
||||
"""레이블이 없으면 생성한다."""
|
||||
label_colors = {
|
||||
"needs-review": "e11d48", # 빨강
|
||||
"auto-update": "16a34a", # 초록
|
||||
}
|
||||
try:
|
||||
_gh(["label", "list", "--json", "name", "--jq", f'.[] | select(.name == "{label}") | .name'], cwd=cwd)
|
||||
except Exception:
|
||||
pass
|
||||
color = label_colors.get(label, "0075ca")
|
||||
try:
|
||||
_gh(["label", "create", label, "--color", color, "--force"], cwd=cwd)
|
||||
except Exception:
|
||||
pass # 레이블 생성 실패해도 PR 생성은 계속
|
||||
|
||||
|
||||
def _build_pr_body(
|
||||
chart: str,
|
||||
from_version: str,
|
||||
to_version: str,
|
||||
breaking: bool,
|
||||
severity: str,
|
||||
reasons: List[BreakingReason],
|
||||
warnings: List[BreakingReason],
|
||||
) -> str:
|
||||
lines = [
|
||||
f"## Helm Chart Update: {chart} `{from_version}` → `{to_version}`",
|
||||
"",
|
||||
f"**Severity**: `{severity}` ",
|
||||
f"**Breaking**: {'✅ Human review required before merge' if breaking else '❌ No breaking changes'}",
|
||||
"",
|
||||
]
|
||||
if reasons:
|
||||
lines.append("### Breaking Changes")
|
||||
for r in reasons:
|
||||
key = r.get("key") or r.get("resource") or ""
|
||||
lines.append(f"- `[{r['type']}]` {key} — {r.get('detail', '')}")
|
||||
lines.append("")
|
||||
if warnings:
|
||||
lines.append(f"### Warnings ({len(warnings)} removed keys not used in custom-values.yaml)")
|
||||
for w in warnings:
|
||||
lines.append(f"- `[{w['type']}]` {w.get('key', '')} — {w.get('detail', '')}")
|
||||
lines.append("")
|
||||
if not reasons:
|
||||
lines += ["No breaking changes detected.", ""]
|
||||
lines.append("---")
|
||||
lines.append("*Generated by update-catalog automation*")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def create_pr(payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
inp = CreatePRInput(**payload)
|
||||
repo_path = Path(inp.repo_path)
|
||||
chart = inp.chart
|
||||
from_version = inp.from_version
|
||||
to_version = inp.to_version
|
||||
breaking = inp.breaking
|
||||
severity = inp.severity
|
||||
reasons = inp.reasons
|
||||
warnings = inp.warnings
|
||||
|
||||
branch_name = f"update-{chart}/{to_version}"
|
||||
chart_dir = f"manifests/helm/{chart}/{to_version}"
|
||||
|
||||
# 1. main으로 이동 후 최신화
|
||||
_git(["checkout", "main"], cwd=repo_path)
|
||||
_git(["pull"], cwd=repo_path)
|
||||
|
||||
# 2. 브랜치 생성 또는 기존 브랜치 체크아웃
|
||||
local_exists = bool(_git(["branch", "--list", branch_name], cwd=repo_path).strip())
|
||||
remote_exists = bool(_git(["branch", "-r", "--list", f"origin/{branch_name}"], cwd=repo_path).strip())
|
||||
|
||||
if local_exists:
|
||||
_git(["checkout", branch_name], cwd=repo_path)
|
||||
elif remote_exists:
|
||||
_git(["checkout", "-b", branch_name, f"origin/{branch_name}"], cwd=repo_path)
|
||||
else:
|
||||
_git(["checkout", "-b", branch_name], cwd=repo_path)
|
||||
|
||||
# 3. 변경 파일 스테이징
|
||||
_git(["add", chart_dir], cwd=repo_path)
|
||||
|
||||
# 4. 변경사항이 있으면 커밋
|
||||
status = _git(["status", "--porcelain", chart_dir], cwd=repo_path)
|
||||
committed = False
|
||||
if status.strip():
|
||||
breaking_tag = " [BREAKING]" if breaking else ""
|
||||
commit_msg = f"update {chart}/{to_version}{breaking_tag}"
|
||||
_git(["commit", "-m", commit_msg], cwd=repo_path)
|
||||
committed = True
|
||||
|
||||
# 5. 원격 브랜치에 푸시
|
||||
_git(["push", "-u", "origin", branch_name], cwd=repo_path)
|
||||
|
||||
# 6. 레이블 준비
|
||||
labels = ["needs-review"] if breaking else ["auto-update"]
|
||||
for label in labels:
|
||||
_ensure_label(label, cwd=repo_path)
|
||||
|
||||
# 7. PR 생성 (이미 존재하면 URL만 반환)
|
||||
pr_url = _get_existing_pr_url(branch_name, repo_path)
|
||||
if not pr_url:
|
||||
breaking_tag = " [BREAKING]" if breaking else ""
|
||||
title = f"update {chart}: {from_version} → {to_version}{breaking_tag}"
|
||||
body = _build_pr_body(chart, from_version, to_version, breaking, severity, reasons, warnings)
|
||||
label_args: List[str] = []
|
||||
for label in labels:
|
||||
label_args += ["--label", label]
|
||||
pr_url = _gh(
|
||||
["pr", "create", "--title", title, "--body", body, "--base", "main"] + label_args,
|
||||
cwd=repo_path,
|
||||
)
|
||||
|
||||
# 8. main으로 복귀 (다음 cronjob 실행을 위해 클린 상태 유지)
|
||||
_git(["checkout", "main"], cwd=repo_path)
|
||||
|
||||
return json.loads(
|
||||
CreatePROutput(
|
||||
pr_url=pr_url,
|
||||
branch_name=branch_name,
|
||||
labels=labels,
|
||||
committed=committed,
|
||||
).model_dump_json()
|
||||
)
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Skill Interface for create_pr."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Literal, Optional
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
Severity = Literal["critical", "high", "medium", "warning"]
|
||||
|
||||
# BreakingReason은 dict로 처리 (breaking_change_check 출력 그대로 수신)
|
||||
BreakingReason = Dict[str, Any]
|
||||
|
||||
|
||||
class CreatePRInput(BaseModel):
|
||||
chart: str
|
||||
from_version: str
|
||||
to_version: str
|
||||
repo_path: str
|
||||
breaking: bool
|
||||
severity: Severity
|
||||
reasons: List[BreakingReason] = Field(default_factory=list)
|
||||
warnings: List[BreakingReason] = Field(default_factory=list)
|
||||
|
||||
|
||||
class CreatePROutput(BaseModel):
|
||||
pr_url: str
|
||||
branch_name: str
|
||||
labels: List[str]
|
||||
committed: bool = False
|
||||
@@ -0,0 +1,33 @@
|
||||
---
|
||||
name: deploy_validate
|
||||
description: Deploy chart to a test namespace and validate health.
|
||||
user-invokable: false
|
||||
---
|
||||
|
||||
# deploy_validate (Skill)
|
||||
|
||||
Runs helm upgrade in a test namespace and reports health.
|
||||
|
||||
## Input schema
|
||||
```json
|
||||
{
|
||||
"chart": "string",
|
||||
"repo": "string | null",
|
||||
"chart_path": "string | null",
|
||||
"version": "string",
|
||||
"values_override": "object | null",
|
||||
"namespace": "string",
|
||||
"timeout": "number (default 300)"
|
||||
}
|
||||
```
|
||||
|
||||
## Output schema
|
||||
```json
|
||||
{
|
||||
"success": "boolean",
|
||||
"dry_run_passed": "boolean",
|
||||
"pod_status": "object",
|
||||
"events": "array",
|
||||
"logs": "string | null"
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
name: generate_upgrade_doc
|
||||
description: Generate upgrade Markdown from structured diff and breaking results.
|
||||
user-invokable: false
|
||||
---
|
||||
|
||||
# generate_upgrade_doc
|
||||
|
||||
Produce Markdown upgrade notes from Structured Diff JSON and breaking analysis.
|
||||
|
||||
## Input schema
|
||||
```json
|
||||
{
|
||||
"diff_json": "object",
|
||||
"breaking_result": "object",
|
||||
"docs_context": "object | null",
|
||||
"max_tokens": "number (default 50000)"
|
||||
}
|
||||
```
|
||||
|
||||
## Output schema
|
||||
```json
|
||||
{
|
||||
"markdown": "string",
|
||||
"truncated": "boolean"
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
DEFAULT_REPO = None
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--diff-file", required=True)
|
||||
parser.add_argument("--breaking-file", required=True)
|
||||
parser.add_argument("--docs-context", default=None)
|
||||
parser.add_argument("--max-tokens", type=int, default=50000)
|
||||
args = parser.parse_args()
|
||||
|
||||
repo_root = os.environ.get("UPDATE_CATALOG_ROOT")
|
||||
if repo_root:
|
||||
sys.path.insert(0, str(Path(repo_root) / "src"))
|
||||
else:
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
from update_catalog.generate_upgrade_doc import generate_upgrade_doc # type: ignore
|
||||
|
||||
with open(args.diff_file, "r", encoding="utf-8") as f:
|
||||
diff_json = json.load(f)
|
||||
with open(args.breaking_file, "r", encoding="utf-8") as f:
|
||||
breaking = json.load(f)
|
||||
|
||||
docs_context = None
|
||||
if args.docs_context:
|
||||
with open(args.docs_context, "r", encoding="utf-8") as f:
|
||||
text = f.read()
|
||||
docs_context = {"CUSTOM-README.md": text}
|
||||
|
||||
out = generate_upgrade_doc({
|
||||
"diff_json": diff_json,
|
||||
"breaking_result": breaking,
|
||||
"docs_context": docs_context,
|
||||
"max_tokens": args.max_tokens,
|
||||
})
|
||||
print(json.dumps(out, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1 @@
|
||||
__all__ = ["skill_interface"]
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
+150
@@ -0,0 +1,150 @@
|
||||
"""generate_upgrade_doc skill implementation.
|
||||
|
||||
항상 실행된다 (breaking 여부 무관).
|
||||
- breaking=true + USE_CLAUDE_CLI=1: LLM으로 custom-values.yaml 수정 방법 포함 상세 가이드 생성
|
||||
- 그 외: 템플릿 기반 간단 요약 생성
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from .skill_interface import GenerateDocInput, GenerateDocOutput
|
||||
|
||||
|
||||
def _fallback_summary(diff: Dict[str, Any], breaking: Dict[str, Any]) -> str:
|
||||
chart = diff.get("chart")
|
||||
to_version = diff.get("to_version")
|
||||
from_version = diff.get("from_version")
|
||||
|
||||
values = diff.get("values", {})
|
||||
templates = diff.get("templates", {})
|
||||
deps = diff.get("dependencies", {})
|
||||
|
||||
values_added = len(values.get("added", []))
|
||||
values_removed = len(values.get("removed", []))
|
||||
values_changed = len(values.get("changed", {}))
|
||||
values_type_changed = len(values.get("type_changed", []))
|
||||
|
||||
template_added = sum(1 for v in templates.values() if isinstance(v, dict) and v.get("added"))
|
||||
template_removed = sum(1 for v in templates.values() if isinstance(v, dict) and v.get("removed"))
|
||||
|
||||
breaking_flag = breaking.get("breaking", False)
|
||||
severity = breaking.get("severity", "warning")
|
||||
reasons = breaking.get("reasons", [])
|
||||
warnings_list = breaking.get("warnings", [])
|
||||
|
||||
lines: List[str] = []
|
||||
|
||||
# update_docs_file이 ## {version} 헤더를 별도 추가하므로 여기서는 생략
|
||||
lines.append("### 변경 요약")
|
||||
if from_version:
|
||||
lines.append(f"- from_version: {from_version}")
|
||||
if to_version:
|
||||
lines.append(f"- to_version: {to_version}")
|
||||
if chart and from_version and to_version:
|
||||
lines.append(f"- Chart `{chart}` {from_version} → {to_version} 업데이트")
|
||||
lines.append(f"- Values: +{values_added} / -{values_removed} / ~{values_changed} / type~{values_type_changed}")
|
||||
lines.append(f"- Templates: +{template_added} / -{template_removed}")
|
||||
if deps:
|
||||
added = len(deps.get("added", []))
|
||||
removed = len(deps.get("removed", []))
|
||||
changed = len(deps.get("version_changed", {}))
|
||||
lines.append(f"- Dependencies: +{added} / -{removed} / ~{changed}")
|
||||
|
||||
lines.append("\n### custom-values.yaml 수정 필요 항목")
|
||||
if breaking_flag:
|
||||
for r in reasons:
|
||||
key = r.get("key") or r.get("resource") or ""
|
||||
detail = r.get("detail") or ""
|
||||
lines.append(f"- **`{key}`**: {detail}".rstrip())
|
||||
else:
|
||||
lines.append("없음")
|
||||
|
||||
if warnings_list:
|
||||
lines.append("\n### 배포 시 주의사항")
|
||||
for w in warnings_list:
|
||||
resource = w.get("resource") or w.get("key") or ""
|
||||
detail = w.get("detail") or ""
|
||||
wtype = w.get("type", "")
|
||||
lines.append(f"- **{wtype}** {resource}: {detail}".rstrip())
|
||||
|
||||
lines.append("\n### 참고")
|
||||
lines.append(f"- severity: {severity}")
|
||||
lines.append(f"- breaking: {str(breaking_flag).lower()}")
|
||||
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def _build_prompt(diff: Dict[str, Any], breaking: Dict[str, Any], docs_context: Dict[str, Any] | None) -> str:
|
||||
prompt = (
|
||||
"당신은 Kubernetes Helm 업그레이드 문서를 작성하는 전문가입니다.\n\n"
|
||||
"두 Helm 차트 버전 간 변경점을 담은 Structured Diff JSON이 제공됩니다.\n\n"
|
||||
"## 작업\n"
|
||||
"1. 핵심 변경 사항을 평문으로 요약합니다.\n"
|
||||
"2. 각 변경의 운영 영향(예: 롤링 업데이트, 재시작 등)을 설명합니다.\n"
|
||||
"3. Breaking Change를 강조하고 구체적인 마이그레이션 절차를 제공합니다.\n"
|
||||
"4. 아래 형식의 간결한 Markdown 문서를 생성합니다.\n\n"
|
||||
"## 규칙\n"
|
||||
"- 입력 JSON에 없는 내용은 절대 추가하지 마세요.\n"
|
||||
"- 추측하지 마세요.\n"
|
||||
"- 값이 비어 있거나 null인 필드는 언급하지 마세요.\n"
|
||||
"- docs_context는 보조 설명에만 사용하고, diff에 없는 변경을 추가하지 마세요.\n"
|
||||
"- errors가 있으면 분석이 불완전할 수 있음을 명시하세요.\n"
|
||||
"- SRE/DevOps 엔지니어가 바로 실행할 수 있도록 명확하게 작성하세요.\n\n"
|
||||
"## 출력 형식\n"
|
||||
"아래 Markdown 구조를 정확히 지키세요 (## {to_version} 헤더는 포함하지 마세요):\n\n"
|
||||
"### 변경 요약\n"
|
||||
"- from_version: <from_version>\n"
|
||||
"- to_version: <to_version>\n"
|
||||
"- <핵심 변경 사항 요약>\n\n"
|
||||
"### custom-values.yaml 수정 필요 항목\n"
|
||||
"<breaking=true면 항목별 key와 수정 방법>\n"
|
||||
"<breaking=false면 \"없음\">\n\n"
|
||||
"### 배포 시 주의사항\n"
|
||||
"<warnings가 있으면 나열>\n"
|
||||
"<없으면 섹션 생략>\n\n"
|
||||
"### 참고\n"
|
||||
"- severity: <severity>\n"
|
||||
"- breaking: <true/false>\n\n"
|
||||
"---\n\n"
|
||||
"Input:\n"
|
||||
)
|
||||
payload = {
|
||||
**diff,
|
||||
**breaking,
|
||||
}
|
||||
if docs_context:
|
||||
payload["docs_context"] = docs_context
|
||||
prompt += json.dumps(payload, ensure_ascii=False)
|
||||
return prompt
|
||||
|
||||
|
||||
def _run_claude(prompt: str) -> str:
|
||||
res = subprocess.run(["claude", "-p", prompt], capture_output=True, text=True)
|
||||
if res.returncode != 0:
|
||||
raise RuntimeError(res.stderr.strip() or "claude CLI failed")
|
||||
return res.stdout.strip()
|
||||
|
||||
|
||||
def generate_upgrade_doc(payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
inp = GenerateDocInput(**payload)
|
||||
|
||||
use_claude = os.environ.get("USE_CLAUDE_CLI", "0") == "1"
|
||||
breaking = inp.breaking_result.get("breaking", False)
|
||||
markdown: str
|
||||
|
||||
# LLM 호출 조건: USE_CLAUDE_CLI=1 AND breaking=true
|
||||
if use_claude and breaking:
|
||||
try:
|
||||
prompt = _build_prompt(inp.diff_json, inp.breaking_result, inp.docs_context)
|
||||
markdown = _run_claude(prompt)
|
||||
except Exception:
|
||||
markdown = _fallback_summary(inp.diff_json, inp.breaking_result)
|
||||
else:
|
||||
markdown = _fallback_summary(inp.diff_json, inp.breaking_result)
|
||||
|
||||
out = GenerateDocOutput(markdown=markdown, truncated=False)
|
||||
return json.loads(out.model_dump_json())
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
"""Skill Interface v1.0 for Helm upgrade automation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Literal, Optional
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
SKILL_INTERFACE_VERSION = "1.0"
|
||||
|
||||
|
||||
# -------------------------
|
||||
# Common Error Schema
|
||||
# -------------------------
|
||||
class SkillError(BaseModel):
|
||||
code: str
|
||||
message: str
|
||||
retryable: bool = False
|
||||
details: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class ErrorResponse(BaseModel):
|
||||
error: SkillError
|
||||
|
||||
|
||||
# -------------------------
|
||||
# Shared Types
|
||||
# -------------------------
|
||||
Severity = Literal["critical", "high", "medium", "warning"]
|
||||
|
||||
|
||||
class BreakingReason(BaseModel):
|
||||
type: str
|
||||
resource: Optional[str] = None
|
||||
key: Optional[str] = None
|
||||
detail: Optional[str] = None
|
||||
|
||||
|
||||
# -------------------------
|
||||
# helm_diff
|
||||
# -------------------------
|
||||
class HelmDiffInput(BaseModel):
|
||||
chart: str
|
||||
repo: Optional[str] = None
|
||||
chart_path: Optional[str] = None
|
||||
from_version: str
|
||||
to_version: str
|
||||
values_override: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class HelmDiffOutput(BaseModel):
|
||||
chart: str
|
||||
from_version: str
|
||||
to_version: str
|
||||
generated_at: str
|
||||
values: Dict[str, Any]
|
||||
templates: Dict[str, Any]
|
||||
crd: Dict[str, Any]
|
||||
dependencies: Dict[str, Any]
|
||||
errors: List[Dict[str, Any]] = Field(default_factory=list)
|
||||
|
||||
|
||||
# -------------------------
|
||||
# breaking_change_check
|
||||
# -------------------------
|
||||
class BreakingCheckInput(BaseModel):
|
||||
diff_json: Dict[str, Any]
|
||||
|
||||
|
||||
class BreakingCheckOutput(BaseModel):
|
||||
breaking: bool
|
||||
severity: Severity
|
||||
breaking_reasons: List[BreakingReason] = Field(default_factory=list)
|
||||
warnings: List[BreakingReason] = Field(default_factory=list)
|
||||
|
||||
|
||||
# -------------------------
|
||||
# generate_upgrade_doc
|
||||
# -------------------------
|
||||
class GenerateDocInput(BaseModel):
|
||||
diff_json: Dict[str, Any]
|
||||
breaking_result: Dict[str, Any]
|
||||
docs_context: Optional[Dict[str, str]] = None
|
||||
max_tokens: int = 50000
|
||||
|
||||
|
||||
class GenerateDocOutput(BaseModel):
|
||||
markdown: str
|
||||
truncated: bool = False
|
||||
|
||||
|
||||
# -------------------------
|
||||
# update_docs_file
|
||||
# -------------------------
|
||||
class UpdateDocsInput(BaseModel):
|
||||
repo_path: str
|
||||
docs_file: str = "BUILD-README.md"
|
||||
version: str
|
||||
content: str
|
||||
overwrite: bool = False
|
||||
|
||||
|
||||
class UpdateDocsOutput(BaseModel):
|
||||
success: bool
|
||||
file_path: str
|
||||
already_existed: bool = False
|
||||
|
||||
|
||||
# -------------------------
|
||||
# create_pr
|
||||
# -------------------------
|
||||
class CreatePRInput(BaseModel):
|
||||
chart: str
|
||||
from_version: str
|
||||
to_version: str
|
||||
repo_path: str
|
||||
doc_content: str
|
||||
breaking: bool
|
||||
severity: Severity
|
||||
|
||||
|
||||
class CreatePROutput(BaseModel):
|
||||
pr_url: str
|
||||
branch_name: str
|
||||
labels: List[str]
|
||||
|
||||
|
||||
# -------------------------
|
||||
# deploy_validate
|
||||
# -------------------------
|
||||
class DeployValidateInput(BaseModel):
|
||||
chart: str
|
||||
repo: Optional[str] = None
|
||||
chart_path: Optional[str] = None
|
||||
version: str
|
||||
values_override: Optional[Dict[str, Any]] = None
|
||||
namespace: str
|
||||
timeout: int = 300
|
||||
|
||||
|
||||
class DeployValidateOutput(BaseModel):
|
||||
success: bool
|
||||
dry_run_passed: bool
|
||||
pod_status: Dict[str, int]
|
||||
events: List[Dict[str, Any]] = Field(default_factory=list)
|
||||
logs: Optional[str] = None
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
name: helm_diff
|
||||
description: Compare two Helm chart versions and emit Structured Diff JSON.
|
||||
user-invokable: false
|
||||
---
|
||||
|
||||
# helm_diff (Skill)
|
||||
|
||||
Deterministic Helm diff generator. Produces a structured JSON diff across values, templates, CRDs, and dependencies.
|
||||
|
||||
## Input schema
|
||||
```json
|
||||
{
|
||||
"chart": "string",
|
||||
"repo": "string | null",
|
||||
"chart_path": "string | null",
|
||||
"from_version": "string",
|
||||
"to_version": "string",
|
||||
"values_override": "string | object | null"
|
||||
}
|
||||
```
|
||||
|
||||
## Output schema
|
||||
```json
|
||||
{
|
||||
"chart": "string",
|
||||
"from_version": "string",
|
||||
"to_version": "string",
|
||||
"generated_at": "string (ISO8601)",
|
||||
"values": "object",
|
||||
"templates": "object",
|
||||
"crd": "object",
|
||||
"dependencies": "object",
|
||||
"errors": "array"
|
||||
}
|
||||
```
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
DEFAULT_REPO = None
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--chart", required=True)
|
||||
parser.add_argument("--repo")
|
||||
parser.add_argument("--chart-path")
|
||||
parser.add_argument("--from-version", required=True)
|
||||
parser.add_argument("--to-version", required=True)
|
||||
args = parser.parse_args()
|
||||
|
||||
repo_root = os.environ.get("UPDATE_CATALOG_ROOT")
|
||||
if repo_root:
|
||||
sys.path.insert(0, str(Path(repo_root) / "src"))
|
||||
else:
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
from update_catalog.helm_diff import helm_diff # type: ignore
|
||||
|
||||
payload = {
|
||||
"chart": args.chart,
|
||||
"repo": args.repo,
|
||||
"chart_path": args.chart_path,
|
||||
"from_version": args.from_version,
|
||||
"to_version": args.to_version,
|
||||
"values_override": None,
|
||||
}
|
||||
out = helm_diff(payload)
|
||||
print(json.dumps(out, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1 @@
|
||||
__all__ = ["skill_interface"]
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,252 @@
|
||||
"""helm_diff skill implementation (deterministic)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Tuple
|
||||
|
||||
import yaml
|
||||
|
||||
from .skill_interface import HelmDiffInput, HelmDiffOutput
|
||||
|
||||
|
||||
def _run(cmd: List[str], cwd: str | None = None, timeout: int = 60) -> str:
|
||||
res = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, timeout=timeout)
|
||||
if res.returncode != 0:
|
||||
raise RuntimeError(f"command failed: {' '.join(cmd)}\n{res.stderr}")
|
||||
return res.stdout
|
||||
|
||||
|
||||
def _load_yaml(path: Path) -> Dict[str, Any]:
|
||||
if not path.exists():
|
||||
return {}
|
||||
with path.open("r", encoding="utf-8") as f:
|
||||
return yaml.safe_load(f) or {}
|
||||
|
||||
|
||||
def _flatten(d: Dict[str, Any], parent_key: str = "", sep: str = ".") -> Dict[str, Any]:
|
||||
items: Dict[str, Any] = {}
|
||||
for k, v in d.items():
|
||||
new_key = f"{parent_key}{sep}{k}" if parent_key else str(k)
|
||||
if isinstance(v, dict):
|
||||
items.update(_flatten(v, new_key, sep=sep))
|
||||
else:
|
||||
items[new_key] = v
|
||||
return items
|
||||
|
||||
|
||||
def _diff_values(old: Dict[str, Any], new: Dict[str, Any]) -> Dict[str, Any]:
|
||||
old_flat = _flatten(old)
|
||||
new_flat = _flatten(new)
|
||||
added = sorted([k for k in new_flat.keys() if k not in old_flat])
|
||||
removed = sorted([k for k in old_flat.keys() if k not in new_flat])
|
||||
changed: Dict[str, Dict[str, Any]] = {}
|
||||
type_changed: List[Dict[str, Any]] = []
|
||||
for k in old_flat.keys() & new_flat.keys():
|
||||
if old_flat[k] != new_flat[k]:
|
||||
if type(old_flat[k]) != type(new_flat[k]):
|
||||
type_changed.append({"key": k, "old_type": type(old_flat[k]).__name__, "new_type": type(new_flat[k]).__name__})
|
||||
else:
|
||||
changed[k] = {"old": old_flat[k], "new": new_flat[k]}
|
||||
return {
|
||||
"values": {
|
||||
"added": added,
|
||||
"removed": removed,
|
||||
"changed": changed,
|
||||
"type_changed": type_changed,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def _split_manifest(yaml_text: str) -> List[Dict[str, Any]]:
|
||||
docs = []
|
||||
for doc in yaml.safe_load_all(yaml_text):
|
||||
if not doc or not isinstance(doc, dict):
|
||||
continue
|
||||
docs.append(doc)
|
||||
return docs
|
||||
|
||||
|
||||
def _resource_id(obj: Dict[str, Any], fallback_idx: int) -> str:
|
||||
kind = obj.get("kind", "Unknown")
|
||||
meta = obj.get("metadata", {}) or {}
|
||||
name = meta.get("name")
|
||||
if not name and meta.get("generateName"):
|
||||
name = f"{meta.get('generateName')}__gen__{fallback_idx}"
|
||||
ns = meta.get("namespace")
|
||||
if kind and name:
|
||||
if kind in {"Namespace", "CustomResourceDefinition"}:
|
||||
return f"{kind}/{name}"
|
||||
return f"{kind}/{name}" if not ns else f"{kind}/{name}"
|
||||
return f"{kind}/__unknown__{fallback_idx}"
|
||||
|
||||
|
||||
def _index_resources(docs: List[Dict[str, Any]]) -> Dict[str, Dict[str, Any]]:
|
||||
idx: Dict[str, Dict[str, Any]] = {}
|
||||
for i, doc in enumerate(docs):
|
||||
rid = _resource_id(doc, i)
|
||||
idx[rid] = doc
|
||||
return idx
|
||||
|
||||
|
||||
def _diff_templates(old_yaml: str, new_yaml: str) -> Dict[str, Any]:
|
||||
old_docs = _split_manifest(old_yaml)
|
||||
new_docs = _split_manifest(new_yaml)
|
||||
old_idx = _index_resources(old_docs)
|
||||
new_idx = _index_resources(new_docs)
|
||||
|
||||
changes: Dict[str, Any] = {}
|
||||
for rid, new_obj in new_idx.items():
|
||||
if rid not in old_idx:
|
||||
changes[rid] = {"added": True}
|
||||
continue
|
||||
old_obj = old_idx[rid]
|
||||
# Minimal diff: detect image/env/ports for Deployments/StatefulSets/Services
|
||||
kind = new_obj.get("kind")
|
||||
if kind in {"Deployment", "StatefulSet"}:
|
||||
def _containers(obj: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
return (((obj.get("spec") or {}).get("template") or {}).get("spec") or {}).get("containers") or []
|
||||
old_cont = _containers(old_obj)
|
||||
new_cont = _containers(new_obj)
|
||||
old_images = {c.get("name"): c.get("image") for c in old_cont}
|
||||
new_images = {c.get("name"): c.get("image") for c in new_cont}
|
||||
image_changed = old_images != new_images
|
||||
env_added: List[str] = []
|
||||
env_removed: List[str] = []
|
||||
def _env_keys(cont: Dict[str, Any]) -> set:
|
||||
return {e.get("name") for e in (cont.get("env") or []) if e.get("name")}
|
||||
for name, cont in {c.get("name"): c for c in new_cont}.items():
|
||||
old_env = _env_keys({c.get("name"): c for c in old_cont}.get(name, {}) or {})
|
||||
new_env = _env_keys(cont)
|
||||
env_added += sorted(list(new_env - old_env))
|
||||
env_removed += sorted(list(old_env - new_env))
|
||||
changes[rid] = {
|
||||
"image_changed": image_changed,
|
||||
"image": {"old": old_images, "new": new_images},
|
||||
"env_added": sorted(list(set(env_added))),
|
||||
"env_removed": sorted(list(set(env_removed))),
|
||||
}
|
||||
elif kind == "Service":
|
||||
def _ports(obj: Dict[str, Any]) -> List[Tuple[Any, Any]]:
|
||||
ports = (obj.get("spec") or {}).get("ports") or []
|
||||
return [(p.get("port"), p.get("targetPort")) for p in ports]
|
||||
changes[rid] = {
|
||||
"port_changed": _ports(old_obj) != _ports(new_obj)
|
||||
}
|
||||
for rid in old_idx.keys() - new_idx.keys():
|
||||
changes[rid] = {"removed": True}
|
||||
return {"templates": changes}
|
||||
|
||||
|
||||
def _diff_chart_yaml(old_chart: Dict[str, Any], new_chart: Dict[str, Any]) -> Dict[str, Any]:
|
||||
old_deps = {d.get("name"): d for d in (old_chart.get("dependencies") or [])}
|
||||
new_deps = {d.get("name"): d for d in (new_chart.get("dependencies") or [])}
|
||||
added = sorted([k for k in new_deps.keys() if k not in old_deps])
|
||||
removed = sorted([k for k in old_deps.keys() if k not in new_deps])
|
||||
version_changed: Dict[str, Dict[str, Any]] = {}
|
||||
for k in old_deps.keys() & new_deps.keys():
|
||||
if old_deps[k].get("version") != new_deps[k].get("version"):
|
||||
version_changed[k] = {"old": old_deps[k].get("version"), "new": new_deps[k].get("version")}
|
||||
return {"dependencies": {"added": added, "removed": removed, "version_changed": version_changed}}
|
||||
|
||||
|
||||
def _resolve_chart_dir(base: Path, chart_name: str) -> Path:
|
||||
# repo pull: chart is under base/<chart>
|
||||
candidate = base / chart_name
|
||||
if (candidate / "Chart.yaml").exists():
|
||||
return candidate
|
||||
# local copy: Chart.yaml at base root
|
||||
if (base / "Chart.yaml").exists():
|
||||
return base
|
||||
# fallback: first subdir with Chart.yaml
|
||||
for p in base.iterdir():
|
||||
if p.is_dir() and (p / "Chart.yaml").exists():
|
||||
return p
|
||||
return candidate
|
||||
|
||||
|
||||
def helm_diff(payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
inp = HelmDiffInput(**payload)
|
||||
errors: List[Dict[str, Any]] = []
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmpdir = Path(tmp)
|
||||
chart_old = tmpdir / "chart_old"
|
||||
chart_new = tmpdir / "chart_new"
|
||||
chart_old.mkdir()
|
||||
chart_new.mkdir()
|
||||
|
||||
try:
|
||||
if inp.chart_path:
|
||||
# dip-catalog local path for from_version
|
||||
from_path = Path(inp.chart_path)
|
||||
if not from_path.exists():
|
||||
raise FileNotFoundError(f"chart_path not found: {from_path}")
|
||||
shutil.copytree(from_path, chart_old, dirs_exist_ok=True)
|
||||
|
||||
# to_version: if repo provided, pull from repo (mixed mode)
|
||||
if inp.repo:
|
||||
_run([
|
||||
"helm",
|
||||
"pull",
|
||||
f"{inp.repo}/{inp.chart}",
|
||||
"--version",
|
||||
inp.to_version,
|
||||
"--untar",
|
||||
"--untardir",
|
||||
str(chart_new),
|
||||
])
|
||||
else:
|
||||
# local-to-local mode (use sibling version directory)
|
||||
to_path = from_path.parent / inp.to_version
|
||||
shutil.copytree(to_path, chart_new, dirs_exist_ok=True)
|
||||
else:
|
||||
if not inp.repo:
|
||||
raise ValueError("repo is required when chart_path is not provided")
|
||||
_run(["helm", "pull", f"{inp.repo}/{inp.chart}", "--version", inp.from_version, "--untar", "--untardir", str(chart_old)])
|
||||
_run(["helm", "pull", f"{inp.repo}/{inp.chart}", "--version", inp.to_version, "--untar", "--untardir", str(chart_new)])
|
||||
except Exception as e:
|
||||
errors.append({"stage": "helm_pull", "message": str(e)})
|
||||
|
||||
chart_old_dir = _resolve_chart_dir(chart_old, inp.chart)
|
||||
chart_new_dir = _resolve_chart_dir(chart_new, inp.chart)
|
||||
|
||||
# values diff
|
||||
values_old = _load_yaml(chart_old_dir / "values.yaml")
|
||||
values_new = _load_yaml(chart_new_dir / "values.yaml")
|
||||
values_diff = _diff_values(values_old, values_new)
|
||||
|
||||
# template diff
|
||||
templates_diff: Dict[str, Any] = {"templates": {}}
|
||||
try:
|
||||
old_yaml = _run(["helm", "template", str(chart_old_dir), "--include-crds"])
|
||||
new_yaml = _run(["helm", "template", str(chart_new_dir), "--include-crds"])
|
||||
templates_diff = _diff_templates(old_yaml, new_yaml)
|
||||
except Exception as e:
|
||||
errors.append({"stage": "helm_template", "message": str(e)})
|
||||
|
||||
# CRD diff placeholder (minimal)
|
||||
crd_diff = {"crd": {}}
|
||||
|
||||
# dependencies diff
|
||||
chart_old_yaml = _load_yaml(chart_old_dir / "Chart.yaml")
|
||||
chart_new_yaml = _load_yaml(chart_new_dir / "Chart.yaml")
|
||||
dep_diff = _diff_chart_yaml(chart_old_yaml, chart_new_yaml)
|
||||
|
||||
out = HelmDiffOutput(
|
||||
chart=inp.chart,
|
||||
from_version=inp.from_version,
|
||||
to_version=inp.to_version,
|
||||
generated_at=datetime.now(timezone.utc).isoformat(),
|
||||
values=values_diff.get("values", {}),
|
||||
templates=templates_diff.get("templates", {}),
|
||||
crd=crd_diff.get("crd", {}),
|
||||
dependencies=dep_diff.get("dependencies", {}),
|
||||
errors=errors,
|
||||
)
|
||||
return json.loads(out.model_dump_json())
|
||||
@@ -0,0 +1,144 @@
|
||||
"""Skill Interface v1.0 for Helm upgrade automation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Literal, Optional
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
SKILL_INTERFACE_VERSION = "1.0"
|
||||
|
||||
|
||||
# -------------------------
|
||||
# Common Error Schema
|
||||
# -------------------------
|
||||
class SkillError(BaseModel):
|
||||
code: str
|
||||
message: str
|
||||
retryable: bool = False
|
||||
details: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class ErrorResponse(BaseModel):
|
||||
error: SkillError
|
||||
|
||||
|
||||
# -------------------------
|
||||
# Shared Types
|
||||
# -------------------------
|
||||
Severity = Literal["critical", "high", "medium", "warning"]
|
||||
|
||||
|
||||
class BreakingReason(BaseModel):
|
||||
type: str
|
||||
resource: Optional[str] = None
|
||||
key: Optional[str] = None
|
||||
detail: Optional[str] = None
|
||||
|
||||
|
||||
# -------------------------
|
||||
# helm_diff
|
||||
# -------------------------
|
||||
class HelmDiffInput(BaseModel):
|
||||
chart: str
|
||||
repo: Optional[str] = None
|
||||
chart_path: Optional[str] = None
|
||||
from_version: str
|
||||
to_version: str
|
||||
values_override: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class HelmDiffOutput(BaseModel):
|
||||
chart: str
|
||||
from_version: str
|
||||
to_version: str
|
||||
generated_at: str
|
||||
values: Dict[str, Any]
|
||||
templates: Dict[str, Any]
|
||||
crd: Dict[str, Any]
|
||||
dependencies: Dict[str, Any]
|
||||
errors: List[Dict[str, Any]] = Field(default_factory=list)
|
||||
|
||||
|
||||
# -------------------------
|
||||
# breaking_change_check
|
||||
# -------------------------
|
||||
class BreakingCheckInput(BaseModel):
|
||||
diff_json: Dict[str, Any]
|
||||
|
||||
|
||||
class BreakingCheckOutput(BaseModel):
|
||||
breaking: bool
|
||||
severity: Severity
|
||||
reasons: List[BreakingReason] = Field(default_factory=list)
|
||||
warnings: List[BreakingReason] = Field(default_factory=list)
|
||||
|
||||
|
||||
# -------------------------
|
||||
# generate_upgrade_doc
|
||||
# -------------------------
|
||||
class GenerateDocInput(BaseModel):
|
||||
diff_json: Dict[str, Any]
|
||||
breaking_result: Dict[str, Any]
|
||||
docs_context: Optional[Dict[str, str]] = None
|
||||
max_tokens: int = 50000
|
||||
|
||||
|
||||
class GenerateDocOutput(BaseModel):
|
||||
markdown: str
|
||||
truncated: bool = False
|
||||
|
||||
|
||||
# -------------------------
|
||||
# update_docs_file
|
||||
# -------------------------
|
||||
class UpdateDocsInput(BaseModel):
|
||||
repo_path: str
|
||||
docs_file: str = "docs/upgrade.md"
|
||||
version: str
|
||||
content: str
|
||||
overwrite: bool = False
|
||||
|
||||
|
||||
class UpdateDocsOutput(BaseModel):
|
||||
success: bool
|
||||
file_path: str
|
||||
already_existed: bool = False
|
||||
|
||||
|
||||
# -------------------------
|
||||
# create_pr
|
||||
# -------------------------
|
||||
class CreatePRInput(BaseModel):
|
||||
chart: str
|
||||
from_version: str
|
||||
to_version: str
|
||||
repo_path: str
|
||||
doc_content: str
|
||||
breaking: bool
|
||||
severity: Severity
|
||||
|
||||
|
||||
class CreatePROutput(BaseModel):
|
||||
pr_url: str
|
||||
branch_name: str
|
||||
labels: List[str]
|
||||
|
||||
|
||||
# -------------------------
|
||||
# deploy_validate
|
||||
# -------------------------
|
||||
class DeployValidateInput(BaseModel):
|
||||
chart: str
|
||||
repo: Optional[str] = None
|
||||
version: str
|
||||
values_override: Optional[Dict[str, Any]] = None
|
||||
namespace: str
|
||||
timeout: int = 300
|
||||
|
||||
|
||||
class DeployValidateOutput(BaseModel):
|
||||
success: bool
|
||||
dry_run_passed: bool
|
||||
pod_status: Dict[str, int]
|
||||
events: List[Dict[str, Any]] = Field(default_factory=list)
|
||||
logs: Optional[str] = None
|
||||
@@ -0,0 +1,29 @@
|
||||
---
|
||||
name: update_docs_file
|
||||
description: Insert upgrade content into BUILD-README.md for a target chart version.
|
||||
user-invokable: false
|
||||
---
|
||||
|
||||
# update_docs_file (Skill)
|
||||
|
||||
Update a chart version directory's BUILD-README.md by inserting upgrade content.
|
||||
|
||||
## Input schema
|
||||
```json
|
||||
{
|
||||
"repo_path": "string",
|
||||
"docs_file": "string (default: BUILD-README.md)",
|
||||
"version": "string",
|
||||
"content": "string",
|
||||
"overwrite": "boolean"
|
||||
}
|
||||
```
|
||||
|
||||
## Output schema
|
||||
```json
|
||||
{
|
||||
"success": "boolean",
|
||||
"file_path": "string",
|
||||
"already_existed": "boolean"
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,41 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
DEFAULT_REPO = None
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--repo-path", required=True)
|
||||
parser.add_argument("--docs-file", default="BUILD-README.md")
|
||||
parser.add_argument("--version", required=True)
|
||||
parser.add_argument("--content-file", required=True)
|
||||
parser.add_argument("--overwrite", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
repo_root = os.environ.get("UPDATE_CATALOG_ROOT")
|
||||
if repo_root:
|
||||
sys.path.insert(0, str(Path(repo_root) / "src"))
|
||||
else:
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
from update_catalog.update_docs_file import update_docs_file # type: ignore
|
||||
|
||||
content = Path(args.content_file).read_text(encoding="utf-8")
|
||||
out = update_docs_file({
|
||||
"repo_path": args.repo_path,
|
||||
"docs_file": args.docs_file,
|
||||
"version": args.version,
|
||||
"content": content,
|
||||
"overwrite": bool(args.overwrite),
|
||||
})
|
||||
print(json.dumps(out, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1 @@
|
||||
__all__ = ["skill_interface"]
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
+145
@@ -0,0 +1,145 @@
|
||||
"""Skill Interface v1.0 for Helm upgrade automation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Literal, Optional
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
SKILL_INTERFACE_VERSION = "1.0"
|
||||
|
||||
|
||||
# -------------------------
|
||||
# Common Error Schema
|
||||
# -------------------------
|
||||
class SkillError(BaseModel):
|
||||
code: str
|
||||
message: str
|
||||
retryable: bool = False
|
||||
details: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class ErrorResponse(BaseModel):
|
||||
error: SkillError
|
||||
|
||||
|
||||
# -------------------------
|
||||
# Shared Types
|
||||
# -------------------------
|
||||
Severity = Literal["critical", "high", "medium", "warning"]
|
||||
|
||||
|
||||
class BreakingReason(BaseModel):
|
||||
type: str
|
||||
resource: Optional[str] = None
|
||||
key: Optional[str] = None
|
||||
detail: Optional[str] = None
|
||||
|
||||
|
||||
# -------------------------
|
||||
# helm_diff
|
||||
# -------------------------
|
||||
class HelmDiffInput(BaseModel):
|
||||
chart: str
|
||||
repo: Optional[str] = None
|
||||
chart_path: Optional[str] = None
|
||||
from_version: str
|
||||
to_version: str
|
||||
values_override: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class HelmDiffOutput(BaseModel):
|
||||
chart: str
|
||||
from_version: str
|
||||
to_version: str
|
||||
generated_at: str
|
||||
values: Dict[str, Any]
|
||||
templates: Dict[str, Any]
|
||||
crd: Dict[str, Any]
|
||||
dependencies: Dict[str, Any]
|
||||
errors: List[Dict[str, Any]] = Field(default_factory=list)
|
||||
|
||||
|
||||
# -------------------------
|
||||
# breaking_change_check
|
||||
# -------------------------
|
||||
class BreakingCheckInput(BaseModel):
|
||||
diff_json: Dict[str, Any]
|
||||
|
||||
|
||||
class BreakingCheckOutput(BaseModel):
|
||||
breaking: bool
|
||||
severity: Severity
|
||||
breaking_reasons: List[BreakingReason] = Field(default_factory=list)
|
||||
warnings: List[BreakingReason] = Field(default_factory=list)
|
||||
|
||||
|
||||
# -------------------------
|
||||
# generate_upgrade_doc
|
||||
# -------------------------
|
||||
class GenerateDocInput(BaseModel):
|
||||
diff_json: Dict[str, Any]
|
||||
breaking_result: Dict[str, Any]
|
||||
docs_context: Optional[Dict[str, str]] = None
|
||||
max_tokens: int = 50000
|
||||
|
||||
|
||||
class GenerateDocOutput(BaseModel):
|
||||
markdown: str
|
||||
truncated: bool = False
|
||||
|
||||
|
||||
# -------------------------
|
||||
# update_docs_file
|
||||
# -------------------------
|
||||
class UpdateDocsInput(BaseModel):
|
||||
repo_path: str
|
||||
docs_file: str # 예: "manifests/helm/<chart>/<to_version>/CUSTOM-README.md"
|
||||
version: str
|
||||
content: str
|
||||
overwrite: bool = False
|
||||
|
||||
|
||||
class UpdateDocsOutput(BaseModel):
|
||||
success: bool
|
||||
file_path: str
|
||||
already_existed: bool = False
|
||||
|
||||
|
||||
# -------------------------
|
||||
# create_pr
|
||||
# -------------------------
|
||||
class CreatePRInput(BaseModel):
|
||||
chart: str
|
||||
from_version: str
|
||||
to_version: str
|
||||
repo_path: str
|
||||
doc_content: str
|
||||
breaking: bool
|
||||
severity: Severity
|
||||
|
||||
|
||||
class CreatePROutput(BaseModel):
|
||||
pr_url: str
|
||||
branch_name: str
|
||||
labels: List[str]
|
||||
|
||||
|
||||
# -------------------------
|
||||
# deploy_validate
|
||||
# -------------------------
|
||||
class DeployValidateInput(BaseModel):
|
||||
chart: str
|
||||
repo: Optional[str] = None
|
||||
chart_path: Optional[str] = None
|
||||
version: str
|
||||
values_override: Optional[Dict[str, Any]] = None
|
||||
namespace: str
|
||||
timeout: int = 300
|
||||
|
||||
|
||||
class DeployValidateOutput(BaseModel):
|
||||
success: bool
|
||||
dry_run_passed: bool
|
||||
pod_status: Dict[str, int]
|
||||
events: List[Dict[str, Any]] = Field(default_factory=list)
|
||||
logs: Optional[str] = None
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
"""update_docs_file skill implementation."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict
|
||||
|
||||
from .skill_interface import UpdateDocsInput, UpdateDocsOutput
|
||||
|
||||
|
||||
HEADER = "# Upgrade History"
|
||||
|
||||
|
||||
def _insert_section(text: str, version: str, content: str) -> tuple[str, bool]:
|
||||
marker = f"## {version}"
|
||||
if marker in text:
|
||||
return text, True
|
||||
|
||||
if HEADER in text:
|
||||
parts = text.split(HEADER, 1)
|
||||
head = parts[0] + HEADER
|
||||
rest = parts[1].lstrip("\n")
|
||||
new_section = f"\n\n{marker}\n{content.strip()}\n"
|
||||
return head + new_section + "\n" + rest, False
|
||||
|
||||
# If no header, prepend
|
||||
new_text = f"{HEADER}\n\n{marker}\n{content.strip()}\n\n{text}"
|
||||
return new_text, False
|
||||
|
||||
|
||||
def update_docs_file(payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
inp = UpdateDocsInput(**payload)
|
||||
repo_path = Path(inp.repo_path)
|
||||
docs_path = repo_path / inp.docs_file
|
||||
|
||||
text = ""
|
||||
if docs_path.exists():
|
||||
text = docs_path.read_text(encoding="utf-8")
|
||||
|
||||
new_text, existed = _insert_section(text, inp.version, inp.content)
|
||||
if existed and not inp.overwrite:
|
||||
out = UpdateDocsOutput(success=True, file_path=str(docs_path), already_existed=True)
|
||||
return json.loads(out.model_dump_json())
|
||||
|
||||
if existed and inp.overwrite:
|
||||
# overwrite: replace section between marker and next header
|
||||
marker = f"## {inp.version}"
|
||||
parts = new_text.split(marker, 1)
|
||||
if len(parts) > 1:
|
||||
after = parts[1]
|
||||
tail_idx = after.find("\n## ")
|
||||
if tail_idx != -1:
|
||||
after = after[tail_idx:]
|
||||
else:
|
||||
after = ""
|
||||
new_text = parts[0] + marker + "\n" + inp.content.strip() + "\n" + after
|
||||
|
||||
docs_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
docs_path.write_text(new_text, encoding="utf-8")
|
||||
|
||||
out = UpdateDocsOutput(success=True, file_path=str(docs_path), already_existed=existed)
|
||||
return json.loads(out.model_dump_json())
|
||||
Reference in New Issue
Block a user