diff --git a/confluence-mdx/bin/reverse-sync b/confluence-mdx/bin/reverse-sync new file mode 100755 index 000000000..a5d242a90 --- /dev/null +++ b/confluence-mdx/bin/reverse-sync @@ -0,0 +1,16 @@ +#!/usr/bin/env python3 +"""Reverse Sync CLI — executable entry point. + +Usage: + reverse-sync push [--original-mdx ] [--dry-run] + reverse-sync verify [--original-mdx ] +""" +import sys +from pathlib import Path + +# bin/ 디렉토리를 Python path에 추가하여 reverse_sync 패키지 import 가능 +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from reverse_sync_cli import main + +main() diff --git a/confluence-mdx/bin/reverse_sync_cli.py b/confluence-mdx/bin/reverse_sync_cli.py index 3a88ff5f6..0170c637d 100644 --- a/confluence-mdx/bin/reverse_sync_cli.py +++ b/confluence-mdx/bin/reverse_sync_cli.py @@ -1,10 +1,6 @@ """Reverse Sync — MDX 변경사항을 Confluence XHTML에 역반영하는 파이프라인. 중간 파일은 var// 에 reverse-sync. prefix로 저장된다. - -Usage: - python reverse_sync_cli.py verify --improved-mdx [--original-mdx ] - python reverse_sync_cli.py push --mdx-path """ import argparse import json @@ -258,87 +254,188 @@ def _build_patches( return patches +_USAGE_SUMMARY = """\ +reverse-sync — MDX 변경사항을 Confluence XHTML에 역반영 + +Usage: + reverse-sync push [--original-mdx ] [--dry-run] + reverse-sync verify [--original-mdx ] + reverse-sync -h | --help + +Commands: + push verify 수행 후 Confluence에 반영 (--dry-run으로 검증만 가능) + verify push --dry-run의 alias + +Arguments: + + MDX 소스를 지정한다. 두 가지 형식을 사용할 수 있다: + + ref:path git ref와 파일 경로를 콜론으로 구분 + 예) main:src/content/ko/user-manual/user-agent.mdx + proofread/fix-typo:src/content/ko/overview.mdx + HEAD~1:src/content/ko/admin/audit.mdx + + path 로컬 파일 시스템 경로 + 예) src/content/ko/user-manual/user-agent.mdx + /tmp/improved.mdx + + page-id는 경로의 src/content/ko/ 부분에서 var/pages.yaml을 통해 + 자동 유도된다. + +Examples: + # 검증만 수행 (Confluence 반영 없음) + reverse-sync verify "proofread/fix-typo:src/content/ko/user-manual/user-agent.mdx" + + # 검증 + Confluence 반영 + reverse-sync push "proofread/fix-typo:src/content/ko/user-manual/user-agent.mdx" + + # push --dry-run = verify + reverse-sync push --dry-run "proofread/fix-typo:src/content/ko/user-manual/user-agent.mdx" + +Run 'reverse-sync -h' for command-specific help and more examples. +""" + +_PUSH_HELP = """\ +MDX 변경사항을 XHTML에 패치하고, round-trip 검증 후 Confluence에 반영한다. + +파이프라인: + 1. original / improved MDX를 블록 단위로 파싱 + 2. 블록 diff 추출 + 3. 원본 XHTML 블록 매핑 생성 + 4. XHTML 패치 적용 + 5. 패치된 XHTML을 다시 MDX로 forward 변환 (round-trip) + 6. improved MDX와 비교하여 pass/fail 판정 + 7. pass인 경우 Confluence API로 업데이트 (--dry-run 시 생략) + +중간 산출물은 var// 에 reverse-sync.* prefix로 저장된다. + +MDX 소스 지정 방식: + ref:path git ref와 파일 경로를 콜론으로 구분 + 예) main:src/content/ko/user-manual/user-agent.mdx + proofread/fix-typo:src/content/ko/overview.mdx + path 로컬 파일 시스템 경로 + 예) /tmp/improved.mdx + +Examples: + # 검증 + Confluence 반영 + reverse-sync push "proofread/fix-typo:src/content/ko/user-manual/user-agent.mdx" + + # 검증만 수행 (= verify) + reverse-sync push --dry-run "proofread/fix-typo:src/content/ko/user-manual/user-agent.mdx" + + # original을 명시적으로 지정 + reverse-sync push "proofread/fix-typo:src/content/ko/user-manual/user-agent.mdx" \\ + --original-mdx "main:src/content/ko/user-manual/user-agent.mdx" + + # 로컬 파일로 검증 + reverse-sync push --dry-run /tmp/improved.mdx \\ + --original-mdx /tmp/original.mdx \\ + --xhtml /tmp/page.xhtml +""" + + +def _add_common_args(parser: argparse.ArgumentParser): + """verify/push 공통 인자를 등록한다.""" + parser.add_argument('improved_mdx', + help='개선 MDX (ref:path 또는 파일 경로)') + parser.add_argument('--original-mdx', + help='원본 MDX (ref:path 또는 파일 경로, 기본: main:)') + parser.add_argument('--xhtml', help='원본 XHTML 경로 (기본: var//page.xhtml)') + + +def _do_verify(args) -> 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}') + page_id = _resolve_page_id(_extract_ko_mdx_path(improved_src.descriptor)) + return run_verify( + page_id=page_id, + original_src=original_src, + improved_src=improved_src, + xhtml_path=args.xhtml, + ) + + +def _do_push(page_id: str): + """verify 통과 후 Confluence에 push한다.""" + var_dir = Path(f'var/{page_id}') + patched_path = var_dir / 'reverse-sync.patched.xhtml' + xhtml_body = patched_path.read_text() + + from reverse_sync.confluence_client import ConfluenceConfig, get_page_version, update_page_body + config = ConfluenceConfig() + if not config.email or not config.api_token: + print('Error: ~/.config/atlassian/confluence.conf 파일을 설정하세요. (형식: email:api_token)', + file=sys.stderr) + sys.exit(1) + + page_info = get_page_version(config, page_id) + new_version = page_info['version'] + 1 + resp = update_page_body(config, page_id, + title=page_info['title'], + version=new_version, + xhtml_body=xhtml_body) + return { + 'page_id': page_id, + 'title': resp.get('title', page_info['title']), + 'version': resp.get('version', {}).get('number', new_version), + 'url': resp.get('_links', {}).get('webui', ''), + } + + def main(): - parser = argparse.ArgumentParser(description='Reverse Sync: MDX → Confluence XHTML') - subparsers = parser.add_subparsers(dest='command', required=True) - - # verify - verify_parser = subparsers.add_parser('verify', help='로컬 검증') - verify_parser.add_argument('--improved-mdx', required=True, - help='개선 MDX (ref:path 또는 파일 경로)') - verify_parser.add_argument('--original-mdx', - help='원본 MDX (ref:path 또는 파일 경로, 기본: main:)') - verify_parser.add_argument('--xhtml', help='원본 XHTML 경로 (기본: var//page.xhtml)') - - # push - push_parser = subparsers.add_parser('push', help='Confluence 반영') - push_parser.add_argument('--mdx-path', required=True, - help='ko MDX 경로 (예: src/content/ko/user-manual/user-agent.mdx)') + # -h/--help 또는 인자 없음 → 사용법 출력 (argparse 자동 생성 우회) + if len(sys.argv) < 2 or sys.argv[1] in ('-h', '--help', 'help'): + print(_USAGE_SUMMARY, file=sys.stderr if len(sys.argv) < 2 else sys.stdout) + sys.exit(0 if len(sys.argv) >= 2 else 1) + + parser = argparse.ArgumentParser(prog='reverse-sync', add_help=False) + subparsers = parser.add_subparsers(dest='command') + + # push (primary command) + push_parser = subparsers.add_parser( + 'push', prog='reverse-sync push', + description=_PUSH_HELP, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + _add_common_args(push_parser) + push_parser.add_argument('--dry-run', action='store_true', + help='검증만 수행, Confluence 반영 안 함 (= verify)') + + # verify (= push --dry-run alias) + verify_parser = subparsers.add_parser( + 'verify', prog='reverse-sync verify', + description=_PUSH_HELP, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + _add_common_args(verify_parser) args = parser.parse_args() - if args.command == 'verify': - try: - 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}') - page_id = _resolve_page_id(_extract_ko_mdx_path(improved_src.descriptor)) - except ValueError as e: - print(f'Error: {e}', file=sys.stderr) - sys.exit(1) - result = run_verify( - page_id=page_id, - original_src=original_src, - improved_src=improved_src, - xhtml_path=args.xhtml, - ) - print(json.dumps(result, ensure_ascii=False, indent=2)) + if args.command in ('verify', 'push'): + dry_run = args.command == 'verify' or getattr(args, 'dry_run', False) - elif args.command == 'push': try: - page_id = _resolve_page_id(args.mdx_path) + result = _do_verify(args) except ValueError as e: print(f'Error: {e}', file=sys.stderr) sys.exit(1) - var_dir = Path(f'var/{page_id}') - result_path = var_dir / 'reverse-sync.result.yaml' - if not result_path.exists(): - print('Error: verify를 먼저 실행하세요.') - sys.exit(1) - result = yaml.safe_load(result_path.read_text()) - if result.get('status') != 'pass': - print(f"Error: 검증 상태가 '{result.get('status')}'입니다. pass만 push 가능.") - sys.exit(1) - - patched_path = var_dir / 'reverse-sync.patched.xhtml' - xhtml_body = patched_path.read_text() + print(json.dumps(result, ensure_ascii=False, indent=2)) - from reverse_sync.confluence_client import ConfluenceConfig, get_page_version, update_page_body - config = ConfluenceConfig() - if not config.email or not config.api_token: - print('Error: ~/.config/atlassian/confluence.conf 파일을 설정하세요. (형식: email:api_token)') + if not dry_run and result.get('status') == 'pass': + page_id = result['page_id'] + push_result = _do_push(page_id) + print(json.dumps(push_result, ensure_ascii=False, indent=2)) + elif not dry_run and result.get('status') != 'pass': + print(f"Error: 검증 상태가 '{result.get('status')}'입니다. push하지 않습니다.", + file=sys.stderr) sys.exit(1) - # 최신 버전 조회 - page_info = get_page_version(config, page_id) - new_version = page_info['version'] + 1 - - # 업데이트 - resp = update_page_body(config, page_id, - title=page_info['title'], - version=new_version, - xhtml_body=xhtml_body) - print(json.dumps({ - 'page_id': page_id, - 'title': resp.get('title', page_info['title']), - 'version': resp.get('version', {}).get('number', new_version), - 'url': resp.get('_links', {}).get('webui', ''), - }, ensure_ascii=False, indent=2)) - if __name__ == '__main__': main() diff --git a/confluence-mdx/tests/test_reverse_sync_cli.py b/confluence-mdx/tests/test_reverse_sync_cli.py index 9e7f98576..629dbdad8 100644 --- a/confluence-mdx/tests/test_reverse_sync_cli.py +++ b/confluence-mdx/tests/test_reverse_sync_cli.py @@ -5,7 +5,7 @@ from unittest.mock import patch, MagicMock from reverse_sync_cli import ( run_verify, main, MdxSource, _resolve_mdx_source, - _extract_ko_mdx_path, _resolve_page_id, + _extract_ko_mdx_path, _resolve_page_id, _do_verify, _do_push, ) @@ -85,88 +85,88 @@ def mock_forward_convert(patched_xhtml_path, output_mdx_path, page_id): # --- push command tests --- -@pytest.fixture -def setup_push_var(tmp_path, monkeypatch): - """push 테스트용 var// 구조 생성.""" - monkeypatch.chdir(tmp_path) - page_id = "test-page-001" - var_dir = tmp_path / "var" / page_id - var_dir.mkdir(parents=True) - return page_id, var_dir - - -def test_push_requires_verify_first(setup_push_var, monkeypatch): - """result.yaml 없으면 에러.""" - page_id, var_dir = setup_push_var - mdx_path = 'src/content/ko/test/page.mdx' - monkeypatch.setattr('sys.argv', ['reverse_sync_cli.py', 'push', '--mdx-path', mdx_path]) - with patch('reverse_sync_cli._resolve_page_id', return_value=page_id): +def test_push_verify_fail_exits(monkeypatch): + """push 시 verify가 fail이면 exit 1.""" + mdx_arg = 'src/content/ko/test/page.mdx' + monkeypatch.setattr('sys.argv', ['reverse_sync_cli.py', 'push', mdx_arg]) + fail_result = {'status': 'fail', 'page_id': 'test-page-001'} + with patch('reverse_sync_cli._do_verify', return_value=fail_result), \ + patch('builtins.print'): with pytest.raises(SystemExit) as exc_info: main() assert exc_info.value.code == 1 -def test_push_rejects_non_pass(setup_push_var, monkeypatch): - """status가 pass가 아니면 에러.""" - page_id, var_dir = setup_push_var - import yaml - mdx_path = 'src/content/ko/test/page.mdx' - (var_dir / 'reverse-sync.result.yaml').write_text( - yaml.dump({'status': 'fail', 'page_id': page_id})) - monkeypatch.setattr('sys.argv', ['reverse_sync_cli.py', 'push', '--mdx-path', mdx_path]) - with patch('reverse_sync_cli._resolve_page_id', return_value=page_id): - with pytest.raises(SystemExit) as exc_info: - main() - assert exc_info.value.code == 1 - - -def test_push_success(setup_push_var, monkeypatch): - """mock API로 정상 push 확인.""" - page_id, var_dir = setup_push_var - import yaml - mdx_path = 'src/content/ko/test/page.mdx' +def test_push_verify_pass_then_pushes(tmp_path, monkeypatch): + """push 시 verify pass → _do_push 호출.""" + page_id = 'test-page-001' + mdx_arg = 'src/content/ko/test/page.mdx' + monkeypatch.setattr('sys.argv', ['reverse_sync_cli.py', 'push', mdx_arg]) + monkeypatch.chdir(tmp_path) - # verify pass 결과 + patched xhtml 준비 - (var_dir / 'reverse-sync.result.yaml').write_text( - yaml.dump({'status': 'pass', 'page_id': page_id})) - (var_dir / 'reverse-sync.patched.xhtml').write_text('

