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/audit.py
ADDED
|
@@ -0,0 +1,649 @@
|
|
|
1
|
+
"""Repository audit: required files, README contract, changelog contract, promotion metadata.
|
|
2
|
+
|
|
3
|
+
Two modes:
|
|
4
|
+
* local — reads a working tree, never touches the network;
|
|
5
|
+
* remote — reads public metadata through `gh api` (or GITHUB_TOKEN) and compares it
|
|
6
|
+
with what the tree claims.
|
|
7
|
+
|
|
8
|
+
Scoring rule: an item is either pass, fail, warn or not-applicable. Items that could
|
|
9
|
+
not be examined are reported as `skipped` and are excluded from both numerator and
|
|
10
|
+
denominator, so a network outage lowers coverage instead of raising the score.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import re
|
|
18
|
+
import urllib.error
|
|
19
|
+
import urllib.request
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from . import assets as assets_module
|
|
24
|
+
from . import config as cfg
|
|
25
|
+
from . import mdcheck
|
|
26
|
+
from .util import (
|
|
27
|
+
EXIT_FAIL,
|
|
28
|
+
EXIT_OK,
|
|
29
|
+
Report,
|
|
30
|
+
guess_repo_slug,
|
|
31
|
+
read_text,
|
|
32
|
+
rel,
|
|
33
|
+
run,
|
|
34
|
+
scan_structure,
|
|
35
|
+
which,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
REQUIRED_FILES: list[tuple[str, list[str], int, str]] = [
|
|
39
|
+
("readme", ["README.md", "README.markdown", "README.rst", "readme.md"], 10, "error"),
|
|
40
|
+
("license", ["LICENSE", "LICENSE.md", "LICENSE.txt", "COPYING"], 8, "error"),
|
|
41
|
+
("changelog", ["CHANGELOG.md", "CHANGELOG", "HISTORY.md", "CHANGES.md"], 6, "error"),
|
|
42
|
+
("contributing", ["CONTRIBUTING.md", ".github/CONTRIBUTING.md", "docs/CONTRIBUTING.md"], 6, "error"),
|
|
43
|
+
("security", ["SECURITY.md", ".github/SECURITY.md", "docs/SECURITY.md"], 6, "error"),
|
|
44
|
+
("code-of-conduct", ["CODE_OF_CONDUCT.md", ".github/CODE_OF_CONDUCT.md", "docs/CODE_OF_CONDUCT.md"], 4, "warn"),
|
|
45
|
+
("support", ["SUPPORT.md", ".github/SUPPORT.md"], 2, "warn"),
|
|
46
|
+
("agents", ["AGENTS.md", ".github/AGENTS.md"], 5, "warn"),
|
|
47
|
+
("gitattributes", [".gitattributes"], 3, "warn"),
|
|
48
|
+
("editorconfig", [".editorconfig"], 2, "warn"),
|
|
49
|
+
("gitignore", [".gitignore"], 3, "warn"),
|
|
50
|
+
("pr-template", [".github/PULL_REQUEST_TEMPLATE.md", "PULL_REQUEST_TEMPLATE.md", ".github/pull_request_template.md"], 3, "warn"),
|
|
51
|
+
("issue-forms", [".github/ISSUE_TEMPLATE", "docs/ISSUE_TEMPLATE"], 4, "warn"),
|
|
52
|
+
("ci", [".github/workflows", ".gitlab-ci.yml", "azure-pipelines.yml"], 6, "warn"),
|
|
53
|
+
("codeowners", [".github/CODEOWNERS", "CODEOWNERS", "docs/CODEOWNERS"], 2, "warn"),
|
|
54
|
+
("dependabot", [".github/dependabot.yml", ".github/dependabot.yaml"], 2, "warn"),
|
|
55
|
+
("citation", ["CITATION.cff", ".github/CITATION.cff"], 2, "info"),
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
BADGE_RE = re.compile(r"!\[[^\]]*\]\(https?://[^)]*\)|\[!\[[^\]]*\]\(https?://[^)]*\)\]\([^)]*\)")
|
|
59
|
+
SUPERLATIVES = re.compile(
|
|
60
|
+
r"\b(blazing|revolutionary|best-in-class|world-class|cutting-edge|state-of-the-art|"
|
|
61
|
+
r"amazing|awesome|ultimate|next-gen|game-chang\w+|seamless)\b",
|
|
62
|
+
re.IGNORECASE,
|
|
63
|
+
)
|
|
64
|
+
EMOJI_RE = re.compile(
|
|
65
|
+
"[\U0001F300-\U0001FAFF\U00002600-\U000027BF\U0001F1E6-\U0001F1FF\u2b00-\u2bff]"
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass
|
|
70
|
+
class Item:
|
|
71
|
+
ident: str
|
|
72
|
+
title: str
|
|
73
|
+
weight: int
|
|
74
|
+
status: str = "skipped" # pass | fail | warn | skipped
|
|
75
|
+
detail: str = ""
|
|
76
|
+
severity: str = "error"
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def scored(self) -> bool:
|
|
80
|
+
return self.status in {"pass", "warn", "fail"}
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def points(self) -> float:
|
|
84
|
+
if self.status == "pass":
|
|
85
|
+
return self.weight
|
|
86
|
+
if self.status == "warn":
|
|
87
|
+
return self.weight * 0.5
|
|
88
|
+
return 0.0
|
|
89
|
+
|
|
90
|
+
def as_dict(self) -> dict:
|
|
91
|
+
return {
|
|
92
|
+
"id": self.ident,
|
|
93
|
+
"title": self.title,
|
|
94
|
+
"weight": self.weight,
|
|
95
|
+
"status": self.status,
|
|
96
|
+
"detail": self.detail,
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass
|
|
101
|
+
class AuditResult:
|
|
102
|
+
report: Report
|
|
103
|
+
items: list[Item] = field(default_factory=list)
|
|
104
|
+
|
|
105
|
+
def score(self) -> tuple[float, int, int]:
|
|
106
|
+
scored = [item for item in self.items if item.scored]
|
|
107
|
+
total = sum(item.weight for item in scored)
|
|
108
|
+
got = sum(item.points for item in scored)
|
|
109
|
+
percentage = round(100.0 * got / total, 1) if total else 0.0
|
|
110
|
+
return percentage, len(scored), len([i for i in self.items if not i.scored])
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# --------------------------------------------------------------------------
|
|
114
|
+
# local audit
|
|
115
|
+
# --------------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
def _first_existing(root: Path, candidates: list[str]) -> Path | None:
|
|
118
|
+
for candidate in candidates:
|
|
119
|
+
path = root / candidate
|
|
120
|
+
if path.exists():
|
|
121
|
+
if path.is_dir():
|
|
122
|
+
if any(path.iterdir()):
|
|
123
|
+
return path
|
|
124
|
+
continue
|
|
125
|
+
if path.stat().st_size > 0:
|
|
126
|
+
return path
|
|
127
|
+
return None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def audit_local(root: Path, *, config: cfg.Config | None = None, doc_report: Report | None = None) -> AuditResult:
|
|
131
|
+
root = Path(root)
|
|
132
|
+
config = config or cfg.load(root)
|
|
133
|
+
report = Report(title="ghagent audit", root=str(root))
|
|
134
|
+
result = AuditResult(report=report)
|
|
135
|
+
|
|
136
|
+
if not root.exists():
|
|
137
|
+
report.error("repo.missing", f"path does not exist: {root}")
|
|
138
|
+
return result
|
|
139
|
+
|
|
140
|
+
for ident, candidates, weight, severity in REQUIRED_FILES:
|
|
141
|
+
item = Item(ident=ident, title=candidates[0], weight=weight, severity=severity)
|
|
142
|
+
found = _first_existing(root, candidates)
|
|
143
|
+
if found is None:
|
|
144
|
+
item.status = "fail"
|
|
145
|
+
item.detail = "missing"
|
|
146
|
+
if severity == "info":
|
|
147
|
+
report.info(f"repo.{ident}", f"{candidates[0]} is absent (optional)")
|
|
148
|
+
elif severity == "warn":
|
|
149
|
+
report.warn(f"repo.{ident}", f"{candidates[0]} is absent", hint=f"add {candidates[0]}")
|
|
150
|
+
else:
|
|
151
|
+
report.error(f"repo.{ident}", f"{candidates[0]} is absent", hint=f"add {candidates[0]}")
|
|
152
|
+
else:
|
|
153
|
+
item.status = "pass"
|
|
154
|
+
item.detail = rel(found, root)
|
|
155
|
+
result.items.append(item)
|
|
156
|
+
|
|
157
|
+
for extra in config.require_files:
|
|
158
|
+
target = root / extra
|
|
159
|
+
if not target.exists():
|
|
160
|
+
report.error("repo.required-file", f"required by {cfg.CONFIG_NAME}: {extra} is absent")
|
|
161
|
+
result.items.append(Item(ident=f"required:{extra}", title=extra, weight=2, status="fail", detail="missing"))
|
|
162
|
+
|
|
163
|
+
readme_path = _first_existing(root, [config.readme, "README.md", "README.markdown"])
|
|
164
|
+
if readme_path is not None:
|
|
165
|
+
result.items.extend(_audit_readme(readme_path, root, report))
|
|
166
|
+
else:
|
|
167
|
+
report.error("readme.missing", "README.md is absent; README contract cannot be examined")
|
|
168
|
+
|
|
169
|
+
changelog_path = _first_existing(root, [config.changelog, "CHANGELOG.md", "HISTORY.md"])
|
|
170
|
+
if changelog_path is not None:
|
|
171
|
+
result.items.extend(_audit_changelog(changelog_path, root, report))
|
|
172
|
+
else:
|
|
173
|
+
report.error("changelog.missing", "CHANGELOG.md is absent; changelog contract cannot be examined")
|
|
174
|
+
|
|
175
|
+
agents_path = _first_existing(root, [config.agents, "AGENTS.md"])
|
|
176
|
+
if agents_path is not None:
|
|
177
|
+
result.items.extend(_audit_agents(agents_path, root, report))
|
|
178
|
+
|
|
179
|
+
wiki_dir = root / config.wiki_dir
|
|
180
|
+
if wiki_dir.is_dir():
|
|
181
|
+
item = Item(ident="wiki-source", title=f"{config.wiki_dir}/", weight=3)
|
|
182
|
+
item.status = "pass"
|
|
183
|
+
item.detail = f"{len(list(wiki_dir.glob('*.md')))} page(s)"
|
|
184
|
+
result.items.append(item)
|
|
185
|
+
manifest = root / config.wiki_manifest
|
|
186
|
+
if not manifest.exists():
|
|
187
|
+
report.warn("wiki.manifest", f"{config.wiki_manifest} is absent",
|
|
188
|
+
hint="wiki build cannot order pages without a manifest")
|
|
189
|
+
else:
|
|
190
|
+
result.items.append(Item(ident="wiki-source", title=f"{config.wiki_dir}/", weight=3,
|
|
191
|
+
status="fail", detail="no wiki source directory"))
|
|
192
|
+
|
|
193
|
+
assets_report = assets_module.check(root, report=report)
|
|
194
|
+
reference_item = Item(ident="assets.banner", title="README banner", weight=4)
|
|
195
|
+
if assets_report.data.get("readme_references"):
|
|
196
|
+
reference_item.status = "pass"
|
|
197
|
+
reference_item.detail = f"{assets_report.data['readme_references']} image(s)"
|
|
198
|
+
else:
|
|
199
|
+
reference_item.status = "fail"
|
|
200
|
+
reference_item.detail = "no image in README"
|
|
201
|
+
result.items.append(reference_item)
|
|
202
|
+
|
|
203
|
+
social_item = Item(ident="assets.social-preview", title="social preview 1280x640", weight=4)
|
|
204
|
+
if assets_report.data.get("social_preview_ready"):
|
|
205
|
+
social_item.status = "pass"
|
|
206
|
+
social_item.detail = str(assets_report.data.get("social_preview_dimensions"))
|
|
207
|
+
elif assets_report.data.get("social_preview_file"):
|
|
208
|
+
social_item.status = "warn"
|
|
209
|
+
social_item.detail = str(assets_report.data.get("social_preview_dimensions"))
|
|
210
|
+
else:
|
|
211
|
+
social_item.status = "warn"
|
|
212
|
+
social_item.detail = "no assets/social-preview.*"
|
|
213
|
+
result.items.append(social_item)
|
|
214
|
+
|
|
215
|
+
if doc_report is None:
|
|
216
|
+
options = mdcheck.DocCheckOptions(ignore=set(config.ignore_rules))
|
|
217
|
+
checker = mdcheck.DocChecker(root, report, options)
|
|
218
|
+
for doc in _primary_documents(root, config):
|
|
219
|
+
checker.check_file(doc)
|
|
220
|
+
checker._report_coverage()
|
|
221
|
+
else:
|
|
222
|
+
report.findings.extend(doc_report.findings)
|
|
223
|
+
report.data.update(doc_report.data)
|
|
224
|
+
|
|
225
|
+
percentage, scored, skipped = result.score()
|
|
226
|
+
report.data.update({
|
|
227
|
+
"score": percentage,
|
|
228
|
+
"scored_items": scored,
|
|
229
|
+
"skipped_items": skipped,
|
|
230
|
+
"items": [item.as_dict() for item in result.items],
|
|
231
|
+
"mode": "local",
|
|
232
|
+
})
|
|
233
|
+
if scored == 0:
|
|
234
|
+
report.error("audit.no-items", "no items were examined; the score is meaningless")
|
|
235
|
+
elif percentage < 60:
|
|
236
|
+
report.error("audit.score", f"hygiene score {percentage}% is below 60%")
|
|
237
|
+
elif percentage < 80:
|
|
238
|
+
report.warn("audit.score", f"hygiene score {percentage}% is below 80%")
|
|
239
|
+
return result
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _primary_documents(root: Path, config: cfg.Config) -> list[Path]:
|
|
243
|
+
docs: list[Path] = []
|
|
244
|
+
for name in (config.readme, config.agents, config.changelog, "CONTRIBUTING.md", "SECURITY.md",
|
|
245
|
+
"CODE_OF_CONDUCT.md", "SUPPORT.md", "GOVERNANCE.md"):
|
|
246
|
+
path = root / name
|
|
247
|
+
if path.exists() and path.suffix.lower() == ".md":
|
|
248
|
+
docs.append(path)
|
|
249
|
+
docs_dir = root / config.docs_dir
|
|
250
|
+
if docs_dir.is_dir():
|
|
251
|
+
docs.extend(sorted(p for p in docs_dir.glob("*.md")))
|
|
252
|
+
return docs
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _summary_after_h1(lines: list[str], start_index: int) -> str:
|
|
256
|
+
"""First prose line after the H1, ignoring blank lines and HTML comments.
|
|
257
|
+
|
|
258
|
+
A block of `<!-- ... -->` comments is invisible to a reader, so it cannot be the
|
|
259
|
+
summary: the i18n switcher marker sits directly after the H1 in the translated
|
|
260
|
+
READMEs. Comment-only blocks are skipped; a comment followed by prose on the same
|
|
261
|
+
line leaves the prose behind.
|
|
262
|
+
"""
|
|
263
|
+
in_comment = False
|
|
264
|
+
for raw in lines[start_index:]:
|
|
265
|
+
index = 0
|
|
266
|
+
while index < len(raw):
|
|
267
|
+
if in_comment:
|
|
268
|
+
close = raw.find("-->", index)
|
|
269
|
+
if close == -1:
|
|
270
|
+
index = len(raw)
|
|
271
|
+
break
|
|
272
|
+
in_comment = False
|
|
273
|
+
index = close + 3
|
|
274
|
+
continue
|
|
275
|
+
start = raw.find("<!--", index)
|
|
276
|
+
candidate = raw[index:len(raw) if start == -1 else start].strip()
|
|
277
|
+
if candidate:
|
|
278
|
+
return candidate
|
|
279
|
+
if start == -1:
|
|
280
|
+
index = len(raw)
|
|
281
|
+
break
|
|
282
|
+
close = raw.find("-->", start + 4)
|
|
283
|
+
if close == -1:
|
|
284
|
+
in_comment = True
|
|
285
|
+
index = len(raw)
|
|
286
|
+
break
|
|
287
|
+
index = close + 3
|
|
288
|
+
return ""
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _audit_readme(path: Path, root: Path, report: Report) -> list[Item]:
|
|
292
|
+
relative = rel(path, root)
|
|
293
|
+
text, _ = read_text(path)
|
|
294
|
+
headings, fences, links, lines = scan_structure(text)
|
|
295
|
+
items: list[Item] = []
|
|
296
|
+
|
|
297
|
+
item = Item(ident="readme.h1", title="README has one H1", weight=8)
|
|
298
|
+
h1 = [h for h in headings if h.level == 1]
|
|
299
|
+
if len(h1) == 1:
|
|
300
|
+
item.status = "pass"
|
|
301
|
+
item.detail = h1[0].text
|
|
302
|
+
if len(h1[0].text) > 60:
|
|
303
|
+
report.warn("readme.title", f"H1 is {len(h1[0].text)} characters", path=relative, line=h1[0].line,
|
|
304
|
+
hint="keep the title under 60 characters")
|
|
305
|
+
elif not h1:
|
|
306
|
+
item.status = "fail"
|
|
307
|
+
item.detail = "no H1"
|
|
308
|
+
report.error("readme.h1", "README has no H1", path=relative, line=1)
|
|
309
|
+
else:
|
|
310
|
+
item.status = "fail"
|
|
311
|
+
item.detail = f"{len(h1)} H1"
|
|
312
|
+
report.error("readme.h1", f"README has {len(h1)} H1 headings", path=relative, line=h1[1].line)
|
|
313
|
+
items.append(item)
|
|
314
|
+
|
|
315
|
+
item = Item(ident="readme.summary", title="one-paragraph summary after the title", weight=6)
|
|
316
|
+
summary = _summary_after_h1(lines, h1[0].line) if h1 else ""
|
|
317
|
+
if summary and not summary.startswith(("#", "!", "[![", "|", ">", "<")):
|
|
318
|
+
item.status = "pass"
|
|
319
|
+
item.detail = summary[:80]
|
|
320
|
+
if EMOJI_RE.search(summary):
|
|
321
|
+
report.warn("readme.emoji", "emoji in the summary line", path=relative, hint="plain words read better in search results")
|
|
322
|
+
if SUPERLATIVES.search(summary):
|
|
323
|
+
report.warn("readme.superlative", "superlative in the summary line", path=relative,
|
|
324
|
+
hint="state a capability a reader can check")
|
|
325
|
+
if len(summary) > 400:
|
|
326
|
+
report.warn("readme.summary", f"summary paragraph is {len(summary)} characters",
|
|
327
|
+
path=relative, hint="one or two sentences; details belong in sections")
|
|
328
|
+
else:
|
|
329
|
+
item.status = "fail"
|
|
330
|
+
item.detail = summary[:80] or "no prose after the title"
|
|
331
|
+
report.error("readme.summary", "no summary paragraph directly after the H1", path=relative,
|
|
332
|
+
hint="one sentence: what it is and for whom")
|
|
333
|
+
items.append(item)
|
|
334
|
+
|
|
335
|
+
item = Item(ident="readme.badges", title="at least three badges", weight=5)
|
|
336
|
+
badge_lines = [raw for raw in lines[:40] if BADGE_RE.search(raw)]
|
|
337
|
+
count = len(badge_lines)
|
|
338
|
+
if count >= 3:
|
|
339
|
+
item.status = "pass"
|
|
340
|
+
item.detail = f"{count} badge(s)"
|
|
341
|
+
elif count:
|
|
342
|
+
item.status = "warn"
|
|
343
|
+
item.detail = f"{count} badge(s)"
|
|
344
|
+
report.warn("readme.badges", f"only {count} badge(s)", path=relative, hint="3-8 badges: CI, release, license, runtime")
|
|
345
|
+
else:
|
|
346
|
+
item.status = "fail"
|
|
347
|
+
item.detail = "no badges"
|
|
348
|
+
report.error("readme.badges", "no status badges", path=relative, hint="add CI, release and license badges")
|
|
349
|
+
items.append(item)
|
|
350
|
+
|
|
351
|
+
item = Item(ident="readme.toc", title="table of contents", weight=4)
|
|
352
|
+
has_toc = any(h.text.strip().lower().strip(":") in mdcheck.TOC_TITLES for h in headings)
|
|
353
|
+
item.status = "pass" if has_toc else "warn"
|
|
354
|
+
item.detail = "present" if has_toc else "absent"
|
|
355
|
+
if not has_toc:
|
|
356
|
+
report.warn("readme.toc", "no table of contents", path=relative,
|
|
357
|
+
hint="needed once the README passes ~200 lines")
|
|
358
|
+
items.append(item)
|
|
359
|
+
|
|
360
|
+
section_titles = {h.text.strip().lower() for h in headings}
|
|
361
|
+
for ident, weight, aliases, label in [
|
|
362
|
+
("readme.install", 6, ("installation", "install", "установка", "быстрый старт", "quick start", "getting started"), "installation"),
|
|
363
|
+
("readme.usage", 6, ("usage", "quick start", "быстрый старт", "использование", "примеры", "examples", "сценарии использования"), "usage"),
|
|
364
|
+
("readme.license", 5, ("license", "лицензия"), "license"),
|
|
365
|
+
]:
|
|
366
|
+
item = Item(ident=ident, title=f"{label} section", weight=weight)
|
|
367
|
+
found = any(alias in title for title in section_titles for alias in aliases)
|
|
368
|
+
item.status = "pass" if found else "fail"
|
|
369
|
+
item.detail = "present" if found else "absent"
|
|
370
|
+
if not found:
|
|
371
|
+
report.error(ident, f"README has no {label} section", path=relative,
|
|
372
|
+
hint=f"add a '## {label.capitalize()}' section")
|
|
373
|
+
items.append(item)
|
|
374
|
+
|
|
375
|
+
if "```" in text:
|
|
376
|
+
fences_without_lang = [f for f in fences if f.info == "" and f.closed_at]
|
|
377
|
+
if fences_without_lang:
|
|
378
|
+
report.warn("readme.code-fence", f"{len(fences_without_lang)} fence(s) without a language",
|
|
379
|
+
path=relative, line=fences_without_lang[0].line)
|
|
380
|
+
|
|
381
|
+
for link in links:
|
|
382
|
+
if link.target.startswith("http") and "github.com" in link.target and link.target.endswith((".md",)):
|
|
383
|
+
report.info("readme.absolute-link", f"absolute link to a markdown file: {link.target}",
|
|
384
|
+
path=relative, line=link.line, hint="a relative link renders in the tree and in the wiki mirror")
|
|
385
|
+
return items
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
CHANGELOG_ALLOWED_EN = ["Added", "Changed", "Deprecated", "Removed", "Fixed", "Security"]
|
|
389
|
+
CHANGELOG_ALLOWED_RU = ["Добавлено", "Изменено", "Устарело", "Удалено", "Исправлено", "Безопасность"]
|
|
390
|
+
VERSION_HEADING_RE = re.compile(
|
|
391
|
+
r"^##\s+\[?(?P<version>\d+\.\d+\.\d+(?:-[0-9A-Za-z.\-]+)?)\]?"
|
|
392
|
+
r"(?:\s*[-—–]\s*(?P<date>\d{4}-\d{2}-\d{2}))?\s*$"
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _audit_changelog(path: Path, root: Path, report: Report) -> list[Item]:
|
|
397
|
+
relative = rel(path, root)
|
|
398
|
+
text, _ = read_text(path)
|
|
399
|
+
headings, _, _, lines = scan_structure(text)
|
|
400
|
+
items: list[Item] = []
|
|
401
|
+
|
|
402
|
+
item = Item(ident="changelog.versions", title="versions use semantic versioning with ISO dates", weight=6)
|
|
403
|
+
versions = []
|
|
404
|
+
for heading in headings:
|
|
405
|
+
if heading.level != 2:
|
|
406
|
+
continue
|
|
407
|
+
match = VERSION_HEADING_RE.match(f"## {heading.text}")
|
|
408
|
+
if match:
|
|
409
|
+
versions.append((heading, match.group("version"), match.group("date")))
|
|
410
|
+
elif heading.text.strip().strip("[]").lower() not in {"unreleased", "не выпущено", "невыпущено"}:
|
|
411
|
+
report.warn("changelog.heading", f"H2 '{heading.text}' is neither a version nor Unreleased",
|
|
412
|
+
path=relative, line=heading.line)
|
|
413
|
+
if versions:
|
|
414
|
+
item.status = "pass"
|
|
415
|
+
item.detail = f"{len(versions)} version(s)"
|
|
416
|
+
previous = None
|
|
417
|
+
for heading, version, date in versions:
|
|
418
|
+
if not date:
|
|
419
|
+
report.warn("changelog.date", f"version {version} has no ISO date", path=relative, line=heading.line,
|
|
420
|
+
hint="## [x.y.z] - YYYY-MM-DD")
|
|
421
|
+
if previous and date and date > previous:
|
|
422
|
+
report.error("changelog.order", f"version {version} is dated after a newer entry",
|
|
423
|
+
path=relative, line=heading.line, hint="newest release first")
|
|
424
|
+
previous = date or previous
|
|
425
|
+
else:
|
|
426
|
+
item.status = "fail"
|
|
427
|
+
item.detail = "no version heading"
|
|
428
|
+
report.error("changelog.versions", "no '## [x.y.z] - YYYY-MM-DD' heading", path=relative)
|
|
429
|
+
items.append(item)
|
|
430
|
+
|
|
431
|
+
item = Item(ident="changelog.unreleased", title="Unreleased section", weight=3)
|
|
432
|
+
has_unreleased = any(h.level == 2 and h.text.strip().strip("[]").lower() in {"unreleased", "не выпущено"} for h in headings)
|
|
433
|
+
item.status = "pass" if has_unreleased else "warn"
|
|
434
|
+
item.detail = "present" if has_unreleased else "absent"
|
|
435
|
+
if not has_unreleased:
|
|
436
|
+
report.warn("changelog.unreleased", "no Unreleased section", path=relative,
|
|
437
|
+
hint="## [Unreleased] keeps pending work visible")
|
|
438
|
+
items.append(item)
|
|
439
|
+
|
|
440
|
+
item = Item(ident="changelog.types", title="change types from the standard set", weight=5)
|
|
441
|
+
allowed = set(CHANGELOG_ALLOWED_EN) | set(CHANGELOG_ALLOWED_RU)
|
|
442
|
+
h3 = [h for h in headings if h.level == 3]
|
|
443
|
+
unknown = [h for h in h3 if h.text.strip() not in allowed]
|
|
444
|
+
if h3 and not unknown:
|
|
445
|
+
item.status = "pass"
|
|
446
|
+
item.detail = f"{len(h3)} typed section(s)"
|
|
447
|
+
elif unknown:
|
|
448
|
+
item.status = "warn"
|
|
449
|
+
item.detail = f"unknown: {', '.join(h.text for h in unknown[:3])}"
|
|
450
|
+
for heading in unknown:
|
|
451
|
+
report.warn("changelog.types", f"unknown change type '{heading.text}'", path=relative, line=heading.line,
|
|
452
|
+
hint="allowed: " + ", ".join(CHANGELOG_ALLOWED_EN))
|
|
453
|
+
else:
|
|
454
|
+
item.status = "fail"
|
|
455
|
+
item.detail = "no typed subsections"
|
|
456
|
+
report.error("changelog.types", "versions have no Added/Changed/Fixed subsections", path=relative)
|
|
457
|
+
items.append(item)
|
|
458
|
+
return items
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _audit_agents(path: Path, root: Path, report: Report) -> list[Item]:
|
|
462
|
+
relative = rel(path, root)
|
|
463
|
+
text, _ = read_text(path)
|
|
464
|
+
items = [Item(ident="agents.present", title="AGENTS.md present", weight=5, status="pass", detail=relative)]
|
|
465
|
+
lowered = text.lower()
|
|
466
|
+
for ident, needles, weight, label in [
|
|
467
|
+
("agents.commands", ("```", "`"), 4, "copy-paste commands"),
|
|
468
|
+
("agents.verify", ("проверк", "verify", "check"), 3, "a verification step"),
|
|
469
|
+
("agents.traps", ("ловушк", "trap", "не делай", "do not", "запрещ"), 2, "explicit prohibitions"),
|
|
470
|
+
]:
|
|
471
|
+
item = Item(ident=ident, title=label, weight=weight)
|
|
472
|
+
found = any(needle in lowered for needle in needles)
|
|
473
|
+
item.status = "pass" if found else "warn"
|
|
474
|
+
item.detail = "present" if found else "absent"
|
|
475
|
+
if not found:
|
|
476
|
+
report.warn(ident, f"AGENTS.md has no {label}", path=relative)
|
|
477
|
+
items.append(item)
|
|
478
|
+
return items
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
# --------------------------------------------------------------------------
|
|
482
|
+
# remote audit
|
|
483
|
+
# --------------------------------------------------------------------------
|
|
484
|
+
|
|
485
|
+
def _api(path: str) -> dict | None:
|
|
486
|
+
if which("gh"):
|
|
487
|
+
rc, out, err = run(["gh", "api", path])
|
|
488
|
+
if rc == 0 and out.strip():
|
|
489
|
+
try:
|
|
490
|
+
return json.loads(out)
|
|
491
|
+
except json.JSONDecodeError:
|
|
492
|
+
return None
|
|
493
|
+
if rc != 0 and "gh auth" in err.lower():
|
|
494
|
+
return None
|
|
495
|
+
return None
|
|
496
|
+
token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")
|
|
497
|
+
if not token:
|
|
498
|
+
return None
|
|
499
|
+
request = urllib.request.Request(
|
|
500
|
+
f"https://api.github.com/{path.lstrip('/')}",
|
|
501
|
+
headers={"User-Agent": "ghagent", "Authorization": f"Bearer {token}",
|
|
502
|
+
"Accept": "application/vnd.github+json"},
|
|
503
|
+
)
|
|
504
|
+
try:
|
|
505
|
+
with urllib.request.urlopen(request, timeout=25) as response:
|
|
506
|
+
return json.loads(response.read().decode("utf-8"))
|
|
507
|
+
except (urllib.error.URLError, urllib.error.HTTPError, json.JSONDecodeError, TimeoutError):
|
|
508
|
+
return None
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def audit_remote(slug: str, *, config: cfg.Config | None = None, check_http: bool = False) -> AuditResult:
|
|
512
|
+
report = Report(title=f"ghagent audit --repo {slug}")
|
|
513
|
+
result = AuditResult(report=report)
|
|
514
|
+
repo = _api(f"repos/{slug}")
|
|
515
|
+
if repo is None:
|
|
516
|
+
report.error("remote.unavailable", f"cannot read {slug} through gh api or GITHUB_TOKEN",
|
|
517
|
+
hint="run `gh auth status`; without metadata no remote item can be examined")
|
|
518
|
+
return result
|
|
519
|
+
|
|
520
|
+
description = (repo.get("description") or "").strip()
|
|
521
|
+
topics = repo.get("topics") or []
|
|
522
|
+
homepage = (repo.get("homepage") or "").strip()
|
|
523
|
+
limit = (config or cfg.Config(root=Path("."))).description_max
|
|
524
|
+
topic_min, topic_max = 8, 20
|
|
525
|
+
if config:
|
|
526
|
+
topic_min, topic_max = config.topic_min, config.topic_max
|
|
527
|
+
|
|
528
|
+
def item(ident: str, title: str, weight: int, ok: bool, detail: str, level: str = "warn") -> None:
|
|
529
|
+
entry = Item(ident=ident, title=title, weight=weight, status="pass" if ok else "fail", detail=detail)
|
|
530
|
+
result.items.append(entry)
|
|
531
|
+
if not ok:
|
|
532
|
+
report.add(level, ident, detail)
|
|
533
|
+
|
|
534
|
+
item("meta.description", "description set", 8, bool(description), f"{len(description)} character(s)", "error")
|
|
535
|
+
if description:
|
|
536
|
+
item("meta.description-length", f"description under {limit} characters", 4, len(description) <= limit,
|
|
537
|
+
f"{len(description)} character(s)")
|
|
538
|
+
lowered = description.lower()
|
|
539
|
+
bad_prefix = lowered.startswith(("a tool", "a simple", "this is", "the tool"))
|
|
540
|
+
item("meta.description-form", "description starts with the category noun", 4, not bad_prefix,
|
|
541
|
+
"starts with a wasted prefix" if bad_prefix else "ok")
|
|
542
|
+
if EMOJI_RE.search(description):
|
|
543
|
+
report.warn("meta.emoji", "emoji in the repository description", hint="descriptions are metadata, not chat")
|
|
544
|
+
if SUPERLATIVES.search(description):
|
|
545
|
+
report.warn("meta.superlative", "superlative in the repository description")
|
|
546
|
+
item("meta.topics-count", f"{topic_min}-{topic_max} topics", 8, topic_min <= len(topics) <= topic_max,
|
|
547
|
+
f"{len(topics)} topic(s)")
|
|
548
|
+
bad_topics = [t for t in topics if not re.fullmatch(r"[a-z0-9]+(?:-[a-z0-9]+)*", t)]
|
|
549
|
+
item("meta.topics-form", "topics are lowercase and hyphenated", 4, not bad_topics,
|
|
550
|
+
f"offenders: {', '.join(bad_topics[:5])}" if bad_topics else "ok")
|
|
551
|
+
if len(topics) > 20:
|
|
552
|
+
report.error("meta.topics-overflow", f"{len(topics)} topics requested; the API stores at most 20")
|
|
553
|
+
item("meta.homepage", "homepage set", 4, bool(homepage), homepage or "empty", "info")
|
|
554
|
+
item("wiki.enabled", "wiki enabled", 4, bool(repo.get("has_wiki")), f"has_wiki={repo.get('has_wiki')}", "info")
|
|
555
|
+
license_info = (repo.get("license") or {}).get("spdx_id") or ""
|
|
556
|
+
item("meta.license", "license detected by GitHub", 6, license_info not in ("", "NOASSERTION"),
|
|
557
|
+
license_info or "not detected", "error")
|
|
558
|
+
item("meta.releases", "at least one release", 4, True, "unchecked here", "info")
|
|
559
|
+
|
|
560
|
+
releases = _api(f"repos/{slug}/releases?per_page=1")
|
|
561
|
+
if releases is None:
|
|
562
|
+
result.items[-1].status = "skipped"
|
|
563
|
+
report.info("remote.releases", "release list unavailable")
|
|
564
|
+
else:
|
|
565
|
+
result.items[-1] = Item(ident="meta.releases", title="at least one release", weight=4,
|
|
566
|
+
status="pass" if releases else "warn",
|
|
567
|
+
detail=f"{len(releases)} release(s) visible")
|
|
568
|
+
if not releases:
|
|
569
|
+
report.warn("meta.releases", "no release: the project reads as unpublished",
|
|
570
|
+
hint="tag vX.Y.Z with notes from CHANGELOG")
|
|
571
|
+
|
|
572
|
+
profile = _api(f"repos/{slug}/community/profile")
|
|
573
|
+
if profile is None:
|
|
574
|
+
report.info("remote.community", "community profile unavailable")
|
|
575
|
+
else:
|
|
576
|
+
percentage = profile.get("health_percentage")
|
|
577
|
+
entry = Item(ident="meta.community-health", title="community health", weight=5,
|
|
578
|
+
status="pass" if (percentage or 0) >= 80 else "warn",
|
|
579
|
+
detail=f"{percentage}%")
|
|
580
|
+
result.items.append(entry)
|
|
581
|
+
if (percentage or 0) < 80:
|
|
582
|
+
report.warn("meta.community-health", f"community health {percentage}%",
|
|
583
|
+
hint="add CODE_OF_CONDUCT, SUPPORT, issue forms, PR template")
|
|
584
|
+
|
|
585
|
+
if check_http and homepage:
|
|
586
|
+
ok = _http_ok(homepage)
|
|
587
|
+
item("meta.homepage-live", "homepage returns 200", 4, ok, homepage if ok else "did not answer 200")
|
|
588
|
+
|
|
589
|
+
percentage, scored, skipped = result.score()
|
|
590
|
+
report.data.update({
|
|
591
|
+
"score": percentage,
|
|
592
|
+
"scored_items": scored,
|
|
593
|
+
"skipped_items": skipped,
|
|
594
|
+
"items": [i.as_dict() for i in result.items],
|
|
595
|
+
"mode": "remote",
|
|
596
|
+
"repository": {
|
|
597
|
+
"full_name": repo.get("full_name"),
|
|
598
|
+
"description": description,
|
|
599
|
+
"topics": topics,
|
|
600
|
+
"homepage": homepage,
|
|
601
|
+
"has_wiki": repo.get("has_wiki"),
|
|
602
|
+
"has_pages": repo.get("has_pages"),
|
|
603
|
+
"license": license_info,
|
|
604
|
+
"default_branch": repo.get("default_branch"),
|
|
605
|
+
},
|
|
606
|
+
})
|
|
607
|
+
if scored == 0:
|
|
608
|
+
report.error("audit.no-items", "no remote item could be examined")
|
|
609
|
+
return result
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def _http_ok(url: str) -> bool:
|
|
613
|
+
try:
|
|
614
|
+
request = urllib.request.Request(url, method="HEAD", headers={"User-Agent": "ghagent"})
|
|
615
|
+
with urllib.request.urlopen(request, timeout=20) as response:
|
|
616
|
+
return 200 <= response.status < 400
|
|
617
|
+
except Exception: # noqa: BLE001 - any transport failure means "not proven alive"
|
|
618
|
+
return False
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
# --------------------------------------------------------------------------
|
|
622
|
+
# markdown rendering
|
|
623
|
+
# --------------------------------------------------------------------------
|
|
624
|
+
|
|
625
|
+
def render_markdown(result: AuditResult, *, limit: int = 40) -> str:
|
|
626
|
+
report = result.report
|
|
627
|
+
percentage, scored, skipped = result.score()
|
|
628
|
+
lines = [
|
|
629
|
+
f"## Audit — {report.root or report.title}",
|
|
630
|
+
"",
|
|
631
|
+
f"* hygiene score: **{percentage}%** ({scored} item(s) scored, {skipped} skipped)",
|
|
632
|
+
f"* findings: {len(report.errors)} error(s), {len(report.warnings)} warning(s)",
|
|
633
|
+
"",
|
|
634
|
+
"| Item | Status | Weight | Detail |",
|
|
635
|
+
"|---|---|---:|---|",
|
|
636
|
+
]
|
|
637
|
+
for item in result.items:
|
|
638
|
+
lines.append(f"| `{item.ident}` | {item.status} | {item.weight} | {item.detail} |")
|
|
639
|
+
problems = [f for f in report.findings if f.level in {"error", "warn"}]
|
|
640
|
+
if problems:
|
|
641
|
+
lines += ["", f"### Findings (first {min(limit, len(problems))} of {len(problems)})", ""]
|
|
642
|
+
for finding in problems[:limit]:
|
|
643
|
+
location = f"`{finding.path}:{finding.line}`" if finding.line else (f"`{finding.path}`" if finding.path else "—")
|
|
644
|
+
lines.append(f"* **{finding.level}** `{finding.rule}` {location} — {finding.message}")
|
|
645
|
+
return "\n".join(lines)
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
def exit_code_for(result: AuditResult) -> int:
|
|
649
|
+
return EXIT_FAIL if result.report.errors else EXIT_OK
|