github-agent-cli 1.2.1__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.
- ghagent.py +511 -0
- ghalib/__init__.py +5 -0
- ghalib/assets.py +475 -0
- ghalib/audit.py +649 -0
- ghalib/changelog.py +473 -0
- ghalib/config.py +52 -0
- ghalib/install.py +189 -0
- ghalib/mdcheck.py +424 -0
- ghalib/meta.py +274 -0
- ghalib/report.py +90 -0
- ghalib/util.py +522 -0
- ghalib/wiki.py +512 -0
- github_agent_cli-1.2.1.dist-info/METADATA +431 -0
- github_agent_cli-1.2.1.dist-info/RECORD +17 -0
- github_agent_cli-1.2.1.dist-info/WHEEL +4 -0
- github_agent_cli-1.2.1.dist-info/entry_points.txt +2 -0
- github_agent_cli-1.2.1.dist-info/licenses/LICENSE +21 -0
ghalib/wiki.py
ADDED
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
"""GitHub wiki pipeline: manifest -> built wiki tree -> validated -> published.
|
|
2
|
+
|
|
3
|
+
Design constraints that came out of the platform:
|
|
4
|
+
* the wiki is a flat namespace stored in a separate git repository (`<repo>.wiki.git`);
|
|
5
|
+
* pages are addressed by filename without the `.md` suffix;
|
|
6
|
+
* `_Sidebar.md` and `_Footer.md` are the only navigation files;
|
|
7
|
+
* a relative image path cannot work, because the wiki repository has no access to the
|
|
8
|
+
main repository tree — images must be referenced by an absolute URL.
|
|
9
|
+
|
|
10
|
+
Therefore `wiki build` rewrites every link and refuses to build silently-wrong pages:
|
|
11
|
+
a page that is missing from the manifest, a link to a non-existent page, or an image
|
|
12
|
+
that only exists inside the wiki source is an error, not a warning.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import re
|
|
19
|
+
import shutil
|
|
20
|
+
import tempfile
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from .util import (
|
|
25
|
+
FENCE_RE,
|
|
26
|
+
IMAGE_RE,
|
|
27
|
+
INLINE_LINK_RE,
|
|
28
|
+
MARKDOWN_SUFFIXES,
|
|
29
|
+
Report,
|
|
30
|
+
git,
|
|
31
|
+
map_outside_code,
|
|
32
|
+
read_text,
|
|
33
|
+
rel,
|
|
34
|
+
scan_structure,
|
|
35
|
+
write_text,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
WIKI_URL_TEMPLATE = "https://github.com/{slug}/wiki"
|
|
39
|
+
REPO_BLOB_TEMPLATE = "https://github.com/{slug}/blob/{branch}/{path}"
|
|
40
|
+
REPO_RAW_TEMPLATE = "https://github.com/{slug}/raw/{branch}/{path}"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class WikiManifest:
|
|
45
|
+
slug: str
|
|
46
|
+
branch: str = "main"
|
|
47
|
+
title: str = ""
|
|
48
|
+
footer: str = ""
|
|
49
|
+
groups: list[dict] = field(default_factory=list)
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def pages(self) -> list[str]:
|
|
53
|
+
ordered: list[str] = []
|
|
54
|
+
for group in self.groups:
|
|
55
|
+
for page in group.get("pages", []):
|
|
56
|
+
if page not in ordered:
|
|
57
|
+
ordered.append(page)
|
|
58
|
+
return ordered
|
|
59
|
+
|
|
60
|
+
def group_of(self, page: str) -> str:
|
|
61
|
+
for group in self.groups:
|
|
62
|
+
if page in group.get("pages", []):
|
|
63
|
+
return group.get("title", "")
|
|
64
|
+
return ""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def load_manifest(path: Path) -> WikiManifest:
|
|
68
|
+
data = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
69
|
+
return WikiManifest(
|
|
70
|
+
slug=data.get("repo", ""),
|
|
71
|
+
branch=data.get("branch", "main"),
|
|
72
|
+
title=data.get("title", ""),
|
|
73
|
+
footer=data.get("footer", ""),
|
|
74
|
+
groups=data.get("groups", []),
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def page_id(filename: str) -> str:
|
|
79
|
+
return filename[:-3] if filename.lower().endswith(".md") else filename
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _resolve_inside(candidate: Path, root: Path) -> str | None:
|
|
83
|
+
"""Resolve a link target relative to the page, but only inside the repository."""
|
|
84
|
+
try:
|
|
85
|
+
resolved = candidate.resolve()
|
|
86
|
+
return resolved.relative_to(Path(root).resolve()).as_posix()
|
|
87
|
+
except (ValueError, OSError):
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# --------------------------------------------------------------------------
|
|
92
|
+
# build
|
|
93
|
+
# --------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
def build(root: Path, *, wiki_dir: str = "wiki", manifest_path: str = "wiki/wiki.json",
|
|
96
|
+
out_dir: Path | None = None, report: Report | None = None) -> Report:
|
|
97
|
+
root = Path(root)
|
|
98
|
+
report = report or Report(title="ghagent wiki build", root=str(root))
|
|
99
|
+
source = root / wiki_dir
|
|
100
|
+
manifest_file = root / manifest_path
|
|
101
|
+
|
|
102
|
+
if not source.is_dir():
|
|
103
|
+
report.error("wiki.source", f"wiki source directory is missing: {wiki_dir}")
|
|
104
|
+
return report
|
|
105
|
+
if not manifest_file.exists():
|
|
106
|
+
report.error("wiki.manifest", f"manifest is missing: {manifest_path}",
|
|
107
|
+
hint="copy reference/wiki-manifest-template.json and list every page")
|
|
108
|
+
return report
|
|
109
|
+
|
|
110
|
+
manifest = load_manifest(manifest_file)
|
|
111
|
+
if not manifest.slug:
|
|
112
|
+
report.warn("wiki.slug", "manifest has no `repo` field; absolute links will not be generated")
|
|
113
|
+
if not manifest.title:
|
|
114
|
+
report.warn("wiki.title", "manifest has no `title`; the sidebar header will be empty")
|
|
115
|
+
|
|
116
|
+
on_disk = sorted(p.name for p in source.glob("*.md")
|
|
117
|
+
if not p.name.startswith("_") and p.suffix.lower() in MARKDOWN_SUFFIXES)
|
|
118
|
+
listed = manifest.pages
|
|
119
|
+
for page in listed:
|
|
120
|
+
if page not in on_disk:
|
|
121
|
+
report.error("wiki.page-missing", f"manifest lists {page}, which is not in {wiki_dir}/")
|
|
122
|
+
for page in on_disk:
|
|
123
|
+
if page not in listed:
|
|
124
|
+
report.error("wiki.page-unlisted", f"{page} exists but is not listed in the manifest",
|
|
125
|
+
hint="an unlisted page is invisible in the sidebar; list it or delete it")
|
|
126
|
+
if "Home.md" not in on_disk:
|
|
127
|
+
report.error("wiki.home", "Home.md is missing", hint="the wiki landing page must be Home.md")
|
|
128
|
+
if listed and listed[0] != "Home.md" and "Home.md" in listed:
|
|
129
|
+
report.info("wiki.order", "Home.md is not the first manifest page (it is linked explicitly anyway)")
|
|
130
|
+
|
|
131
|
+
out = Path(out_dir) if out_dir else root / ".ghagent" / "wiki"
|
|
132
|
+
if out.exists():
|
|
133
|
+
shutil.rmtree(out)
|
|
134
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
135
|
+
|
|
136
|
+
built: list[str] = []
|
|
137
|
+
for filename in on_disk:
|
|
138
|
+
text, _ = read_text(source / filename)
|
|
139
|
+
rewritten = rewrite_page(text, source / filename, root, manifest, report)
|
|
140
|
+
write_text(out / filename, rewritten)
|
|
141
|
+
built.append(filename)
|
|
142
|
+
|
|
143
|
+
sidebar = render_sidebar(manifest, on_disk)
|
|
144
|
+
footer = render_footer(manifest, report)
|
|
145
|
+
write_text(out / "_Sidebar.md", sidebar)
|
|
146
|
+
write_text(out / "_Footer.md", footer)
|
|
147
|
+
|
|
148
|
+
report.data.update({
|
|
149
|
+
"pages": len(built),
|
|
150
|
+
"out": str(out),
|
|
151
|
+
"slug": manifest.slug,
|
|
152
|
+
"branch": manifest.branch,
|
|
153
|
+
"groups": len(manifest.groups),
|
|
154
|
+
})
|
|
155
|
+
if not built:
|
|
156
|
+
report.error("wiki.empty", "no pages were built")
|
|
157
|
+
return report
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def rewrite_page(text: str, page_path: Path, root: Path, manifest: WikiManifest, report: Report) -> str:
|
|
161
|
+
"""Rewrite links and images for the wiki namespace, line by line, outside code fences."""
|
|
162
|
+
page_name = page_path.name
|
|
163
|
+
pages = set(manifest.pages)
|
|
164
|
+
lines = text.split("\n")
|
|
165
|
+
output: list[str] = []
|
|
166
|
+
in_fence = False
|
|
167
|
+
|
|
168
|
+
for index, raw in enumerate(lines, start=1):
|
|
169
|
+
if FENCE_RE.match(raw):
|
|
170
|
+
in_fence = not in_fence
|
|
171
|
+
output.append(raw)
|
|
172
|
+
continue
|
|
173
|
+
if in_fence:
|
|
174
|
+
output.append(raw)
|
|
175
|
+
continue
|
|
176
|
+
|
|
177
|
+
def replace_image(match: re.Match) -> str:
|
|
178
|
+
alt, target = match.group(1), match.group(2)
|
|
179
|
+
if re.match(r"^(https?:)?//", target) or target.startswith("data:"):
|
|
180
|
+
return match.group(0)
|
|
181
|
+
report.error("wiki.image-relative", f"relative image in {page_name}: {target}",
|
|
182
|
+
path=page_name, line=index,
|
|
183
|
+
hint="the wiki cannot read the main repository tree: commit the image and use an absolute raw URL")
|
|
184
|
+
return match.group(0)
|
|
185
|
+
|
|
186
|
+
def replace_link(match: re.Match) -> str:
|
|
187
|
+
label, target = match.group(1), match.group(2)
|
|
188
|
+
if re.match(r"^(https?://|mailto:|tel:|#)", target):
|
|
189
|
+
if re.match(r"^https?://github\.com/[^/]+/[^/]+/(blob|tree)/", target):
|
|
190
|
+
report.info("wiki.repo-link", f"link goes to the repository tree: {target}",
|
|
191
|
+
path=page_name, line=index)
|
|
192
|
+
return match.group(0)
|
|
193
|
+
path_part, _, anchor = target.partition("#")
|
|
194
|
+
suffix = f"#{anchor}" if anchor else ""
|
|
195
|
+
if path_part.lower().endswith(".md"):
|
|
196
|
+
candidate = (page_path.parent / path_part).name
|
|
197
|
+
if candidate in pages and (page_path.parent / path_part).exists():
|
|
198
|
+
return f"[{label}]({page_id(candidate)}{suffix})"
|
|
199
|
+
resolved = _resolve_inside(page_path.parent / path_part, root)
|
|
200
|
+
if resolved is not None and (root / resolved).exists():
|
|
201
|
+
return f"[{label}]({REPO_BLOB_TEMPLATE.format(slug=manifest.slug, branch=manifest.branch, path=resolved)}{suffix})"
|
|
202
|
+
if resolved is None:
|
|
203
|
+
report.error("wiki.link-outside", f"link leaves the repository: {target}",
|
|
204
|
+
path=page_name, line=index)
|
|
205
|
+
else:
|
|
206
|
+
report.error("wiki.link-broken", f"link target does not exist: {target}",
|
|
207
|
+
path=page_name, line=index)
|
|
208
|
+
return match.group(0)
|
|
209
|
+
if path_part and not path_part.startswith("#"):
|
|
210
|
+
resolved = _resolve_inside(page_path.parent / path_part, root)
|
|
211
|
+
if resolved is not None and (root / resolved).exists():
|
|
212
|
+
report.info("wiki.repo-relative", f"link to a repository path stays relative: {target}",
|
|
213
|
+
path=page_name, line=index,
|
|
214
|
+
hint="use an absolute GitHub URL, or leave it if the wiki should not link there")
|
|
215
|
+
return match.group(0)
|
|
216
|
+
|
|
217
|
+
line = map_outside_code(raw, lambda part: IMAGE_RE.sub(replace_image, part))
|
|
218
|
+
# INLINE_LINK_RE carries a negative lookbehind for '!', so image syntax is not touched here
|
|
219
|
+
if "[" in line:
|
|
220
|
+
line = map_outside_code(line, lambda part: INLINE_LINK_RE.sub(replace_link, part))
|
|
221
|
+
output.append(line)
|
|
222
|
+
|
|
223
|
+
return "\n".join(output)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def render_sidebar(manifest: WikiManifest, on_disk: list[str]) -> str:
|
|
227
|
+
lines: list[str] = []
|
|
228
|
+
if manifest.title:
|
|
229
|
+
lines.append(f"# {manifest.title}")
|
|
230
|
+
lines.append("")
|
|
231
|
+
lines.append("**[Home](Home)**")
|
|
232
|
+
for group in manifest.groups:
|
|
233
|
+
title = group.get("title", "").strip()
|
|
234
|
+
pages = [p for p in group.get("pages", []) if p in on_disk]
|
|
235
|
+
if not title or not pages:
|
|
236
|
+
continue
|
|
237
|
+
lines.append("")
|
|
238
|
+
lines.append(f"### {title}")
|
|
239
|
+
lines.append("")
|
|
240
|
+
for page in pages:
|
|
241
|
+
label = group.get("labels", {}).get(page) if isinstance(group.get("labels"), dict) else None
|
|
242
|
+
if not label:
|
|
243
|
+
label = page_id(page)
|
|
244
|
+
label = re.sub(r"^(\d+)-", r"\1 · ", label).replace("-", " ")
|
|
245
|
+
lines.append(f"- [{label}]({page_id(page)})")
|
|
246
|
+
if manifest.slug:
|
|
247
|
+
lines.append("")
|
|
248
|
+
lines.append("### Ссылки")
|
|
249
|
+
lines.append("")
|
|
250
|
+
lines.append(f"- [Репозиторий](https://github.com/{manifest.slug})")
|
|
251
|
+
lines.append(f"- [Релизы](https://github.com/{manifest.slug}/releases)")
|
|
252
|
+
lines.append(f"- [Issues](https://github.com/{manifest.slug}/issues)")
|
|
253
|
+
return "\n".join(lines).strip() + "\n"
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def render_footer(manifest: WikiManifest, report: Report) -> str:
|
|
257
|
+
if not manifest.slug:
|
|
258
|
+
report.warn("wiki.footer", "footer is generated without repository links because `repo` is empty")
|
|
259
|
+
return "---\n*Published by ghagent.*\n"
|
|
260
|
+
parts = [f"[репозиторий](https://github.com/{manifest.slug})",
|
|
261
|
+
f"[релизы](https://github.com/{manifest.slug}/releases)"]
|
|
262
|
+
if manifest.footer:
|
|
263
|
+
parts.append(manifest.footer)
|
|
264
|
+
return "---\n*" + " · ".join(parts) + "*\n"
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
# --------------------------------------------------------------------------
|
|
268
|
+
# verify (built tree)
|
|
269
|
+
# --------------------------------------------------------------------------
|
|
270
|
+
|
|
271
|
+
def verify(build_dir: Path, *, report: Report | None = None) -> Report:
|
|
272
|
+
build_dir = Path(build_dir)
|
|
273
|
+
report = report or Report(title="ghagent wiki verify", root=str(build_dir))
|
|
274
|
+
if not build_dir.is_dir():
|
|
275
|
+
report.error("wiki.build-missing", f"no built wiki at {build_dir}", hint="run `ghagent wiki build` first")
|
|
276
|
+
return report
|
|
277
|
+
|
|
278
|
+
pages = {p.stem for p in build_dir.glob("*.md") if not p.name.startswith("_")}
|
|
279
|
+
if not pages:
|
|
280
|
+
report.error("wiki.pages", "no pages in the built wiki; nothing was examined")
|
|
281
|
+
return report
|
|
282
|
+
for required in ("Home", "_Sidebar", "_Footer"):
|
|
283
|
+
if not (build_dir / f"{required}.md").exists():
|
|
284
|
+
report.error("wiki.required", f"{required}.md is missing from the built wiki")
|
|
285
|
+
|
|
286
|
+
internal_links: dict[str, list[tuple[int, str]]] = {}
|
|
287
|
+
for path in sorted(build_dir.glob("*.md")):
|
|
288
|
+
text, meta = read_text(path)
|
|
289
|
+
if meta.has_bom:
|
|
290
|
+
report.error("doc.bom", "BOM in a wiki page", path=path.name)
|
|
291
|
+
if meta.has_crlf:
|
|
292
|
+
report.error("doc.crlf", "CRLF in a wiki page", path=path.name)
|
|
293
|
+
headings, fences, links, _ = scan_structure(text)
|
|
294
|
+
h1 = [h for h in headings if h.level == 1]
|
|
295
|
+
if not h1 and path.name not in {"_Sidebar.md", "_Footer.md"}:
|
|
296
|
+
report.warn("wiki.no-h1", "page has no H1", path=path.name)
|
|
297
|
+
for fence in fences:
|
|
298
|
+
if fence.closed_at is None:
|
|
299
|
+
report.error("md.fence", "unclosed code fence", path=path.name, line=fence.line)
|
|
300
|
+
for link in links:
|
|
301
|
+
target = link.target
|
|
302
|
+
if link.is_image and not target.startswith(("http://", "https://", "data:")):
|
|
303
|
+
report.error("wiki.image-relative", f"relative image {target}", path=path.name, line=link.line)
|
|
304
|
+
if re.match(r"^(https?:|mailto:|tel:)", target):
|
|
305
|
+
continue
|
|
306
|
+
path_part = target.split("#", 1)[0]
|
|
307
|
+
if path_part.endswith(".md"):
|
|
308
|
+
report.error("wiki.md-suffix", f"internal link keeps the .md suffix: {target}",
|
|
309
|
+
path=path.name, line=link.line, hint="wiki pages are addressed without .md")
|
|
310
|
+
path_part = path_part[:-3]
|
|
311
|
+
if path_part and path_part not in pages:
|
|
312
|
+
report.error("wiki.link-target", f"link points to a non-existent page: {target}",
|
|
313
|
+
path=path.name, line=link.line)
|
|
314
|
+
if path_part:
|
|
315
|
+
internal_links.setdefault(path_part, []).append((link.line, path.name))
|
|
316
|
+
|
|
317
|
+
sidebar = build_dir / "_Sidebar.md"
|
|
318
|
+
if sidebar.exists():
|
|
319
|
+
text, _ = read_text(sidebar)
|
|
320
|
+
_, _, links, _ = scan_structure(text)
|
|
321
|
+
linked = {link.target.split("#", 1)[0] for link in links if not link.target.startswith(("http", "mailto", "#"))}
|
|
322
|
+
listed = {target[:-3] if target.endswith(".md") else target for target in linked}
|
|
323
|
+
for page in sorted(pages - listed - {"Home"}):
|
|
324
|
+
report.warn("wiki.page-orphan", f"page {page} is not reachable from the sidebar")
|
|
325
|
+
for target in sorted(linked):
|
|
326
|
+
clean = target[:-3] if target.endswith(".md") else target
|
|
327
|
+
if clean and clean not in pages and not target.startswith("http"):
|
|
328
|
+
report.error("wiki.sidebar-target", f"sidebar links to a non-existent page: {target}")
|
|
329
|
+
|
|
330
|
+
footer = build_dir / "_Footer.md"
|
|
331
|
+
if footer.exists():
|
|
332
|
+
text, _ = read_text(footer)
|
|
333
|
+
_, _, links, _ = scan_structure(text)
|
|
334
|
+
for link in links:
|
|
335
|
+
if not link.target.startswith(("http://", "https://")):
|
|
336
|
+
report.warn("wiki.footer-relative", f"footer has a relative link: {link.target}",
|
|
337
|
+
hint="the footer is rendered on every page; absolute links only")
|
|
338
|
+
|
|
339
|
+
report.data.update({"pages": len(pages), "links_internal": sum(len(v) for v in internal_links.values())})
|
|
340
|
+
return report
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
# --------------------------------------------------------------------------
|
|
344
|
+
# publish
|
|
345
|
+
# --------------------------------------------------------------------------
|
|
346
|
+
|
|
347
|
+
def wiki_remote(slug: str) -> str:
|
|
348
|
+
return f"https://github.com/{slug}.wiki.git"
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def publish(
|
|
352
|
+
slug: str,
|
|
353
|
+
build_dir: Path,
|
|
354
|
+
*,
|
|
355
|
+
push: bool = False,
|
|
356
|
+
prune: bool = False,
|
|
357
|
+
message: str = "",
|
|
358
|
+
author_name: str = "",
|
|
359
|
+
author_email: str = "",
|
|
360
|
+
work_dir: Path | None = None,
|
|
361
|
+
remote: str = "",
|
|
362
|
+
report: Report | None = None,
|
|
363
|
+
) -> Report:
|
|
364
|
+
"""Publish a built wiki tree. Dry-run unless `push=True`; never deletes unless `prune=True`."""
|
|
365
|
+
build_dir = Path(build_dir)
|
|
366
|
+
report = report or Report(title=f"ghagent wiki publish {slug}", root=str(build_dir))
|
|
367
|
+
if not build_dir.is_dir():
|
|
368
|
+
report.error("wiki.build-missing", f"no built wiki at {build_dir}")
|
|
369
|
+
return report
|
|
370
|
+
files = sorted(p.name for p in build_dir.glob("*.md"))
|
|
371
|
+
if not files:
|
|
372
|
+
report.error("wiki.empty", "nothing to publish: the build directory has no markdown files")
|
|
373
|
+
return report
|
|
374
|
+
if "Home.md" not in files:
|
|
375
|
+
report.error("wiki.home", "refusing to publish without Home.md")
|
|
376
|
+
return report
|
|
377
|
+
|
|
378
|
+
temp_created = work_dir is None
|
|
379
|
+
work_dir = Path(work_dir) if work_dir else Path(tempfile.mkdtemp(prefix="ghagent-wiki-"))
|
|
380
|
+
clone_dir = work_dir / "wiki"
|
|
381
|
+
remote = remote or wiki_remote(slug)
|
|
382
|
+
|
|
383
|
+
if clone_dir.exists():
|
|
384
|
+
shutil.rmtree(clone_dir)
|
|
385
|
+
work_dir.mkdir(parents=True, exist_ok=True)
|
|
386
|
+
rc, _, err = git(["clone", "--quiet", remote, str(clone_dir)], work_dir)
|
|
387
|
+
if rc != 0:
|
|
388
|
+
probe_rc, _, _ = git(["ls-remote", "--quiet", remote], work_dir)
|
|
389
|
+
if probe_rc != 0:
|
|
390
|
+
report.error(
|
|
391
|
+
"wiki.remote-missing",
|
|
392
|
+
f"the wiki repository does not exist: {remote}",
|
|
393
|
+
hint=("GitHub creates <repo>.wiki.git only when the first page is saved in the web "
|
|
394
|
+
"interface (Wiki → New page); a push cannot create it. Create one page, then publish."),
|
|
395
|
+
)
|
|
396
|
+
return report
|
|
397
|
+
clone_dir.mkdir(parents=True, exist_ok=True)
|
|
398
|
+
git(["init", "--quiet"], clone_dir)
|
|
399
|
+
git(["remote", "add", "origin", remote], clone_dir)
|
|
400
|
+
report.info("wiki.remote-empty", f"remote {remote} is initialised but has no commits yet")
|
|
401
|
+
if err.strip():
|
|
402
|
+
report.info("wiki.clone-note", err.strip().splitlines()[0])
|
|
403
|
+
|
|
404
|
+
changed_added: list[str] = []
|
|
405
|
+
changed_modified: list[str] = []
|
|
406
|
+
unchanged: list[str] = []
|
|
407
|
+
for name in files:
|
|
408
|
+
source = build_dir / name
|
|
409
|
+
target = clone_dir / name
|
|
410
|
+
payload, _ = read_text(source)
|
|
411
|
+
if target.exists():
|
|
412
|
+
current, _ = read_text(target)
|
|
413
|
+
if current == payload:
|
|
414
|
+
unchanged.append(name)
|
|
415
|
+
continue
|
|
416
|
+
changed_modified.append(name)
|
|
417
|
+
else:
|
|
418
|
+
changed_added.append(name)
|
|
419
|
+
write_text(target, payload)
|
|
420
|
+
|
|
421
|
+
existing = sorted(p.name for p in clone_dir.glob("*.md"))
|
|
422
|
+
removed = [name for name in existing if name not in files]
|
|
423
|
+
if removed and not prune:
|
|
424
|
+
report.info("wiki.kept", f"{len(removed)} page(s) exist on the remote and are kept: {', '.join(removed[:6])}",
|
|
425
|
+
hint="pass --prune to delete pages that are no longer in the manifest")
|
|
426
|
+
if removed and prune:
|
|
427
|
+
for name in removed:
|
|
428
|
+
(clone_dir / name).unlink()
|
|
429
|
+
|
|
430
|
+
report.data.update({
|
|
431
|
+
"added": changed_added,
|
|
432
|
+
"modified": changed_modified,
|
|
433
|
+
"unchanged": len(unchanged),
|
|
434
|
+
"deleted": removed if prune else [],
|
|
435
|
+
"kept": removed if not prune else [],
|
|
436
|
+
"pushed": False,
|
|
437
|
+
"remote": remote,
|
|
438
|
+
})
|
|
439
|
+
|
|
440
|
+
if not changed_added and not changed_modified and not (removed and prune):
|
|
441
|
+
report.info("wiki.no-change", "the remote already matches the built tree; nothing to commit")
|
|
442
|
+
return report
|
|
443
|
+
|
|
444
|
+
git(["add", "-A"], clone_dir)
|
|
445
|
+
identity: list[str] = []
|
|
446
|
+
if author_name:
|
|
447
|
+
identity += ["-c", f"user.name={author_name}"]
|
|
448
|
+
if author_email:
|
|
449
|
+
identity += ["-c", f"user.email={author_email}"]
|
|
450
|
+
summary = message or f"docs(wiki): publish {len(changed_added)} new and {len(changed_modified)} updated page(s)"
|
|
451
|
+
rc, out, err = git([*identity, "commit", "--quiet", "-m", summary], clone_dir)
|
|
452
|
+
if rc != 0:
|
|
453
|
+
report.error("wiki.commit", f"commit failed: {err.strip() or out.strip()}")
|
|
454
|
+
return report
|
|
455
|
+
rc, out, _ = git(["rev-parse", "--short", "HEAD"], clone_dir)
|
|
456
|
+
commit = out.strip()
|
|
457
|
+
report.data["commit"] = commit
|
|
458
|
+
|
|
459
|
+
if not push:
|
|
460
|
+
report.info("wiki.dry-run", f"would push commit {commit} to {remote}", hint="pass --push to publish")
|
|
461
|
+
report.data["pushed"] = False
|
|
462
|
+
return report
|
|
463
|
+
|
|
464
|
+
rc, out, err = git(["push", "--quiet", "origin", "HEAD"], clone_dir)
|
|
465
|
+
if rc != 0:
|
|
466
|
+
report.error("wiki.push", f"push failed: {err.strip() or out.strip()}")
|
|
467
|
+
return report
|
|
468
|
+
report.data["pushed"] = True
|
|
469
|
+
report.data["wiki_url"] = WIKI_URL_TEMPLATE.format(slug=slug)
|
|
470
|
+
if temp_created:
|
|
471
|
+
report.info("wiki.tempdir", f"work tree left at {clone_dir} for inspection")
|
|
472
|
+
return report
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
# --------------------------------------------------------------------------
|
|
476
|
+
# scaffold
|
|
477
|
+
# --------------------------------------------------------------------------
|
|
478
|
+
|
|
479
|
+
def scaffold(root: Path, *, slug: str, title: str, wiki_dir: str = "wiki", from_docs: bool = True,
|
|
480
|
+
report: Report | None = None) -> Report:
|
|
481
|
+
root = Path(root)
|
|
482
|
+
report = report or Report(title="ghagent wiki init", root=str(root))
|
|
483
|
+
target = root / wiki_dir
|
|
484
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
485
|
+
manifest_file = target / "wiki.json"
|
|
486
|
+
if manifest_file.exists():
|
|
487
|
+
report.error("wiki.manifest-exists", f"{manifest_file} already exists; refusing to overwrite")
|
|
488
|
+
return report
|
|
489
|
+
|
|
490
|
+
groups: list[dict] = []
|
|
491
|
+
if from_docs and (root / "docs").is_dir():
|
|
492
|
+
pages = sorted(p.name for p in (root / "docs").glob("*.md"))
|
|
493
|
+
for index, page in enumerate(pages, start=1):
|
|
494
|
+
new_name = f"{index:02d}-{page}"
|
|
495
|
+
source = root / "docs" / page
|
|
496
|
+
text, _ = read_text(source)
|
|
497
|
+
write_text(target / new_name, text)
|
|
498
|
+
groups.append({"title": "Документация", "pages": [new_name]})
|
|
499
|
+
report.info("wiki.scaffold-docs", f"copied {len(pages)} page(s) from docs/")
|
|
500
|
+
if not (target / "Home.md").exists():
|
|
501
|
+
write_text(target / "Home.md", f"# {title}\n\nКраткое описание проекта и навигация по вики.\n\n"
|
|
502
|
+
"Начните с [первой страницы](01-Getting-Started).\n")
|
|
503
|
+
manifest = {
|
|
504
|
+
"repo": slug,
|
|
505
|
+
"branch": "main",
|
|
506
|
+
"title": title,
|
|
507
|
+
"footer": f"{title} · MIT",
|
|
508
|
+
"groups": [{"title": "Начало", "pages": ["Home.md"]}, *groups],
|
|
509
|
+
}
|
|
510
|
+
write_text(manifest_file, json.dumps(manifest, ensure_ascii=False, indent=2) + "\n")
|
|
511
|
+
report.data["created"] = rel(manifest_file, root)
|
|
512
|
+
return report
|