Updated content

') + # var 디렉토리에 patched xhtml 준비 + var_dir = tmp_path / 'var' / page_id + var_dir.mkdir(parents=True) + (var_dir / 'reverse-sync.patched.xhtml').write_text('

Updated

') - monkeypatch.setattr('sys.argv', ['reverse_sync_cli.py', 'push', '--mdx-path', mdx_path]) + pass_result = {'status': 'pass', 'page_id': page_id, 'changes_count': 1} + push_result = {'page_id': page_id, 'title': 'Test', 'version': 6, 'url': '/test'} - mock_get_version = MagicMock(return_value={'version': 5, 'title': 'Test Page'}) + mock_get_version = MagicMock(return_value={'version': 5, 'title': 'Test'}) mock_update = MagicMock(return_value={ - 'title': 'Test Page', - 'version': {'number': 6}, - '_links': {'webui': '/spaces/QP/pages/test-page-001'}, + 'title': 'Test', 'version': {'number': 6}, + '_links': {'webui': '/test'}, }) - mock_load = MagicMock(return_value=('test@example.com', 'test-token')) - with patch('reverse_sync_cli._resolve_page_id', return_value=page_id), \ - patch('reverse_sync.confluence_client._load_credentials', mock_load), \ + with patch('reverse_sync_cli._do_verify', return_value=pass_result), \ + patch('reverse_sync.confluence_client._load_credentials', + return_value=('e@x.com', 'tok')), \ patch('reverse_sync.confluence_client.get_page_version', mock_get_version), \ patch('reverse_sync.confluence_client.update_page_body', mock_update), \ patch('builtins.print') as mock_print: main() - # get_page_version 호출 확인 - mock_get_version.assert_called_once() - call_args = mock_get_version.call_args - assert call_args[0][1] == page_id - - # update_page_body 호출 확인 + # push API 호출 확인 mock_update.assert_called_once() call_args = mock_update.call_args assert call_args[0][1] == page_id - assert call_args[1]['title'] == 'Test Page' - assert call_args[1]['version'] == 6 - assert call_args[1]['xhtml_body'] == '

