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/assets.py
ADDED
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
"""Repository imagery: banner and social preview, rendered locally and then checked.
|
|
2
|
+
|
|
3
|
+
Why this exists. A repository with no picture reads as abandoned, and the reference
|
|
4
|
+
project's README opens with a 1280x640 banner. The image is easy to forget and easy to
|
|
5
|
+
break, so it is generated from the repository's own metadata and then verified:
|
|
6
|
+
|
|
7
|
+
* the social preview must be PNG/JPG/GIF under 1 MB, at least 640x320, 1280x640 for
|
|
8
|
+
best display (GitHub documentation, "Customizing your repository's social media preview");
|
|
9
|
+
* an SVG is a fine README banner but cannot be uploaded as a social preview;
|
|
10
|
+
* a reference to a missing file renders as a broken box, so every referenced image is
|
|
11
|
+
resolved against the tree.
|
|
12
|
+
|
|
13
|
+
Rendering uses Pillow when it is installed (PNG), and always writes an SVG, which needs
|
|
14
|
+
nothing at all. A missing Pillow lowers the output format, never the honesty of the report.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import base64
|
|
20
|
+
import re
|
|
21
|
+
import struct
|
|
22
|
+
import zlib
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
from .util import IMAGE_RE, Report, iter_text_files, read_text, rel
|
|
27
|
+
|
|
28
|
+
SOCIAL_SIZE = (1280, 640)
|
|
29
|
+
SOCIAL_MIN = (640, 320)
|
|
30
|
+
SOCIAL_MAX_BYTES = 1_000_000
|
|
31
|
+
IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".avif"}
|
|
32
|
+
SOCIAL_ACCEPTED = {".png", ".jpg", ".jpeg", ".gif"}
|
|
33
|
+
|
|
34
|
+
PALETTE = {
|
|
35
|
+
"bg": "#0d1117",
|
|
36
|
+
"panel": "#161b22",
|
|
37
|
+
"line": "#30363d",
|
|
38
|
+
"text": "#e6edf3",
|
|
39
|
+
"muted": "#8b949e",
|
|
40
|
+
"accent": "#58a6ff",
|
|
41
|
+
"accent2": "#3fb950",
|
|
42
|
+
"warn": "#d29922",
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# --------------------------------------------------------------------------
|
|
47
|
+
# image metadata without dependencies
|
|
48
|
+
# --------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
def image_size(path: Path) -> tuple[int, int] | None:
|
|
51
|
+
"""Read width/height from the file header. Pure stdlib, no decoding."""
|
|
52
|
+
try:
|
|
53
|
+
raw = Path(path).read_bytes()[:64]
|
|
54
|
+
except OSError:
|
|
55
|
+
return None
|
|
56
|
+
if raw[:8] == b"\x89PNG\r\n\x1a\n" and raw[12:16] == b"IHDR":
|
|
57
|
+
width, height = struct.unpack(">II", raw[16:24])
|
|
58
|
+
return int(width), int(height)
|
|
59
|
+
if raw[:6] in (b"GIF87a", b"GIF89a"):
|
|
60
|
+
width, height = struct.unpack("<HH", raw[6:10])
|
|
61
|
+
return int(width), int(height)
|
|
62
|
+
if raw[:2] == b"\xff\xd8": # JPEG: walk the marker chain
|
|
63
|
+
try:
|
|
64
|
+
data = Path(path).read_bytes()
|
|
65
|
+
except OSError:
|
|
66
|
+
return None
|
|
67
|
+
index = 2
|
|
68
|
+
while index + 9 < len(data):
|
|
69
|
+
if data[index] != 0xFF:
|
|
70
|
+
index += 1
|
|
71
|
+
continue
|
|
72
|
+
marker = data[index + 1]
|
|
73
|
+
if marker in (0xC0, 0xC1, 0xC2, 0xC3, 0xC5, 0xC6, 0xC7, 0xC9, 0xCA, 0xCB, 0xCD, 0xCE, 0xCF):
|
|
74
|
+
height, width = struct.unpack(">HH", data[index + 5:index + 9])
|
|
75
|
+
return int(width), int(height)
|
|
76
|
+
if marker in (0xD8, 0xD9) or 0xD0 <= marker <= 0xD7:
|
|
77
|
+
index += 2
|
|
78
|
+
continue
|
|
79
|
+
length = struct.unpack(">H", data[index + 2:index + 4])[0]
|
|
80
|
+
index += 2 + length
|
|
81
|
+
return None
|
|
82
|
+
if raw[:4] == b"RIFF" and raw[8:12] == b"WEBP":
|
|
83
|
+
return None # not needed for the rules below
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def make_header_png(width: int, height: int) -> bytes:
|
|
88
|
+
"""A structurally valid PNG with an IHDR of the given size (used by the self-tests)."""
|
|
89
|
+
def chunk(kind: bytes, payload: bytes) -> bytes:
|
|
90
|
+
return (struct.pack(">I", len(payload)) + kind + payload
|
|
91
|
+
+ struct.pack(">I", zlib.crc32(kind + payload) & 0xFFFFFFFF))
|
|
92
|
+
|
|
93
|
+
ihdr = struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0)
|
|
94
|
+
idat = zlib.compress(b"\x00\x00\x00\x00")
|
|
95
|
+
return b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", ihdr) + chunk(b"IDAT", idat) + chunk(b"IEND", b"")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# --------------------------------------------------------------------------
|
|
99
|
+
# rendering
|
|
100
|
+
# --------------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
@dataclass
|
|
103
|
+
class BannerSpec:
|
|
104
|
+
title: str
|
|
105
|
+
tagline: str = ""
|
|
106
|
+
command: str = ""
|
|
107
|
+
chips: list[str] = field(default_factory=list)
|
|
108
|
+
footer: str = ""
|
|
109
|
+
width: int = SOCIAL_SIZE[0]
|
|
110
|
+
height: int = SOCIAL_SIZE[1]
|
|
111
|
+
|
|
112
|
+
def normalised(self) -> "BannerSpec":
|
|
113
|
+
self.chips = [chip for chip in self.chips if chip][:6]
|
|
114
|
+
return self
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _escape(text: str) -> str:
|
|
118
|
+
return (text.replace("&", "&").replace("<", "<").replace(">", ">")
|
|
119
|
+
.replace('"', """))
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _wrap(text: str, limit: int, max_lines: int) -> list[str]:
|
|
123
|
+
words = text.split()
|
|
124
|
+
lines: list[str] = []
|
|
125
|
+
current = ""
|
|
126
|
+
for word in words:
|
|
127
|
+
candidate = f"{current} {word}".strip()
|
|
128
|
+
if len(candidate) <= limit:
|
|
129
|
+
current = candidate
|
|
130
|
+
continue
|
|
131
|
+
if current:
|
|
132
|
+
lines.append(current)
|
|
133
|
+
current = word
|
|
134
|
+
if len(lines) == max_lines - 1:
|
|
135
|
+
break
|
|
136
|
+
if current and len(lines) < max_lines:
|
|
137
|
+
lines.append(current)
|
|
138
|
+
if not lines:
|
|
139
|
+
return []
|
|
140
|
+
remainder = " ".join(words)[len(" ".join(lines)):].strip()
|
|
141
|
+
if remainder and lines:
|
|
142
|
+
lines[-1] = lines[-1].rstrip(" .,;") + "…"
|
|
143
|
+
return lines
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def render_svg(spec: BannerSpec) -> str:
|
|
147
|
+
spec = spec.normalised()
|
|
148
|
+
width, height = spec.width, spec.height
|
|
149
|
+
pad = 72
|
|
150
|
+
title_lines = _wrap(spec.title, 26, 2)
|
|
151
|
+
tagline_lines = _wrap(spec.tagline, 62, 3)
|
|
152
|
+
title_size = 64 if len(title_lines) <= 1 else 52
|
|
153
|
+
parts: list[str] = [
|
|
154
|
+
f'<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" '
|
|
155
|
+
f'viewBox="0 0 {width} {height}" role="img" aria-label="{_escape(spec.title)}">',
|
|
156
|
+
f' <rect width="{width}" height="{height}" fill="{PALETTE["bg"]}"/>',
|
|
157
|
+
f' <rect x="0" y="0" width="{width}" height="6" fill="{PALETTE["accent"]}"/>',
|
|
158
|
+
f' <rect x="{pad}" y="{pad}" width="{width - 2 * pad}" height="{height - 2 * pad}" '
|
|
159
|
+
f'rx="18" fill="{PALETTE["panel"]}" stroke="{PALETTE["line"]}"/>',
|
|
160
|
+
]
|
|
161
|
+
y = pad + 96
|
|
162
|
+
for line in title_lines:
|
|
163
|
+
parts.append(
|
|
164
|
+
f' <text x="{pad + 48}" y="{y}" fill="{PALETTE["text"]}" font-size="{title_size}" '
|
|
165
|
+
f'font-weight="700" font-family="Segoe UI, Helvetica, Arial, sans-serif">{_escape(line)}</text>')
|
|
166
|
+
y += int(title_size * 1.18)
|
|
167
|
+
y += 10
|
|
168
|
+
for line in tagline_lines:
|
|
169
|
+
parts.append(
|
|
170
|
+
f' <text x="{pad + 48}" y="{y}" fill="{PALETTE["muted"]}" font-size="26" '
|
|
171
|
+
f'font-family="Segoe UI, Helvetica, Arial, sans-serif">{_escape(line)}</text>')
|
|
172
|
+
y += 36
|
|
173
|
+
if spec.command:
|
|
174
|
+
box_y = height - pad - 128
|
|
175
|
+
parts.append(
|
|
176
|
+
f' <rect x="{pad + 48}" y="{box_y}" width="{width - 2 * pad - 96}" height="56" rx="10" '
|
|
177
|
+
f'fill="{PALETTE["bg"]}" stroke="{PALETTE["line"]}"/>')
|
|
178
|
+
parts.append(
|
|
179
|
+
f' <text x="{pad + 72}" y="{box_y + 36}" fill="{PALETTE["accent2"]}" font-size="22" '
|
|
180
|
+
f'font-family="Consolas, Menlo, monospace">{_escape(spec.command)}</text>')
|
|
181
|
+
chip_x = pad + 48
|
|
182
|
+
chip_y = height - pad - 56
|
|
183
|
+
for chip in spec.chips:
|
|
184
|
+
chip_w = 14 + len(chip) * 12
|
|
185
|
+
parts.append(
|
|
186
|
+
f' <rect x="{chip_x}" y="{chip_y}" width="{chip_w}" height="32" rx="16" '
|
|
187
|
+
f'fill="none" stroke="{PALETTE["line"]}"/>')
|
|
188
|
+
parts.append(
|
|
189
|
+
f' <text x="{chip_x + 12}" y="{chip_y + 22}" fill="{PALETTE["muted"]}" font-size="16" '
|
|
190
|
+
f'font-family="Consolas, Menlo, monospace">{_escape(chip)}</text>')
|
|
191
|
+
chip_x += chip_w + 10
|
|
192
|
+
if spec.footer:
|
|
193
|
+
parts.append(
|
|
194
|
+
f' <text x="{width - pad - 48}" y="{height - 26}" text-anchor="end" fill="{PALETTE["muted"]}" '
|
|
195
|
+
f'font-size="18" font-family="Consolas, Menlo, monospace">{_escape(spec.footer)}</text>')
|
|
196
|
+
parts.append("</svg>")
|
|
197
|
+
return "\n".join(parts) + "\n"
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _font(size: int, bold: bool = False, mono: bool = False):
|
|
201
|
+
from PIL import ImageFont
|
|
202
|
+
candidates = []
|
|
203
|
+
if mono:
|
|
204
|
+
candidates += ["C:/Windows/Fonts/consola.ttf", "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf"]
|
|
205
|
+
if bold:
|
|
206
|
+
candidates += ["C:/Windows/Fonts/segoeuib.ttf", "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"]
|
|
207
|
+
candidates += ["C:/Windows/Fonts/segoeui.ttf", "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"]
|
|
208
|
+
for candidate in candidates:
|
|
209
|
+
if Path(candidate).exists():
|
|
210
|
+
return ImageFont.truetype(candidate, size)
|
|
211
|
+
return ImageFont.load_default()
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def render_png(spec: BannerSpec) -> bytes | None:
|
|
215
|
+
"""PNG bytes, or None when Pillow is not installed."""
|
|
216
|
+
try:
|
|
217
|
+
from PIL import Image, ImageDraw
|
|
218
|
+
except ImportError:
|
|
219
|
+
return None
|
|
220
|
+
spec = spec.normalised()
|
|
221
|
+
width, height = spec.width, spec.height
|
|
222
|
+
pad = 72
|
|
223
|
+
image = Image.new("RGB", (width, height), PALETTE["bg"])
|
|
224
|
+
draw = ImageDraw.Draw(image)
|
|
225
|
+
draw.rectangle([0, 0, width, 7], fill=PALETTE["accent"])
|
|
226
|
+
draw.rounded_rectangle([pad, pad, width - pad, height - pad], radius=18,
|
|
227
|
+
fill=PALETTE["panel"], outline=PALETTE["line"], width=2)
|
|
228
|
+
|
|
229
|
+
title_lines = _wrap(spec.title, 26, 2)
|
|
230
|
+
title_font = _font(64 if len(title_lines) <= 1 else 52, bold=True)
|
|
231
|
+
y = pad + 58
|
|
232
|
+
for line in title_lines:
|
|
233
|
+
draw.text((pad + 48, y), line, font=title_font, fill=PALETTE["text"])
|
|
234
|
+
y += title_font.size + 12
|
|
235
|
+
y += 6
|
|
236
|
+
tagline_font = _font(26)
|
|
237
|
+
for line in _wrap(spec.tagline, 62, 3):
|
|
238
|
+
draw.text((pad + 48, y), line, font=tagline_font, fill=PALETTE["muted"])
|
|
239
|
+
y += tagline_font.size + 10
|
|
240
|
+
if spec.command:
|
|
241
|
+
box_y = height - pad - 128
|
|
242
|
+
draw.rounded_rectangle([pad + 48, box_y, width - pad - 48, box_y + 56], radius=10,
|
|
243
|
+
fill=PALETTE["bg"], outline=PALETTE["line"], width=2)
|
|
244
|
+
draw.text((pad + 72, box_y + 15), spec.command, font=_font(22, mono=True), fill=PALETTE["accent2"])
|
|
245
|
+
chip_font = _font(16, mono=True)
|
|
246
|
+
chip_x = pad + 48
|
|
247
|
+
chip_y = height - pad - 56
|
|
248
|
+
for chip in spec.chips:
|
|
249
|
+
chip_w = 14 + int(chip_font.getlength(chip))
|
|
250
|
+
draw.rounded_rectangle([chip_x, chip_y, chip_x + chip_w, chip_y + 32], radius=16,
|
|
251
|
+
outline=PALETTE["line"], width=2)
|
|
252
|
+
draw.text((chip_x + 12, chip_y + 7), chip, font=chip_font, fill=PALETTE["muted"])
|
|
253
|
+
chip_x += chip_w + 10
|
|
254
|
+
if spec.footer:
|
|
255
|
+
footer_font = _font(18, mono=True)
|
|
256
|
+
draw.text((width - pad - 48 - footer_font.getlength(spec.footer), height - 44),
|
|
257
|
+
spec.footer, font=footer_font, fill=PALETTE["muted"])
|
|
258
|
+
from io import BytesIO
|
|
259
|
+
buffer = BytesIO()
|
|
260
|
+
image.save(buffer, format="PNG", optimize=True)
|
|
261
|
+
return buffer.getvalue()
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def spec_from_repository(root: Path, *, title: str = "", tagline: str = "", command: str = "",
|
|
265
|
+
chips: list[str] | None = None) -> BannerSpec:
|
|
266
|
+
from . import meta as meta_module
|
|
267
|
+
facts = meta_module.derive(root)
|
|
268
|
+
readme = Path(root) / "README.md"
|
|
269
|
+
summary = ""
|
|
270
|
+
if readme.exists():
|
|
271
|
+
text, _ = read_text(readme)
|
|
272
|
+
for block in text.split("\n\n"):
|
|
273
|
+
stripped = block.strip()
|
|
274
|
+
if stripped.startswith("**") and stripped.endswith("**") and len(stripped) > 20:
|
|
275
|
+
summary = stripped.strip("*").strip()
|
|
276
|
+
break
|
|
277
|
+
if not summary:
|
|
278
|
+
summary = facts.get("summary", "")
|
|
279
|
+
languages = [language for language, _count in facts.get("languages", [])[:3]]
|
|
280
|
+
return BannerSpec(
|
|
281
|
+
title=title or facts.get("title") or Path(root).name,
|
|
282
|
+
tagline=tagline or summary[:180],
|
|
283
|
+
command=command,
|
|
284
|
+
chips=(chips if chips is not None else languages + ["mit"])[:6],
|
|
285
|
+
footer=" · ".join(filter(None, ["MIT", ", ".join(languages)])),
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
# --------------------------------------------------------------------------
|
|
290
|
+
# writing, checking, suggesting
|
|
291
|
+
# --------------------------------------------------------------------------
|
|
292
|
+
|
|
293
|
+
def write_assets(root: Path, *, out_dir: str = "assets", spec: BannerSpec | None = None,
|
|
294
|
+
write: bool = False, report: Report | None = None) -> Report:
|
|
295
|
+
root = Path(root)
|
|
296
|
+
report = report or Report(title="ghagent assets render", root=str(root))
|
|
297
|
+
spec = (spec or spec_from_repository(root)).normalised()
|
|
298
|
+
target = root / out_dir
|
|
299
|
+
svg_path = target / "social-preview.svg"
|
|
300
|
+
png_path = target / "social-preview.png"
|
|
301
|
+
svg = render_svg(spec)
|
|
302
|
+
png = render_png(spec)
|
|
303
|
+
|
|
304
|
+
report.data.update({
|
|
305
|
+
"svg": rel(svg_path, root),
|
|
306
|
+
"png": rel(png_path, root) if png else "",
|
|
307
|
+
"size": f"{spec.width}x{spec.height}",
|
|
308
|
+
"written": [],
|
|
309
|
+
"png_unavailable": "" if png else "Pillow is not installed: only the SVG was produced",
|
|
310
|
+
})
|
|
311
|
+
if not png:
|
|
312
|
+
report.warn("assets.no-pillow", "Pillow is not available, so no PNG was rendered",
|
|
313
|
+
hint="a social preview must be PNG/JPG/GIF; install Pillow or supply your own image")
|
|
314
|
+
if not write:
|
|
315
|
+
report.info("assets.dry-run", f"would write {rel(svg_path, root)} and {rel(png_path, root)}",
|
|
316
|
+
hint="pass --write to create the files")
|
|
317
|
+
return report
|
|
318
|
+
|
|
319
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
320
|
+
svg_path.write_bytes(svg.replace("\r\n", "\n").encode("utf-8"))
|
|
321
|
+
written = [rel(svg_path, root)]
|
|
322
|
+
if png:
|
|
323
|
+
png_path.write_bytes(png)
|
|
324
|
+
written.append(rel(png_path, root))
|
|
325
|
+
report.data["written"] = written
|
|
326
|
+
report.info("assets.written", f"{len(written)} file(s) under {out_dir}/")
|
|
327
|
+
return report
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
HTML_IMG_RE = re.compile(r"<img\b[^>]*>", re.IGNORECASE)
|
|
331
|
+
HTML_ATTR_RE = re.compile(r"\b(src|alt)\s*=\s*[\"']([^\"']*)[\"']", re.IGNORECASE)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def readme_references(readme: Path) -> list[tuple[str, str, int]]:
|
|
335
|
+
"""(alt, target, line) for every image in a markdown file, markdown or HTML."""
|
|
336
|
+
if not Path(readme).exists():
|
|
337
|
+
return []
|
|
338
|
+
text, _ = read_text(readme)
|
|
339
|
+
found: list[tuple[str, str, int]] = []
|
|
340
|
+
for number, line in enumerate(text.split("\n"), start=1):
|
|
341
|
+
for match in IMAGE_RE.finditer(line):
|
|
342
|
+
found.append((match.group(1), match.group(2), number))
|
|
343
|
+
for match in HTML_IMG_RE.finditer(line):
|
|
344
|
+
attributes = {name.lower(): value for name, value in HTML_ATTR_RE.findall(match.group(0))}
|
|
345
|
+
if attributes.get("src"):
|
|
346
|
+
found.append((attributes.get("alt", ""), attributes["src"], number))
|
|
347
|
+
return found
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def check(root: Path, *, report: Report | None = None) -> Report:
|
|
351
|
+
root = Path(root)
|
|
352
|
+
report = report or Report(title="ghagent assets check", root=str(root))
|
|
353
|
+
readme = root / "README.md"
|
|
354
|
+
if not readme.exists():
|
|
355
|
+
report.error("assets.no-readme", "README.md is absent, so no image reference can be examined")
|
|
356
|
+
return report
|
|
357
|
+
|
|
358
|
+
references = readme_references(readme)
|
|
359
|
+
if not references:
|
|
360
|
+
report.error("assets.readme-banner", "README references no image",
|
|
361
|
+
hint="render one with `ghagent assets render --write` and put it above the first section")
|
|
362
|
+
relative_targets: list[str] = []
|
|
363
|
+
for alt, target, line in references:
|
|
364
|
+
if not alt.strip():
|
|
365
|
+
report.warn("assets.alt", f"image without alt text: {target}", path="README.md", line=line)
|
|
366
|
+
if target.startswith(("http://", "//")):
|
|
367
|
+
report.warn("assets.insecure-url", f"image over a plain-http URL: {target}",
|
|
368
|
+
path="README.md", line=line)
|
|
369
|
+
continue
|
|
370
|
+
if target.startswith("https://"):
|
|
371
|
+
continue
|
|
372
|
+
relative_targets.append(target)
|
|
373
|
+
resolved = (root / target.split("?")[0].split("#")[0]).resolve()
|
|
374
|
+
if not resolved.exists():
|
|
375
|
+
report.error("assets.missing-file", f"README references an image that does not exist: {target}",
|
|
376
|
+
path="README.md", line=line,
|
|
377
|
+
hint="commit the file or fix the path; a missing image renders as a broken box")
|
|
378
|
+
continue
|
|
379
|
+
suffix = resolved.suffix.lower()
|
|
380
|
+
size = image_size(resolved)
|
|
381
|
+
if suffix in SOCIAL_ACCEPTED and size:
|
|
382
|
+
report.info("assets.dimensions", f"{target}: {size[0]}x{size[1]}", path="README.md")
|
|
383
|
+
|
|
384
|
+
assets_dir = root / "assets"
|
|
385
|
+
candidates = sorted(p for p in assets_dir.glob("*") if p.suffix.lower() in IMAGE_SUFFIXES) if assets_dir.is_dir() else []
|
|
386
|
+
if not candidates:
|
|
387
|
+
report.warn("assets.no-directory", "no assets/ directory with an image",
|
|
388
|
+
hint="`ghagent assets render --write` creates assets/social-preview.png and .svg")
|
|
389
|
+
report.data["social_preview_ready"] = False
|
|
390
|
+
return report
|
|
391
|
+
|
|
392
|
+
preview = next((p for p in candidates if p.stem.lower() in {"social-preview", "social_preview", "preview"}), None)
|
|
393
|
+
if preview is None:
|
|
394
|
+
report.warn("assets.social-preview", "no file named social-preview.* under assets/",
|
|
395
|
+
hint="the social preview is what link cards show; name it social-preview")
|
|
396
|
+
report.data["social_preview_ready"] = False
|
|
397
|
+
return report
|
|
398
|
+
|
|
399
|
+
size_bytes = preview.stat().st_size
|
|
400
|
+
if preview.suffix.lower() not in SOCIAL_ACCEPTED:
|
|
401
|
+
report.warn("assets.svg-social", f"{rel(preview, root)} cannot be uploaded as a social preview",
|
|
402
|
+
hint="GitHub accepts PNG, JPG or GIF; keep the SVG for the README and add a PNG")
|
|
403
|
+
if size_bytes > SOCIAL_MAX_BYTES:
|
|
404
|
+
report.error("assets.social-size", f"{rel(preview, root)} is {size_bytes} bytes, over the 1 MB limit",
|
|
405
|
+
hint="compress the image; the platform rejects anything larger")
|
|
406
|
+
dimensions = image_size(preview)
|
|
407
|
+
if dimensions is None:
|
|
408
|
+
report.warn("assets.social-dimensions", f"cannot read the size of {rel(preview, root)}",
|
|
409
|
+
hint="PNG, JPEG and GIF headers are readable; other formats are not")
|
|
410
|
+
else:
|
|
411
|
+
width, height = dimensions
|
|
412
|
+
if width < SOCIAL_MIN[0] or height < SOCIAL_MIN[1]:
|
|
413
|
+
report.error("assets.social-dimensions",
|
|
414
|
+
f"{rel(preview, root)} is {width}x{height}, below the 640x320 minimum")
|
|
415
|
+
elif (width, height) != SOCIAL_SIZE:
|
|
416
|
+
report.warn("assets.social-dimensions",
|
|
417
|
+
f"{rel(preview, root)} is {width}x{height}; 1280x640 renders best")
|
|
418
|
+
else:
|
|
419
|
+
report.info("assets.social-dimensions", f"{rel(preview, root)} is 1280x640")
|
|
420
|
+
|
|
421
|
+
ready = (preview.suffix.lower() in SOCIAL_ACCEPTED and size_bytes <= SOCIAL_MAX_BYTES
|
|
422
|
+
and dimensions is not None and dimensions[0] >= SOCIAL_MIN[0] and dimensions[1] >= SOCIAL_MIN[1])
|
|
423
|
+
report.data.update({
|
|
424
|
+
"social_preview_ready": ready,
|
|
425
|
+
"social_preview_file": rel(preview, root),
|
|
426
|
+
"social_preview_bytes": size_bytes,
|
|
427
|
+
"social_preview_dimensions": list(dimensions) if dimensions else None,
|
|
428
|
+
"images_in_assets": len(candidates),
|
|
429
|
+
"readme_references": len(references),
|
|
430
|
+
})
|
|
431
|
+
if ready:
|
|
432
|
+
report.info("assets.upload-hint",
|
|
433
|
+
"upload this file in Settings → Social preview; the file in the repository is not used automatically")
|
|
434
|
+
return report
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
RECOMMENDATIONS = {
|
|
438
|
+
"banner": "1280x640 social preview: it is what link cards in chat and search show",
|
|
439
|
+
"diagram": "one architecture diagram; prefer a Mermaid block over a picture, because it stays editable",
|
|
440
|
+
"screenshot": "one screenshot of the main screen at 1440 px wide, with personal data removed",
|
|
441
|
+
"terminal": "one terminal capture of the primary command, as a code block rather than an image",
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def suggest(root: Path, *, report: Report | None = None) -> Report:
|
|
446
|
+
root = Path(root)
|
|
447
|
+
report = report or Report(title="ghagent assets suggest", root=str(root))
|
|
448
|
+
from . import meta as meta_module
|
|
449
|
+
facts = meta_module.derive(root)
|
|
450
|
+
languages = [language for language, _count in facts.get("languages", [])]
|
|
451
|
+
wanted: list[dict] = [{"kind": "banner", "why": RECOMMENDATIONS["banner"]}]
|
|
452
|
+
if any(language in {"php", "python", "javascript", "typescript", "csharp", "go", "rust"} for language in languages):
|
|
453
|
+
wanted.append({"kind": "diagram", "why": RECOMMENDATIONS["diagram"]})
|
|
454
|
+
readme = root / "README.md"
|
|
455
|
+
has_ui = False
|
|
456
|
+
if readme.exists():
|
|
457
|
+
text, _ = read_text(readme)
|
|
458
|
+
has_ui = any(word in text.lower() for word in ("интерфейс", "ui", "screenshot", "панель", "screen"))
|
|
459
|
+
if has_ui:
|
|
460
|
+
wanted.append({"kind": "screenshot", "why": RECOMMENDATIONS["screenshot"]})
|
|
461
|
+
wanted.append({"kind": "terminal", "why": RECOMMENDATIONS["terminal"]})
|
|
462
|
+
present = set()
|
|
463
|
+
assets_dir = root / "assets"
|
|
464
|
+
if assets_dir.is_dir():
|
|
465
|
+
present = {p.stem.lower() for p in assets_dir.glob("*") if p.suffix.lower() in IMAGE_SUFFIXES}
|
|
466
|
+
for entry in wanted:
|
|
467
|
+
entry["present"] = entry["kind"] in present or (entry["kind"] == "banner" and "social-preview" in present)
|
|
468
|
+
report.data.update({"recommended": wanted, "present_in_assets": sorted(present)})
|
|
469
|
+
missing = [entry["kind"] for entry in wanted if not entry["present"]]
|
|
470
|
+
if missing:
|
|
471
|
+
report.info("assets.missing", "no image for: " + ", ".join(missing),
|
|
472
|
+
hint="render the banner with `assets render --write`; the rest needs a human decision")
|
|
473
|
+
else:
|
|
474
|
+
report.info("assets.complete", "every recommended image kind is present")
|
|
475
|
+
return report
|