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.
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