github-agent-cli 1.2.2__py3-none-any.whl → 1.3.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.
- ghagent.py +66 -31
- ghalib/assets.py +46 -16
- ghalib/audit.py +7 -2
- ghalib/changelog.py +53 -8
- ghalib/config.py +7 -0
- ghalib/mdcheck.py +67 -12
- ghalib/meta.py +0 -1
- ghalib/report.py +1 -1
- ghalib/util.py +28 -3
- ghalib/wiki.py +38 -3
- {github_agent_cli-1.2.2.dist-info → github_agent_cli-1.3.0.dist-info}/METADATA +62 -17
- github_agent_cli-1.3.0.dist-info/RECORD +17 -0
- github_agent_cli-1.2.2.dist-info/RECORD +0 -17
- {github_agent_cli-1.2.2.dist-info → github_agent_cli-1.3.0.dist-info}/WHEEL +0 -0
- {github_agent_cli-1.2.2.dist-info → github_agent_cli-1.3.0.dist-info}/entry_points.txt +0 -0
- {github_agent_cli-1.2.2.dist-info → github_agent_cli-1.3.0.dist-info}/licenses/LICENSE +0 -0
ghagent.py
CHANGED
|
@@ -27,16 +27,16 @@ HERE = Path(__file__).resolve().parent
|
|
|
27
27
|
if str(HERE) not in sys.path:
|
|
28
28
|
sys.path.insert(0, str(HERE))
|
|
29
29
|
|
|
30
|
-
from ghalib import assets as assets_module
|
|
31
|
-
from ghalib import audit as audit_module
|
|
32
|
-
from ghalib import changelog as changelog_module
|
|
33
|
-
from ghalib import config as cfg
|
|
34
|
-
from ghalib import install as install_module
|
|
35
|
-
from ghalib import mdcheck
|
|
36
|
-
from ghalib import meta as meta_module
|
|
37
|
-
from ghalib import report as report_module
|
|
38
|
-
from ghalib import wiki as wiki_module
|
|
39
|
-
from ghalib.util import (
|
|
30
|
+
from ghalib import assets as assets_module # noqa: E402
|
|
31
|
+
from ghalib import audit as audit_module # noqa: E402
|
|
32
|
+
from ghalib import changelog as changelog_module # noqa: E402
|
|
33
|
+
from ghalib import config as cfg # noqa: E402
|
|
34
|
+
from ghalib import install as install_module # noqa: E402
|
|
35
|
+
from ghalib import mdcheck # noqa: E402
|
|
36
|
+
from ghalib import meta as meta_module # noqa: E402
|
|
37
|
+
from ghalib import report as report_module # noqa: E402
|
|
38
|
+
from ghalib import wiki as wiki_module # noqa: E402
|
|
39
|
+
from ghalib.util import ( # noqa: E402
|
|
40
40
|
EXIT_FAIL,
|
|
41
41
|
EXIT_OK,
|
|
42
42
|
EXIT_USAGE,
|
|
@@ -46,7 +46,6 @@ from ghalib.util import ( # noqa: E402
|
|
|
46
46
|
emit,
|
|
47
47
|
fail,
|
|
48
48
|
force_utf8_stdio,
|
|
49
|
-
read_text,
|
|
50
49
|
rel,
|
|
51
50
|
write_text,
|
|
52
51
|
)
|
|
@@ -138,22 +137,33 @@ def cmd_audit(args: argparse.Namespace) -> int:
|
|
|
138
137
|
|
|
139
138
|
def cmd_doc(args: argparse.Namespace) -> int:
|
|
140
139
|
root = Path(args.root)
|
|
140
|
+
config = cfg.load(root)
|
|
141
|
+
# `.ghagent.json` carries `ignore_rules` for the whole pack; `audit` applied them and
|
|
142
|
+
# `doc` did not, so the two commands disagreed about the same tree.
|
|
143
|
+
ignore = {item.strip() for item in args.ignore.split(",") if item.strip()}
|
|
144
|
+
ignore |= {item.strip() for item in config.ignore_rules if item.strip()}
|
|
141
145
|
options = mdcheck.DocCheckOptions(
|
|
142
|
-
ignore=
|
|
146
|
+
ignore=ignore,
|
|
143
147
|
privacy=not args.no_privacy,
|
|
144
148
|
toc=not args.no_toc,
|
|
149
|
+
safe_email_domains=set(config.safe_email_domains),
|
|
150
|
+
wiki_dirs={config.wiki_dir} if config.wiki_dir else set(),
|
|
145
151
|
)
|
|
146
152
|
report = Report(title="ghagent doc", root=str(root))
|
|
147
153
|
checker = mdcheck.DocChecker(root, report, options)
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
154
|
+
if args.paths:
|
|
155
|
+
targets = [Path(item) for item in args.paths]
|
|
156
|
+
else:
|
|
157
|
+
# One walker for the whole pack: it knows which directories are build output or
|
|
158
|
+
# tool state (`.git`, `.swarm`, `node_modules`), so `doc` and `privacy` see the
|
|
159
|
+
# same tree. The wiki sources keep their own contract (`wiki build`/`wiki verify`).
|
|
160
|
+
targets = []
|
|
161
|
+
exclude = set(config.exclude_dirs)
|
|
162
|
+
for path in mdcheck.iter_text_files(root, suffix_set=set(mdcheck.MARKDOWN_SUFFIXES), exclude=exclude):
|
|
163
|
+
if not args.include_wiki and config.wiki_dir in path.relative_to(root).parts:
|
|
164
|
+
continue
|
|
165
|
+
targets.append(path)
|
|
166
|
+
targets.sort()
|
|
157
167
|
if not targets:
|
|
158
168
|
return fail("no markdown files found to check")
|
|
159
169
|
for path in targets:
|
|
@@ -171,7 +181,7 @@ def cmd_privacy(args: argparse.Namespace) -> int:
|
|
|
171
181
|
"agents.md-length"}
|
|
172
182
|
report = Report(title="ghagent privacy", root=str(root))
|
|
173
183
|
checker = mdcheck.DocChecker(root, report, options)
|
|
174
|
-
for path in mdcheck.iter_text_files(root):
|
|
184
|
+
for path in mdcheck.iter_text_files(root, exclude=set(cfg.load(root).exclude_dirs)):
|
|
175
185
|
checker.check_file(path)
|
|
176
186
|
checker._report_coverage()
|
|
177
187
|
return emit(report, args.json)
|
|
@@ -180,19 +190,23 @@ def cmd_privacy(args: argparse.Namespace) -> int:
|
|
|
180
190
|
def cmd_wiki(args: argparse.Namespace) -> int:
|
|
181
191
|
root = Path(args.root)
|
|
182
192
|
config = cfg.load(root)
|
|
193
|
+
# None means "not given"; the default is applied here so that `verify` can tell an
|
|
194
|
+
# explicit `--wiki-dir <clone>` from the default `wiki/`.
|
|
195
|
+
wiki_dir_given = args.wiki_dir is not None
|
|
196
|
+
wiki_dir = args.wiki_dir or "wiki"
|
|
183
197
|
if args.action == "init":
|
|
184
198
|
report = wiki_module.scaffold(root, slug=args.repo or config.owner_repo, title=args.title,
|
|
185
|
-
wiki_dir=
|
|
199
|
+
wiki_dir=wiki_dir, from_docs=not args.no_docs)
|
|
186
200
|
return emit(report, args.json)
|
|
187
201
|
if args.action == "build":
|
|
188
|
-
report = wiki_module.build(root, wiki_dir=
|
|
202
|
+
report = wiki_module.build(root, wiki_dir=wiki_dir, manifest_path=args.manifest,
|
|
189
203
|
out_dir=Path(args.out) if args.out else None)
|
|
190
204
|
if not args.json and report.data.get("out"):
|
|
191
205
|
print(f"built {report.data.get('pages')} page(s) into {report.data['out']}")
|
|
192
206
|
return emit(report, args.json)
|
|
193
207
|
if args.action == "verify":
|
|
194
|
-
|
|
195
|
-
|
|
208
|
+
report = wiki_module.verify_target(root, out=Path(args.out) if args.out else None,
|
|
209
|
+
wiki_dir=wiki_dir, wiki_dir_given=wiki_dir_given)
|
|
196
210
|
return emit(report, args.json)
|
|
197
211
|
if args.action == "publish":
|
|
198
212
|
slug = args.repo or config.owner_repo
|
|
@@ -272,9 +286,29 @@ def cmd_meta(args: argparse.Namespace) -> int:
|
|
|
272
286
|
return report.exit_code()
|
|
273
287
|
if args.action == "check":
|
|
274
288
|
report = Report(title="ghagent meta check")
|
|
275
|
-
|
|
289
|
+
slug = args.repo or ""
|
|
290
|
+
description = args.description
|
|
291
|
+
topics = [item.strip() for item in args.topics.split(",") if item.strip()] if args.topics else []
|
|
292
|
+
if slug:
|
|
293
|
+
# `--repo` used to be accepted and ignored: the command validated the empty
|
|
294
|
+
# arguments and reported "description is empty, 0 topics" for a repository that
|
|
295
|
+
# had both. A remote that cannot be read is an error, not an empty field.
|
|
296
|
+
current = meta_module.read_remote(slug)
|
|
297
|
+
if current is None:
|
|
298
|
+
report.error("meta.remote", f"cannot read {slug} through gh api",
|
|
299
|
+
hint="run `gh auth status`; an unread remote is not an empty description")
|
|
300
|
+
return emit(report, args.json)
|
|
301
|
+
if description is None:
|
|
302
|
+
description = (current.get("description") or "").strip()
|
|
303
|
+
if not topics:
|
|
304
|
+
topics = list(current.get("topics") or [])
|
|
305
|
+
report.data["source"] = f"gh api repos/{slug}"
|
|
306
|
+
report.data["topics"] = topics
|
|
307
|
+
report.data["description"] = description
|
|
308
|
+
for level, rule, message in meta_module.validate_description(description):
|
|
276
309
|
report.add(level, rule, message)
|
|
277
|
-
|
|
310
|
+
if not topics:
|
|
311
|
+
report.info("meta.source", "topics were not given and no remote was read")
|
|
278
312
|
for level, rule, message in meta_module.check_topics(topics, minimum=config.topic_min,
|
|
279
313
|
maximum=config.topic_max):
|
|
280
314
|
report.add(level, rule, message)
|
|
@@ -283,11 +317,12 @@ def cmd_meta(args: argparse.Namespace) -> int:
|
|
|
283
317
|
slug = args.repo or config.owner_repo
|
|
284
318
|
if not slug:
|
|
285
319
|
return fail("meta apply needs --repo owner/name or owner_repo in .ghagent.json")
|
|
286
|
-
|
|
320
|
+
apply_topics: list[str] | None = ([item.strip() for item in args.topics.split(",") if item.strip()]
|
|
321
|
+
if args.topics else None)
|
|
287
322
|
report = meta_module.apply_metadata(
|
|
288
323
|
slug,
|
|
289
324
|
description=args.description,
|
|
290
|
-
topics=
|
|
325
|
+
topics=apply_topics,
|
|
291
326
|
homepage=args.homepage,
|
|
292
327
|
merge_topics=not args.replace_topics,
|
|
293
328
|
write=args.write,
|
|
@@ -431,7 +466,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
431
466
|
wiki_parser.add_argument("--root", default=".")
|
|
432
467
|
wiki_parser.add_argument("--repo", default="")
|
|
433
468
|
wiki_parser.add_argument("--title", default="Project")
|
|
434
|
-
wiki_parser.add_argument("--wiki-dir", default=
|
|
469
|
+
wiki_parser.add_argument("--wiki-dir", default=None)
|
|
435
470
|
wiki_parser.add_argument("--manifest", default="wiki/wiki.json")
|
|
436
471
|
wiki_parser.add_argument("--out", default="")
|
|
437
472
|
wiki_parser.add_argument("--push", action="store_true", help="actually push to the wiki remote")
|
ghalib/assets.py
CHANGED
|
@@ -16,20 +16,24 @@ nothing at all. A missing Pillow lowers the output format, never the honesty of
|
|
|
16
16
|
|
|
17
17
|
from __future__ import annotations
|
|
18
18
|
|
|
19
|
-
import base64
|
|
20
19
|
import re
|
|
21
20
|
import struct
|
|
22
21
|
import zlib
|
|
23
22
|
from dataclasses import dataclass, field
|
|
24
23
|
from pathlib import Path
|
|
25
24
|
|
|
26
|
-
from .util import IMAGE_RE, Report,
|
|
25
|
+
from .util import IMAGE_RE, Report, read_text, rel
|
|
27
26
|
|
|
28
27
|
SOCIAL_SIZE = (1280, 640)
|
|
29
28
|
SOCIAL_MIN = (640, 320)
|
|
30
29
|
SOCIAL_MAX_BYTES = 1_000_000
|
|
31
30
|
IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".avif"}
|
|
32
31
|
SOCIAL_ACCEPTED = {".png", ".jpg", ".jpeg", ".gif"}
|
|
32
|
+
# An emoji in an H1 is fine on GitHub and unreadable on a rendered card: the font set has
|
|
33
|
+
# no colour glyphs, so it lands as a box or disappears. The banner strips it.
|
|
34
|
+
EMOJI_RE = re.compile(
|
|
35
|
+
"[\U0001F300-\U0001FAFF\U00002600-\U000027BF\U0001F1E6-\U0001F1FF\u2b00-\u2bff\uFE0F]"
|
|
36
|
+
)
|
|
33
37
|
|
|
34
38
|
PALETTE = {
|
|
35
39
|
"bg": "#0d1117",
|
|
@@ -120,26 +124,29 @@ def _escape(text: str) -> str:
|
|
|
120
124
|
|
|
121
125
|
|
|
122
126
|
def _wrap(text: str, limit: int, max_lines: int) -> list[str]:
|
|
127
|
+
"""Wrap into at most `max_lines` lines of about `limit` characters.
|
|
128
|
+
|
|
129
|
+
The last line is filled before anything is dropped: an earlier version stopped at
|
|
130
|
+
`max_lines - 1` lines and marked the rest as cut, so a 38-character title was reported
|
|
131
|
+
as truncated although two lines had room for it.
|
|
132
|
+
"""
|
|
123
133
|
words = text.split()
|
|
124
134
|
lines: list[str] = []
|
|
125
135
|
current = ""
|
|
126
136
|
for word in words:
|
|
127
137
|
candidate = f"{current} {word}".strip()
|
|
128
|
-
if len(candidate) <= limit:
|
|
138
|
+
if not current or len(candidate) <= limit:
|
|
129
139
|
current = candidate
|
|
130
140
|
continue
|
|
131
|
-
|
|
132
|
-
lines.append(current)
|
|
141
|
+
lines.append(current)
|
|
133
142
|
current = word
|
|
134
|
-
if len(lines) == max_lines
|
|
143
|
+
if len(lines) == max_lines:
|
|
135
144
|
break
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
return []
|
|
140
|
-
|
|
141
|
-
if remainder and lines:
|
|
142
|
-
lines[-1] = lines[-1].rstrip(" .,;") + "…"
|
|
145
|
+
else:
|
|
146
|
+
if current:
|
|
147
|
+
lines.append(current)
|
|
148
|
+
return lines[:max_lines]
|
|
149
|
+
lines[-1] = lines[-1].rstrip(" .,;") + "…"
|
|
143
150
|
return lines
|
|
144
151
|
|
|
145
152
|
|
|
@@ -261,6 +268,11 @@ def render_png(spec: BannerSpec) -> bytes | None:
|
|
|
261
268
|
return buffer.getvalue()
|
|
262
269
|
|
|
263
270
|
|
|
271
|
+
def _truncated(lines: list[str]) -> bool:
|
|
272
|
+
"""`_wrap` marks a loss of text with an ellipsis; the caller must say so out loud."""
|
|
273
|
+
return bool(lines) and lines[-1].endswith("…")
|
|
274
|
+
|
|
275
|
+
|
|
264
276
|
def spec_from_repository(root: Path, *, title: str = "", tagline: str = "", command: str = "",
|
|
265
277
|
chips: list[str] | None = None) -> BannerSpec:
|
|
266
278
|
from . import meta as meta_module
|
|
@@ -277,12 +289,19 @@ def spec_from_repository(root: Path, *, title: str = "", tagline: str = "", comm
|
|
|
277
289
|
if not summary:
|
|
278
290
|
summary = facts.get("summary", "")
|
|
279
291
|
languages = [language for language, _count in facts.get("languages", [])[:3]]
|
|
292
|
+
# Chips describe what the project IS, not what its tooling is written in: a PHP library
|
|
293
|
+
# whose scripts happen to be Python and shell was advertised as "php python shell".
|
|
294
|
+
# Declared keywords come first; languages are only the fallback.
|
|
295
|
+
keywords = [str(word) for word in facts.get("declared_keywords", []) if str(word).strip()][:3]
|
|
296
|
+
default_chips = keywords or languages
|
|
297
|
+
resolved_title = title or facts.get("title") or Path(root).name
|
|
298
|
+
resolved_title = EMOJI_RE.sub("", resolved_title).strip() or Path(root).name
|
|
280
299
|
return BannerSpec(
|
|
281
|
-
title=
|
|
300
|
+
title=resolved_title,
|
|
282
301
|
tagline=tagline or summary[:180],
|
|
283
302
|
command=command,
|
|
284
|
-
chips=(chips if chips is not None else
|
|
285
|
-
footer=" · ".join(filter(None, ["MIT",
|
|
303
|
+
chips=(chips if chips is not None else default_chips + ["mit"])[:6],
|
|
304
|
+
footer=" · ".join(filter(None, ["MIT", languages[0] if languages else ""])),
|
|
286
305
|
)
|
|
287
306
|
|
|
288
307
|
|
|
@@ -308,6 +327,17 @@ def write_assets(root: Path, *, out_dir: str = "assets", spec: BannerSpec | None
|
|
|
308
327
|
"written": [],
|
|
309
328
|
"png_unavailable": "" if png else "Pillow is not installed: only the SVG was produced",
|
|
310
329
|
})
|
|
330
|
+
# A silently cut title is a defect of the render, not of the reader's screen: the PNG
|
|
331
|
+
# is what the social preview shows and it cannot grow.
|
|
332
|
+
if _truncated(_wrap(spec.title, 26, 2)):
|
|
333
|
+
report.warn("assets.truncated", f"the title is cut at two lines ({len(spec.title)} characters)",
|
|
334
|
+
hint="shorten --title: it renders as two lines of about 26 characters")
|
|
335
|
+
if _truncated(_wrap(spec.tagline, 62, 3)):
|
|
336
|
+
report.warn("assets.truncated", f"the tagline is cut at three lines ({len(spec.tagline)} characters)",
|
|
337
|
+
hint="shorten --tagline: it renders as three lines of about 62 characters")
|
|
338
|
+
if not spec.command:
|
|
339
|
+
report.info("assets.no-command", "the banner carries no command line",
|
|
340
|
+
hint="pass --command 'ghagent audit .' to show one")
|
|
311
341
|
if not png:
|
|
312
342
|
report.warn("assets.no-pillow", "Pillow is not available, so no PNG was rendered",
|
|
313
343
|
hint="a social preview must be PNG/JPG/GIF; install Pillow or supply your own image")
|
ghalib/audit.py
CHANGED
|
@@ -27,7 +27,6 @@ from .util import (
|
|
|
27
27
|
EXIT_FAIL,
|
|
28
28
|
EXIT_OK,
|
|
29
29
|
Report,
|
|
30
|
-
guess_repo_slug,
|
|
31
30
|
read_text,
|
|
32
31
|
rel,
|
|
33
32
|
run,
|
|
@@ -187,8 +186,14 @@ def audit_local(root: Path, *, config: cfg.Config | None = None, doc_report: Rep
|
|
|
187
186
|
report.warn("wiki.manifest", f"{config.wiki_manifest} is absent",
|
|
188
187
|
hint="wiki build cannot order pages without a manifest")
|
|
189
188
|
else:
|
|
189
|
+
# A repository may keep its wiki in its own git repository, or not have one at all;
|
|
190
|
+
# neither is a defect. Skipping keeps the item out of the score instead of charging
|
|
191
|
+
# three points for a file the project never intended to publish here.
|
|
190
192
|
result.items.append(Item(ident="wiki-source", title=f"{config.wiki_dir}/", weight=3,
|
|
191
|
-
status="
|
|
193
|
+
status="skipped",
|
|
194
|
+
detail=f"no {config.wiki_dir}/ directory (wiki not maintained in this repository)"))
|
|
195
|
+
report.info("wiki.source-absent", f"no {config.wiki_dir}/ directory; the wiki item is not scored",
|
|
196
|
+
hint="a wiki kept in its own repository is checked in that checkout")
|
|
192
197
|
|
|
193
198
|
assets_report = assets_module.check(root, report=report)
|
|
194
199
|
reference_item = Item(ident="assets.banner", title="README banner", weight=4)
|
ghalib/changelog.py
CHANGED
|
@@ -57,6 +57,11 @@ TYPE_TO_SECTION = {
|
|
|
57
57
|
NOISE_TYPES = {"chore", "test", "tests", "ci", "wip", "release"}
|
|
58
58
|
SECTION_ORDER = {name.lower(): index for index, name in enumerate(TYPES_EN)}
|
|
59
59
|
SECTION_ORDER.update({name.lower(): index for index, name in enumerate(TYPES_RU)})
|
|
60
|
+
# A group heading may carry a subtitle: `### Added — template language`. The subtitle
|
|
61
|
+
# groups entries inside the type, it does not invent a new type, so it is dropped before
|
|
62
|
+
# the lookup. Measured on a real changelog: 10 false "unknown change type" warnings, one
|
|
63
|
+
# per grouped section, on a document that follows Keep a Changelog.
|
|
64
|
+
SUBTITLE_SPLIT = re.compile(r"\s+[—–-]\s+|\s*:\s+")
|
|
60
65
|
|
|
61
66
|
|
|
62
67
|
@dataclass
|
|
@@ -66,10 +71,19 @@ class VersionSection:
|
|
|
66
71
|
line: int
|
|
67
72
|
sections: list[tuple[str, int]] # (heading text, line)
|
|
68
73
|
items: int = 0
|
|
74
|
+
prose: int = 0 # non-blank body lines outside a `###` subsection
|
|
69
75
|
|
|
70
76
|
|
|
71
77
|
def section_name(heading: str) -> str | None:
|
|
72
|
-
|
|
78
|
+
"""Map a `###` heading to a canonical change type, ignoring a grouping subtitle."""
|
|
79
|
+
text = heading.strip()
|
|
80
|
+
canonical = TYPE_ALIASES.get(text.lower())
|
|
81
|
+
if canonical:
|
|
82
|
+
return canonical
|
|
83
|
+
base = SUBTITLE_SPLIT.split(text, maxsplit=1)[0].strip()
|
|
84
|
+
if base != text:
|
|
85
|
+
return TYPE_ALIASES.get(base.lower())
|
|
86
|
+
return None
|
|
73
87
|
|
|
74
88
|
|
|
75
89
|
def parse(text: str) -> tuple[list[VersionSection], dict[str, str]]:
|
|
@@ -91,6 +105,8 @@ def parse(text: str) -> tuple[list[VersionSection], dict[str, str]]:
|
|
|
91
105
|
if level == 2:
|
|
92
106
|
if current and current_section_start is not None:
|
|
93
107
|
current.items += _count_items(lines, current_section_start, index)
|
|
108
|
+
if current:
|
|
109
|
+
current.prose += _count_prose(lines, current.line, index)
|
|
94
110
|
current_section_start = None
|
|
95
111
|
if title.strip().strip("[]").lower() in {"unreleased", "не выпущено", "невыпущено"}:
|
|
96
112
|
current = VersionSection(version="Unreleased", date="", line=index, sections=[])
|
|
@@ -109,9 +125,22 @@ def parse(text: str) -> tuple[list[VersionSection], dict[str, str]]:
|
|
|
109
125
|
current_section_start = index
|
|
110
126
|
if current and current_section_start is not None:
|
|
111
127
|
current.items += _count_items(lines, current_section_start, len(lines) + 1)
|
|
128
|
+
if current:
|
|
129
|
+
current.prose += _count_prose(lines, current.line, len(lines) + 1)
|
|
112
130
|
return versions, references
|
|
113
131
|
|
|
114
132
|
|
|
133
|
+
def _count_prose(lines: list[str], start: int, end: int) -> int:
|
|
134
|
+
"""Body lines that are neither a heading, a list item nor blank."""
|
|
135
|
+
count = 0
|
|
136
|
+
for raw in lines[start:end - 1]:
|
|
137
|
+
stripped = raw.strip()
|
|
138
|
+
if not stripped or stripped.startswith("#") or re.match(r"^[-*]\s+\S", stripped):
|
|
139
|
+
continue
|
|
140
|
+
count += 1
|
|
141
|
+
return count
|
|
142
|
+
|
|
143
|
+
|
|
115
144
|
def _count_items(lines: list[str], start: int, end: int) -> int:
|
|
116
145
|
count = 0
|
|
117
146
|
for raw in lines[start:end - 1]:
|
|
@@ -207,11 +236,23 @@ def check(path: Path, *, report: Report | None = None, slug: str = "", today: dt
|
|
|
207
236
|
previous_key = key
|
|
208
237
|
|
|
209
238
|
if not section.sections:
|
|
210
|
-
|
|
211
|
-
|
|
239
|
+
if section.prose:
|
|
240
|
+
report.warn("changelog.prose-version",
|
|
241
|
+
f"version {section.version} describes its changes in prose, without `###` subsections",
|
|
242
|
+
path=path.name, line=section.line,
|
|
243
|
+
hint="Keep a Changelog reads a version by subsection; a paragraph is accepted but not comparable")
|
|
244
|
+
else:
|
|
245
|
+
report.error("changelog.empty-version", f"version {section.version} has no change subsections",
|
|
246
|
+
path=path.name, line=section.line)
|
|
212
247
|
if section.sections and section.items == 0:
|
|
213
|
-
|
|
214
|
-
|
|
248
|
+
if section.prose:
|
|
249
|
+
report.warn("changelog.prose-version",
|
|
250
|
+
f"version {section.version}: the subsections carry no list items",
|
|
251
|
+
path=path.name, line=section.line,
|
|
252
|
+
hint="list each change as a `- ` item so it can be compared and released")
|
|
253
|
+
else:
|
|
254
|
+
report.error("changelog.empty-version", f"version {section.version} has headings but no entries",
|
|
255
|
+
path=path.name, line=section.line)
|
|
215
256
|
|
|
216
257
|
names: list[str] = []
|
|
217
258
|
for heading, line in section.sections:
|
|
@@ -229,8 +270,12 @@ def check(path: Path, *, report: Report | None = None, slug: str = "", today: dt
|
|
|
229
270
|
uses_brackets = any(re.match(r"^##\s+\[", raw) for raw in lines)
|
|
230
271
|
if uses_brackets:
|
|
231
272
|
if not references:
|
|
232
|
-
|
|
233
|
-
|
|
273
|
+
# Keep a Changelog makes the link references optional: `## [Unreleased]` without
|
|
274
|
+
# a bottom-of-file `[unreleased]:` line is a legal document. Reporting it as an
|
|
275
|
+
# error turned a correct changelog into a red gate (measured: exit 1 on a
|
|
276
|
+
# repository whose only defect was the missing optional block).
|
|
277
|
+
report.warn("changelog.link-refs", "versions use [x.y.z] but there are no link references at the bottom",
|
|
278
|
+
path=path.name, hint="optional in Keep a Changelog; `[x.y.z]: https://…/compare/…` links the versions")
|
|
234
279
|
else:
|
|
235
280
|
for label in ["unreleased", *[v.version.lower() for v in released]]:
|
|
236
281
|
if label not in references:
|
|
@@ -390,7 +435,7 @@ def suggest(root: Path, since: str, *, include_noise: bool = False, limit: int =
|
|
|
390
435
|
breaking: list[str] = []
|
|
391
436
|
for record in records:
|
|
392
437
|
parts = record.split("\x1f")
|
|
393
|
-
|
|
438
|
+
_sha = parts[0].strip()[:7] # the record format is `<sha>\x1f<subject>\x1f<body>`
|
|
394
439
|
subject = parts[1].strip() if len(parts) > 1 else ""
|
|
395
440
|
body = parts[2].strip() if len(parts) > 2 else ""
|
|
396
441
|
match = CONVENTIONAL_RE.match(subject)
|
ghalib/config.py
CHANGED
|
@@ -27,6 +27,13 @@ class Config:
|
|
|
27
27
|
ci_workflow: str = ".github/workflows/ci.yml"
|
|
28
28
|
require_files: list[str] = field(default_factory=list)
|
|
29
29
|
ignore_rules: list[str] = field(default_factory=list)
|
|
30
|
+
# Domains that are role addresses in this repository (`@mycompany.com`). The built-in
|
|
31
|
+
# list holds only RFC examples and GitHub noreply addresses, so no project's personal
|
|
32
|
+
# domain is whitelisted by default for everybody else.
|
|
33
|
+
safe_email_domains: list[str] = field(default_factory=list)
|
|
34
|
+
# Extra directories the documentation walk must not enter (build output whose name the
|
|
35
|
+
# pack cannot know: `site`, `_site`, `public`, `out`).
|
|
36
|
+
exclude_dirs: list[str] = field(default_factory=list)
|
|
30
37
|
topic_min: int = 8
|
|
31
38
|
topic_max: int = 20
|
|
32
39
|
description_max: int = 200
|
ghalib/mdcheck.py
CHANGED
|
@@ -10,11 +10,9 @@ from __future__ import annotations
|
|
|
10
10
|
import re
|
|
11
11
|
from dataclasses import dataclass, field
|
|
12
12
|
from pathlib import Path
|
|
13
|
-
from urllib.parse import unquote
|
|
13
|
+
from urllib.parse import unquote
|
|
14
14
|
|
|
15
15
|
from .util import (
|
|
16
|
-
FENCE_RE,
|
|
17
|
-
IMAGE_RE,
|
|
18
16
|
INLINE_LINK_RE,
|
|
19
17
|
MARKDOWN_SUFFIXES,
|
|
20
18
|
Report,
|
|
@@ -27,6 +25,11 @@ from .util import (
|
|
|
27
25
|
)
|
|
28
26
|
|
|
29
27
|
ALLOWED_ALERTS = {"NOTE", "TIP", "IMPORTANT", "WARNING", "CAUTION"}
|
|
28
|
+
# GitHub wiki furniture: partial pages that carry navigation, not documents. A wiki
|
|
29
|
+
# renders `_Sidebar.md` and `_Footer.md` on every page, so neither the H1 rule nor a
|
|
30
|
+
# page-shaped link contract applies to them (measured: 2 false errors on a 24-page wiki).
|
|
31
|
+
WIKI_FURNITURE = {"_sidebar.md", "_footer.md", "_header.md"}
|
|
32
|
+
WIKI_MODE_MARKERS = ("_Sidebar.md", "_Footer.md")
|
|
30
33
|
# AGENTS.md is the import entry point an agent reads first: a map, not a manual. The
|
|
31
34
|
# canonical root file is the only one this rule measures — translations and nested
|
|
32
35
|
# copies are checked by their own contracts.
|
|
@@ -44,9 +47,11 @@ ABS_PATH_PATTERNS = [
|
|
|
44
47
|
re.compile(r"(?<![\w.])/(?:home|Users)/[a-z0-9._-]+/", re.IGNORECASE),
|
|
45
48
|
]
|
|
46
49
|
PERSONAL_EMAIL_RE = re.compile(r"\b[A-Za-z0-9._%+-]+@([A-Za-z0-9.-]+\.[A-Za-z]{2,})\b")
|
|
50
|
+
# Role addresses only: a personal domain that happens to belong to the pack's author has
|
|
51
|
+
# no place here, or the whitelist becomes a leak of its own. A repository adds its own
|
|
52
|
+
# domains with `safe_email_domains` in `.ghagent.json`.
|
|
47
53
|
SAFE_EMAIL_DOMAINS = {
|
|
48
|
-
"users.noreply.github.com", "example.com", "example.org", "example.net",
|
|
49
|
-
"localhost", "deynekin.com",
|
|
54
|
+
"users.noreply.github.com", "example.com", "example.org", "example.net", "localhost",
|
|
50
55
|
}
|
|
51
56
|
KEBAB_TOPIC_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
|
|
52
57
|
|
|
@@ -67,6 +72,13 @@ class DocCheckOptions:
|
|
|
67
72
|
toc: bool = True
|
|
68
73
|
line_length: int = 200
|
|
69
74
|
max_line_rule: bool = True
|
|
75
|
+
# None means "decide from the tree": a directory that carries `_Sidebar.md` or
|
|
76
|
+
# `_Footer.md` is a wiki checkout, and a wiki addresses pages by name.
|
|
77
|
+
wiki_links: bool | None = None
|
|
78
|
+
# Directory names whose contents are wiki sources even without the generated markers
|
|
79
|
+
# (`wiki/` holds page-name links; the sidebar is generated into the build).
|
|
80
|
+
wiki_dirs: set[str] = field(default_factory=set)
|
|
81
|
+
safe_email_domains: set[str] = field(default_factory=set)
|
|
70
82
|
|
|
71
83
|
|
|
72
84
|
class DocChecker:
|
|
@@ -76,6 +88,26 @@ class DocChecker:
|
|
|
76
88
|
self.options = options or DocCheckOptions()
|
|
77
89
|
self.stats: dict[str, RuleStats] = {}
|
|
78
90
|
self.anchor_cache: dict[Path, set[str]] = {}
|
|
91
|
+
self.wiki_cache: dict[Path, bool] = {}
|
|
92
|
+
|
|
93
|
+
def wiki_tree(self, path: Path) -> bool:
|
|
94
|
+
"""True when the document lives in a GitHub wiki checkout.
|
|
95
|
+
|
|
96
|
+
A wiki links its own pages by name (`[Architecture](Architecture)`), and GitHub
|
|
97
|
+
resolves that to `Architecture.md`. In an ordinary repository the same link is a
|
|
98
|
+
404, so the resolution is enabled only where the context proves it: the directory
|
|
99
|
+
carries `_Sidebar.md` or `_Footer.md`. Measured on a 24-page wiki before the fix:
|
|
100
|
+
119 errors, of which 117 were correct links reported as missing files.
|
|
101
|
+
"""
|
|
102
|
+
if self.options.wiki_links is not None:
|
|
103
|
+
return self.options.wiki_links
|
|
104
|
+
directory = path.parent.resolve()
|
|
105
|
+
if directory not in self.wiki_cache:
|
|
106
|
+
named = bool(self.options.wiki_dirs) and any(part in self.options.wiki_dirs
|
|
107
|
+
for part in path.parts[:-1])
|
|
108
|
+
self.wiki_cache[directory] = named or any((directory / marker).exists()
|
|
109
|
+
for marker in WIKI_MODE_MARKERS)
|
|
110
|
+
return self.wiki_cache[directory]
|
|
79
111
|
|
|
80
112
|
# -- infrastructure ----------------------------------------------------
|
|
81
113
|
def _rule(self, rule: str) -> bool:
|
|
@@ -164,10 +196,14 @@ class DocChecker:
|
|
|
164
196
|
h1 = [h for h in headings if h.level == 1]
|
|
165
197
|
is_template = ".github" in path.parts or "template" in path.name.lower()
|
|
166
198
|
is_template = is_template or "<!-- ghagent: template -->" in text
|
|
199
|
+
is_furniture = path.name.lower() in WIKI_FURNITURE
|
|
167
200
|
if not h1:
|
|
168
201
|
if is_template:
|
|
169
202
|
self.report.info("md.h1", "template without an H1 (expected: the body starts at H2)",
|
|
170
203
|
path=relative, line=1)
|
|
204
|
+
elif is_furniture:
|
|
205
|
+
self.report.info("md.h1", "wiki partial without an H1 (navigation is rendered on every page)",
|
|
206
|
+
path=relative, line=1)
|
|
171
207
|
else:
|
|
172
208
|
self.report.error("md.h1", "no H1 heading", path=relative, line=1,
|
|
173
209
|
hint="a document needs exactly one H1")
|
|
@@ -288,6 +324,7 @@ class DocChecker:
|
|
|
288
324
|
|
|
289
325
|
def _check_links(self, path: Path, relative: str, links, headings, text: str) -> None:
|
|
290
326
|
local_anchors = {h.anchor for h in headings}
|
|
327
|
+
wiki = self.wiki_tree(path)
|
|
291
328
|
for link in links:
|
|
292
329
|
if link.kind != "inline":
|
|
293
330
|
continue
|
|
@@ -304,9 +341,23 @@ class DocChecker:
|
|
|
304
341
|
continue # placeholder kept on purpose in templates
|
|
305
342
|
if self._rule("md.link-local"):
|
|
306
343
|
self._hit("md.link-local")
|
|
307
|
-
self._resolve_link(path, relative, link, local_anchors)
|
|
344
|
+
self._resolve_link(path, relative, link, local_anchors, wiki)
|
|
308
345
|
|
|
309
|
-
|
|
346
|
+
@staticmethod
|
|
347
|
+
def wiki_page_target(candidate: Path) -> Path | None:
|
|
348
|
+
"""Resolve a wiki page name to its file: `Architecture` -> `Architecture.md`.
|
|
349
|
+
|
|
350
|
+
A wiki page name may contain dots (`Migration-3.x-to-4.0`), so the suffix is
|
|
351
|
+
appended to the whole name instead of replacing what looks like an extension.
|
|
352
|
+
"""
|
|
353
|
+
for suffix in (".md", ".markdown", ".mdown"):
|
|
354
|
+
alternative = candidate.with_name(candidate.name + suffix)
|
|
355
|
+
if alternative.exists():
|
|
356
|
+
return alternative
|
|
357
|
+
return None
|
|
358
|
+
|
|
359
|
+
def _resolve_link(self, path: Path, relative: str, link, local_anchors: set[str],
|
|
360
|
+
wiki: bool = False) -> None:
|
|
310
361
|
target = link.target.strip()
|
|
311
362
|
anchorless = target.split("#", 1)[0]
|
|
312
363
|
anchor = target.split("#", 1)[1] if "#" in target else ""
|
|
@@ -319,10 +370,13 @@ class DocChecker:
|
|
|
319
370
|
hint="a trailing slash is a browsable directory on GitHub; a file link is better for readers")
|
|
320
371
|
return
|
|
321
372
|
if not candidate.exists():
|
|
322
|
-
self.
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
373
|
+
resolved = self.wiki_page_target(candidate) if wiki else None
|
|
374
|
+
if resolved is None:
|
|
375
|
+
self.report.error("md.link-local", f"target does not exist: {target}",
|
|
376
|
+
path=relative, line=link.line,
|
|
377
|
+
hint="fix the path or drop the link")
|
|
378
|
+
return
|
|
379
|
+
candidate = resolved
|
|
326
380
|
if anchor and candidate.suffix.lower() in MARKDOWN_SUFFIXES:
|
|
327
381
|
if anchor.lower() not in {a.lower() for a in self.anchors_of(candidate)}:
|
|
328
382
|
self.report.error("md.link-anchor",
|
|
@@ -391,9 +445,10 @@ class DocChecker:
|
|
|
391
445
|
path=relative, line=line,
|
|
392
446
|
hint="use a relative path or a <placeholder>")
|
|
393
447
|
if self._rule("privacy.email"):
|
|
448
|
+
allowed = SAFE_EMAIL_DOMAINS | {domain.lower() for domain in self.options.safe_email_domains}
|
|
394
449
|
for match in PERSONAL_EMAIL_RE.finditer(text):
|
|
395
450
|
domain = match.group(1).lower()
|
|
396
|
-
if domain in
|
|
451
|
+
if domain in allowed:
|
|
397
452
|
continue
|
|
398
453
|
self._hit("privacy.email")
|
|
399
454
|
line = text.count("\n", 0, match.start()) + 1
|
ghalib/meta.py
CHANGED
ghalib/report.py
CHANGED
|
@@ -9,7 +9,7 @@ from . import changelog as changelog_module
|
|
|
9
9
|
from . import config as cfg
|
|
10
10
|
from . import mdcheck
|
|
11
11
|
from . import wiki as wiki_module
|
|
12
|
-
from .util import EXIT_FAIL, EXIT_OK, Report
|
|
12
|
+
from .util import EXIT_FAIL, EXIT_OK, Report
|
|
13
13
|
|
|
14
14
|
|
|
15
15
|
def build(root: Path) -> tuple[str, int]:
|
ghalib/util.py
CHANGED
|
@@ -17,7 +17,7 @@ from dataclasses import dataclass, field
|
|
|
17
17
|
from pathlib import Path
|
|
18
18
|
|
|
19
19
|
PACK_NAME = "github-agent"
|
|
20
|
-
PACK_VERSION = "1.
|
|
20
|
+
PACK_VERSION = "1.3.0"
|
|
21
21
|
|
|
22
22
|
EXIT_OK = 0
|
|
23
23
|
EXIT_FAIL = 1
|
|
@@ -121,11 +121,36 @@ def force_utf8_stdio() -> None:
|
|
|
121
121
|
pass
|
|
122
122
|
|
|
123
123
|
|
|
124
|
-
|
|
124
|
+
# A directory whose name starts with a dot carries tool state, not documentation: `.git`,
|
|
125
|
+
# `.idea`, `.ruff_cache`, and the scratch space of a coding agent (`.swarm`, `.claude`,
|
|
126
|
+
# `.dsh`, `.cursor`). Walking it produced privacy errors for machine paths written in a
|
|
127
|
+
# plan file that is never published — measured on a real repository: 6 errors in `.swarm/`
|
|
128
|
+
# that blocked the gate. The allowlist holds the one dot-directory that IS a published
|
|
129
|
+
# surface: `.github/` (issue forms, PR template) and `.well-known/`.
|
|
130
|
+
HIDDEN_DIR_ALLOWLIST = {".github", ".well-known"}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def visible_dirs(names, exclude=()) -> list[str]:
|
|
134
|
+
"""Directories a documentation walk may enter: no build output, no tool state.
|
|
135
|
+
|
|
136
|
+
`exclude` carries the names a repository adds itself (`.ghagent.json` → `exclude_dirs`),
|
|
137
|
+
so a project with a generated `site/` directory does not teach the pack the name of its
|
|
138
|
+
build tool.
|
|
139
|
+
"""
|
|
140
|
+
blocked = set(exclude) | EXCLUDED_DIRS
|
|
141
|
+
return sorted(
|
|
142
|
+
name for name in names
|
|
143
|
+
if name not in blocked
|
|
144
|
+
and not name.startswith(".ghagent-")
|
|
145
|
+
and (not name.startswith(".") or name in HIDDEN_DIR_ALLOWLIST)
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def iter_text_files(root: Path, *, suffix_set: set[str] | None = None, exclude=()):
|
|
125
150
|
root = Path(root)
|
|
126
151
|
suffixes = suffix_set if suffix_set is not None else TEXT_SUFFIXES
|
|
127
152
|
for dirpath, dirnames, filenames in os.walk(root):
|
|
128
|
-
dirnames[:] =
|
|
153
|
+
dirnames[:] = visible_dirs(dirnames, exclude)
|
|
129
154
|
for name in sorted(filenames):
|
|
130
155
|
path = Path(dirpath) / name
|
|
131
156
|
if path.suffix.lower() in suffixes:
|
ghalib/wiki.py
CHANGED
|
@@ -175,7 +175,7 @@ def rewrite_page(text: str, page_path: Path, root: Path, manifest: WikiManifest,
|
|
|
175
175
|
continue
|
|
176
176
|
|
|
177
177
|
def replace_image(match: re.Match) -> str:
|
|
178
|
-
|
|
178
|
+
target = match.group(2)
|
|
179
179
|
if re.match(r"^(https?:)?//", target) or target.startswith("data:"):
|
|
180
180
|
return match.group(0)
|
|
181
181
|
report.error("wiki.image-relative", f"relative image in {page_name}: {target}",
|
|
@@ -264,15 +264,50 @@ def render_footer(manifest: WikiManifest, report: Report) -> str:
|
|
|
264
264
|
return "---\n*" + " · ".join(parts) + "*\n"
|
|
265
265
|
|
|
266
266
|
|
|
267
|
+
# --------------------------------------------------------------------------
|
|
268
|
+
# verify target selection
|
|
269
|
+
# --------------------------------------------------------------------------
|
|
270
|
+
|
|
271
|
+
def verify_target(root: Path, *, out: Path | None, wiki_dir: str, wiki_dir_given: bool) -> Report:
|
|
272
|
+
"""Decide what to verify: an explicit --out, a --wiki-dir that is already a wiki, or the build.
|
|
273
|
+
|
|
274
|
+
`wiki verify --root <repo> --wiki-dir <clone>` used to ignore the flag and look in
|
|
275
|
+
`<repo>/.ghagent/wiki`, which made the command useless for the growing number of
|
|
276
|
+
repositories whose wiki lives in its own checkout: the answer was always
|
|
277
|
+
`wiki.build-missing`, and the header named a directory the caller never passed.
|
|
278
|
+
"""
|
|
279
|
+
root = Path(root)
|
|
280
|
+
if out is not None:
|
|
281
|
+
return verify(out, root=root, wiki_dir=wiki_dir)
|
|
282
|
+
candidate = root / wiki_dir
|
|
283
|
+
if wiki_dir_given and candidate.is_dir():
|
|
284
|
+
looks_built = any((candidate / f"{name}.md").exists() for name in ("Home", "_Sidebar", "_Footer"))
|
|
285
|
+
if looks_built:
|
|
286
|
+
return verify(candidate, root=root, wiki_dir=wiki_dir)
|
|
287
|
+
return verify(root / ".ghagent" / "wiki", root=root, wiki_dir=wiki_dir)
|
|
288
|
+
|
|
289
|
+
|
|
267
290
|
# --------------------------------------------------------------------------
|
|
268
291
|
# verify (built tree)
|
|
269
292
|
# --------------------------------------------------------------------------
|
|
270
293
|
|
|
271
|
-
def verify(build_dir: Path, *, report: Report | None = None
|
|
294
|
+
def verify(build_dir: Path, *, report: Report | None = None, root: Path | None = None,
|
|
295
|
+
wiki_dir: str = "wiki") -> Report:
|
|
272
296
|
build_dir = Path(build_dir)
|
|
273
297
|
report = report or Report(title="ghagent wiki verify", root=str(build_dir))
|
|
274
298
|
if not build_dir.is_dir():
|
|
275
|
-
|
|
299
|
+
sources = Path(root) / wiki_dir if root is not None else None
|
|
300
|
+
if sources is not None and sources.is_dir() and any(sources.glob("*.md")):
|
|
301
|
+
report.error("wiki.build-missing", f"no built wiki at {build_dir}",
|
|
302
|
+
hint=f"sources exist at {wiki_dir}/: run `ghagent wiki build` (or `wiki publish --push`, which builds and verifies)")
|
|
303
|
+
else:
|
|
304
|
+
# Nothing to verify and nothing missing: a repository may keep its wiki in a
|
|
305
|
+
# separate git repository, and then there is no build here at all. Reporting
|
|
306
|
+
# that as an error made the command unusable for those repositories.
|
|
307
|
+
report.info("wiki.not-applicable",
|
|
308
|
+
f"no built wiki at {build_dir} and no sources at {wiki_dir}/; nothing to verify",
|
|
309
|
+
hint="a wiki kept in its own repository is checked in that checkout: "
|
|
310
|
+
"`ghagent doc --root <clone>` (wiki mode is detected from _Sidebar.md)")
|
|
276
311
|
return report
|
|
277
312
|
|
|
278
313
|
pages = {p.stem for p in build_dir.glob("*.md") if not p.name.startswith("_")}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: github-agent-cli
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.3.0
|
|
4
4
|
Summary: Repository and wiki steward: one command per documentation job, standard library only.
|
|
5
5
|
Project-URL: Homepage, https://github.com/paulmann/github-agent
|
|
6
6
|
Project-URL: Documentation, https://github.com/paulmann/github-agent/tree/main/docs
|
|
@@ -42,13 +42,20 @@ the repository — one command per job.
|
|
|
42
42
|
[](https://www.python.org/)
|
|
43
43
|
[](LICENSE)
|
|
44
44
|
[](pyproject.toml)
|
|
45
|
-
[](tests/run_tests.py)
|
|
46
46
|
|
|
47
47
|
<img src="https://github.com/paulmann/github-agent/raw/main/assets/social-preview.png"
|
|
48
48
|
alt="github-agent — repository and wiki steward for AI agents"
|
|
49
49
|
width="100%">
|
|
50
50
|
|
|
51
|
-
|
|
51
|
+
<img src="https://github.com/paulmann/github-agent/raw/main/assets/demo.gif"
|
|
52
|
+
alt="Three ghagent commands in a terminal: audit scores 100%, doc and changelog check pass"
|
|
53
|
+
width="100%">
|
|
54
|
+
|
|
55
|
+
<sub>The animation is produced by `tools/make_demo.py` from the real output of three commands; it
|
|
56
|
+
cannot show anything the tool did not print.</sub>
|
|
57
|
+
|
|
58
|
+
[**Installation**](#installation) · [**Usage**](#usage) · [**Why this exists**](#why-this-exists) · [**How it compares**](#how-it-compares) · [**Docs site**](https://paulmann.github.io/github-agent/) · [**Wiki**](https://github.com/paulmann/github-agent/wiki) · [**AGENTS.md**](AGENTS.md) · [**Packaging**](docs/PACKAGING.md) · [**Changelog**](CHANGELOG.md) · [**Issues**](https://github.com/paulmann/github-agent/issues) · [**Security**](SECURITY.md) · [**Русская версия**](README.ru.md)
|
|
52
59
|
|
|
53
60
|
A repository rarely fails on code; it fails on documentation. The README has no installation
|
|
54
61
|
section, the changelog has no dates, the wiki is three pages without navigation, the banner is
|
|
@@ -58,8 +65,8 @@ This pack puts a verifiable contract on each of those places and reduces the wor
|
|
|
58
65
|
command. It does not write marketing copy and does not invent numbers: it checks what a human
|
|
59
66
|
wrote and refuses to publish what is broken.
|
|
60
67
|
|
|
61
|
-
> **Documentation status.** Every number in this README was measured on 2026-
|
|
62
|
-
> version 1.
|
|
68
|
+
> **Documentation status.** Every number in this README was measured on 2026-11-01 against
|
|
69
|
+
> version 1.3.0 with the commands named in the table. Long guides live in the
|
|
63
70
|
> [Wiki](https://github.com/paulmann/github-agent/wiki) and [`docs/`](docs/IMPORT.md); the
|
|
64
71
|
> protocol for agents is [`AGENTS.md`](AGENTS.md), the packaging and migration notes are
|
|
65
72
|
> [`docs/PACKAGING.md`](docs/PACKAGING.md) and [`MIGRATION.md`](MIGRATION.md).
|
|
@@ -69,9 +76,11 @@ wrote and refuses to publish what is broken.
|
|
|
69
76
|
|
|
70
77
|
## Contents
|
|
71
78
|
|
|
79
|
+
- [Why this exists](#why-this-exists)
|
|
72
80
|
- [Installation](#installation)
|
|
73
81
|
- [Usage](#usage)
|
|
74
82
|
- [What the pack does](#what-the-pack-does)
|
|
83
|
+
- [How it compares](#how-it-compares)
|
|
75
84
|
- [In numbers](#in-numbers)
|
|
76
85
|
- [Checks that run](#checks-that-run)
|
|
77
86
|
- [Images](#images)
|
|
@@ -86,6 +95,24 @@ wrote and refuses to publish what is broken.
|
|
|
86
95
|
|
|
87
96
|
---
|
|
88
97
|
|
|
98
|
+
## Why this exists
|
|
99
|
+
|
|
100
|
+
GitHub renders whatever you commit. It does not tell you that the installation section is
|
|
101
|
+
missing, that the changelog has no dates, that the wiki has no navigation, that the banner is a
|
|
102
|
+
broken image or that the description is one word. `git push` succeeds either way, and the defect
|
|
103
|
+
survives for years because nobody is looking at the repository as a product.
|
|
104
|
+
|
|
105
|
+
Three ideas decide how the pack behaves:
|
|
106
|
+
|
|
107
|
+
1. **A check you cannot run is not a check.** Every rule is one command with an exit code:
|
|
108
|
+
`0` clean, `1` findings at error level, `2` usage or environment problem. A rule that examined
|
|
109
|
+
zero objects reports `coverage.skipped` — a green check over an empty set is a defect.
|
|
110
|
+
2. **A false `PASS` is worse than a false `FAIL`.** The tool would rather stop a correct pull
|
|
111
|
+
request than wave a broken one through. Where that costs a real repository time, the rule is
|
|
112
|
+
narrowed until both answers are right — see [Field notes](https://paulmann.github.io/github-agent/FIELD-NOTES/).
|
|
113
|
+
3. **Numbers over adjectives.** The README, the changelog and the metadata are checked against
|
|
114
|
+
what the repository can prove. Nothing here writes marketing copy.
|
|
115
|
+
|
|
89
116
|
## Installation
|
|
90
117
|
|
|
91
118
|
Python 3.11 or newer is required; CI runs the suite on 3.11 and 3.13. There are no runtime
|
|
@@ -99,10 +126,13 @@ the wheel ships only the CLI.
|
|
|
99
126
|
uv tool install github-agent-cli # uv, isolated tool environment
|
|
100
127
|
pipx install github-agent-cli # pipx, isolated virtual environment
|
|
101
128
|
pip install github-agent-cli # pip into the active environment
|
|
129
|
+
brew install paulmann/homebrew-github-agent-cli/github-agent-cli # macOS and Linux
|
|
130
|
+
scoop bucket add github-agent-cli https://github.com/paulmann/scoop-github-agent-cli
|
|
131
|
+
scoop install github-agent-cli # Windows
|
|
102
132
|
ghagent --version
|
|
103
133
|
```
|
|
104
134
|
|
|
105
|
-
> **Published on PyPI.** `github-agent-cli` 1.
|
|
135
|
+
> **Published on PyPI.** `github-agent-cli` 1.3.0 has been available since 2026-11-01:
|
|
106
136
|
> <https://pypi.org/project/github-agent-cli/>. The wheel ships the CLI only; the pack
|
|
107
137
|
> (skills, tests, reference, wiki sources) comes from the clone below.
|
|
108
138
|
|
|
@@ -145,7 +175,7 @@ docker run --rm -v "$PWD:/repo" ghagent audit .
|
|
|
145
175
|
|
|
146
176
|
On Windows PowerShell the same mount is `-v "${PWD}:/repo"`. The entry point is `ghagent`, so
|
|
147
177
|
the first argument after the image name is the command. No registry image is published as part
|
|
148
|
-
of 1.
|
|
178
|
+
of 1.3.0 and CI does not build the container: the tag is a local convention. See
|
|
149
179
|
[examples/docker.md](examples/docker.md).
|
|
150
180
|
|
|
151
181
|
### GitHub Action
|
|
@@ -155,7 +185,7 @@ release and works as soon as the tag exists:
|
|
|
155
185
|
|
|
156
186
|
```yaml
|
|
157
187
|
- uses: actions/checkout@v7
|
|
158
|
-
- uses: paulmann/github-agent@v1.
|
|
188
|
+
- uses: paulmann/github-agent@v1.3.0 # pin the release tag
|
|
159
189
|
with:
|
|
160
190
|
command: audit .
|
|
161
191
|
min-score: "80"
|
|
@@ -182,7 +212,7 @@ The repository is also a pre-commit hook source; the hooks are `ghagent-doc`,
|
|
|
182
212
|
```yaml
|
|
183
213
|
repos:
|
|
184
214
|
- repo: https://github.com/paulmann/github-agent
|
|
185
|
-
rev: v1.
|
|
215
|
+
rev: v1.3.0 # placeholder: pin the release tag you decide to trust
|
|
186
216
|
hooks:
|
|
187
217
|
- id: ghagent-doc
|
|
188
218
|
- id: ghagent-audit
|
|
@@ -200,7 +230,7 @@ tag, because pre-commit builds the tool from the tagged tree.
|
|
|
200
230
|
After any install, one command answers whether the CLI is the released build:
|
|
201
231
|
|
|
202
232
|
```bash
|
|
203
|
-
ghagent --version # prints the version, for example github-agent 1.
|
|
233
|
+
ghagent --version # prints the version, for example github-agent 1.3.0
|
|
204
234
|
ghagent audit . # 0 clean, 1 findings at error level, 2 usage or environment problem
|
|
205
235
|
```
|
|
206
236
|
|
|
@@ -210,7 +240,7 @@ there exits `2` with a message that points at the clone rather than reporting a
|
|
|
210
240
|
```bash
|
|
211
241
|
git clone https://github.com/paulmann/github-agent.git
|
|
212
242
|
cd github-agent
|
|
213
|
-
python tools/ghagent.py selftest #
|
|
243
|
+
python tools/ghagent.py selftest # 194/194 checks passed
|
|
214
244
|
```
|
|
215
245
|
|
|
216
246
|
## Usage
|
|
@@ -224,7 +254,7 @@ ghagent assets check # format, dimensions, alt text, referenced fi
|
|
|
224
254
|
ghagent wiki build # rewrite links for the wiki namespace
|
|
225
255
|
ghagent wiki verify # every internal link resolves
|
|
226
256
|
ghagent changelog check # Keep a Changelog and SemVer
|
|
227
|
-
ghagent changelog release 1.
|
|
257
|
+
ghagent changelog release 1.3.0 --write
|
|
228
258
|
ghagent meta suggest # description and topics
|
|
229
259
|
```
|
|
230
260
|
|
|
@@ -256,7 +286,7 @@ score: 72.5% (22 scored, 1 skipped)
|
|
|
256
286
|
| Find secrets and machine paths | `ghagent privacy --root .` |
|
|
257
287
|
| Render the banner and social preview | `ghagent assets render --write` |
|
|
258
288
|
| Build, verify and publish the wiki | `ghagent wiki build`, `verify`, `publish --push` |
|
|
259
|
-
| Release a version | `ghagent changelog release 1.
|
|
289
|
+
| Release a version | `ghagent changelog release 1.3.0 --write` |
|
|
260
290
|
| Fix description and topics | `ghagent meta apply --repo owner/name --write` |
|
|
261
291
|
| Install the skill into another agent | `ghagent install --profile harness --write` |
|
|
262
292
|
|
|
@@ -265,19 +295,34 @@ The CLI has 13 commands; the table names the ones used every day. The rest (`rep
|
|
|
265
295
|
`changelog notes`, `meta suggest`, `wiki init`, `wiki publish`) carry their flags in
|
|
266
296
|
`ghagent --help`; the recurring jobs are mapped in [`AGENTS.md`](AGENTS.md).
|
|
267
297
|
|
|
298
|
+
## How it compares
|
|
299
|
+
|
|
300
|
+
The pack does not replace the tools below and does not compete with them on their ground: it
|
|
301
|
+
orchestrates the documentation surface of a repository and calls the same ideas from one place,
|
|
302
|
+
with one exit code.
|
|
303
|
+
|
|
304
|
+
| Tool | Strong at | Where this pack differs |
|
|
305
|
+
|---|---|---|
|
|
306
|
+
| `markdownlint` | markdown style, one rule id per finding | `ghagent doc` adds links, anchors, TOC, tables, privacy and the CHANGELOG contract; it also checks documents that are not markdown |
|
|
307
|
+
| `vale` / `textlint` | prose style with a dictionary you maintain | the pack checks structure a machine can prove and does not judge wording; only the README summary is checked for superlatives |
|
|
308
|
+
| `action-gh-release` | creating a release from a tag | `changelog notes <version>` produces the body from the CHANGELOG, and `changelog check` refuses a release without a dated section |
|
|
309
|
+
| `pre-commit` hooks | running linters before a commit | the pack ships hooks for its own commands; the hooks are the delivery, not the check |
|
|
310
|
+
| `release-please` | version bumps from conventional commits | the CHANGELOG stays hand-written and machine-verified; `changelog suggest` drafts entries but a human writes them |
|
|
311
|
+
| GitHub community health files | a checklist in the UI | `audit` scores the same files from the tree and from the remote, so a missing `SECURITY.md` is a number, not a badge nobody opens |
|
|
312
|
+
|
|
268
313
|
## In numbers
|
|
269
314
|
|
|
270
|
-
Measured in this tree on 2026-
|
|
315
|
+
Measured in this tree on 2026-11-01 against version 1.3.0:
|
|
271
316
|
|
|
272
317
|
| Surface | Value | Measurement source |
|
|
273
318
|
|---|---:|---|
|
|
274
|
-
| Self-tests |
|
|
319
|
+
| Self-tests | 194 | `python tests/run_tests.py` |
|
|
275
320
|
| CLI commands | 13 | `ghagent --help` |
|
|
276
321
|
| Library modules | 11 | `tools/ghalib/*.py` |
|
|
277
322
|
| Document rules examined on this tree | 17 | `ghagent doc --json`, field `rules_examined` |
|
|
278
323
|
| Runtime dependencies | 0 | `pyproject.toml`, `dependencies = []` |
|
|
279
324
|
| Platform traps recorded | 15 | the traps table in [docs/RULES.md](docs/RULES.md) |
|
|
280
|
-
| Python lines |
|
|
325
|
+
| Python lines | 4412 | `wc -l tools/ghagent.py tools/ghalib/*.py` |
|
|
281
326
|
|
|
282
327
|
## Checks that run
|
|
283
328
|
|
|
@@ -387,7 +432,7 @@ flowchart LR
|
|
|
387
432
|
| `tools/ghalib/install.py` | skill installation, sha256 manifest, drift, removal |
|
|
388
433
|
| `tools/ghalib/report.py` | findings, severities, markdown and `--json` output |
|
|
389
434
|
| `tools/ghalib/config.py`, `tools/ghalib/util.py` | `.ghagent.json`, paths, text I/O, anchors |
|
|
390
|
-
| `tests/run_tests.py` |
|
|
435
|
+
| `tests/run_tests.py` | 194 self-tests with fixtures and decoys |
|
|
391
436
|
|
|
392
437
|
Anchors follow GitHub's rules: lowercase, punctuation dropped, spaces to hyphens, a repeated
|
|
393
438
|
heading gets `-1`. The rule was verified against a live table of contents: an em dash in a
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
ghagent.py,sha256=Fa_Ammn3M4yL03EIjdkSPnZ9UAyOTacnBIJoNnm2A-M,25278
|
|
2
|
+
ghalib/__init__.py,sha256=ur7kSxCjbmuv0oq97bo9bwE0a8QEDiDeVxxf7wACOAc,161
|
|
3
|
+
ghalib/assets.py,sha256=RtP0hzZ1f6C8-B5hhkY1QSpb_5y_kA4EMNCzE6d1NnM,23279
|
|
4
|
+
ghalib/audit.py,sha256=89_HSzHTHEUN_rSpv0xBXGUa3eHLWrUJLS1LcyxBJpU,29813
|
|
5
|
+
ghalib/changelog.py,sha256=LXXPX-zDusqm-ZgZCP6ic5wopUsACp7jkj-fainGm8Y,22931
|
|
6
|
+
ghalib/config.py,sha256=WLkY5IM-jfIrSMB2x4Rj4fJKgLEQ9eNmlt8EZMuHkio,1968
|
|
7
|
+
ghalib/install.py,sha256=RWku4z3pzCe76vWlERAzzJIkF-tT-ieZ_EQvIsIg3RI,7410
|
|
8
|
+
ghalib/mdcheck.py,sha256=grUeKev4r-fCDa4mWdkIwYQtpuuFn8UEu22_6QUerLg,24094
|
|
9
|
+
ghalib/meta.py,sha256=uJBEVMz-BqRt8SjgtlWCm1_bRlbNaWXENMb96pVtgCU,12471
|
|
10
|
+
ghalib/report.py,sha256=He-CkxUu0e9JpfPtQtIJcaveaSQ7Mo9G6HmNP2ETvaw,4093
|
|
11
|
+
ghalib/util.py,sha256=-a7UoN7lUVJnti6yCRgxERbbezO8g7sZtdBzWQ9ikiM,19362
|
|
12
|
+
ghalib/wiki.py,sha256=2oN9Iz91gglCGGKb9Zgvq6SOfG7ZmmIw2EmJ47Is5b8,24242
|
|
13
|
+
github_agent_cli-1.3.0.dist-info/METADATA,sha256=75umbhzCD-NS3vEjvKS03zLWJP9pUB-LfKz3ZZucXAA,23600
|
|
14
|
+
github_agent_cli-1.3.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
15
|
+
github_agent_cli-1.3.0.dist-info/entry_points.txt,sha256=ygzVll1rjzq2XexecCuJxEEfijExjJkWFtszftav9ys,41
|
|
16
|
+
github_agent_cli-1.3.0.dist-info/licenses/LICENSE,sha256=tI9-MOZ-IBbjmioMIxKVwx3x727NsJflGKEJV8seQUY,1082
|
|
17
|
+
github_agent_cli-1.3.0.dist-info/RECORD,,
|
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
ghagent.py,sha256=W_ywmCY89ZUIHA4-siPYswtXh6kX8QCbsJ73f__ODlI,23238
|
|
2
|
-
ghalib/__init__.py,sha256=ur7kSxCjbmuv0oq97bo9bwE0a8QEDiDeVxxf7wACOAc,161
|
|
3
|
-
ghalib/assets.py,sha256=Wak4liTekNdDYHewKZMgZL1FOW11OhmEzoTHc9vPLRQ,21302
|
|
4
|
-
ghalib/audit.py,sha256=gaHcuP_FE_IDK6rAXDHQquoqcoKg1Btaz37U3uQpLDY,29290
|
|
5
|
-
ghalib/changelog.py,sha256=mMAQHZGUA59AXWWRA2qTHeqNfvOBz3oADYvpVX1PiPk,20357
|
|
6
|
-
ghalib/config.py,sha256=r_VoPi1vp8J5OZrd3Jf9Gmi5iCILwukZEx7Hr_Q02sA,1457
|
|
7
|
-
ghalib/install.py,sha256=RWku4z3pzCe76vWlERAzzJIkF-tT-ieZ_EQvIsIg3RI,7410
|
|
8
|
-
ghalib/mdcheck.py,sha256=k1raRBLMWUIZsT5jzy3abnk_EJ_FyheeCjurHKbYoME,20715
|
|
9
|
-
ghalib/meta.py,sha256=mIt6NZVxo6dZhaEHjCqunVZJ3QqA4S2voxiX17cgbGo,12481
|
|
10
|
-
ghalib/report.py,sha256=eljoa6UUnpwcCJ3zTSJSpKdMHiml2hSu4yMWZhQ-uEw,4098
|
|
11
|
-
ghalib/util.py,sha256=hEOUmSJggNKq4-Sy0NeAXhLTbksoT4wuIzfjkzSMQcA,18246
|
|
12
|
-
ghalib/wiki.py,sha256=s0BxCxxU9v6WccjmIJdtPz5HM6uUG3slerDPS68LYJ0,22139
|
|
13
|
-
github_agent_cli-1.2.2.dist-info/METADATA,sha256=6Oz8ZJcK7XS1GtQnPSqzsiz69D44SXBWTTEsbRLB2k8,20260
|
|
14
|
-
github_agent_cli-1.2.2.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
15
|
-
github_agent_cli-1.2.2.dist-info/entry_points.txt,sha256=ygzVll1rjzq2XexecCuJxEEfijExjJkWFtszftav9ys,41
|
|
16
|
-
github_agent_cli-1.2.2.dist-info/licenses/LICENSE,sha256=tI9-MOZ-IBbjmioMIxKVwx3x727NsJflGKEJV8seQUY,1082
|
|
17
|
-
github_agent_cli-1.2.2.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|