From 83e4d59897469504952ed5fec6ed325e79581919 Mon Sep 17 00:00:00 2001 From: NaMinhyeok Date: Sun, 12 Apr 2026 22:23:27 +0900 Subject: [PATCH 1/4] =?UTF-8?q?PR=EC=97=90=20Spring=20Modulith=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=20=EB=B3=B4=EA=B3=A0=EB=A5=BC=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 54 +++++++ scripts/render_modulith_pr_comment.py | 225 ++++++++++++++++++++++++++ 2 files changed, 279 insertions(+) create mode 100644 scripts/render_modulith_pr_comment.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2ffaa1f6..5d3aa32c 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,56 @@ 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: 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..49763ec8 --- /dev/null +++ b/scripts/render_modulith_pr_comment.py @@ -0,0 +1,225 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import argparse +import html +import re +from pathlib import Path + + +COMMENT_MARKER = "" + + +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.", + ) + 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_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: + for source, target, label in relationships: + lines.append(f"- `{source}` -> `{target}` (`{label}`)") + else: + lines.append("- 감지된 모듈 간 의존성이 없습니다.") + + 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 main() -> None: + args = parse_args() + input_dir = Path(args.input_dir) + print(render_comment(input_dir), end="") + + +if __name__ == "__main__": + main() From 88714c8d1a5265fe674223ce6e08af46a259d42c Mon Sep 17 00:00:00 2001 From: NaMinhyeok Date: Sun, 12 Apr 2026 22:30:03 +0900 Subject: [PATCH 2/4] =?UTF-8?q?Modulith=20=EA=B5=AC=EC=A1=B0=20=EB=B3=B4?= =?UTF-8?q?=EA=B3=A0=EC=97=90=20=EB=82=B4=EB=B6=80=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=EB=A5=BC=20=EB=93=9C=EB=9F=AC=EB=82=B8?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../architecture/ModulithStructureTest.java | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) 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"); } } From 62f2118cb04392f1b8b01d17a4b4edc3c4a9c28f Mon Sep 17 00:00:00 2001 From: NaMinhyeok Date: Sun, 12 Apr 2026 22:38:29 +0900 Subject: [PATCH 3/4] =?UTF-8?q?PR=20=EB=B3=B8=EB=AC=B8=EC=97=90=20Modulith?= =?UTF-8?q?=20=EA=B5=AC=EC=A1=B0=EB=8F=84=EB=A5=BC=20=EB=85=B8=EC=B6=9C?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 59 +++++++++++++++++++ scripts/render_modulith_pr_comment.py | 59 ++++++++++++++++++- .../tests/test_render_modulith_pr_comment.py | 52 ++++++++++++++++ 3 files changed, 167 insertions(+), 3 deletions(-) create mode 100644 scripts/tests/test_render_modulith_pr_comment.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5d3aa32c..45296b0b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -79,6 +79,65 @@ jobs: '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 diff --git a/scripts/render_modulith_pr_comment.py b/scripts/render_modulith_pr_comment.py index 49763ec8..b98ce841 100644 --- a/scripts/render_modulith_pr_comment.py +++ b/scripts/render_modulith_pr_comment.py @@ -9,6 +9,8 @@ COMMENT_MARKER = "" +BODY_SECTION_START = "" +BODY_SECTION_END = "" def parse_args() -> argparse.Namespace: @@ -20,6 +22,12 @@ def parse_args() -> argparse.Namespace: 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() @@ -131,6 +139,13 @@ def render_mermaid(modules: list[str], relationships: list[tuple[str, str, str]] 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 ( @@ -195,10 +210,9 @@ def render_comment(input_dir: Path) -> str: ) if relationships: - for source, target, label in relationships: - lines.append(f"- `{source}` -> `{target}` (`{label}`)") + lines.extend(render_relationships(relationships)) else: - lines.append("- 감지된 모듈 간 의존성이 없습니다.") + lines.extend(render_relationships(relationships)) lines.extend( [ @@ -215,9 +229,48 @@ def render_comment(input_dir: Path) -> str: return "\n".join(lines).strip() + "\n" +def render_pr_body_section(input_dir: Path) -> str: + modules, relationships = parse_components(input_dir / "components.puml") + + 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), + "", + 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="") 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..f4f25c3e --- /dev/null +++ b/scripts/tests/test_render_modulith_pr_comment.py @@ -0,0 +1,52 @@ +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", + ) + + 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) + + 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() From 812fb738c84cdeb4d76fd1a0e3d259724f076a4d Mon Sep 17 00:00:00 2001 From: NaMinhyeok Date: Sun, 12 Apr 2026 22:44:28 +0900 Subject: [PATCH 4/4] =?UTF-8?q?PR=20=EB=B3=B8=EB=AC=B8=EC=97=90=20Modulith?= =?UTF-8?q?=20=EB=AA=A8=EB=93=88=20=EC=84=B8=EB=B6=80=EC=82=AC=ED=95=AD?= =?UTF-8?q?=EC=9D=84=20=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/render_modulith_pr_comment.py | 16 +++++++- .../tests/test_render_modulith_pr_comment.py | 39 +++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/scripts/render_modulith_pr_comment.py b/scripts/render_modulith_pr_comment.py index b98ce841..308d4b86 100644 --- a/scripts/render_modulith_pr_comment.py +++ b/scripts/render_modulith_pr_comment.py @@ -231,6 +231,13 @@ def render_comment(input_dir: Path) -> str: 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, @@ -258,9 +265,16 @@ def render_pr_body_section(input_dir: Path) -> str: "", *render_relationships(relationships), "", - BODY_SECTION_END, + "### 모듈 세부사항", + "", ] ) + + 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" diff --git a/scripts/tests/test_render_modulith_pr_comment.py b/scripts/tests/test_render_modulith_pr_comment.py index f4f25c3e..888fd544 100644 --- a/scripts/tests/test_render_modulith_pr_comment.py +++ b/scripts/tests/test_render_modulith_pr_comment.py @@ -30,6 +30,39 @@ def test_본문_섹션은_mermaid와_관리_마커를_포함한다(self) -> None ), 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) @@ -38,6 +71,12 @@ def test_본문_섹션은_mermaid와_관리_마커를_포함한다(self) -> None 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: