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");
}
}