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/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)
|