diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2ffaa1f6..45296b0b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,6 +9,7 @@ on: permissions: contents: read checks: write + issues: write pull-requests: write jobs: @@ -65,3 +66,115 @@ jobs: with: check_name: 'JUnit Test Report' report_paths: '**/build/test-results/*[tT]est/TEST-*.xml' + - name: render modulith PR comment + if: always() && github.event_name == 'pull_request' + run: | + mkdir -p build + if ! python3 scripts/render_modulith_pr_comment.py > build/modulith-pr-comment.md; then + printf '%s\n' \ + '' \ + '## Spring Modulith 구조 보고서' \ + '' \ + 'Modulith PR 코멘트 생성에 실패했습니다.' \ + 'workflow 로그에서 `render modulith PR comment` step을 확인해 주세요.' \ + > build/modulith-pr-comment.md + fi + - name: render modulith PR body + if: always() && github.event_name == 'pull_request' + run: | + mkdir -p build + if ! python3 scripts/render_modulith_pr_comment.py --target body > build/modulith-pr-body.md; then + printf '%s\n' \ + '' \ + '## Spring Modulith 구조도' \ + '' \ + 'Modulith PR 본문 섹션 생성에 실패했습니다.' \ + 'workflow 로그에서 `render modulith PR body` step을 확인해 주세요.' \ + '' \ + > build/modulith-pr-body.md + fi + - name: publish modulith PR body + if: always() && github.event_name == 'pull_request' + continue-on-error: true + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + const startMarker = ''; + const endMarker = ''; + const managedSection = fs.readFileSync('build/modulith-pr-body.md', 'utf8').trim(); + const pull_number = context.payload.pull_request.number; + const { owner, repo } = context.repo; + + const pull = await github.rest.pulls.get({ + owner, + repo, + pull_number, + }); + + const currentBody = pull.data.body ?? ''; + const escapedStart = startMarker.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const escapedEnd = endMarker.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const managedSectionPattern = new RegExp(`${escapedStart}[\\s\\S]*?${escapedEnd}`, 'm'); + + let nextBody; + + if (managedSectionPattern.test(currentBody)) { + nextBody = currentBody.replace(managedSectionPattern, managedSection); + } else if (currentBody.trim().length > 0) { + nextBody = `${managedSection}\n\n${currentBody}`; + } else { + nextBody = managedSection; + } + + if (nextBody !== currentBody) { + await github.rest.pulls.update({ + owner, + repo, + pull_number, + body: nextBody, + }); + core.info(`Updated Modulith PR body for PR #${pull_number}`); + } else { + core.info(`Modulith PR body already up to date for PR #${pull_number}`); + } + - name: publish modulith PR comment + if: always() && github.event_name == 'pull_request' + continue-on-error: true + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + const marker = ''; + const body = fs.readFileSync('build/modulith-pr-comment.md', 'utf8'); + const issue_number = context.payload.pull_request.number; + const { owner, repo } = context.repo; + + const comments = await github.paginate(github.rest.issues.listComments, { + owner, + repo, + issue_number, + per_page: 100, + }); + + const existing = comments.find((comment) => + comment.body && comment.body.includes(marker) + ); + + if (existing) { + await github.rest.issues.updateComment({ + owner, + repo, + comment_id: existing.id, + body, + }); + core.info(`Updated Modulith PR comment: ${existing.id}`); + } else { + const created = await github.rest.issues.createComment({ + owner, + repo, + issue_number, + body, + }); + core.info(`Created Modulith PR comment: ${created.data.id}`); + } diff --git a/scripts/render_modulith_pr_comment.py b/scripts/render_modulith_pr_comment.py new file mode 100644 index 00000000..308d4b86 --- /dev/null +++ b/scripts/render_modulith_pr_comment.py @@ -0,0 +1,292 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import argparse +import html +import re +from pathlib import Path + + +COMMENT_MARKER = "" +BODY_SECTION_START = "" +BODY_SECTION_END = "" + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Render Spring Modulith documentation into a PR comment." + ) + parser.add_argument( + "--input-dir", + default="build/spring-modulith", + help="Directory containing Spring Modulith generated files.", + ) + parser.add_argument( + "--target", + choices=("comment", "body"), + default="comment", + help="Select the Markdown target format to render.", + ) + return parser.parse_args() + + +def parse_components(puml_path: Path) -> tuple[list[str], list[tuple[str, str, str]]]: + if not puml_path.exists(): + return [], [] + + components: dict[str, str] = {} + relationships: list[tuple[str, str, str]] = [] + + component_pattern = re.compile(r'Component\(([^,]+),\s*"([^"]+)"') + relationship_pattern = re.compile(r'Rel\(([^,]+),\s*([^,]+),\s*"([^"]*)"') + + for line in puml_path.read_text(encoding="utf-8").splitlines(): + component_match = component_pattern.search(line) + if component_match: + alias = component_match.group(1).strip() + components[alias] = component_match.group(2).strip() + continue + + relationship_match = relationship_pattern.search(line) + if relationship_match: + source_alias = relationship_match.group(1).strip() + target_alias = relationship_match.group(2).strip() + label = relationship_match.group(3).strip() or "depends on" + relationships.append( + ( + components.get(source_alias, source_alias), + components.get(target_alias, target_alias), + label, + ) + ) + + modules = sorted(set(components.values())) + return modules, relationships + + +def parse_module_canvas(adoc_path: Path) -> dict[str, str]: + if not adoc_path.exists(): + return {} + + rows: dict[str, str] = {} + current_key: str | None = None + current_value_lines: list[str] = [] + inside_table = False + + def flush() -> None: + nonlocal current_key, current_value_lines + if current_key is None: + return + value = "\n".join(line.rstrip() for line in current_value_lines).strip() + rows[current_key] = value + current_key = None + current_value_lines = [] + + for raw_line in adoc_path.read_text(encoding="utf-8").splitlines(): + line = raw_line.rstrip() + + if line == "|===": + if inside_table: + flush() + inside_table = not inside_table + continue + + if not inside_table: + continue + + if line.startswith("|"): + content = line[1:].strip() + if current_key is None: + current_key = content + continue + + if not current_value_lines: + current_value_lines.append(content) + continue + + flush() + current_key = content + continue + + if current_key is not None and current_value_lines: + current_value_lines.append(line) + + flush() + return rows + + +def mermaid_id(name: str) -> str: + normalized = re.sub(r"[^a-zA-Z0-9]+", "_", name).strip("_").lower() + return normalized or "module" + + +def render_mermaid(modules: list[str], relationships: list[tuple[str, str, str]]) -> str: + lines = ["```mermaid", "flowchart LR"] + + for module in modules: + lines.append(f' {mermaid_id(module)}["{module}"]') + + if relationships: + for source, target, label in relationships: + lines.append( + f' {mermaid_id(source)} -->|"{label}"| {mermaid_id(target)}' + ) + else: + lines.append(" %% No cross-module dependencies detected.") + + lines.append("```") + return "\n".join(lines) + + +def render_relationships(relationships: list[tuple[str, str, str]]) -> list[str]: + if relationships: + return [f"- `{source}` -> `{target}` (`{label}`)" for source, target, label in relationships] + + return ["- 감지된 모듈 간 의존성이 없습니다."] + + +def render_module_details(module_name: str, rows: dict[str, str]) -> str: + if not rows: + return ( + f"
\n" + f"{html.escape(module_name)}\n\n" + f"생성된 모듈 세부 정보가 없습니다.\n" + f"
" + ) + + lines = [ + "
", + f"{html.escape(module_name)}", + "", + ] + + for key, value in rows.items(): + lines.append(f"**{html.escape(key)}**") + lines.append("") + lines.append(value or "_없음_") + lines.append("") + + lines.append("
") + return "\n".join(lines) + + +def render_comment(input_dir: Path) -> str: + modules, relationships = parse_components(input_dir / "components.puml") + module_rows: dict[str, dict[str, str]] = {} + + for canvas_path in sorted(input_dir.glob("module-*.adoc")): + module_name = canvas_path.stem.removeprefix("module-").replace("-", " ").title() + module_rows[module_name] = parse_module_canvas(canvas_path) + + available_modules = sorted(set(modules) | set(module_rows.keys())) + + lines = [ + COMMENT_MARKER, + "## Spring Modulith 구조 보고서", + "", + ] + + if not input_dir.exists() or not available_modules: + lines.extend( + [ + "이번 실행에서는 `build/spring-modulith` 산출물을 찾지 못했습니다.", + "`./gradlew check` 단계가 Modulith 문서 생성 전에 실패했는지 확인해 주세요.", + ] + ) + return "\n".join(lines).strip() + "\n" + + lines.extend( + [ + "CI가 생성한 현재 모듈 구조와 관찰된 의존성입니다.", + "", + "### 모듈 그래프", + "", + render_mermaid(available_modules, relationships), + "", + "### 모듈 의존성", + "", + ] + ) + + if relationships: + lines.extend(render_relationships(relationships)) + else: + lines.extend(render_relationships(relationships)) + + lines.extend( + [ + "", + "### 모듈 세부사항", + "", + ] + ) + + for module_name in available_modules: + lines.append(render_module_details(module_name, module_rows.get(module_name, {}))) + lines.append("") + + return "\n".join(lines).strip() + "\n" + + +def render_pr_body_section(input_dir: Path) -> str: + modules, relationships = parse_components(input_dir / "components.puml") + module_rows: dict[str, dict[str, str]] = {} + + for canvas_path in sorted(input_dir.glob("module-*.adoc")): + module_name = canvas_path.stem.removeprefix("module-").replace("-", " ").title() + module_rows[module_name] = parse_module_canvas(canvas_path) + + available_modules = sorted(set(modules) | set(module_rows.keys())) + + lines = [ + BODY_SECTION_START, + "## Spring Modulith 구조도", + "", + "_이 섹션은 CI가 자동으로 갱신합니다._", + "", + ] + + if not input_dir.exists() or not modules: + lines.extend( + [ + "이번 실행에서는 `build/spring-modulith` 산출물을 찾지 못했습니다.", + "`./gradlew check` 단계가 Modulith 문서 생성 전에 실패했는지 확인해 주세요.", + BODY_SECTION_END, + ] + ) + return "\n".join(lines).strip() + "\n" + + lines.extend( + [ + render_mermaid(modules, relationships), + "", + "### 모듈 의존성", + "", + *render_relationships(relationships), + "", + "### 모듈 세부사항", + "", + ] + ) + + for module_name in available_modules: + lines.append(render_module_details(module_name, module_rows.get(module_name, {}))) + lines.append("") + + lines.append(BODY_SECTION_END) + return "\n".join(lines).strip() + "\n" + + +def main() -> None: + args = parse_args() + input_dir = Path(args.input_dir) + if args.target == "body": + print(render_pr_body_section(input_dir), end="") + return + + print(render_comment(input_dir), end="") + + +if __name__ == "__main__": + main() diff --git a/scripts/tests/test_render_modulith_pr_comment.py b/scripts/tests/test_render_modulith_pr_comment.py new file mode 100644 index 00000000..888fd544 --- /dev/null +++ b/scripts/tests/test_render_modulith_pr_comment.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +import importlib.util +import tempfile +import textwrap +import unittest +from pathlib import Path + + +SCRIPT_PATH = Path(__file__).resolve().parents[1] / "render_modulith_pr_comment.py" +SPEC = importlib.util.spec_from_file_location("render_modulith_pr_comment", SCRIPT_PATH) +MODULE = importlib.util.module_from_spec(SPEC) +assert SPEC.loader is not None +SPEC.loader.exec_module(MODULE) + + +class ModulithPr본문렌더링테스트(unittest.TestCase): + def test_본문_섹션은_mermaid와_관리_마커를_포함한다(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + input_dir = Path(temp_dir) + input_dir.joinpath("components.puml").write_text( + textwrap.dedent( + """\ + @startuml + Component(foo, "Room", $techn="Module") + Component(bar, "Template", $techn="Module") + Rel(foo, bar, "uses") + @enduml + """ + ), + encoding="utf-8", + ) + input_dir.joinpath("module-room.adoc").write_text( + textwrap.dedent( + """\ + [%autowidth.stretch, cols="h,a"] + |=== + |Base package + |`com.example.room` + |Spring components + |_Services_ + + * `CreateRoom` + * `JoinRoom` + |=== + """ + ), + encoding="utf-8", + ) + input_dir.joinpath("module-template.adoc").write_text( + textwrap.dedent( + """\ + [%autowidth.stretch, cols="h,a"] + |=== + |Base package + |`com.example.template` + |Spring components + |_Services_ + + * `CreateTemplate` + |=== + """ + ), + encoding="utf-8", + ) + + rendered = MODULE.render_pr_body_section(input_dir) + + self.assertIn("", rendered) + self.assertIn("", rendered) + self.assertIn("```mermaid", rendered) + self.assertIn('room["Room"]', rendered) + self.assertIn('room -->|"uses"| template', rendered) + self.assertIn("### 모듈 세부사항", rendered) + self.assertIn("Room", rendered) + self.assertIn("**Base package**", rendered) + self.assertIn("CreateRoom", rendered) + self.assertIn("Template", rendered) + self.assertIn("CreateTemplate", rendered) + + def test_본문_섹션은_산출물이_없을때도_안내_문구를_보존한다(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + rendered = MODULE.render_pr_body_section(Path(temp_dir) / "missing") + + self.assertIn("", rendered) + self.assertIn("Spring Modulith 구조도", rendered) + self.assertIn("산출물을 찾지 못했습니다", rendered) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/test/java/com/naminhyeok/fantazzk/architecture/ModulithStructureTest.java b/src/test/java/com/naminhyeok/fantazzk/architecture/ModulithStructureTest.java index 8924dd37..6a0dd067 100644 --- a/src/test/java/com/naminhyeok/fantazzk/architecture/ModulithStructureTest.java +++ b/src/test/java/com/naminhyeok/fantazzk/architecture/ModulithStructureTest.java @@ -1,5 +1,6 @@ package com.naminhyeok.fantazzk.architecture; +import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatCode; import com.naminhyeok.fantazzk.FantazzkApplication; @@ -22,9 +23,17 @@ class ModulithStructureTest { Path outputDirectory = Path.of("build/spring-modulith"); Files.createDirectories(outputDirectory); - new Documenter(modules, outputDirectory.toString()) + new Documenter(modules, Documenter.Options.defaults().withOutputFolder(outputDirectory.toString())) .writeModulesAsPlantUml() .writeIndividualModulesAsPlantUml() - .writeModuleCanvases(); + .writeModuleCanvases(Documenter.CanvasOptions.defaults().revealInternals()); + + String roomCanvas = Files.readString(outputDirectory.resolve("module-room.adoc")); + String templateCanvas = Files.readString(outputDirectory.resolve("module-template.adoc")); + + assertThat(roomCanvas).contains("CreateRoom"); + assertThat(roomCanvas).contains("RoomApiController"); + assertThat(templateCanvas).contains("ProvideTemplateCatalog"); + assertThat(templateCanvas).contains("CreateTemplate"); } }