pyrepowiki-cli 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.
- pyrepowiki_cli-0.1.0.dist-info/METADATA +875 -0
- pyrepowiki_cli-0.1.0.dist-info/RECORD +28 -0
- pyrepowiki_cli-0.1.0.dist-info/WHEEL +4 -0
- pyrepowiki_cli-0.1.0.dist-info/entry_points.txt +2 -0
- repowiki/__init__.py +3 -0
- repowiki/cli.py +43 -0
- repowiki/services/__init__.py +1 -0
- repowiki/services/codewiki/__init__.py +5 -0
- repowiki/services/codewiki/boq.py +57 -0
- repowiki/services/codewiki/cli.py +315 -0
- repowiki/services/codewiki/client.py +237 -0
- repowiki/services/codewiki/wiki.py +162 -0
- repowiki/services/deepwiki/__init__.py +5 -0
- repowiki/services/deepwiki/cli.py +851 -0
- repowiki/services/deepwiki/client.py +164 -0
- repowiki/services/deepwiki/codemap.py +74 -0
- repowiki/services/deepwiki/devin.py +355 -0
- repowiki/services/zread/__init__.py +5 -0
- repowiki/services/zread/cli.py +653 -0
- repowiki/services/zread/client.py +414 -0
- repowiki/services/zread/flight.py +116 -0
- repowiki/shared/__init__.py +1 -0
- repowiki/shared/async_.py +15 -0
- repowiki/shared/export.py +36 -0
- repowiki/shared/model.py +29 -0
- repowiki/shared/output.py +204 -0
- repowiki/shared/repo.py +45 -0
- repowiki/shared/save.py +27 -0
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""CodeWiki client: bootstrap + Google batchexecute RPC."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import ssl
|
|
9
|
+
import time
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
from repowiki.services.codewiki import boq
|
|
16
|
+
from repowiki.services.codewiki.wiki import Wiki, parse as parse_wiki
|
|
17
|
+
|
|
18
|
+
SITE = "https://codewiki.google"
|
|
19
|
+
ENDPOINT = f"{SITE}/_/BoqAngularSdlcAgentsUi/data/batchexecute"
|
|
20
|
+
USER_AGENT = (
|
|
21
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
|
|
22
|
+
"(KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36"
|
|
23
|
+
)
|
|
24
|
+
CACHE_TTL_SECS = 6 * 60 * 60
|
|
25
|
+
|
|
26
|
+
FALLBACK_BL = "boq_sdlc-agents-ui_20260504.02_p0"
|
|
27
|
+
FALLBACK_SID = "-8491411211174446345"
|
|
28
|
+
|
|
29
|
+
_WIZ_RE = re.compile(r"WIZ_global_data\s*=\s*(\{[\s\S]*?\});")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class CodeWikiError(Exception):
|
|
33
|
+
"""Base error for CodeWiki."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class CodeWikiConnectionError(CodeWikiError):
|
|
37
|
+
"""Failed to reach codewiki.google."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class _Bootstrap:
|
|
42
|
+
bl: str
|
|
43
|
+
sid: str
|
|
44
|
+
fetched_at: float = 0.0
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def extract_bootstrap(html: str) -> _Bootstrap:
|
|
48
|
+
match = _WIZ_RE.search(html)
|
|
49
|
+
if match is None:
|
|
50
|
+
raise ValueError("WIZ_global_data not found in page HTML")
|
|
51
|
+
try:
|
|
52
|
+
data = json.loads(match.group(1))
|
|
53
|
+
except json.JSONDecodeError as exc:
|
|
54
|
+
raise ValueError("WIZ_global_data is not valid JSON") from exc
|
|
55
|
+
bl = data.get("cfb2h")
|
|
56
|
+
sid = data.get("FdrFJe")
|
|
57
|
+
if not isinstance(bl, str):
|
|
58
|
+
raise ValueError("missing cfb2h (build label) in WIZ_global_data")
|
|
59
|
+
if not isinstance(sid, str):
|
|
60
|
+
raise ValueError("missing FdrFJe (session id) in WIZ_global_data")
|
|
61
|
+
return _Bootstrap(bl=bl, sid=sid, fetched_at=time.time())
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _cache_path() -> Path | None:
|
|
65
|
+
if env := os.environ.get("CODEWIKI_CACHE_DIR"):
|
|
66
|
+
return Path(env) / "bootstrap.json"
|
|
67
|
+
base = os.environ.get("XDG_CACHE_HOME") or (Path.home() / ".cache")
|
|
68
|
+
return Path(base) / "codewiki" / "bootstrap.json"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _read_cache(path: Path) -> _Bootstrap | None:
|
|
72
|
+
try:
|
|
73
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
74
|
+
return _Bootstrap(
|
|
75
|
+
bl=data["bl"], sid=data["sid"], fetched_at=float(data.get("fetched_at", 0))
|
|
76
|
+
)
|
|
77
|
+
except (OSError, ValueError, KeyError, TypeError):
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _write_cache(path: Path | None, bs: _Bootstrap) -> None:
|
|
82
|
+
if path is None:
|
|
83
|
+
return
|
|
84
|
+
try:
|
|
85
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
86
|
+
path.write_text(
|
|
87
|
+
json.dumps({"bl": bs.bl, "sid": bs.sid, "fetched_at": bs.fetched_at}),
|
|
88
|
+
encoding="utf-8",
|
|
89
|
+
)
|
|
90
|
+
except OSError:
|
|
91
|
+
pass
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
async def _fetch_bootstrap(http: httpx.AsyncClient) -> _Bootstrap:
|
|
95
|
+
resp = await http.get(SITE)
|
|
96
|
+
resp.raise_for_status()
|
|
97
|
+
return extract_bootstrap(resp.text)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
async def _load_bootstrap(http: httpx.AsyncClient) -> _Bootstrap:
|
|
101
|
+
path = _cache_path()
|
|
102
|
+
cached = _read_cache(path) if path is not None else None
|
|
103
|
+
if cached is not None and time.time() - cached.fetched_at < CACHE_TTL_SECS:
|
|
104
|
+
return cached
|
|
105
|
+
try:
|
|
106
|
+
fresh = await _fetch_bootstrap(http)
|
|
107
|
+
except Exception:
|
|
108
|
+
return _Bootstrap(bl=FALLBACK_BL, sid=FALLBACK_SID, fetched_at=time.time())
|
|
109
|
+
_write_cache(path, fresh)
|
|
110
|
+
return fresh
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class CodeWikiClient:
|
|
114
|
+
"""Stateless client; the bootstrap pair is cached on disk (6h TTL)."""
|
|
115
|
+
|
|
116
|
+
def __init__(self, transport: httpx.AsyncBaseTransport | None = None) -> None:
|
|
117
|
+
self._transport = transport
|
|
118
|
+
|
|
119
|
+
def _client(self) -> httpx.AsyncClient:
|
|
120
|
+
return httpx.AsyncClient(
|
|
121
|
+
timeout=30.0,
|
|
122
|
+
verify=True,
|
|
123
|
+
headers={"User-Agent": USER_AGENT},
|
|
124
|
+
transport=self._transport,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
async def read_wiki(self, repo: str) -> Wiki:
|
|
128
|
+
async with self._client() as http:
|
|
129
|
+
bs = await _load_bootstrap(http)
|
|
130
|
+
inner = json.dumps([_github_url(repo)])
|
|
131
|
+
payload = await self._call(http, bs, "VSX6ub", repo, inner)
|
|
132
|
+
return parse_wiki(payload)
|
|
133
|
+
|
|
134
|
+
async def ask(self, repo: str, question: str) -> str:
|
|
135
|
+
async with self._client() as http:
|
|
136
|
+
bs = await _load_bootstrap(http)
|
|
137
|
+
inner = json.dumps([[[question, "user"]], [None, _github_url(repo)]])
|
|
138
|
+
payload = await self._call(http, bs, "EgIxfe", repo, inner)
|
|
139
|
+
if isinstance(payload, list) and payload and isinstance(payload[0], str):
|
|
140
|
+
return payload[0]
|
|
141
|
+
raise CodeWikiError("EgIxfe response did not contain an answer string")
|
|
142
|
+
|
|
143
|
+
async def github_head(self, repo: str) -> dict:
|
|
144
|
+
"""Return the GitHub HEAD sha and commit date for ``repo``.
|
|
145
|
+
|
|
146
|
+
Used to compare the wiki's ``commit_sha`` against the live branch to
|
|
147
|
+
detect a stale wiki.
|
|
148
|
+
"""
|
|
149
|
+
owner, name = repo.split("/", 1)
|
|
150
|
+
try:
|
|
151
|
+
async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:
|
|
152
|
+
resp = await client.get(
|
|
153
|
+
f"https://api.github.com/repos/{owner}/{name}/commits/HEAD"
|
|
154
|
+
)
|
|
155
|
+
resp.raise_for_status()
|
|
156
|
+
commit = resp.json()
|
|
157
|
+
except httpx.TransportError as exc:
|
|
158
|
+
message = f"Failed to connect to GitHub: {exc}"
|
|
159
|
+
if _is_cert_error(exc):
|
|
160
|
+
message += (
|
|
161
|
+
" (TLS certificate verification failed; set SSL_CERT_FILE to "
|
|
162
|
+
"your CA bundle to trust a proxy/mirror)"
|
|
163
|
+
)
|
|
164
|
+
raise CodeWikiConnectionError(message) from exc
|
|
165
|
+
except httpx.HTTPStatusError as exc:
|
|
166
|
+
raise CodeWikiError(
|
|
167
|
+
f"GitHub API returned HTTP {exc.response.status_code}"
|
|
168
|
+
) from exc
|
|
169
|
+
if not isinstance(commit, dict):
|
|
170
|
+
raise CodeWikiError("unexpected GitHub response")
|
|
171
|
+
committer = commit.get("commit", {}).get("committer") or {}
|
|
172
|
+
when = committer.get("date") if isinstance(committer, dict) else None
|
|
173
|
+
return {"sha": commit.get("sha") or "", "when": when}
|
|
174
|
+
|
|
175
|
+
async def _call(
|
|
176
|
+
self,
|
|
177
|
+
http: httpx.AsyncClient,
|
|
178
|
+
bs: _Bootstrap,
|
|
179
|
+
rpc_id: str,
|
|
180
|
+
repo: str,
|
|
181
|
+
inner_json: str,
|
|
182
|
+
) -> object:
|
|
183
|
+
params = {
|
|
184
|
+
"rpcids": rpc_id,
|
|
185
|
+
"source-path": f"/github.com/{repo}",
|
|
186
|
+
"bl": bs.bl,
|
|
187
|
+
"f.sid": bs.sid,
|
|
188
|
+
"hl": "en-US",
|
|
189
|
+
"_reqid": _pseudo_reqid(),
|
|
190
|
+
"rt": "c",
|
|
191
|
+
}
|
|
192
|
+
headers = {
|
|
193
|
+
"Accept-Language": "en-US,en;q=0.9",
|
|
194
|
+
"Origin": SITE,
|
|
195
|
+
"Referer": f"{SITE}/github.com/{repo}",
|
|
196
|
+
"X-Same-Domain": "1",
|
|
197
|
+
"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
|
|
198
|
+
}
|
|
199
|
+
try:
|
|
200
|
+
resp = await http.post(
|
|
201
|
+
ENDPOINT,
|
|
202
|
+
params=params,
|
|
203
|
+
headers=headers,
|
|
204
|
+
content=boq.encode_request(rpc_id, inner_json),
|
|
205
|
+
)
|
|
206
|
+
except httpx.TransportError as exc:
|
|
207
|
+
message = f"Failed to connect to CodeWiki: {exc}"
|
|
208
|
+
if _is_cert_error(exc):
|
|
209
|
+
message += (
|
|
210
|
+
" (TLS certificate verification failed; set SSL_CERT_FILE to "
|
|
211
|
+
"your CA bundle to trust a proxy/mirror)"
|
|
212
|
+
)
|
|
213
|
+
raise CodeWikiConnectionError(message) from exc
|
|
214
|
+
if resp.status_code != 200:
|
|
215
|
+
raise CodeWikiError(
|
|
216
|
+
f"batchexecute returned HTTP {resp.status_code}: {resp.text[:200]}"
|
|
217
|
+
)
|
|
218
|
+
try:
|
|
219
|
+
return boq.decode_response(resp.text, rpc_id)
|
|
220
|
+
except ValueError as exc:
|
|
221
|
+
raise CodeWikiError(str(exc)) from exc
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _github_url(repo: str) -> str:
|
|
225
|
+
return f"https://github.com/{repo}"
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _pseudo_reqid() -> int:
|
|
229
|
+
return 100_000 + (int(time.time()) % 900_000)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _is_cert_error(exc: BaseException) -> bool:
|
|
233
|
+
while exc is not None:
|
|
234
|
+
if isinstance(exc, ssl.SSLCertVerificationError):
|
|
235
|
+
return True
|
|
236
|
+
exc = exc.__cause__ or exc.__context__
|
|
237
|
+
return False
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Parse and render CodeWiki VSX6ub wiki payloads."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from urllib.parse import unquote
|
|
7
|
+
|
|
8
|
+
_GITHUB_PREFIX = "https://github.com"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class Section:
|
|
13
|
+
title: str
|
|
14
|
+
level: int
|
|
15
|
+
markdown: str
|
|
16
|
+
diagrams: list[str] = field(default_factory=list)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class Wiki:
|
|
21
|
+
repo_slug: str
|
|
22
|
+
commit_sha: str
|
|
23
|
+
sections: list[Section]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def parse(payload: object) -> Wiki:
|
|
27
|
+
"""Parse a VSX6ub payload: ``[wiki, [null, url], true, n]``."""
|
|
28
|
+
if not isinstance(payload, list) or not payload:
|
|
29
|
+
raise ValueError("missing wiki container")
|
|
30
|
+
container = payload[0]
|
|
31
|
+
if not isinstance(container, list) or not container:
|
|
32
|
+
raise ValueError("missing wiki container")
|
|
33
|
+
header = container[0]
|
|
34
|
+
if not isinstance(header, list) or not header:
|
|
35
|
+
raise ValueError("missing wiki header array")
|
|
36
|
+
repo_slug = header[0] if header and isinstance(header[0], str) else ""
|
|
37
|
+
if not repo_slug:
|
|
38
|
+
raise ValueError("missing repo slug")
|
|
39
|
+
commit_sha = header[1] if len(header) > 1 and isinstance(header[1], str) else ""
|
|
40
|
+
raw_sections = container[1] if len(container) > 1 else None
|
|
41
|
+
if not isinstance(raw_sections, list):
|
|
42
|
+
raise ValueError("missing sections array")
|
|
43
|
+
sections = [parse_section(s, i) for i, s in enumerate(raw_sections)]
|
|
44
|
+
return Wiki(repo_slug=repo_slug, commit_sha=commit_sha, sections=sections)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def parse_section(value: object, index: int) -> Section:
|
|
48
|
+
if not isinstance(value, list):
|
|
49
|
+
raise ValueError(f"section #{index} is not an array")
|
|
50
|
+
title = value[0] if value and isinstance(value[0], str) else ""
|
|
51
|
+
if not title:
|
|
52
|
+
raise ValueError(f"missing title in section #{index}")
|
|
53
|
+
level = value[1] if len(value) > 1 and isinstance(value[1], int) else 1
|
|
54
|
+
markdown = ""
|
|
55
|
+
if len(value) > 5 and isinstance(value[5], str):
|
|
56
|
+
markdown = value[5]
|
|
57
|
+
elif len(value) > 4 and isinstance(value[4], str):
|
|
58
|
+
markdown = value[4]
|
|
59
|
+
diagrams = extract_diagrams(value[7] if len(value) > 7 else None)
|
|
60
|
+
return Section(title=title, level=level, markdown=markdown, diagrams=diagrams)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def extract_diagrams(outer: object) -> list[str]:
|
|
64
|
+
out: list[str] = []
|
|
65
|
+
if not isinstance(outer, list):
|
|
66
|
+
return out
|
|
67
|
+
for group in outer:
|
|
68
|
+
if not isinstance(group, list):
|
|
69
|
+
continue
|
|
70
|
+
for diagram in group:
|
|
71
|
+
if not isinstance(diagram, list):
|
|
72
|
+
continue
|
|
73
|
+
if len(diagram) > 4 and isinstance(diagram[4], str) and diagram[4]:
|
|
74
|
+
out.append(diagram[4])
|
|
75
|
+
return out
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def render_structure(wiki: Wiki) -> str:
|
|
79
|
+
lines = []
|
|
80
|
+
for s in wiki.sections:
|
|
81
|
+
indent = " " * max(0, s.level - 1)
|
|
82
|
+
lines.append(f"{indent}- {s.title}")
|
|
83
|
+
return "\n".join(lines) + ("\n" if lines else "")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def render_section(s: Section) -> str:
|
|
87
|
+
"""Render a single section as Markdown (heading + body + dot diagrams)."""
|
|
88
|
+
hashes = "#" * min(max(s.level, 1), 6)
|
|
89
|
+
parts = [f"{hashes} {s.title}\n"]
|
|
90
|
+
body = resolve_links(s.markdown)
|
|
91
|
+
parts.append(body)
|
|
92
|
+
if body and not body.endswith("\n"):
|
|
93
|
+
parts.append("\n")
|
|
94
|
+
for dot in s.diagrams:
|
|
95
|
+
parts.append(f"\n```dot\n{dot.strip()}\n```\n")
|
|
96
|
+
parts.append("\n")
|
|
97
|
+
return "".join(parts)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def render_markdown(wiki: Wiki) -> str:
|
|
101
|
+
parts = [f"# {wiki.repo_slug} (commit {wiki.commit_sha})\n"]
|
|
102
|
+
for s in wiki.sections:
|
|
103
|
+
parts.append(render_section(s))
|
|
104
|
+
return "".join(parts)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def render_page(wiki: Wiki, title: str) -> str:
|
|
108
|
+
"""Render the single section whose title matches (case-insensitive exact).
|
|
109
|
+
|
|
110
|
+
Raises ValueError listing available titles if none matches.
|
|
111
|
+
"""
|
|
112
|
+
target = title.strip().lower()
|
|
113
|
+
for s in wiki.sections:
|
|
114
|
+
if s.title.strip().lower() == target:
|
|
115
|
+
return render_section(s).strip()
|
|
116
|
+
available = "\n".join(f" - {s.title}" for s in wiki.sections) or " - (none)"
|
|
117
|
+
raise ValueError(f"Page '{title}' not found. Available pages:\n{available}")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def resolve_links(markdown: str) -> str:
|
|
121
|
+
"""Rewrite ``](%2F...)`` / ``](/...)`` targets to ``](https://github.com/...)``."""
|
|
122
|
+
out: list[str] = []
|
|
123
|
+
rest = markdown
|
|
124
|
+
while True:
|
|
125
|
+
open_idx = rest.find("](")
|
|
126
|
+
if open_idx == -1:
|
|
127
|
+
out.append(rest)
|
|
128
|
+
break
|
|
129
|
+
out.append(rest[:open_idx])
|
|
130
|
+
after = rest[open_idx + 2:]
|
|
131
|
+
end = _find_link_end(after)
|
|
132
|
+
if end is None:
|
|
133
|
+
out.append("](")
|
|
134
|
+
rest = after
|
|
135
|
+
continue
|
|
136
|
+
out.append("](")
|
|
137
|
+
out.append(_rewrite_target(after[:end]))
|
|
138
|
+
out.append(")")
|
|
139
|
+
rest = after[end + 1:]
|
|
140
|
+
return "".join(out)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _find_link_end(s: str) -> int | None:
|
|
144
|
+
depth = 0
|
|
145
|
+
for i, ch in enumerate(s):
|
|
146
|
+
if ch == "(":
|
|
147
|
+
depth += 1
|
|
148
|
+
elif ch == ")":
|
|
149
|
+
if depth == 0:
|
|
150
|
+
return i
|
|
151
|
+
depth -= 1
|
|
152
|
+
return None
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _rewrite_target(target: str) -> str:
|
|
156
|
+
needs_rewrite = target.startswith(("%2F", "%2f", "/"))
|
|
157
|
+
if not needs_rewrite:
|
|
158
|
+
return target
|
|
159
|
+
decoded = unquote(target)
|
|
160
|
+
if decoded.startswith("/"):
|
|
161
|
+
return f"{_GITHUB_PREFIX}{decoded}"
|
|
162
|
+
return decoded
|