docwright 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.
- ai_docgen/__init__.py +0 -0
- ai_docgen/analyzer.py +59 -0
- ai_docgen/built_in_templates/__init__.py +0 -0
- ai_docgen/built_in_templates/readme/__init__.py +0 -0
- ai_docgen/built_in_templates/readme/default.md.j2 +30 -0
- ai_docgen/built_in_templates/wiki/__init__.py +0 -0
- ai_docgen/built_in_templates/wiki/adr.md.j2 +27 -0
- ai_docgen/built_in_templates/wiki/api-contracts.md.j2 +20 -0
- ai_docgen/built_in_templates/wiki/architecture.md.j2 +25 -0
- ai_docgen/built_in_templates/wiki/data-model.md.j2 +22 -0
- ai_docgen/built_in_templates/wiki/db-schema.md.j2 +22 -0
- ai_docgen/built_in_templates/wiki/development-guide.md.j2 +22 -0
- ai_docgen/built_in_templates/wiki/integrations.md.j2 +22 -0
- ai_docgen/built_in_templates/wiki/operations.md.j2 +27 -0
- ai_docgen/built_in_templates/wiki/security.md.j2 +22 -0
- ai_docgen/built_in_templates/wiki/troubleshooting.md.j2 +22 -0
- ai_docgen/cli.py +136 -0
- ai_docgen/config.py +71 -0
- ai_docgen/engine.py +165 -0
- ai_docgen/outputs/__init__.py +0 -0
- ai_docgen/outputs/base.py +7 -0
- ai_docgen/outputs/direct.py +16 -0
- ai_docgen/outputs/factory.py +12 -0
- ai_docgen/outputs/pull_request.py +69 -0
- ai_docgen/providers/__init__.py +0 -0
- ai_docgen/providers/base.py +6 -0
- ai_docgen/providers/claude.py +22 -0
- ai_docgen/providers/factory.py +26 -0
- ai_docgen/providers/ollama.py +25 -0
- ai_docgen/providers/openai.py +20 -0
- ai_docgen/registry.py +125 -0
- ai_docgen/renderer.py +104 -0
- ai_docgen/reporters/__init__.py +0 -0
- ai_docgen/reporters/html.py +54 -0
- ai_docgen/reporters/terminal.py +31 -0
- ai_docgen/scaffolder.py +163 -0
- docwright-0.1.0.dist-info/METADATA +188 -0
- docwright-0.1.0.dist-info/RECORD +41 -0
- docwright-0.1.0.dist-info/WHEEL +4 -0
- docwright-0.1.0.dist-info/entry_points.txt +3 -0
- docwright-0.1.0.dist-info/licenses/LICENSE +21 -0
ai_docgen/engine.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from ai_docgen.analyzer import DiffAnalyzer
|
|
7
|
+
from ai_docgen.config import Config
|
|
8
|
+
from ai_docgen.outputs.base import Output
|
|
9
|
+
from ai_docgen.providers.base import LLMProvider
|
|
10
|
+
from ai_docgen.registry import DocumentEntry, ProjectEntry, Registry
|
|
11
|
+
from ai_docgen.renderer import DocumentRenderer, TemplateLoader
|
|
12
|
+
|
|
13
|
+
SYSTEM_PROMPT = (
|
|
14
|
+
"You are a technical documentation writer. You update specific sections of documentation "
|
|
15
|
+
"based on code changes. Return ONLY the updated section content — no markdown fences, "
|
|
16
|
+
"no explanations, no surrounding text. Write in clear, concise English. "
|
|
17
|
+
"Be accurate and specific to the actual code."
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class DocsEngine:
|
|
22
|
+
def __init__(self, repo_root: Path, provider: LLMProvider, output: Output) -> None:
|
|
23
|
+
self.repo_root = repo_root
|
|
24
|
+
self.provider = provider
|
|
25
|
+
self.output = output
|
|
26
|
+
self.config = Config.load(repo_root)
|
|
27
|
+
self.renderer = DocumentRenderer()
|
|
28
|
+
self.loader = TemplateLoader(
|
|
29
|
+
source=self.config.templates.source,
|
|
30
|
+
local_path=(
|
|
31
|
+
repo_root / self.config.templates.local_path
|
|
32
|
+
if self.config.templates.source == "local"
|
|
33
|
+
else None
|
|
34
|
+
),
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
async def init(self) -> None:
|
|
38
|
+
changed_files: list[Path] = []
|
|
39
|
+
for doc_config in self.config.documents:
|
|
40
|
+
target = self.repo_root / doc_config.target
|
|
41
|
+
template_text = self.loader.load(doc_config.template)
|
|
42
|
+
document = target.read_text() if target.exists() else template_text
|
|
43
|
+
section_names = self.renderer.auto_section_names(template_text)
|
|
44
|
+
repo_context = self.gather_repo_context()
|
|
45
|
+
for section_name in section_names:
|
|
46
|
+
user_prompt = (
|
|
47
|
+
f"Repository context:\n{repo_context}\n\n"
|
|
48
|
+
f"Document type: {doc_config.type}\n"
|
|
49
|
+
f"Update the '{section_name}' section with accurate, detailed information."
|
|
50
|
+
)
|
|
51
|
+
updated_content = await self.provider.complete(
|
|
52
|
+
system=SYSTEM_PROMPT, user=user_prompt
|
|
53
|
+
)
|
|
54
|
+
document = self.renderer.patch_section(
|
|
55
|
+
document, section_name, updated_content + "\n"
|
|
56
|
+
)
|
|
57
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
58
|
+
target.write_text(document)
|
|
59
|
+
changed_files.append(target)
|
|
60
|
+
if changed_files:
|
|
61
|
+
self.output.apply(changed_files, "docs: generate initial documentation")
|
|
62
|
+
Config.mark_initialized(self.repo_root)
|
|
63
|
+
self.register_in_registry()
|
|
64
|
+
|
|
65
|
+
async def run(self, diff_text: str) -> bool:
|
|
66
|
+
if not Config.is_initialized(self.repo_root):
|
|
67
|
+
await self.init()
|
|
68
|
+
return False
|
|
69
|
+
triggers = self.config.triggers
|
|
70
|
+
analyzer = DiffAnalyzer(
|
|
71
|
+
diff_text=diff_text,
|
|
72
|
+
trigger_paths=triggers.paths if triggers else [],
|
|
73
|
+
ignore_paths=triggers.ignore if triggers else [],
|
|
74
|
+
)
|
|
75
|
+
if not analyzer.has_relevant_changes():
|
|
76
|
+
return True
|
|
77
|
+
changed_files: list[Path] = []
|
|
78
|
+
diff_summary = analyzer.diff_summary()
|
|
79
|
+
for doc_config in self.config.documents:
|
|
80
|
+
target = self.repo_root / doc_config.target
|
|
81
|
+
if not target.exists():
|
|
82
|
+
continue
|
|
83
|
+
document = target.read_text()
|
|
84
|
+
section_names = self.renderer.auto_section_names(document)
|
|
85
|
+
updated = False
|
|
86
|
+
for section_name in section_names:
|
|
87
|
+
user_prompt = (
|
|
88
|
+
f"Diff summary:\n{diff_summary}\n\n"
|
|
89
|
+
f"Current document:\n{document}\n\n"
|
|
90
|
+
f"Update the '{section_name}' section if the diff affects it. "
|
|
91
|
+
f"If no update is needed, return the current section content unchanged."
|
|
92
|
+
)
|
|
93
|
+
updated_content = await self.provider.complete(
|
|
94
|
+
system=SYSTEM_PROMPT, user=user_prompt
|
|
95
|
+
)
|
|
96
|
+
new_document = self.renderer.patch_section(
|
|
97
|
+
document, section_name, updated_content + "\n"
|
|
98
|
+
)
|
|
99
|
+
if new_document != document:
|
|
100
|
+
document = new_document
|
|
101
|
+
updated = True
|
|
102
|
+
if updated:
|
|
103
|
+
target.write_text(document)
|
|
104
|
+
changed_files.append(target)
|
|
105
|
+
if changed_files:
|
|
106
|
+
self.output.apply(changed_files, "docs: update documentation")
|
|
107
|
+
return False
|
|
108
|
+
|
|
109
|
+
async def sync(self) -> None:
|
|
110
|
+
for doc_config in self.config.documents:
|
|
111
|
+
target = self.repo_root / doc_config.target
|
|
112
|
+
template_text = self.loader.load(doc_config.template)
|
|
113
|
+
document = target.read_text() if target.exists() else template_text
|
|
114
|
+
section_names = self.renderer.auto_section_names(template_text)
|
|
115
|
+
repo_context = self.gather_repo_context()
|
|
116
|
+
for section_name in section_names:
|
|
117
|
+
user_prompt = (
|
|
118
|
+
f"Repository context:\n{repo_context}\n\n"
|
|
119
|
+
f"Current document:\n{document}\n\n"
|
|
120
|
+
f"Re-sync the '{section_name}' section to be accurate and up to date."
|
|
121
|
+
)
|
|
122
|
+
updated_content = await self.provider.complete(
|
|
123
|
+
system=SYSTEM_PROMPT, user=user_prompt
|
|
124
|
+
)
|
|
125
|
+
document = self.renderer.patch_section(
|
|
126
|
+
document, section_name, updated_content + "\n"
|
|
127
|
+
)
|
|
128
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
129
|
+
target.write_text(document)
|
|
130
|
+
|
|
131
|
+
def gather_repo_context(self) -> str:
|
|
132
|
+
lines: list[str] = [f"Repo root: {self.repo_root.name}"]
|
|
133
|
+
for candidate in ["pyproject.toml", "package.json", "composer.json", "go.mod"]:
|
|
134
|
+
path = self.repo_root / candidate
|
|
135
|
+
if path.exists():
|
|
136
|
+
lines.append(f"\n{candidate}:\n{path.read_text()[:2000]}")
|
|
137
|
+
break
|
|
138
|
+
src_dirs = ["app", "src", "lib"]
|
|
139
|
+
for src_dir in src_dirs:
|
|
140
|
+
full = self.repo_root / src_dir
|
|
141
|
+
if full.exists():
|
|
142
|
+
files = [str(p.relative_to(self.repo_root)) for p in full.rglob("*.py")][:20]
|
|
143
|
+
lines.append(f"\nSource files in {src_dir}/: {', '.join(files)}")
|
|
144
|
+
break
|
|
145
|
+
return "\n".join(lines)
|
|
146
|
+
|
|
147
|
+
def register_in_registry(self) -> None:
|
|
148
|
+
registry_path = self.repo_root / self.config.registry.path
|
|
149
|
+
registry = Registry(registry_path)
|
|
150
|
+
try:
|
|
151
|
+
remote = (
|
|
152
|
+
subprocess.check_output(["git", "remote", "get-url", "origin"], cwd=self.repo_root)
|
|
153
|
+
.decode()
|
|
154
|
+
.strip()
|
|
155
|
+
)
|
|
156
|
+
except subprocess.CalledProcessError:
|
|
157
|
+
remote = ""
|
|
158
|
+
registry.register(
|
|
159
|
+
ProjectEntry(
|
|
160
|
+
name=self.repo_root.name,
|
|
161
|
+
path=str(self.repo_root),
|
|
162
|
+
remote=remote,
|
|
163
|
+
documents=[DocumentEntry(target=d.target) for d in self.config.documents],
|
|
164
|
+
)
|
|
165
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from git import Repo
|
|
4
|
+
|
|
5
|
+
from ai_docgen.outputs.base import Output
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class DirectOutput(Output):
|
|
9
|
+
def __init__(self, repo_root: Path) -> None:
|
|
10
|
+
self.repo_root = repo_root
|
|
11
|
+
|
|
12
|
+
def apply(self, changed_files: list[Path], message: str) -> None:
|
|
13
|
+
repo = Repo(self.repo_root)
|
|
14
|
+
str_paths = [str(f.relative_to(self.repo_root)) for f in changed_files]
|
|
15
|
+
repo.index.add(str_paths)
|
|
16
|
+
repo.index.commit(message)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from ai_docgen.config import OutputConfig
|
|
4
|
+
from ai_docgen.outputs.base import Output
|
|
5
|
+
from ai_docgen.outputs.direct import DirectOutput
|
|
6
|
+
from ai_docgen.outputs.pull_request import PullRequestOutput
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def build_output(config: OutputConfig, repo_root: Path) -> Output:
|
|
10
|
+
if config.mode == "direct":
|
|
11
|
+
return DirectOutput(repo_root=repo_root)
|
|
12
|
+
return PullRequestOutput(repo_root=repo_root, config=config)
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import subprocess
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from ai_docgen.config import OutputConfig
|
|
12
|
+
from ai_docgen.outputs.base import Output
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class PullRequestOutput(Output):
|
|
16
|
+
def __init__(self, repo_root: Path, config: OutputConfig) -> None:
|
|
17
|
+
self.repo_root = repo_root
|
|
18
|
+
self.config = config
|
|
19
|
+
|
|
20
|
+
def apply(self, changed_files: list[Path], message: str) -> None:
|
|
21
|
+
short_hash = hashlib.sha1(message.encode()).hexdigest()[:8]
|
|
22
|
+
branch = f"{self.config.branch_prefix}{short_hash}"
|
|
23
|
+
self.run(["git", "checkout", "-b", branch])
|
|
24
|
+
rel_paths = [str(f.relative_to(self.repo_root)) for f in changed_files]
|
|
25
|
+
self.run(["git", "add"] + rel_paths)
|
|
26
|
+
self.run(["git", "commit", "-m", message])
|
|
27
|
+
self.run(["git", "push", "origin", branch])
|
|
28
|
+
token = os.environ.get("GITHUB_TOKEN", "")
|
|
29
|
+
if token:
|
|
30
|
+
self.create_github_pr(branch, message, token)
|
|
31
|
+
|
|
32
|
+
def run(self, cmd: list[str]) -> None:
|
|
33
|
+
subprocess.run(cmd, cwd=self.repo_root, check=True)
|
|
34
|
+
|
|
35
|
+
def create_github_pr(self, branch: str, title: str, token: str) -> None:
|
|
36
|
+
remote_url = (
|
|
37
|
+
subprocess.check_output(["git", "remote", "get-url", "origin"], cwd=self.repo_root)
|
|
38
|
+
.decode()
|
|
39
|
+
.strip()
|
|
40
|
+
)
|
|
41
|
+
owner_repo = self.extract_owner_repo(remote_url)
|
|
42
|
+
if not owner_repo:
|
|
43
|
+
return
|
|
44
|
+
default_branch = (
|
|
45
|
+
subprocess.check_output(
|
|
46
|
+
["git", "symbolic-ref", "refs/remotes/origin/HEAD"],
|
|
47
|
+
cwd=self.repo_root,
|
|
48
|
+
)
|
|
49
|
+
.decode()
|
|
50
|
+
.strip()
|
|
51
|
+
.replace("refs/remotes/origin/", "")
|
|
52
|
+
)
|
|
53
|
+
httpx.post(
|
|
54
|
+
f"https://api.github.com/repos/{owner_repo}/pulls",
|
|
55
|
+
headers={
|
|
56
|
+
"Authorization": f"Bearer {token}",
|
|
57
|
+
"Accept": "application/vnd.github+json",
|
|
58
|
+
},
|
|
59
|
+
json={
|
|
60
|
+
"title": self.config.pr_title,
|
|
61
|
+
"head": branch,
|
|
62
|
+
"base": default_branch,
|
|
63
|
+
"body": title,
|
|
64
|
+
},
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
def extract_owner_repo(self, remote_url: str) -> str | None:
|
|
68
|
+
match = re.search(r"[:/]([^/]+/[^/]+?)(?:\.git)?$", remote_url)
|
|
69
|
+
return match.group(1) if match else None
|
|
File without changes
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from anthropic import AsyncAnthropic
|
|
2
|
+
from anthropic.types import TextBlock
|
|
3
|
+
|
|
4
|
+
from ai_docgen.providers.base import LLMProvider
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ClaudeProvider(LLMProvider):
|
|
8
|
+
def __init__(self, model: str, api_key: str) -> None:
|
|
9
|
+
self.model = model
|
|
10
|
+
self.client = AsyncAnthropic(api_key=api_key)
|
|
11
|
+
|
|
12
|
+
async def complete(self, system: str, user: str) -> str:
|
|
13
|
+
message = await self.client.messages.create(
|
|
14
|
+
model=self.model,
|
|
15
|
+
max_tokens=4096,
|
|
16
|
+
system=system,
|
|
17
|
+
messages=[{"role": "user", "content": user}],
|
|
18
|
+
)
|
|
19
|
+
block = message.content[0]
|
|
20
|
+
if not isinstance(block, TextBlock):
|
|
21
|
+
raise ValueError(f"Unexpected content block type: {type(block)}")
|
|
22
|
+
return block.text
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
from ai_docgen.config import ProviderConfig
|
|
4
|
+
from ai_docgen.providers.base import LLMProvider
|
|
5
|
+
from ai_docgen.providers.claude import ClaudeProvider
|
|
6
|
+
from ai_docgen.providers.ollama import OllamaProvider
|
|
7
|
+
from ai_docgen.providers.openai import OpenAIProvider
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def build_provider(config: ProviderConfig) -> LLMProvider:
|
|
11
|
+
if config.type == "ollama":
|
|
12
|
+
return OllamaProvider(
|
|
13
|
+
model=config.model,
|
|
14
|
+
base_url=config.base_url or "http://localhost:11434",
|
|
15
|
+
)
|
|
16
|
+
api_key = os.environ.get(config.api_key_env, "")
|
|
17
|
+
if not api_key:
|
|
18
|
+
raise OSError(
|
|
19
|
+
f"Environment variable '{config.api_key_env}' is not set. "
|
|
20
|
+
f"Required for provider '{config.type}'."
|
|
21
|
+
)
|
|
22
|
+
if config.type == "claude":
|
|
23
|
+
return ClaudeProvider(model=config.model, api_key=api_key)
|
|
24
|
+
if config.type == "openai":
|
|
25
|
+
return OpenAIProvider(model=config.model, api_key=api_key)
|
|
26
|
+
raise ValueError(f"Unknown provider type: {config.type}")
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import httpx
|
|
2
|
+
|
|
3
|
+
from ai_docgen.providers.base import LLMProvider
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class OllamaProvider(LLMProvider):
|
|
7
|
+
def __init__(self, model: str, base_url: str = "http://localhost:11434") -> None:
|
|
8
|
+
self.model = model
|
|
9
|
+
self.base_url = base_url.rstrip("/")
|
|
10
|
+
|
|
11
|
+
async def complete(self, system: str, user: str) -> str:
|
|
12
|
+
async with httpx.AsyncClient(timeout=120) as client:
|
|
13
|
+
response = await client.post(
|
|
14
|
+
f"{self.base_url}/api/chat",
|
|
15
|
+
json={
|
|
16
|
+
"model": self.model,
|
|
17
|
+
"stream": False,
|
|
18
|
+
"messages": [
|
|
19
|
+
{"role": "system", "content": system},
|
|
20
|
+
{"role": "user", "content": user},
|
|
21
|
+
],
|
|
22
|
+
},
|
|
23
|
+
)
|
|
24
|
+
response.raise_for_status()
|
|
25
|
+
return str(response.json()["message"]["content"])
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from openai import AsyncOpenAI
|
|
2
|
+
|
|
3
|
+
from ai_docgen.providers.base import LLMProvider
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class OpenAIProvider(LLMProvider):
|
|
7
|
+
def __init__(self, model: str, api_key: str) -> None:
|
|
8
|
+
self.model = model
|
|
9
|
+
self.client = AsyncOpenAI(api_key=api_key)
|
|
10
|
+
|
|
11
|
+
async def complete(self, system: str, user: str) -> str:
|
|
12
|
+
response = await self.client.chat.completions.create(
|
|
13
|
+
model=self.model,
|
|
14
|
+
messages=[
|
|
15
|
+
{"role": "system", "content": system},
|
|
16
|
+
{"role": "user", "content": user},
|
|
17
|
+
],
|
|
18
|
+
)
|
|
19
|
+
content = response.choices[0].message.content
|
|
20
|
+
return content or ""
|
ai_docgen/registry.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from datetime import date, timedelta
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import yaml
|
|
8
|
+
|
|
9
|
+
STALE_DAYS = 30
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _as_date(value: date | str | None, fallback: date) -> date:
|
|
13
|
+
if value is None:
|
|
14
|
+
return fallback
|
|
15
|
+
if isinstance(value, date):
|
|
16
|
+
return value
|
|
17
|
+
return date.fromisoformat(value)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _as_optional_date(value: date | str | None) -> date | None:
|
|
21
|
+
if value is None:
|
|
22
|
+
return None
|
|
23
|
+
if isinstance(value, date):
|
|
24
|
+
return value
|
|
25
|
+
return date.fromisoformat(value)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class DocumentEntry:
|
|
30
|
+
target: str
|
|
31
|
+
last_updated: date | None = None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class ProjectEntry:
|
|
36
|
+
name: str
|
|
37
|
+
path: str
|
|
38
|
+
remote: str
|
|
39
|
+
documents: list[DocumentEntry] = field(default_factory=list)
|
|
40
|
+
registered_at: date = field(default_factory=date.today)
|
|
41
|
+
last_updated: date = field(default_factory=date.today)
|
|
42
|
+
status: str = "synced"
|
|
43
|
+
|
|
44
|
+
def compute_status(self) -> str:
|
|
45
|
+
if (date.today() - self.last_updated) > timedelta(days=STALE_DAYS):
|
|
46
|
+
return "stale"
|
|
47
|
+
return "synced"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Registry:
|
|
51
|
+
def __init__(self, path: Path) -> None:
|
|
52
|
+
self.path = path
|
|
53
|
+
|
|
54
|
+
def all_projects(self) -> list[ProjectEntry]:
|
|
55
|
+
if not self.path.exists():
|
|
56
|
+
return []
|
|
57
|
+
data = yaml.safe_load(self.path.read_text()) or {}
|
|
58
|
+
projects = []
|
|
59
|
+
for p in data.get("projects", []):
|
|
60
|
+
docs = [
|
|
61
|
+
DocumentEntry(
|
|
62
|
+
target=d["target"],
|
|
63
|
+
last_updated=_as_optional_date(d.get("last_updated")),
|
|
64
|
+
)
|
|
65
|
+
for d in p.get("documents", [])
|
|
66
|
+
]
|
|
67
|
+
projects.append(
|
|
68
|
+
ProjectEntry(
|
|
69
|
+
name=p["name"],
|
|
70
|
+
path=p["path"],
|
|
71
|
+
remote=p.get("remote", ""),
|
|
72
|
+
documents=docs,
|
|
73
|
+
registered_at=_as_date(p.get("registered_at"), date.today()),
|
|
74
|
+
last_updated=_as_date(p.get("last_updated"), date.today()),
|
|
75
|
+
status=p.get("status", "synced"),
|
|
76
|
+
)
|
|
77
|
+
)
|
|
78
|
+
return projects
|
|
79
|
+
|
|
80
|
+
def register(self, entry: ProjectEntry) -> None:
|
|
81
|
+
projects = self.all_projects()
|
|
82
|
+
existing = next((p for p in projects if p.name == entry.name), None)
|
|
83
|
+
if existing:
|
|
84
|
+
projects.remove(existing)
|
|
85
|
+
entry.last_updated = date.today()
|
|
86
|
+
entry.status = entry.compute_status()
|
|
87
|
+
projects.append(entry)
|
|
88
|
+
self.write(projects)
|
|
89
|
+
|
|
90
|
+
def update_document_timestamp(self, project_name: str, target: str, updated: date) -> None:
|
|
91
|
+
projects = self.all_projects()
|
|
92
|
+
for project in projects:
|
|
93
|
+
if project.name == project_name:
|
|
94
|
+
for doc in project.documents:
|
|
95
|
+
if doc.target == target:
|
|
96
|
+
doc.last_updated = updated
|
|
97
|
+
project.last_updated = updated
|
|
98
|
+
project.status = project.compute_status()
|
|
99
|
+
self.write(projects)
|
|
100
|
+
|
|
101
|
+
def write(self, projects: list[ProjectEntry]) -> None:
|
|
102
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
103
|
+
data = {
|
|
104
|
+
"projects": [
|
|
105
|
+
{
|
|
106
|
+
"name": p.name,
|
|
107
|
+
"path": p.path,
|
|
108
|
+
"remote": p.remote,
|
|
109
|
+
"registered_at": p.registered_at.isoformat(),
|
|
110
|
+
"last_updated": p.last_updated.isoformat(),
|
|
111
|
+
"status": p.status,
|
|
112
|
+
"documents": [
|
|
113
|
+
{
|
|
114
|
+
"target": d.target,
|
|
115
|
+
"last_updated": (
|
|
116
|
+
d.last_updated.isoformat() if d.last_updated else None
|
|
117
|
+
),
|
|
118
|
+
}
|
|
119
|
+
for d in p.documents
|
|
120
|
+
],
|
|
121
|
+
}
|
|
122
|
+
for p in projects
|
|
123
|
+
]
|
|
124
|
+
}
|
|
125
|
+
self.path.write_text(yaml.dump(data, default_flow_style=False, allow_unicode=True))
|
ai_docgen/renderer.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from jinja2 import Environment, select_autoescape
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class Section:
|
|
12
|
+
name: str
|
|
13
|
+
is_auto: bool
|
|
14
|
+
content: str
|
|
15
|
+
start: int
|
|
16
|
+
end: int
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
AUTO_OPEN = re.compile(r"<!-- AUTO:(\w+) -->")
|
|
20
|
+
AUTO_CLOSE = re.compile(r"<!-- /AUTO:(\w+) -->")
|
|
21
|
+
MANUAL_OPEN = re.compile(r"<!-- MANUAL -->")
|
|
22
|
+
MANUAL_CLOSE = re.compile(r"<!-- /MANUAL -->")
|
|
23
|
+
|
|
24
|
+
BUILTIN_TEMPLATES_DIR = Path(__file__).parent / "built_in_templates"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class DocumentRenderer:
|
|
28
|
+
def parse_sections(self, text: str) -> list[Section]:
|
|
29
|
+
sections: list[Section] = []
|
|
30
|
+
lines = text.splitlines(keepends=True)
|
|
31
|
+
i = 0
|
|
32
|
+
while i < len(lines):
|
|
33
|
+
auto_match = AUTO_OPEN.match(lines[i].strip())
|
|
34
|
+
manual_match = MANUAL_OPEN.match(lines[i].strip())
|
|
35
|
+
if auto_match:
|
|
36
|
+
name = auto_match.group(1)
|
|
37
|
+
start = i
|
|
38
|
+
content_lines: list[str] = []
|
|
39
|
+
i += 1
|
|
40
|
+
while i < len(lines) and not AUTO_CLOSE.match(lines[i].strip()):
|
|
41
|
+
content_lines.append(lines[i])
|
|
42
|
+
i += 1
|
|
43
|
+
sections.append(
|
|
44
|
+
Section(
|
|
45
|
+
name=name,
|
|
46
|
+
is_auto=True,
|
|
47
|
+
content="".join(content_lines),
|
|
48
|
+
start=start,
|
|
49
|
+
end=i,
|
|
50
|
+
)
|
|
51
|
+
)
|
|
52
|
+
elif manual_match:
|
|
53
|
+
start = i
|
|
54
|
+
content_lines = []
|
|
55
|
+
i += 1
|
|
56
|
+
while i < len(lines) and not MANUAL_CLOSE.match(lines[i].strip()):
|
|
57
|
+
content_lines.append(lines[i])
|
|
58
|
+
i += 1
|
|
59
|
+
sections.append(
|
|
60
|
+
Section(
|
|
61
|
+
name="MANUAL",
|
|
62
|
+
is_auto=False,
|
|
63
|
+
content="".join(content_lines),
|
|
64
|
+
start=start,
|
|
65
|
+
end=i,
|
|
66
|
+
)
|
|
67
|
+
)
|
|
68
|
+
i += 1
|
|
69
|
+
return sections
|
|
70
|
+
|
|
71
|
+
def auto_section_names(self, text: str) -> list[str]:
|
|
72
|
+
return [s.name for s in self.parse_sections(text) if s.is_auto]
|
|
73
|
+
|
|
74
|
+
def patch_section(self, text: str, section_name: str, new_content: str) -> str:
|
|
75
|
+
open_marker = f"<!-- AUTO:{section_name} -->"
|
|
76
|
+
close_marker = f"<!-- /AUTO:{section_name} -->"
|
|
77
|
+
if open_marker not in text:
|
|
78
|
+
return text
|
|
79
|
+
before, rest = text.split(open_marker, 1)
|
|
80
|
+
_, after = rest.split(close_marker, 1)
|
|
81
|
+
return f"{before}{open_marker}\n{new_content}{close_marker}{after}"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class TemplateLoader:
|
|
85
|
+
def __init__(self, source: str = "builtin", local_path: Path | None = None) -> None:
|
|
86
|
+
self.source = source
|
|
87
|
+
self.local_path = local_path
|
|
88
|
+
|
|
89
|
+
def load(self, template_name: str) -> str:
|
|
90
|
+
template_path = f"{template_name}.md.j2"
|
|
91
|
+
if self.source == "local" and self.local_path:
|
|
92
|
+
full_path = self.local_path / template_path
|
|
93
|
+
if full_path.exists():
|
|
94
|
+
return full_path.read_text()
|
|
95
|
+
builtin_path = BUILTIN_TEMPLATES_DIR / template_path
|
|
96
|
+
if not builtin_path.exists():
|
|
97
|
+
raise FileNotFoundError(f"Template not found: {template_name}")
|
|
98
|
+
return builtin_path.read_text()
|
|
99
|
+
|
|
100
|
+
def render(self, template_name: str, context: dict[str, str]) -> str:
|
|
101
|
+
template_content = self.load(template_name)
|
|
102
|
+
env = Environment(autoescape=select_autoescape([]))
|
|
103
|
+
tmpl = env.from_string(template_content)
|
|
104
|
+
return tmpl.render(**context)
|
|
File without changes
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from ai_docgen.registry import Registry
|
|
4
|
+
|
|
5
|
+
HTML_TEMPLATE = """\
|
|
6
|
+
<!DOCTYPE html>
|
|
7
|
+
<html lang="en">
|
|
8
|
+
<head>
|
|
9
|
+
<meta charset="UTF-8">
|
|
10
|
+
<title>ai-docgen — Project Status</title>
|
|
11
|
+
<style>
|
|
12
|
+
body {{
|
|
13
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
14
|
+
max-width: 900px; margin: 40px auto; padding: 0 20px; color: #333;
|
|
15
|
+
}}
|
|
16
|
+
h1 {{ color: #1a1a2e; }}
|
|
17
|
+
table {{ width: 100%; border-collapse: collapse; margin-top: 20px; }}
|
|
18
|
+
th {{ background: #1a1a2e; color: white; padding: 10px 14px; text-align: left; }}
|
|
19
|
+
td {{ padding: 10px 14px; border-bottom: 1px solid #eee; }}
|
|
20
|
+
tr:hover td {{ background: #f9f9f9; }}
|
|
21
|
+
.synced {{ color: #22863a; font-weight: 600; }}
|
|
22
|
+
.stale {{ color: #cb2431; font-weight: 600; }}
|
|
23
|
+
footer {{ margin-top: 40px; color: #999; font-size: 0.85em; }}
|
|
24
|
+
</style>
|
|
25
|
+
</head>
|
|
26
|
+
<body>
|
|
27
|
+
<h1>ai-docgen — Project Status</h1>
|
|
28
|
+
<table>
|
|
29
|
+
<thead><tr><th>Project</th><th>Status</th><th>Last Updated</th><th>Documents</th></tr></thead>
|
|
30
|
+
<tbody>
|
|
31
|
+
{rows}
|
|
32
|
+
</tbody>
|
|
33
|
+
</table>
|
|
34
|
+
<footer>Generated by ai-docgen</footer>
|
|
35
|
+
</body>
|
|
36
|
+
</html>
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def render_html_report(registry_path: Path, output_path: Path) -> None:
|
|
41
|
+
registry = Registry(registry_path)
|
|
42
|
+
projects = registry.all_projects()
|
|
43
|
+
rows: list[str] = []
|
|
44
|
+
for project in projects:
|
|
45
|
+
doc_links = ", ".join(f'<a href="{d.target}">{d.target}</a>' for d in project.documents)
|
|
46
|
+
rows.append(
|
|
47
|
+
f" <tr>"
|
|
48
|
+
f"<td>{project.name}</td>"
|
|
49
|
+
f'<td class="{project.status}">{project.status}</td>'
|
|
50
|
+
f"<td>{project.last_updated}</td>"
|
|
51
|
+
f"<td>{doc_links}</td>"
|
|
52
|
+
f"</tr>"
|
|
53
|
+
)
|
|
54
|
+
output_path.write_text(HTML_TEMPLATE.format(rows="\n".join(rows)))
|