docforge-gen 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- docforge/__init__.py +3 -0
- docforge/analysis/__init__.py +5 -0
- docforge/analysis/context_builder.py +131 -0
- docforge/analysis/dependency_detector.py +120 -0
- docforge/analysis/file_walker.py +63 -0
- docforge/analysis/repo_analyzer.py +53 -0
- docforge/cli/__init__.py +3 -0
- docforge/cli/app.py +29 -0
- docforge/cli/commands/__init__.py +0 -0
- docforge/cli/commands/generate.py +171 -0
- docforge/cli/console.py +3 -0
- docforge/config/__init__.py +4 -0
- docforge/config/loader.py +48 -0
- docforge/config/schema.py +40 -0
- docforge/generators/__init__.py +16 -0
- docforge/generators/api_generator.py +111 -0
- docforge/generators/architecture_generator.py +18 -0
- docforge/generators/base_generator.py +29 -0
- docforge/generators/changelog_generator.py +25 -0
- docforge/generators/installation_generator.py +22 -0
- docforge/generators/readme_generator.py +20 -0
- docforge/git/__init__.py +4 -0
- docforge/git/changelog_builder.py +88 -0
- docforge/git/repo_reader.py +44 -0
- docforge/llm/__init__.py +5 -0
- docforge/llm/chunker.py +69 -0
- docforge/llm/client.py +49 -0
- docforge/llm/prompt_manager.py +23 -0
- docforge/output/__init__.py +5 -0
- docforge/output/github_action_writer.py +34 -0
- docforge/output/mkdocs_builder.py +29 -0
- docforge/output/writer.py +21 -0
- docforge/parsing/__init__.py +4 -0
- docforge/parsing/base_parser.py +39 -0
- docforge/parsing/go_parser.py +99 -0
- docforge/parsing/java_parser.py +113 -0
- docforge/parsing/javascript_parser.py +84 -0
- docforge/parsing/python_parser.py +103 -0
- docforge/parsing/registry.py +29 -0
- docforge/parsing/rust_parser.py +107 -0
- docforge/parsing/typescript_parser.py +105 -0
- docforge/source/__init__.py +3 -0
- docforge/source/github_fetcher.py +58 -0
- docforge/source/local_fetcher.py +12 -0
- docforge/source/resolver.py +17 -0
- docforge/templates/outputs/github_action.yml.j2 +50 -0
- docforge/templates/outputs/mkdocs.yml.j2 +53 -0
- docforge/templates/prompts/api_module.j2 +38 -0
- docforge/templates/prompts/architecture.j2 +42 -0
- docforge/templates/prompts/changelog.j2 +22 -0
- docforge/templates/prompts/installation.j2 +41 -0
- docforge/templates/prompts/readme.j2 +47 -0
- docforge_gen-0.1.0.dist-info/METADATA +107 -0
- docforge_gen-0.1.0.dist-info/RECORD +57 -0
- docforge_gen-0.1.0.dist-info/WHEEL +4 -0
- docforge_gen-0.1.0.dist-info/entry_points.txt +2 -0
- docforge_gen-0.1.0.dist-info/licenses/LICENSE +21 -0
docforge/__init__.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from ..parsing.base_parser import ParsedFile
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class CommitGroup:
|
|
9
|
+
tag: str # "v1.2.0" or "Unreleased"
|
|
10
|
+
date: str
|
|
11
|
+
commits: list[dict] = field(default_factory=list)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class RepoContext:
|
|
16
|
+
name: str
|
|
17
|
+
description: str
|
|
18
|
+
primary_language: str
|
|
19
|
+
languages: list[str]
|
|
20
|
+
file_tree: list[str]
|
|
21
|
+
parsed_files: list[ParsedFile]
|
|
22
|
+
dependencies: dict[str, list[str]]
|
|
23
|
+
commit_groups: list[CommitGroup]
|
|
24
|
+
github_url: str | None = None
|
|
25
|
+
topics: list[str] = field(default_factory=list)
|
|
26
|
+
license: str | None = None
|
|
27
|
+
has_tests: bool = False
|
|
28
|
+
has_ci: bool = False
|
|
29
|
+
entry_points: list[str] = field(default_factory=list)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def build_context(
|
|
33
|
+
repo_path: Path,
|
|
34
|
+
parsed_files: list[ParsedFile],
|
|
35
|
+
dependencies: dict[str, list[str]],
|
|
36
|
+
commit_groups: list[CommitGroup],
|
|
37
|
+
gh_metadata: dict | None = None,
|
|
38
|
+
) -> RepoContext:
|
|
39
|
+
gh = gh_metadata or {}
|
|
40
|
+
|
|
41
|
+
name = gh.get("name") or repo_path.name
|
|
42
|
+
description = gh.get("description") or _infer_description(parsed_files, repo_path)
|
|
43
|
+
languages = _detect_languages(parsed_files)
|
|
44
|
+
primary_language = languages[0] if languages else "unknown"
|
|
45
|
+
file_tree = _build_file_tree(repo_path, parsed_files)
|
|
46
|
+
has_tests = _has_tests(repo_path)
|
|
47
|
+
has_ci = _has_ci(repo_path)
|
|
48
|
+
entry_points = _find_entry_points(repo_path, parsed_files)
|
|
49
|
+
|
|
50
|
+
return RepoContext(
|
|
51
|
+
name=name,
|
|
52
|
+
description=description,
|
|
53
|
+
primary_language=primary_language,
|
|
54
|
+
languages=languages,
|
|
55
|
+
file_tree=file_tree,
|
|
56
|
+
parsed_files=parsed_files,
|
|
57
|
+
dependencies=dependencies,
|
|
58
|
+
commit_groups=commit_groups,
|
|
59
|
+
github_url=gh.get("html_url"),
|
|
60
|
+
topics=gh.get("topics", []),
|
|
61
|
+
license=gh.get("license", {}).get("name") if gh.get("license") else None,
|
|
62
|
+
has_tests=has_tests,
|
|
63
|
+
has_ci=has_ci,
|
|
64
|
+
entry_points=entry_points,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _detect_languages(parsed_files: list[ParsedFile]) -> list[str]:
|
|
69
|
+
counts: dict[str, int] = {}
|
|
70
|
+
for f in parsed_files:
|
|
71
|
+
counts[f.language] = counts.get(f.language, 0) + 1
|
|
72
|
+
return sorted(counts, key=lambda k: counts[k], reverse=True)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _build_file_tree(repo_path: Path, parsed_files: list[ParsedFile]) -> list[str]:
|
|
76
|
+
paths = []
|
|
77
|
+
for f in parsed_files:
|
|
78
|
+
try:
|
|
79
|
+
rel = Path(f.path).relative_to(repo_path)
|
|
80
|
+
paths.append(str(rel))
|
|
81
|
+
except ValueError:
|
|
82
|
+
paths.append(f.path)
|
|
83
|
+
return sorted(paths)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _infer_description(parsed_files: list[ParsedFile], repo_path: Path) -> str:
|
|
87
|
+
# Try to read existing README for description
|
|
88
|
+
for readme_name in ["README.md", "README.rst", "README.txt", "README"]:
|
|
89
|
+
readme = repo_path / readme_name
|
|
90
|
+
if readme.exists():
|
|
91
|
+
try:
|
|
92
|
+
content = readme.read_text(errors="replace")
|
|
93
|
+
lines = [l.strip() for l in content.splitlines() if l.strip()]
|
|
94
|
+
# Find first non-heading line
|
|
95
|
+
for line in lines:
|
|
96
|
+
if not line.startswith("#") and len(line) > 20:
|
|
97
|
+
return line[:200]
|
|
98
|
+
except Exception:
|
|
99
|
+
pass
|
|
100
|
+
return "A software project"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _has_tests(repo_path: Path) -> bool:
|
|
104
|
+
test_indicators = ["tests/", "test/", "spec/", "__tests__/", "test_*.py", "*_test.go"]
|
|
105
|
+
for indicator in test_indicators:
|
|
106
|
+
if list(repo_path.glob(f"**/{indicator}")):
|
|
107
|
+
return True
|
|
108
|
+
return False
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _has_ci(repo_path: Path) -> bool:
|
|
112
|
+
ci_paths = [
|
|
113
|
+
".github/workflows",
|
|
114
|
+
".gitlab-ci.yml",
|
|
115
|
+
".circleci/config.yml",
|
|
116
|
+
"Jenkinsfile",
|
|
117
|
+
".travis.yml",
|
|
118
|
+
]
|
|
119
|
+
for ci_path in ci_paths:
|
|
120
|
+
if (repo_path / ci_path).exists():
|
|
121
|
+
return True
|
|
122
|
+
return False
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _find_entry_points(repo_path: Path, parsed_files: list[ParsedFile]) -> list[str]:
|
|
126
|
+
entry_points = []
|
|
127
|
+
candidates = ["main.py", "app.py", "cli.py", "main.go", "main.rs", "index.js", "index.ts"]
|
|
128
|
+
for candidate in candidates:
|
|
129
|
+
if (repo_path / candidate).exists():
|
|
130
|
+
entry_points.append(candidate)
|
|
131
|
+
return entry_points
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import tomllib
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def detect_dependencies(repo_path: Path) -> dict[str, list[str]]:
|
|
7
|
+
"""Read manifest files and return dependencies per language/ecosystem."""
|
|
8
|
+
deps: dict[str, list[str]] = {}
|
|
9
|
+
|
|
10
|
+
# Python
|
|
11
|
+
py_deps = _detect_python(repo_path)
|
|
12
|
+
if py_deps:
|
|
13
|
+
deps["python"] = py_deps
|
|
14
|
+
|
|
15
|
+
# JavaScript/TypeScript
|
|
16
|
+
js_deps = _detect_javascript(repo_path)
|
|
17
|
+
if js_deps:
|
|
18
|
+
deps["javascript"] = js_deps
|
|
19
|
+
|
|
20
|
+
# Go
|
|
21
|
+
go_deps = _detect_go(repo_path)
|
|
22
|
+
if go_deps:
|
|
23
|
+
deps["go"] = go_deps
|
|
24
|
+
|
|
25
|
+
# Rust
|
|
26
|
+
rust_deps = _detect_rust(repo_path)
|
|
27
|
+
if rust_deps:
|
|
28
|
+
deps["rust"] = rust_deps
|
|
29
|
+
|
|
30
|
+
# Java
|
|
31
|
+
java_deps = _detect_java(repo_path)
|
|
32
|
+
if java_deps:
|
|
33
|
+
deps["java"] = java_deps
|
|
34
|
+
|
|
35
|
+
return deps
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _detect_python(repo_path: Path) -> list[str]:
|
|
39
|
+
deps = []
|
|
40
|
+
|
|
41
|
+
pyproject = repo_path / "pyproject.toml"
|
|
42
|
+
if pyproject.exists():
|
|
43
|
+
try:
|
|
44
|
+
with open(pyproject, "rb") as f:
|
|
45
|
+
data = tomllib.load(f)
|
|
46
|
+
deps += data.get("project", {}).get("dependencies", [])
|
|
47
|
+
except Exception:
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
requirements = repo_path / "requirements.txt"
|
|
51
|
+
if requirements.exists():
|
|
52
|
+
try:
|
|
53
|
+
lines = requirements.read_text().splitlines()
|
|
54
|
+
deps += [l.strip() for l in lines if l.strip() and not l.startswith("#")]
|
|
55
|
+
except Exception:
|
|
56
|
+
pass
|
|
57
|
+
|
|
58
|
+
return deps
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _detect_javascript(repo_path: Path) -> list[str]:
|
|
62
|
+
package_json = repo_path / "package.json"
|
|
63
|
+
if not package_json.exists():
|
|
64
|
+
return []
|
|
65
|
+
try:
|
|
66
|
+
data = json.loads(package_json.read_text())
|
|
67
|
+
deps = list(data.get("dependencies", {}).keys())
|
|
68
|
+
deps += list(data.get("devDependencies", {}).keys())
|
|
69
|
+
return deps
|
|
70
|
+
except Exception:
|
|
71
|
+
return []
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _detect_go(repo_path: Path) -> list[str]:
|
|
75
|
+
go_mod = repo_path / "go.mod"
|
|
76
|
+
if not go_mod.exists():
|
|
77
|
+
return []
|
|
78
|
+
deps = []
|
|
79
|
+
try:
|
|
80
|
+
for line in go_mod.read_text().splitlines():
|
|
81
|
+
line = line.strip()
|
|
82
|
+
if line.startswith("require ") or (line and not line.startswith("//") and " v" in line):
|
|
83
|
+
parts = line.split()
|
|
84
|
+
if len(parts) >= 2 and "/" in parts[0]:
|
|
85
|
+
deps.append(parts[0])
|
|
86
|
+
except Exception:
|
|
87
|
+
pass
|
|
88
|
+
return deps
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _detect_rust(repo_path: Path) -> list[str]:
|
|
92
|
+
cargo_toml = repo_path / "Cargo.toml"
|
|
93
|
+
if not cargo_toml.exists():
|
|
94
|
+
return []
|
|
95
|
+
try:
|
|
96
|
+
with open(cargo_toml, "rb") as f:
|
|
97
|
+
data = tomllib.load(f)
|
|
98
|
+
deps = list(data.get("dependencies", {}).keys())
|
|
99
|
+
deps += list(data.get("dev-dependencies", {}).keys())
|
|
100
|
+
return deps
|
|
101
|
+
except Exception:
|
|
102
|
+
return []
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _detect_java(repo_path: Path) -> list[str]:
|
|
106
|
+
# Basic pom.xml detection
|
|
107
|
+
pom = repo_path / "pom.xml"
|
|
108
|
+
if pom.exists():
|
|
109
|
+
try:
|
|
110
|
+
content = pom.read_text()
|
|
111
|
+
# Very basic extraction - just flag that Maven is used
|
|
112
|
+
return ["[Maven - see pom.xml]"]
|
|
113
|
+
except Exception:
|
|
114
|
+
pass
|
|
115
|
+
|
|
116
|
+
build_gradle = repo_path / "build.gradle"
|
|
117
|
+
if build_gradle.exists():
|
|
118
|
+
return ["[Gradle - see build.gradle]"]
|
|
119
|
+
|
|
120
|
+
return []
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import fnmatch
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
BINARY_EXTENSIONS = {
|
|
6
|
+
".png", ".jpg", ".jpeg", ".gif", ".bmp", ".ico", ".svg",
|
|
7
|
+
".pdf", ".zip", ".tar", ".gz", ".bz2", ".xz", ".rar",
|
|
8
|
+
".exe", ".dll", ".so", ".dylib", ".a", ".o",
|
|
9
|
+
".woff", ".woff2", ".ttf", ".eot",
|
|
10
|
+
".mp3", ".mp4", ".wav", ".avi", ".mov",
|
|
11
|
+
".db", ".sqlite", ".lock",
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def walk_repo(
|
|
16
|
+
repo_path: Path,
|
|
17
|
+
exclude_patterns: list[str],
|
|
18
|
+
max_file_size_kb: int = 512,
|
|
19
|
+
supported_extensions: list[str] | None = None,
|
|
20
|
+
):
|
|
21
|
+
"""
|
|
22
|
+
Yield (file_path, extension) for all code files in repo_path.
|
|
23
|
+
Respects exclude patterns and size limits.
|
|
24
|
+
"""
|
|
25
|
+
max_bytes = max_file_size_kb * 1024
|
|
26
|
+
|
|
27
|
+
for file_path in sorted(repo_path.rglob("*")):
|
|
28
|
+
if not file_path.is_file():
|
|
29
|
+
continue
|
|
30
|
+
|
|
31
|
+
# Check binary extension
|
|
32
|
+
if file_path.suffix.lower() in BINARY_EXTENSIONS:
|
|
33
|
+
continue
|
|
34
|
+
|
|
35
|
+
# Check exclude patterns
|
|
36
|
+
relative = str(file_path.relative_to(repo_path))
|
|
37
|
+
if _is_excluded(relative, exclude_patterns):
|
|
38
|
+
continue
|
|
39
|
+
|
|
40
|
+
# Check supported extension
|
|
41
|
+
ext = file_path.suffix.lower()
|
|
42
|
+
if supported_extensions and ext not in supported_extensions:
|
|
43
|
+
continue
|
|
44
|
+
|
|
45
|
+
# Check file size
|
|
46
|
+
try:
|
|
47
|
+
if file_path.stat().st_size > max_bytes:
|
|
48
|
+
continue
|
|
49
|
+
except OSError:
|
|
50
|
+
continue
|
|
51
|
+
|
|
52
|
+
yield file_path, ext
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _is_excluded(relative_path: str, patterns: list[str]) -> bool:
|
|
56
|
+
for pattern in patterns:
|
|
57
|
+
if fnmatch.fnmatch(relative_path, pattern):
|
|
58
|
+
return True
|
|
59
|
+
# Also match path components
|
|
60
|
+
parts = relative_path.replace("\\", "/")
|
|
61
|
+
if fnmatch.fnmatch(parts, pattern):
|
|
62
|
+
return True
|
|
63
|
+
return False
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, TaskProgressColumn
|
|
5
|
+
|
|
6
|
+
from ..config.schema import DocForgeConfig
|
|
7
|
+
from ..parsing.base_parser import ParsedFile
|
|
8
|
+
from ..parsing.registry import get_parser, supported_extensions
|
|
9
|
+
from .file_walker import walk_repo
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def analyze_repo(repo_path: Path, config: DocForgeConfig) -> list[ParsedFile]:
|
|
13
|
+
"""Parse all code files in repo_path and return ParsedFile list."""
|
|
14
|
+
exts = supported_extensions()
|
|
15
|
+
files_to_parse = list(walk_repo(
|
|
16
|
+
repo_path,
|
|
17
|
+
exclude_patterns=config.exclude_patterns,
|
|
18
|
+
max_file_size_kb=config.max_file_size_kb,
|
|
19
|
+
supported_extensions=exts,
|
|
20
|
+
))
|
|
21
|
+
|
|
22
|
+
parsed: list[ParsedFile] = []
|
|
23
|
+
|
|
24
|
+
with Progress(
|
|
25
|
+
SpinnerColumn(),
|
|
26
|
+
TextColumn("[bold blue]Parsing files..."),
|
|
27
|
+
BarColumn(),
|
|
28
|
+
TaskProgressColumn(),
|
|
29
|
+
transient=True,
|
|
30
|
+
) as progress:
|
|
31
|
+
task = progress.add_task("parsing", total=len(files_to_parse))
|
|
32
|
+
|
|
33
|
+
def parse_file(args):
|
|
34
|
+
file_path, ext = args
|
|
35
|
+
parser = get_parser(ext)
|
|
36
|
+
if parser is None:
|
|
37
|
+
return None
|
|
38
|
+
try:
|
|
39
|
+
source = file_path.read_bytes()
|
|
40
|
+
return parser.parse(file_path, source)
|
|
41
|
+
except Exception:
|
|
42
|
+
return None
|
|
43
|
+
finally:
|
|
44
|
+
progress.advance(task)
|
|
45
|
+
|
|
46
|
+
with ThreadPoolExecutor(max_workers=8) as executor:
|
|
47
|
+
futures = {executor.submit(parse_file, item): item for item in files_to_parse}
|
|
48
|
+
for future in as_completed(futures):
|
|
49
|
+
result = future.result()
|
|
50
|
+
if result is not None:
|
|
51
|
+
parsed.append(result)
|
|
52
|
+
|
|
53
|
+
return parsed
|
docforge/cli/__init__.py
ADDED
docforge/cli/app.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
from . import __version__
|
|
3
|
+
from .commands.generate import generate
|
|
4
|
+
|
|
5
|
+
app = typer.Typer(
|
|
6
|
+
name="docforge",
|
|
7
|
+
help="AI-powered documentation generator for any GitHub repository.",
|
|
8
|
+
add_completion=False,
|
|
9
|
+
rich_markup_mode="rich",
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
app.command(name="generate", help="Generate documentation for a repo.")(generate)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def version_callback(value: bool):
|
|
16
|
+
if value:
|
|
17
|
+
typer.echo(f"DocForge v{__version__}")
|
|
18
|
+
raise typer.Exit()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@app.callback()
|
|
22
|
+
def main_callback(
|
|
23
|
+
version: bool = typer.Option(None, "--version", "-v", callback=version_callback, is_eager=True),
|
|
24
|
+
):
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def main():
|
|
29
|
+
app()
|
|
File without changes
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
3
|
+
|
|
4
|
+
import typer
|
|
5
|
+
from rich.panel import Panel
|
|
6
|
+
from rich.table import Table
|
|
7
|
+
from rich import box
|
|
8
|
+
|
|
9
|
+
from ..console import console
|
|
10
|
+
from ...config.loader import load_config
|
|
11
|
+
from ...source.resolver import SourceResolver
|
|
12
|
+
from ...git.repo_reader import read_repo
|
|
13
|
+
from ...git.changelog_builder import build_changelog
|
|
14
|
+
from ...analysis.repo_analyzer import analyze_repo
|
|
15
|
+
from ...analysis.dependency_detector import detect_dependencies
|
|
16
|
+
from ...analysis.context_builder import build_context
|
|
17
|
+
from ...llm.client import LLMClient
|
|
18
|
+
from ...llm.prompt_manager import PromptManager
|
|
19
|
+
from ...generators.readme_generator import ReadmeGenerator
|
|
20
|
+
from ...generators.installation_generator import InstallationGenerator
|
|
21
|
+
from ...generators.api_generator import ApiGenerator
|
|
22
|
+
from ...generators.architecture_generator import ArchitectureGenerator
|
|
23
|
+
from ...generators.changelog_generator import ChangelogGenerator
|
|
24
|
+
from ...generators.base_generator import GeneratorResult
|
|
25
|
+
from ...output.writer import write_results
|
|
26
|
+
from ...output.mkdocs_builder import build_mkdocs
|
|
27
|
+
from ...output.github_action_writer import write_github_action
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def generate(
|
|
31
|
+
source: str = typer.Argument(..., help="GitHub URL or local path to repository"),
|
|
32
|
+
model: str = typer.Option(None, "--model", "-m", help="LLM model (e.g. gpt-4o, claude-sonnet-4-6, ollama/llama3)"),
|
|
33
|
+
api_base: str = typer.Option(None, "--api-base", help="Custom API base URL (for Ollama: http://localhost:11434)"),
|
|
34
|
+
output_dir: str = typer.Option(None, "--output-dir", "-o", help="Output directory (default: ./docs)"),
|
|
35
|
+
mkdocs: bool = typer.Option(False, "--mkdocs", help="Generate mkdocs.yml"),
|
|
36
|
+
github_action: bool = typer.Option(False, "--github-action", help="Generate GitHub Action workflow"),
|
|
37
|
+
overwrite: bool = typer.Option(False, "--overwrite", help="Overwrite existing docs"),
|
|
38
|
+
no_api: bool = typer.Option(False, "--no-api", help="Skip per-file API docs generation"),
|
|
39
|
+
no_changelog: bool = typer.Option(False, "--no-changelog", help="Skip changelog generation"),
|
|
40
|
+
):
|
|
41
|
+
"""Generate documentation for a GitHub repository or local project."""
|
|
42
|
+
|
|
43
|
+
console.print(Panel.fit(
|
|
44
|
+
"[bold blue]DocForge[/bold blue] — AI-powered documentation generator",
|
|
45
|
+
border_style="blue",
|
|
46
|
+
))
|
|
47
|
+
|
|
48
|
+
# 1. Resolve source
|
|
49
|
+
with console.status("[bold]Resolving source..."):
|
|
50
|
+
resolver = SourceResolver()
|
|
51
|
+
try:
|
|
52
|
+
repo_path, gh_metadata = resolver.resolve(source)
|
|
53
|
+
except Exception as e:
|
|
54
|
+
console.print(f"[red]Error resolving source:[/red] {e}")
|
|
55
|
+
raise typer.Exit(1)
|
|
56
|
+
|
|
57
|
+
console.print(f"[green]✓[/green] Source: [bold]{repo_path}[/bold]")
|
|
58
|
+
|
|
59
|
+
# 2. Load config
|
|
60
|
+
config = load_config(
|
|
61
|
+
repo_path=repo_path,
|
|
62
|
+
model=model,
|
|
63
|
+
api_base=api_base,
|
|
64
|
+
output_dir=output_dir,
|
|
65
|
+
mkdocs=mkdocs,
|
|
66
|
+
github_action=github_action,
|
|
67
|
+
overwrite=overwrite,
|
|
68
|
+
)
|
|
69
|
+
out_dir = Path(config.output.directory)
|
|
70
|
+
|
|
71
|
+
console.print(f"[green]✓[/green] Model: [bold]{config.llm.model}[/bold]")
|
|
72
|
+
console.print(f"[green]✓[/green] Output: [bold]{out_dir}[/bold]")
|
|
73
|
+
|
|
74
|
+
# 3. Git history
|
|
75
|
+
with console.status("[bold]Reading git history..."):
|
|
76
|
+
commits, tags = read_repo(repo_path)
|
|
77
|
+
commit_groups = build_changelog(commits) if commits else []
|
|
78
|
+
|
|
79
|
+
console.print(f"[green]✓[/green] Git: {len(commits)} commits, {len(tags)} tags")
|
|
80
|
+
|
|
81
|
+
# 4. Parse code
|
|
82
|
+
console.print("[bold]Parsing code...[/bold]")
|
|
83
|
+
parsed_files = analyze_repo(repo_path, config)
|
|
84
|
+
console.print(f"[green]✓[/green] Parsed {len(parsed_files)} files")
|
|
85
|
+
|
|
86
|
+
# 5. Detect dependencies
|
|
87
|
+
with console.status("[bold]Detecting dependencies..."):
|
|
88
|
+
dependencies = detect_dependencies(repo_path)
|
|
89
|
+
|
|
90
|
+
# 6. Build context
|
|
91
|
+
context = build_context(
|
|
92
|
+
repo_path=repo_path,
|
|
93
|
+
parsed_files=parsed_files,
|
|
94
|
+
dependencies=dependencies,
|
|
95
|
+
commit_groups=commit_groups,
|
|
96
|
+
gh_metadata=gh_metadata,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
console.print(f"[green]✓[/green] Project: [bold]{context.name}[/bold] ({', '.join(context.languages) or 'unknown'})")
|
|
100
|
+
|
|
101
|
+
# 7. Initialize LLM
|
|
102
|
+
llm = LLMClient(config.llm)
|
|
103
|
+
prompts = PromptManager()
|
|
104
|
+
|
|
105
|
+
# 8. Run generators
|
|
106
|
+
console.print("\n[bold]Generating documentation...[/bold]")
|
|
107
|
+
all_results: list[GeneratorResult] = []
|
|
108
|
+
|
|
109
|
+
generators = [
|
|
110
|
+
("README", ReadmeGenerator(llm, prompts)),
|
|
111
|
+
("Installation", InstallationGenerator(llm, prompts)),
|
|
112
|
+
("Architecture", ArchitectureGenerator(llm, prompts)),
|
|
113
|
+
]
|
|
114
|
+
|
|
115
|
+
if not no_changelog:
|
|
116
|
+
generators.append(("Changelog", ChangelogGenerator(llm, prompts)))
|
|
117
|
+
|
|
118
|
+
def run_generator(name_gen):
|
|
119
|
+
name, gen = name_gen
|
|
120
|
+
with console.status(f"[bold]Generating {name}..."):
|
|
121
|
+
result = gen.generate(context)
|
|
122
|
+
console.print(f"[green]✓[/green] {name}")
|
|
123
|
+
return result
|
|
124
|
+
|
|
125
|
+
# Run core generators (README, Installation, Architecture, Changelog) in parallel
|
|
126
|
+
with ThreadPoolExecutor(max_workers=4) as executor:
|
|
127
|
+
futures = {executor.submit(run_generator, ng): ng[0] for ng in generators}
|
|
128
|
+
for future in as_completed(futures):
|
|
129
|
+
try:
|
|
130
|
+
all_results.append(future.result())
|
|
131
|
+
except Exception as e:
|
|
132
|
+
name = futures[future]
|
|
133
|
+
console.print(f"[red]✗[/red] {name}: {e}")
|
|
134
|
+
|
|
135
|
+
# API docs (sequential to avoid rate limits)
|
|
136
|
+
if not no_api:
|
|
137
|
+
with console.status("[bold]Generating API docs..."):
|
|
138
|
+
api_gen = ApiGenerator(llm, prompts)
|
|
139
|
+
try:
|
|
140
|
+
api_results = api_gen.generate_all(context)
|
|
141
|
+
all_results.extend(api_results)
|
|
142
|
+
console.print(f"[green]✓[/green] API docs ({len(api_results)} files)")
|
|
143
|
+
except Exception as e:
|
|
144
|
+
console.print(f"[red]✗[/red] API docs: {e}")
|
|
145
|
+
|
|
146
|
+
# 9. Write output
|
|
147
|
+
with console.status("[bold]Writing files..."):
|
|
148
|
+
written = write_results(all_results, out_dir, overwrite=config.output.overwrite)
|
|
149
|
+
|
|
150
|
+
# 10. Optional outputs
|
|
151
|
+
if config.output.mkdocs:
|
|
152
|
+
with console.status("[bold]Generating mkdocs.yml..."):
|
|
153
|
+
mkdocs_path = build_mkdocs(context, all_results, out_dir, prompts)
|
|
154
|
+
console.print(f"[green]✓[/green] MkDocs config: {mkdocs_path}")
|
|
155
|
+
|
|
156
|
+
if config.output.github_action:
|
|
157
|
+
with console.status("[bold]Writing GitHub Action..."):
|
|
158
|
+
action_path = write_github_action(repo_path, config.llm, prompts)
|
|
159
|
+
console.print(f"[green]✓[/green] GitHub Action: {action_path}")
|
|
160
|
+
|
|
161
|
+
# 11. Summary table
|
|
162
|
+
table = Table(title="\nGenerated Documentation", box=box.ROUNDED)
|
|
163
|
+
table.add_column("File", style="cyan")
|
|
164
|
+
table.add_column("Size", justify="right", style="green")
|
|
165
|
+
|
|
166
|
+
for path in sorted(written):
|
|
167
|
+
size = path.stat().st_size
|
|
168
|
+
table.add_row(str(path.relative_to(Path.cwd()) if path.is_relative_to(Path.cwd()) else path), f"{size:,} bytes")
|
|
169
|
+
|
|
170
|
+
console.print(table)
|
|
171
|
+
console.print(f"\n[bold green]Done![/bold green] {len(written)} files written to [bold]{out_dir}[/bold]")
|
docforge/cli/console.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import tomllib
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from .schema import DocForgeConfig, LLMConfig, OutputConfig
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def load_config(
|
|
9
|
+
repo_path: Path,
|
|
10
|
+
model: str | None = None,
|
|
11
|
+
api_base: str | None = None,
|
|
12
|
+
output_dir: str | None = None,
|
|
13
|
+
mkdocs: bool = False,
|
|
14
|
+
github_action: bool = False,
|
|
15
|
+
overwrite: bool = False,
|
|
16
|
+
) -> DocForgeConfig:
|
|
17
|
+
"""Load config from .docforge.toml if present, then apply CLI overrides."""
|
|
18
|
+
config = DocForgeConfig()
|
|
19
|
+
|
|
20
|
+
config_file = repo_path / ".docforge.toml"
|
|
21
|
+
if config_file.exists():
|
|
22
|
+
with open(config_file, "rb") as f:
|
|
23
|
+
data = tomllib.load(f)
|
|
24
|
+
config = DocForgeConfig.model_validate(data)
|
|
25
|
+
|
|
26
|
+
# Apply CLI flag overrides
|
|
27
|
+
if model:
|
|
28
|
+
config.llm.model = model
|
|
29
|
+
if api_base:
|
|
30
|
+
config.llm.api_base = api_base
|
|
31
|
+
if output_dir:
|
|
32
|
+
config.output.directory = output_dir
|
|
33
|
+
if mkdocs:
|
|
34
|
+
config.output.mkdocs = True
|
|
35
|
+
if github_action:
|
|
36
|
+
config.output.github_action = True
|
|
37
|
+
if overwrite:
|
|
38
|
+
config.output.overwrite = True
|
|
39
|
+
|
|
40
|
+
# Apply environment variable overrides
|
|
41
|
+
if env_key := os.getenv("OPENAI_API_KEY"):
|
|
42
|
+
config.llm.api_key = config.llm.api_key or env_key
|
|
43
|
+
if env_model := os.getenv("DOCFORGE_MODEL"):
|
|
44
|
+
config.llm.model = env_model
|
|
45
|
+
if env_base := os.getenv("DOCFORGE_API_BASE"):
|
|
46
|
+
config.llm.api_base = env_base
|
|
47
|
+
|
|
48
|
+
return config
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from pydantic import BaseModel, Field
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class LLMConfig(BaseModel):
|
|
5
|
+
model: str = "gpt-4o"
|
|
6
|
+
api_base: str | None = None # For Ollama: "http://localhost:11434"
|
|
7
|
+
api_key: str | None = None
|
|
8
|
+
max_tokens: int = 4096
|
|
9
|
+
temperature: float = 0.2
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class GeneratorConfig(BaseModel):
|
|
13
|
+
enabled: bool = True
|
|
14
|
+
extra_instructions: str = ""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class OutputConfig(BaseModel):
|
|
18
|
+
directory: str = "./docs"
|
|
19
|
+
mkdocs: bool = False
|
|
20
|
+
github_action: bool = False
|
|
21
|
+
overwrite: bool = False
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class DocForgeConfig(BaseModel):
|
|
25
|
+
llm: LLMConfig = Field(default_factory=LLMConfig)
|
|
26
|
+
output: OutputConfig = Field(default_factory=OutputConfig)
|
|
27
|
+
generators: dict[str, GeneratorConfig] = Field(default_factory=dict)
|
|
28
|
+
exclude_patterns: list[str] = [
|
|
29
|
+
"**/vendor/**",
|
|
30
|
+
"**/node_modules/**",
|
|
31
|
+
"**/.git/**",
|
|
32
|
+
"**/dist/**",
|
|
33
|
+
"**/build/**",
|
|
34
|
+
"**/__pycache__/**",
|
|
35
|
+
"**/*.pyc",
|
|
36
|
+
"**/*.min.js",
|
|
37
|
+
"**/*.min.css",
|
|
38
|
+
]
|
|
39
|
+
max_file_size_kb: int = 512
|
|
40
|
+
languages: list[str] = ["python", "javascript", "typescript", "go", "rust", "java"]
|