diff --git a/confluence-mdx/bin/reverse_sync/batch_service.py b/confluence-mdx/bin/reverse_sync/batch_service.py new file mode 100644 index 000000000..1eeb12bd4 --- /dev/null +++ b/confluence-mdx/bin/reverse_sync/batch_service.py @@ -0,0 +1,178 @@ +"""Branch 단위 reverse-sync verification/publish state transition service.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Callable, List + +from reverse_sync.prepare_service import VerificationRequest +from reverse_sync.publish_service import PushConflictError + + +@dataclass(frozen=True) +class BatchRuntime: + """CLI, git, 인증, publisher 의존성을 batch lifecycle에 주입합니다.""" + + get_changed_files: Callable[[str], List[str]] + verify_one: Callable[..., dict] + ensure_config: Callable[[], object] + publish_one: Callable[..., dict] + confirm: Callable[[str], bool] + is_success_status: Callable[[str], bool] + emit: Callable[..., None] + + +def run_batch( + branch: str, + *, + runtime: BatchRuntime, + limit: int = 0, + failures_only: bool = False, + push: bool = False, + yes: bool = False, + lenient: bool = False, + no_normalize: bool = False, + prepare_push: bool = False, +) -> List[dict]: + """branch 변경 문서를 검증하고 eligible run만 순차 발행합니다.""" + + files = runtime.get_changed_files(branch) + if not files: + return [{"status": "no_changes", "branch": branch, "changes_count": 0}] + + total = len(files) + if limit > 0 and not failures_only: + files = files[:limit] + count_label = ( + f"up to {total}" if failures_only and limit > 0 else str(len(files)) + ) + runtime.emit( + f"Processing {count_label}/{total} file(s) from branch {branch}..." + ) + + results = [] + failure_count = 0 + online = push or prepare_push + config = runtime.ensure_config() if online else None + for index, ko_path in enumerate(files, 1): + runtime.emit( + f"[{index}/{len(files)}] {ko_path} ... ", + end="", + flush=True, + ) + try: + request = VerificationRequest( + improved_mdx=f"{branch}:{ko_path}", + original_mdx=None, + lenient=lenient, + no_normalize=no_normalize, + ) + if online: + result = runtime.verify_one( + request, + config=config, + prepare_push=True, + ) + else: + result = runtime.verify_one(request) + if online and result.get("status") == "pass": + result.update( + status="blocked", + push_eligible=False, + reason_code="diagnostic_result_not_pushable", + ) + result["file"] = ko_path + runtime.emit(result.get("status", "unknown")) + results.append(result) + except Exception as exc: + runtime.emit("error") + results.append( + { + "file": ko_path, + "status": "error", + "error": str(exc), + } + ) + if not runtime.is_success_status( + results[-1].get("status", "unknown") + ): + failure_count += 1 + if not push and failures_only and limit > 0 and failure_count >= limit: + break + + if not push: + return results + + pushable = [ + result + for result in results + if result.get("status") == "verified_local" + and result.get("push_eligible") is True + ] + if not pushable: + runtime.emit("\nPush 대상 없음 (verified_local 0건)") + return results + + if not yes: + runtime.emit( + f"\n검증 완료: verified_local {len(pushable)}건 / " + f"전체 {len(results)}건" + ) + if not runtime.confirm( + f"{len(pushable)}건을 Confluence에 push 할까요? [y/N] " + ): + runtime.emit("Push 취소") + for result in pushable: + result["push"] = { + "status": "not_attempted", + "reason_code": "user_cancelled", + } + return results + + push_count = 0 + for push_index, result in enumerate(pushable): + page_id = result["page_id"] + try: + push_result = runtime.publish_one( + page_id, + config=config, + manifest_path=result.get("manifest_path"), + ) + result["push"] = push_result + push_count += 1 + runtime.emit( + f" pushed {page_id} (v{push_result.get('version', '?')})" + ) + except PushConflictError as exc: + result["push"] = { + "status": "conflict", + "reason_code": "version_conflict", + "error": str(exc), + } + runtime.emit(f" conflict {page_id}: {exc}") + except Exception as exc: + reason_code = getattr(exc, "reason_code", "") or "push_error" + if reason_code == "postcondition_failed": + result["push"] = { + "status": "postcondition_failed", + "reason_code": reason_code, + "error": str(exc), + } + for pending in pushable[push_index + 1 :]: + pending["push"] = { + "status": "not_attempted", + "reason_code": ( + "batch_halted_after_postcondition_failure" + ), + } + runtime.emit(f" postcondition failed {page_id}: {exc}") + break + result["push"] = { + "status": "error", + "reason_code": reason_code, + "error": str(exc), + } + runtime.emit(f" error {page_id}: {exc}") + + runtime.emit(f"\nPushed {push_count}/{len(pushable)} file(s)") + return results diff --git a/confluence-mdx/bin/reverse_sync/prepare_service.py b/confluence-mdx/bin/reverse_sync/prepare_service.py new file mode 100644 index 000000000..a3591ba75 --- /dev/null +++ b/confluence-mdx/bin/reverse_sync/prepare_service.py @@ -0,0 +1,88 @@ +"""MDX source와 remote snapshot을 verification 입력으로 준비하는 service.""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Callable + +from reverse_sync.verification_service import MdxSource + + +@dataclass(frozen=True) +class VerificationRequest: + """단일 MDX verification에 필요한 사용자 입력입니다.""" + + improved_mdx: str + original_mdx: str | None = None + page_id: str | None = None + page_dir: str | None = None + lenient: bool = False + no_normalize: bool = False + + +@dataclass(frozen=True) +class PrepareRuntime: + """CLI와 repository 환경 의존성을 prepare lifecycle에 주입합니다.""" + + resolve_mdx_source: Callable[[str], MdxSource] + extract_ko_mdx_path: Callable[[str], str] + resolve_page_id: Callable[[str], str] + ensure_config: Callable[[], object] + run_verification: Callable[..., dict] + + +def prepare_verification( + request: VerificationRequest, + *, + runtime: PrepareRuntime, + config=None, + prepare_push: bool = False, +) -> dict: + """MDX identity를 해석하고 필요할 때 remote snapshot을 결합합니다.""" + + improved_src = runtime.resolve_mdx_source(request.improved_mdx) + if request.original_mdx: + original_src = runtime.resolve_mdx_source(request.original_mdx) + else: + ko_path = runtime.extract_ko_mdx_path(improved_src.descriptor) + original_src = runtime.resolve_mdx_source(f"main:{ko_path}") + + page_id = request.page_id + if not page_id: + page_id = runtime.resolve_page_id( + runtime.extract_ko_mdx_path(improved_src.descriptor) + ) + + page_dir = request.page_dir + xhtml_path = str(Path(page_dir) / "page.xhtml") if page_dir else None + base_snapshot = None + attachment_catalog = None + if prepare_push: + from reverse_sync.confluence_client import ( + get_attachment_catalog, + get_page_snapshot, + ) + from reverse_sync.dependencies import added_attachment_filenames + + if config is None: + config = runtime.ensure_config() + base_snapshot = get_page_snapshot(config, page_id) + if added_attachment_filenames( + original_src.content, + improved_src.content, + ): + attachment_catalog = get_attachment_catalog(config, page_id) + + return runtime.run_verification( + page_id=page_id, + original_src=original_src, + improved_src=improved_src, + xhtml_path=xhtml_path, + lenient=request.lenient, + no_normalize=request.no_normalize, + page_dir=page_dir, + base_snapshot=base_snapshot, + attachment_catalog=attachment_catalog, + for_push=prepare_push, + ) diff --git a/confluence-mdx/bin/reverse_sync_cli.py b/confluence-mdx/bin/reverse_sync_cli.py index 72a025fa7..f9839f95a 100755 --- a/confluence-mdx/bin/reverse_sync_cli.py +++ b/confluence-mdx/bin/reverse_sync_cli.py @@ -21,8 +21,14 @@ sys.path.insert(0, str(_SCRIPT_DIR)) from reverse_sync.batch_report import BatchReport +from reverse_sync.batch_service import BatchRuntime, run_batch from reverse_sync.planner import plan_patches from reverse_sync.equivalence import PUSH_EQUIVALENCE_POLICY +from reverse_sync.prepare_service import ( + PrepareRuntime, + VerificationRequest, + prepare_verification, +) from reverse_sync.publish_service import ( ManifestPushSummary, PublishRuntime, @@ -582,50 +588,28 @@ def _add_common_args(parser: argparse.ArgumentParser): def _do_verify(args, *, config=None, prepare_push: bool = False) -> dict: - """공통 verify 로직: MDX 소스 해석 → run_verify() 실행 → 결과 반환.""" - improved_src = _resolve_mdx_source(args.improved_mdx) - if args.original_mdx: - original_src = _resolve_mdx_source(args.original_mdx) - else: - ko_path = _extract_ko_mdx_path(improved_src.descriptor) - original_src = _resolve_mdx_source(f'main:{ko_path}') - if getattr(args, 'page_id', None): - page_id = args.page_id - else: - page_id = _resolve_page_id(_extract_ko_mdx_path(improved_src.descriptor)) - - # --page-dir: var// 를 대체하는 디렉토리 (page.xhtml, page.v1.yaml 제공) - page_dir = getattr(args, 'page_dir', None) - xhtml_path = str(Path(page_dir) / 'page.xhtml') if page_dir else None - base_snapshot = None - attachment_catalog = None - if prepare_push: - from reverse_sync.confluence_client import ( - get_attachment_catalog, - get_page_snapshot, - ) - from reverse_sync.dependencies import added_attachment_filenames - - if config is None: - config = _ensure_confluence_config() - base_snapshot = get_page_snapshot(config, page_id) - if added_attachment_filenames( - original_src.content, - improved_src.content, - ): - attachment_catalog = get_attachment_catalog(config, page_id) - - return run_verify( - page_id=page_id, - original_src=original_src, - improved_src=improved_src, - xhtml_path=xhtml_path, - lenient=getattr(args, 'lenient', False), - no_normalize=getattr(args, 'no_normalize', False), - page_dir=page_dir, - base_snapshot=base_snapshot, - attachment_catalog=attachment_catalog, - for_push=prepare_push, + """CLI 입력을 typed request로 변환하여 prepare lifecycle을 실행합니다.""" + + request = VerificationRequest( + improved_mdx=args.improved_mdx, + original_mdx=getattr(args, "original_mdx", None), + page_id=getattr(args, "page_id", None), + page_dir=getattr(args, "page_dir", None), + lenient=getattr(args, "lenient", False), + no_normalize=getattr(args, "no_normalize", False), + ) + runtime = PrepareRuntime( + resolve_mdx_source=_resolve_mdx_source, + extract_ko_mdx_path=_extract_ko_mdx_path, + resolve_page_id=_resolve_page_id, + ensure_config=_ensure_confluence_config, + run_verification=run_verify, + ) + return prepare_verification( + request, + runtime=runtime, + config=config, + prepare_push=prepare_push, ) @@ -644,138 +628,49 @@ def _confirm(prompt: str) -> bool: return answer in ('y', 'yes') -def _do_verify_batch(branch: str, limit: int = 0, failures_only: bool = False, - push: bool = False, yes: bool = False, - lenient: bool = False, - no_normalize: bool = False, - prepare_push: bool = False) -> List[dict]: - """브랜치의 변경 ko MDX 파일을 배치 처리한다. +def _emit_batch_progress( + message: str, + *, + end: str = "\n", + flush: bool = False, +) -> None: + """batch service event를 CLI stderr에 표시합니다.""" - push=True이면 online verify 전체 완료 후 verified_local 건만 일괄 push한다. - yes=True이면 확인 프롬프트를 스킵한다. - lenient=True이면 변경된 행만 검사하는 관대 모드로 검증한다. - """ - files = _get_changed_ko_mdx_files(branch) - if not files: - return [{'status': 'no_changes', 'branch': branch, 'changes_count': 0}] - total = len(files) - if limit > 0 and not failures_only: - files = files[:limit] - print(f"Processing {'up to ' + str(total) if failures_only and limit > 0 else str(len(files))}/{total} file(s) from branch {branch}...", file=sys.stderr) - results = [] - failure_count = 0 - online = push or prepare_push - config = _ensure_confluence_config() if online else None - for idx, ko_path in enumerate(files, 1): - print(f"[{idx}/{len(files)}] {ko_path} ... ", end='', file=sys.stderr, flush=True) - try: - args = argparse.Namespace( - improved_mdx=f"{branch}:{ko_path}", - original_mdx=None, - lenient=lenient, - no_normalize=no_normalize, - ) - if online: - result = _do_verify( - args, - config=config, - prepare_push=True, - ) - else: - result = _do_verify(args) - if online and result.get("status") == "pass": - result.update( - status="blocked", - push_eligible=False, - reason_code="diagnostic_result_not_pushable", - ) - result['file'] = ko_path - status = result.get('status', 'unknown') - print(status, file=sys.stderr) - results.append(result) - except Exception as e: - print("error", file=sys.stderr) - results.append({'file': ko_path, 'status': 'error', 'error': str(e)}) - if not _is_success_status(results[-1].get('status', 'unknown')): - failure_count += 1 - if not push and failures_only and limit > 0 and failure_count >= limit: - break - - if not push: - return results - - # push 대상 집계 - pushable = [ - r for r in results - if r.get('status') == 'verified_local' - and r.get('push_eligible') is True - ] - if not pushable: - print("\nPush 대상 없음 (verified_local 0건)", file=sys.stderr) - return results - - # 확인 프롬프트 - if not yes: - print( - f"\n검증 완료: verified_local {len(pushable)}건 / " - f"전체 {len(results)}건", - file=sys.stderr, - ) - if not _confirm(f"{len(pushable)}건을 Confluence에 push 할까요? [y/N] "): - print("Push 취소", file=sys.stderr) - for result in pushable: - result["push"] = { - "status": "not_attempted", - "reason_code": "user_cancelled", - } - return results + print(message, end=end, flush=flush, file=sys.stderr) - # 일괄 push - push_count = 0 - for push_index, r in enumerate(pushable): - page_id = r['page_id'] - try: - push_result = _do_push( - page_id, - config=config, - manifest_path=r.get("manifest_path"), - ) - r['push'] = push_result - push_count += 1 - print(f" pushed {page_id} (v{push_result.get('version', '?')})", file=sys.stderr) - except PushConflictError as e: - r['push'] = { - 'status': 'conflict', - 'reason_code': 'version_conflict', - 'error': str(e), - } - print(f" conflict {page_id}: {e}", file=sys.stderr) - except Exception as e: - reason_code = getattr(e, "reason_code", "") or "push_error" - if reason_code == "postcondition_failed": - r['push'] = { - 'status': 'postcondition_failed', - 'reason_code': reason_code, - 'error': str(e), - } - for pending in pushable[push_index + 1:]: - pending['push'] = { - 'status': 'not_attempted', - 'reason_code': ( - 'batch_halted_after_postcondition_failure' - ), - } - print(f" postcondition failed {page_id}: {e}", file=sys.stderr) - break - r['push'] = { - 'status': 'error', - 'reason_code': reason_code, - 'error': str(e), - } - print(f" error {page_id}: {e}", file=sys.stderr) - - print(f"\nPushed {push_count}/{len(pushable)} file(s)", file=sys.stderr) - return results + +def _do_verify_batch( + branch: str, + limit: int = 0, + failures_only: bool = False, + push: bool = False, + yes: bool = False, + lenient: bool = False, + no_normalize: bool = False, + prepare_push: bool = False, +) -> List[dict]: + """CLI dependency를 주입하여 branch batch lifecycle을 실행합니다.""" + + runtime = BatchRuntime( + get_changed_files=_get_changed_ko_mdx_files, + verify_one=_do_verify, + ensure_config=_ensure_confluence_config, + publish_one=_do_push, + confirm=_confirm, + is_success_status=_is_success_status, + emit=_emit_batch_progress, + ) + return run_batch( + branch, + runtime=runtime, + limit=limit, + failures_only=failures_only, + push=push, + yes=yes, + lenient=lenient, + no_normalize=no_normalize, + prepare_push=prepare_push, + ) def _ensure_confluence_config(): diff --git a/confluence-mdx/tests/test_reverse_sync_cli.py b/confluence-mdx/tests/test_reverse_sync_cli.py index 4ecae721d..a58e45669 100644 --- a/confluence-mdx/tests/test_reverse_sync_cli.py +++ b/confluence-mdx/tests/test_reverse_sync_cli.py @@ -3,6 +3,7 @@ import pytest from datetime import datetime, timezone from pathlib import Path +from types import SimpleNamespace from unittest.mock import patch, MagicMock from reverse_sync_cli import ( run_verify, main, MdxSource, _resolve_mdx_source, @@ -186,6 +187,41 @@ def run_verification_stub(page_id, original_src, improved_src, **kwargs): assert runtime.tool_version == "reverse-sync-cli-v5" +def test_do_verify_delegates_to_prepare_service(monkeypatch): + """CLI namespace는 typed request로 변환한 뒤 prepare service에 위임합니다.""" + captured = {} + + def prepare_stub(request, **kwargs): + captured.update(request=request, kwargs=kwargs) + return {"status": "delegated"} + + monkeypatch.setattr("reverse_sync_cli.prepare_verification", prepare_stub) + args = SimpleNamespace( + improved_mdx="branch:src/content/ko/test.mdx", + original_mdx="main:src/content/ko/test.mdx", + page_id="page-1", + page_dir="/tmp/page-1", + lenient=True, + no_normalize=False, + ) + + result = _do_verify(args, config="config", prepare_push=True) + + runtime = captured["kwargs"].pop("runtime") + assert result == {"status": "delegated"} + assert captured["request"].improved_mdx == args.improved_mdx + assert captured["request"].original_mdx == args.original_mdx + assert captured["request"].page_id == "page-1" + assert captured["request"].page_dir == "/tmp/page-1" + assert captured["request"].lenient is True + assert captured["kwargs"] == { + "config": "config", + "prepare_push": True, + } + assert runtime.resolve_mdx_source is _resolve_mdx_source + assert runtime.run_verification is run_verify + + def test_verify_no_changes(setup_var, tmp_path): """변경 없으면 no_changes, rsync/result.yaml 생성.""" page_id, var_dir = setup_var @@ -838,6 +874,47 @@ def test_do_verify_batch_no_changes(): assert results[0]['branch'] == 'proofread/fix-typo' +def test_do_verify_batch_delegates_to_batch_service(monkeypatch): + """CLI batch adapter는 lifecycle dependency와 option만 service에 전달합니다.""" + captured = {} + + def run_batch_stub(branch, **kwargs): + captured.update(branch=branch, kwargs=kwargs) + return [{"status": "delegated"}] + + monkeypatch.setattr("reverse_sync_cli.run_batch", run_batch_stub) + + results = _do_verify_batch( + "proofread/fix-typo", + limit=3, + failures_only=True, + push=True, + yes=True, + lenient=True, + no_normalize=True, + prepare_push=True, + ) + + runtime = captured["kwargs"].pop("runtime") + assert results == [{"status": "delegated"}] + assert captured == { + "branch": "proofread/fix-typo", + "kwargs": { + "limit": 3, + "failures_only": True, + "push": True, + "yes": True, + "lenient": True, + "no_normalize": True, + "prepare_push": True, + }, + } + assert runtime.get_changed_files is _get_changed_ko_mdx_files + assert runtime.verify_one is _do_verify + assert runtime.publish_one is _do_push + assert runtime.confirm is _confirm + + # --- main() batch tests --- diff --git a/openspec/changes/complete-reverse-sync/design.md b/openspec/changes/complete-reverse-sync/design.md index 520b83153..16ad1c89c 100644 --- a/openspec/changes/complete-reverse-sync/design.md +++ b/openspec/changes/complete-reverse-sync/design.md @@ -585,6 +585,8 @@ Confluence update는 외부 side effect이며 postcondition 실패 후 완전한 | Confluence snapshot adapter | `reverse_sync/confluence_client.py`, `reverse_sync/snapshot.py` | | base parity | `reverse_sync/base_parity.py` | | attachment/link dependency gate | `reverse_sync/dependencies.py` | +| source/page/snapshot prepare lifecycle | `reverse_sync/prepare_service.py` | +| branch batch state transition | `reverse_sync/batch_service.py` | | capability/identity/edit planning | `reverse_sync/planner.py`, `reverse_sync/capabilities.py` | | visible edit/node operation | `reverse_sync/visible_model.py`, `reverse_sync/operations.py` | | capability별 render | `reverse_sync/strategies/**` | diff --git a/openspec/changes/complete-reverse-sync/tasks.md b/openspec/changes/complete-reverse-sync/tasks.md index 91f329416..ba05fbcd5 100644 --- a/openspec/changes/complete-reverse-sync/tasks.md +++ b/openspec/changes/complete-reverse-sync/tasks.md @@ -127,14 +127,16 @@ ### 2.7 P1 — CLI와 batch state 전환 -- [ ] `reverse_sync_cli.py`를 prepare/verify/push lifecycle orchestration으로 축소합니다. +- [x] `reverse_sync_cli.py`를 prepare/verify/push lifecycle orchestration으로 축소합니다. - [x] candidate 준비, roundtrip 검증, local proof/manifest 생성을 `reverse_sync/verification_service.py`로 분리하고 CLI는 runtime dependency를 주입하는 호환 adapter만 유지합니다. - [x] explicit manifest integrity/identity 검증, publisher 호출, semantic postcondition, backup 생성을 `reverse_sync/publish_service.py`로 분리합니다. - - [ ] batch 대상 선택과 online snapshot 준비의 state 전환을 package service로 - 분리하고 CLI에는 argument parsing, display, orchestration만 유지합니다. + - [x] source/page identity와 online snapshot 준비를 + `reverse_sync/prepare_service.py`로, batch 대상 선택과 publish 중단 정책을 + `reverse_sync/batch_service.py`로 분리합니다. CLI에는 argument parsing, + stderr event adapter, confirmation, display, service orchestration만 유지합니다. - [x] online verify(`push --dry-run`) 출력에 run ID, base version/hash, local gates, push eligibility, reason code를 표시합니다. - [x] `push`가 explicit run/manifest를 받도록 합니다.