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/meta.py ADDED
@@ -0,0 +1,274 @@
1
+ """Repository metadata: description, topics, homepage.
2
+
3
+ The tool never invents marketing copy. It (a) derives the factual parts — language,
4
+ runtime, existing topics, keywords actually present in the tree — and (b) validates a
5
+ human-written description against the rules that decide whether the repository is
6
+ findable: keyword first, no wasted prefix, no superlatives, under the truncation limit.
7
+
8
+ `apply` is dry-run unless `--write` is passed, and it never replaces the whole topic
9
+ list: `PUT /repos/{owner}/{repo}/topics` is documented as a replacement, so a careless
10
+ call erases topics that were not part of the edit.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import os
17
+ import re
18
+ from collections import Counter
19
+ from pathlib import Path
20
+
21
+ from .util import Report, read_text, run, scan_structure, which
22
+
23
+ BAD_PREFIXES = ("a tool", "a simple", "a small", "this is", "this repo", "the tool", "tool for")
24
+ SUPERLATIVES = re.compile(
25
+ r"\b(blazing|revolutionary|best-in-class|world-class|cutting-edge|state-of-the-art|"
26
+ r"amazing|awesome|ultimate|next-gen|game-chang\w+|seamless|powerful|robust)\b",
27
+ re.IGNORECASE,
28
+ )
29
+ EMOJI_RE = re.compile("[\U0001F300-\U0001FAFF\U00002600-\U000027BF]")
30
+ TOPIC_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
31
+ DESCRIPTION_MAX = 200
32
+ DESCRIPTION_HARD_MAX = 350
33
+ TOPIC_LIMIT = 20
34
+
35
+ LANGUAGE_BY_SUFFIX = {
36
+ ".php": "php", ".py": "python", ".ps1": "powershell", ".psm1": "powershell",
37
+ ".js": "javascript", ".mjs": "javascript", ".ts": "typescript", ".cs": "csharp",
38
+ ".go": "go", ".rs": "rust", ".rb": "ruby", ".java": "java", ".pl": "perl",
39
+ ".sh": "shell", ".bash": "shell", ".sql": "sql", ".css": "css", ".html": "html",
40
+ }
41
+ MANIFESTS = {
42
+ "pyproject.toml": "python", "package.json": "node", "composer.json": "php",
43
+ "Cargo.toml": "rust", "go.mod": "go", "Gemfile": "ruby", "*.csproj": "dotnet",
44
+ }
45
+ STOPWORDS = {
46
+ "the", "and", "for", "with", "that", "this", "from", "into", "your", "you", "are",
47
+ "all", "any", "not", "but", "can", "how", "what", "when", "which", "will", "它的",
48
+ "для", "или", "как", "что", "это", "при", "без", "все", "его", "она", "они", "котор",
49
+ "проект", "проекта", "нужно", "можно", "если", "чтобы", "также",
50
+ }
51
+
52
+
53
+ def validate_description(text: str, *, limit: int = DESCRIPTION_MAX) -> list[tuple[str, str, str]]:
54
+ """Return (level, rule, message) tuples for one description string."""
55
+ problems: list[tuple[str, str, str]] = []
56
+ stripped = (text or "").strip()
57
+ if not stripped:
58
+ return [("error", "meta.empty", "description is empty")]
59
+ if len(stripped) > DESCRIPTION_HARD_MAX:
60
+ problems.append(("error", "meta.hard-limit", f"{len(stripped)} characters exceeds the API limit of {DESCRIPTION_HARD_MAX}"))
61
+ elif len(stripped) > limit:
62
+ problems.append(("warn", "meta.length", f"{len(stripped)} characters; search results truncate past {limit}"))
63
+ if stripped.lower().startswith(BAD_PREFIXES):
64
+ problems.append(("warn", "meta.prefix", "starts with a wasted prefix; lead with the category noun"))
65
+ if SUPERLATIVES.search(stripped):
66
+ problems.append(("warn", "meta.superlative", "contains a superlative a reader cannot verify"))
67
+ if EMOJI_RE.search(stripped):
68
+ problems.append(("warn", "meta.emoji", "contains emoji"))
69
+ if not re.search(r"[.:—-]", stripped):
70
+ problems.append(("info", "meta.shape", "no clause separator; state one capability after another"))
71
+ if stripped.endswith(".") and stripped.count(".") < 2:
72
+ problems.append(("info", "meta.shape", "single sentence: name what it is and one capability"))
73
+ return problems
74
+
75
+
76
+ def derive(root: Path) -> dict:
77
+ """Facts the tree can prove: languages, manifests, existing config, title/summary."""
78
+ root = Path(root)
79
+ languages: Counter[str] = Counter()
80
+ for path in root.rglob("*"):
81
+ if not path.is_file():
82
+ continue
83
+ parts = set(path.parts)
84
+ if parts & {".git", "node_modules", "vendor", ".venv", "__pycache__", "dist", "build", ".ghagent"}:
85
+ continue
86
+ language = LANGUAGE_BY_SUFFIX.get(path.suffix.lower())
87
+ if language:
88
+ languages[language] += 1
89
+ manifests = [name for name in MANIFESTS if list(root.glob(name))]
90
+ title = ""
91
+ summary = ""
92
+ readme = root / "README.md"
93
+ if readme.exists():
94
+ text, _ = read_text(readme)
95
+ headings, _, _, lines = scan_structure(text)
96
+ h1 = [h for h in headings if h.level == 1]
97
+ title = h1[0].text if h1 else ""
98
+ start = h1[0].line if h1 else 0
99
+ for raw in lines[start:]:
100
+ if raw.strip():
101
+ summary = raw.strip()
102
+ break
103
+ declared: list[str] = []
104
+ for name in ("pyproject.toml", "package.json", "composer.json"):
105
+ candidate = root / name
106
+ if candidate.exists():
107
+ text, _ = read_text(candidate)
108
+ for keyword_section in ("keywords", "topics"):
109
+ for match in re.finditer(rf'"{keyword_section}"\s*:\s*\[([^\]]*)\]', text):
110
+ declared.extend(re.findall(r'"([^"]+)"', match.group(1)))
111
+ config_file = root / ".ghagent.json"
112
+ configured_topics: list[str] = []
113
+ if config_file.exists():
114
+ data = json.loads(read_text(config_file)[0])
115
+ configured_topics = data.get("topics", [])
116
+ return {
117
+ "languages": languages.most_common(6),
118
+ "manifests": manifests,
119
+ "title": title,
120
+ "summary": summary,
121
+ "declared_keywords": sorted(set(declared))[:20],
122
+ "configured_topics": configured_topics,
123
+ }
124
+
125
+
126
+ def suggest_topics(facts: dict, *, extra: list[str] | None = None) -> list[str]:
127
+ """Build a candidate topic list: technology, platform, use case.
128
+
129
+ Order matters — the API preserves it and readers treat the first terms as priority.
130
+ """
131
+ ordered: list[str] = []
132
+ for topic in (extra or []) + facts.get("configured_topics", []):
133
+ topic = normalise_topic(topic)
134
+ if topic and topic not in ordered:
135
+ ordered.append(topic)
136
+ for language, _count in facts.get("languages", []):
137
+ topic = normalise_topic(language)
138
+ if topic and topic not in ordered:
139
+ ordered.append(topic)
140
+ for topic in ("github", "documentation", "cli", "devops"):
141
+ topic = normalise_topic(topic)
142
+ if topic and topic not in ordered:
143
+ ordered.append(topic)
144
+ return ordered[:TOPIC_LIMIT]
145
+
146
+
147
+ def normalise_topic(raw: str) -> str:
148
+ topic = re.sub(r"[^a-zA-Z0-9]+", "-", (raw or "").strip().lower()).strip("-")
149
+ return topic
150
+
151
+
152
+ def check_topics(topics: list[str], *, minimum: int = 8, maximum: int = TOPIC_LIMIT) -> list[tuple[str, str, str]]:
153
+ problems: list[tuple[str, str, str]] = []
154
+ if len(topics) < minimum:
155
+ problems.append(("warn", "meta.topics-count", f"{len(topics)} topic(s); the search surface wants {minimum}-{maximum}"))
156
+ if len(topics) > maximum:
157
+ problems.append(("error", "meta.topics-overflow", f"{len(topics)} topic(s); the API stores at most {maximum}"))
158
+ for topic in topics:
159
+ if not TOPIC_RE.match(topic):
160
+ problems.append(("error", "meta.topics-form", f"'{topic}' is not lowercase-hyphenated"))
161
+ if re.search(r"(19|20)\d\d$", topic):
162
+ problems.append(("warn", "meta.topic-year", f"'{topic}' carries a year, which rots"))
163
+ duplicates = [t for t, count in Counter(topics).items() if count > 1]
164
+ if duplicates:
165
+ problems.append(("warn", "meta.topic-duplicate", f"duplicate topic(s): {', '.join(duplicates)}"))
166
+ return problems
167
+
168
+
169
+ # --------------------------------------------------------------------------
170
+ # remote read / write through gh
171
+ # --------------------------------------------------------------------------
172
+
173
+ def _gh(arguments: list[str], payload: dict | None = None) -> tuple[int, str, str]:
174
+ if not which("gh"):
175
+ return 127, "", "gh CLI is not installed"
176
+ command = ["gh", "api", *arguments]
177
+ if payload is not None:
178
+ command += ["--input", "-"]
179
+ try:
180
+ import subprocess
181
+ proc = subprocess.run(command, input=json.dumps(payload), capture_output=True, text=True,
182
+ encoding="utf-8", errors="replace")
183
+ return proc.returncode, proc.stdout, proc.stderr
184
+ except OSError as exc:
185
+ return 1, "", str(exc)
186
+ rc, out, err = run(command)
187
+ return rc, out, err
188
+
189
+
190
+ def read_remote(slug: str) -> dict | None:
191
+ rc, out, _ = _gh([f"repos/{slug}"])
192
+ if rc != 0 or not out.strip():
193
+ return None
194
+ try:
195
+ return json.loads(out)
196
+ except json.JSONDecodeError:
197
+ return None
198
+
199
+
200
+ def apply_metadata(slug: str, *, description: str | None = None, topics: list[str] | None = None,
201
+ homepage: str | None = None, merge_topics: bool = True, write: bool = False,
202
+ report: Report | None = None) -> Report:
203
+ report = report or Report(title=f"ghagent meta apply {slug}")
204
+ current = read_remote(slug)
205
+ if current is None:
206
+ report.error("meta.remote", f"cannot read {slug} through gh api", hint="run `gh auth status`")
207
+ return report
208
+
209
+ before = {
210
+ "description": (current.get("description") or "").strip(),
211
+ "topics": current.get("topics") or [],
212
+ "homepage": (current.get("homepage") or "").strip(),
213
+ }
214
+ changes: dict[str, dict] = {}
215
+
216
+ if description is not None:
217
+ for level, rule, message in validate_description(description):
218
+ report.add(level, rule, message)
219
+ if description.strip() and description != before["description"]:
220
+ changes["description"] = {"from": before["description"], "to": description.strip()}
221
+ if homepage is not None and homepage != before["homepage"]:
222
+ changes["homepage"] = {"from": before["homepage"], "to": homepage.strip()}
223
+ if topics is not None:
224
+ merged = list(before["topics"]) if merge_topics else []
225
+ for topic in topics:
226
+ normalised = normalise_topic(topic)
227
+ if normalised and normalised not in merged:
228
+ merged.append(normalised)
229
+ for level, rule, message in check_topics(merged):
230
+ report.add(level, rule, message)
231
+ if merged[:TOPIC_LIMIT] != before["topics"]:
232
+ changes["topics"] = {"from": before["topics"], "to": merged[:TOPIC_LIMIT]}
233
+
234
+ report.data.update({"before": before, "changes": changes, "written": False})
235
+ if not changes:
236
+ report.info("meta.no-change", "nothing to change: the remote already matches the request")
237
+ return report
238
+ if not write:
239
+ report.info("meta.dry-run", f"would change {', '.join(sorted(changes))}", hint="pass --write to apply")
240
+ return report
241
+ if report.errors:
242
+ report.error("meta.refused", "not applied: validation produced errors")
243
+ return report
244
+
245
+ if "description" in changes or "homepage" in changes:
246
+ payload = {}
247
+ if "description" in changes:
248
+ payload["description"] = changes["description"]["to"]
249
+ if "homepage" in changes:
250
+ payload["homepage"] = changes["homepage"]["to"]
251
+ rc, out, err = _gh(["-X", "PATCH", f"repos/{slug}"], payload)
252
+ if rc != 0:
253
+ report.error("meta.patch", f"PATCH failed: {err.strip()[:300]}")
254
+ return report
255
+ if "topics" in changes:
256
+ rc, out, err = _gh(["-X", "PUT", f"repos/{slug}/topics"], {"names": changes["topics"]["to"]})
257
+ if rc != 0:
258
+ report.error("meta.topics-put", f"PUT topics failed: {err.strip()[:300]}")
259
+ return report
260
+
261
+ after = read_remote(slug) or {}
262
+ report.data["after"] = {
263
+ "description": (after.get("description") or "").strip(),
264
+ "topics": after.get("topics") or [],
265
+ "homepage": (after.get("homepage") or "").strip(),
266
+ }
267
+ report.data["written"] = True
268
+ for field_name in ("description", "homepage"):
269
+ if field_name in changes and report.data["after"][field_name] != changes[field_name]["to"]:
270
+ report.error("meta.verify", f"{field_name} did not change on the remote")
271
+ if "topics" in changes and sorted(report.data["after"]["topics"]) != sorted(changes["topics"]["to"]):
272
+ report.warn("meta.verify", "the stored topic list differs from the requested one",
273
+ hint="the API may truncate or reorder; re-read before claiming success")
274
+ return report
ghalib/report.py ADDED
@@ -0,0 +1,90 @@
1
+ """One report that combines every check the pack can run on a working tree."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from . import audit as audit_module
8
+ from . import changelog as changelog_module
9
+ from . import config as cfg
10
+ from . import mdcheck
11
+ from . import wiki as wiki_module
12
+ from .util import EXIT_FAIL, EXIT_OK, Report, rel
13
+
14
+
15
+ def build(root: Path) -> tuple[str, int]:
16
+ root = Path(root)
17
+ config = cfg.load(root)
18
+ lines: list[str] = [f"# Repository report — {root.name}", "", f"* root: `{root}`"]
19
+ failures = 0
20
+
21
+ audit_result = audit_module.audit_local(root, config=config)
22
+ percentage, scored, skipped = audit_result.score()
23
+ lines += [
24
+ "",
25
+ "## 1. Hygiene",
26
+ "",
27
+ f"* score: **{percentage}%** ({scored} scored, {skipped} skipped)",
28
+ f"* errors: {len(audit_result.report.errors)}, warnings: {len(audit_result.report.warnings)}",
29
+ "",
30
+ "| Item | Status | Weight | Detail |",
31
+ "|---|---|---:|---|",
32
+ ]
33
+ for item in audit_result.items:
34
+ lines.append(f"| `{item.ident}` | {item.status} | {item.weight} | {item.detail} |")
35
+ failures += len(audit_result.report.errors)
36
+
37
+ doc_report = Report(title="doc", root=str(root))
38
+ checker = mdcheck.DocChecker(root, doc_report, mdcheck.DocCheckOptions(ignore=set(config.ignore_rules)))
39
+ for document in audit_module._primary_documents(root, config):
40
+ checker.check_file(document)
41
+ checker._report_coverage()
42
+ lines += ["", "## 2. Documents", "",
43
+ f"* errors: {len(doc_report.errors)}, warnings: {len(doc_report.warnings)}"]
44
+ for finding in doc_report.findings:
45
+ if finding.level == "error":
46
+ location = f"`{finding.path}:{finding.line}`" if finding.line else f"`{finding.path}`"
47
+ lines.append(f"* **{finding.rule}** {location} — {finding.message}")
48
+ failures += len(doc_report.errors)
49
+
50
+ changelog_path = root / config.changelog
51
+ lines += ["", "## 3. Changelog", ""]
52
+ if changelog_path.exists():
53
+ changelog_report = changelog_module.check(changelog_path, slug=config.owner_repo)
54
+ data = changelog_report.data
55
+ lines.append(f"* versions: {data.get('versions', 0)}, latest: `{data.get('latest') or 'none'}`")
56
+ lines.append(f"* pending under Unreleased: {data.get('unreleased_items', 0)}")
57
+ for finding in changelog_report.errors:
58
+ lines.append(f"* **{finding.rule}** — {finding.message}")
59
+ failures += len(changelog_report.errors)
60
+ else:
61
+ lines.append("* CHANGELOG.md is absent")
62
+ failures += 1
63
+
64
+ lines += ["", "## 4. Wiki", ""]
65
+ build_dir = root / ".ghagent" / "wiki"
66
+ wiki_source = root / config.wiki_dir
67
+ if wiki_source.is_dir():
68
+ build_report = wiki_module.build(root, wiki_dir=config.wiki_dir,
69
+ manifest_path=config.wiki_manifest, out_dir=build_dir)
70
+ if build_report.errors:
71
+ for finding in build_report.errors:
72
+ lines.append(f"* **{finding.rule}** — {finding.message}")
73
+ failures += len(build_report.errors)
74
+ else:
75
+ verify_report = wiki_module.verify(build_dir)
76
+ lines.append(f"* pages built: {verify_report.data.get('pages', 0)}")
77
+ lines.append(f"* internal links: {verify_report.data.get('links_internal', 0)}")
78
+ if verify_report.errors:
79
+ for finding in verify_report.errors:
80
+ lines.append(f"* **{finding.rule}** — {finding.message}")
81
+ failures += len(verify_report.errors)
82
+ else:
83
+ lines.append("* built tree verified: sidebar, footer, anchors, no relative images")
84
+ else:
85
+ lines.append(f"* no `{config.wiki_dir}/` directory: the wiki is not maintained from the repository")
86
+
87
+ lines += ["", "## 5. Verdict", ""]
88
+ lines.append(f"* blocking findings: **{failures}**")
89
+ lines.append("* next step: " + ("fix the errors above" if failures else "ready for review"))
90
+ return "\n".join(lines), (EXIT_FAIL if failures else EXIT_OK)