Updated content

' - - # 출력 JSON 확인 - output = mock_print.call_args[0][0] - result = json.loads(output) - assert result['page_id'] == page_id - assert result['version'] == 6 - assert result['title'] == 'Test Page' + assert call_args[1]['xhtml_body'] == '

Updated

' + + # 출력 확인: verify 결과 + push 결과 2번 출력 + assert mock_print.call_count == 2 + push_output = json.loads(mock_print.call_args_list[1][0][0]) + assert push_output['page_id'] == page_id + assert push_output['version'] == 6 + + +def test_push_dry_run_skips_push(monkeypatch): + """push --dry-run은 verify만 수행하고 push하지 않는다.""" + mdx_arg = 'src/content/ko/test/page.mdx' + monkeypatch.setattr('sys.argv', ['reverse_sync_cli.py', 'push', '--dry-run', mdx_arg]) + pass_result = {'status': 'pass', 'page_id': 'test-page-001', 'changes_count': 1} + + with patch('reverse_sync_cli._do_verify', return_value=pass_result) as mock_verify, \ + patch('reverse_sync_cli._do_push') as mock_push, \ + patch('builtins.print'): + main() + + mock_verify.assert_called_once() + mock_push.assert_not_called() + + +def test_verify_is_dry_run_alias(monkeypatch): + """verify 커맨드는 push --dry-run과 동일하게 동작한다.""" + mdx_arg = 'src/content/ko/test/page.mdx' + monkeypatch.setattr('sys.argv', ['reverse_sync_cli.py', 'verify', mdx_arg]) + pass_result = {'status': 'pass', 'page_id': 'test-page-001', 'changes_count': 1} + + with patch('reverse_sync_cli._do_verify', return_value=pass_result) as mock_verify, \ + patch('reverse_sync_cli._do_push') as mock_push, \ + patch('builtins.print'): + main() + + mock_verify.assert_called_once() + mock_push.assert_not_called() # --- _resolve_mdx_source tests ---