open-growth-loop 0.2.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.
- open_growth_loop/__init__.py +5 -0
- open_growth_loop/__main__.py +4 -0
- open_growth_loop/audit.py +558 -0
- open_growth_loop/candidates.py +390 -0
- open_growth_loop/cli.py +892 -0
- open_growth_loop/config.py +168 -0
- open_growth_loop/doctor.py +189 -0
- open_growth_loop/events.py +130 -0
- open_growth_loop/experiments.py +198 -0
- open_growth_loop/fix.py +218 -0
- open_growth_loop/freshness.py +231 -0
- open_growth_loop/github_evidence.py +216 -0
- open_growth_loop/inventory.py +51 -0
- open_growth_loop/io_utils.py +108 -0
- open_growth_loop/issue_drafts.py +155 -0
- open_growth_loop/memory.py +178 -0
- open_growth_loop/planner.py +466 -0
- open_growth_loop/privacy.py +147 -0
- open_growth_loop/prompts.py +33 -0
- open_growth_loop/query_backlog.py +76 -0
- open_growth_loop/release_brief.py +423 -0
- open_growth_loop/report_index.py +152 -0
- open_growth_loop/reporting.py +78 -0
- open_growth_loop/search.py +105 -0
- open_growth_loop/steward.py +330 -0
- open_growth_loop/templates/CHANGELOG.md +7 -0
- open_growth_loop/templates/CODE_OF_CONDUCT.md +13 -0
- open_growth_loop/templates/CONTRIBUTING.md +22 -0
- open_growth_loop/templates/ISSUE_TEMPLATE-bug_report.md +21 -0
- open_growth_loop/templates/ISSUE_TEMPLATE-feature_request.md +13 -0
- open_growth_loop/templates/LICENSE-apache-2.0.txt +201 -0
- open_growth_loop/templates/LICENSE-mit.txt +21 -0
- open_growth_loop/templates/README.md +25 -0
- open_growth_loop/templates/SECURITY.md +13 -0
- open_growth_loop/templates/ci-go.yml +16 -0
- open_growth_loop/templates/ci-node.yml +19 -0
- open_growth_loop/templates/ci-python.yml +19 -0
- open_growth_loop/templates/ci-rust.yml +13 -0
- open_growth_loop/templates/pull_request_template.md +11 -0
- open_growth_loop/weekly.py +290 -0
- open_growth_loop/workspace.py +136 -0
- open_growth_loop-0.2.0.dist-info/METADATA +204 -0
- open_growth_loop-0.2.0.dist-info/RECORD +48 -0
- open_growth_loop-0.2.0.dist-info/WHEEL +5 -0
- open_growth_loop-0.2.0.dist-info/entry_points.txt +2 -0
- open_growth_loop-0.2.0.dist-info/licenses/LICENSE +201 -0
- open_growth_loop-0.2.0.dist-info/licenses/NOTICE +4 -0
- open_growth_loop-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,558 @@
|
|
|
1
|
+
"""Zero-config repository audit.
|
|
2
|
+
|
|
3
|
+
Unlike the CSV-driven planner, the audit reads only files that every
|
|
4
|
+
repository already has (README, LICENSE, community files, CI workflows,
|
|
5
|
+
changelog, docs and examples directories) plus optional local git tag
|
|
6
|
+
history. It never touches the network, so it stays inside the same
|
|
7
|
+
privacy boundary as the rest of the tool.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
import subprocess
|
|
14
|
+
from dataclasses import asdict, dataclass
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from .io_utils import today_iso, write_json_report, write_text_report
|
|
18
|
+
from .reporting import (
|
|
19
|
+
collapsible_section,
|
|
20
|
+
key_value_table,
|
|
21
|
+
markdown_table,
|
|
22
|
+
status_label,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class AuditCheck:
|
|
28
|
+
id: str
|
|
29
|
+
category: str
|
|
30
|
+
name: str
|
|
31
|
+
status: str
|
|
32
|
+
detail: str
|
|
33
|
+
recommendation: str
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class AuditAction:
|
|
38
|
+
check_id: str
|
|
39
|
+
title: str
|
|
40
|
+
reason: str
|
|
41
|
+
confidence: str
|
|
42
|
+
next_steps: list[str]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class RepoAudit:
|
|
47
|
+
ok: bool
|
|
48
|
+
generated_at: str
|
|
49
|
+
repo: str
|
|
50
|
+
project_name: str
|
|
51
|
+
score: dict[str, int]
|
|
52
|
+
checks: list[AuditCheck]
|
|
53
|
+
recommended_action: AuditAction | None
|
|
54
|
+
warnings: list[str]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
README_NAMES = ["README.md", "README.rst", "README.txt", "README"]
|
|
58
|
+
LICENSE_PREFIXES = ("LICENSE", "LICENCE", "COPYING")
|
|
59
|
+
CHANGELOG_NAMES = ["CHANGELOG.md", "CHANGELOG.rst", "CHANGELOG.txt", "CHANGELOG", "CHANGES.md", "CHANGES", "HISTORY.md", "NEWS.md", "RELEASES.md"]
|
|
60
|
+
COMMUNITY_DIRS = ["", ".github", "docs"]
|
|
61
|
+
CI_PATHS = [
|
|
62
|
+
".gitlab-ci.yml",
|
|
63
|
+
".circleci/config.yml",
|
|
64
|
+
"azure-pipelines.yml",
|
|
65
|
+
"Jenkinsfile",
|
|
66
|
+
".travis.yml",
|
|
67
|
+
]
|
|
68
|
+
THIN_README_CHARS = 400
|
|
69
|
+
NO_TAG_COMMIT_WARNING = 20
|
|
70
|
+
COMMITS_SINCE_TAG_WARNING = 30
|
|
71
|
+
|
|
72
|
+
INSTALL_PATTERN = re.compile(
|
|
73
|
+
r"\b(pip3? install|pipx (install|run)|uv (pip install|tool install)|npm (install|i)\b|yarn add|pnpm add"
|
|
74
|
+
r"|cargo (install|add)|go (install|get)|gem install|composer require|brew install"
|
|
75
|
+
r"|apt(-get)? install|docker (pull|run)|dotnet add|nuget install)\b",
|
|
76
|
+
re.IGNORECASE,
|
|
77
|
+
)
|
|
78
|
+
INSTALL_HEADING_PATTERN = re.compile(r"^#{1,6}\s*.*\b(install|installation|setup)\b", re.IGNORECASE | re.MULTILINE)
|
|
79
|
+
QUICKSTART_HEADING_PATTERN = re.compile(
|
|
80
|
+
r"^#{1,6}\s*.*\b(usage|quick\s?start|getting started|example|examples|tutorial|how to use|demo)\b",
|
|
81
|
+
re.IGNORECASE | re.MULTILINE,
|
|
82
|
+
)
|
|
83
|
+
DOCS_LINK_PATTERN = re.compile(r"readthedocs|docs\.rs|hexdocs|pkg\.go\.dev|/docs/|\bdocumentation\b", re.IGNORECASE)
|
|
84
|
+
|
|
85
|
+
# Ordered by how much a gap hurts a new visitor; the first non-pass check
|
|
86
|
+
# becomes the single recommended action.
|
|
87
|
+
RECOMMENDATION_ORDER = [
|
|
88
|
+
"license",
|
|
89
|
+
"readme",
|
|
90
|
+
"install",
|
|
91
|
+
"quickstart",
|
|
92
|
+
"ci",
|
|
93
|
+
"changelog",
|
|
94
|
+
"release_tags",
|
|
95
|
+
"security_policy",
|
|
96
|
+
"contributing",
|
|
97
|
+
"docs",
|
|
98
|
+
"examples",
|
|
99
|
+
"issue_templates",
|
|
100
|
+
"pr_template",
|
|
101
|
+
"code_of_conduct",
|
|
102
|
+
]
|
|
103
|
+
|
|
104
|
+
NEXT_STEPS: dict[str, list[str]] = {
|
|
105
|
+
"license": [
|
|
106
|
+
"Choose a license that matches how you want the project reused (for example MIT or Apache-2.0).",
|
|
107
|
+
"Add the license text as a top-level LICENSE file.",
|
|
108
|
+
"Reference the license at the end of the README.",
|
|
109
|
+
],
|
|
110
|
+
"readme": [
|
|
111
|
+
"Write a README that states in one sentence what the project does and who it is for.",
|
|
112
|
+
"Add an install command and one copy-pasteable usage example.",
|
|
113
|
+
"Link to docs, examples, and contribution guidance if they exist.",
|
|
114
|
+
],
|
|
115
|
+
"install": [
|
|
116
|
+
"Add an Install section to the README with the exact command a new user runs.",
|
|
117
|
+
"Verify the command works from a clean environment.",
|
|
118
|
+
],
|
|
119
|
+
"quickstart": [
|
|
120
|
+
"Add a Quickstart or Usage section with one copy-pasteable example.",
|
|
121
|
+
"Show the expected output so a new user can confirm it worked.",
|
|
122
|
+
],
|
|
123
|
+
"ci": [
|
|
124
|
+
"Add a CI workflow that installs the project and runs its tests on push and pull request.",
|
|
125
|
+
"Add the CI status badge to the README once it passes.",
|
|
126
|
+
],
|
|
127
|
+
"changelog": [
|
|
128
|
+
"Add a CHANGELOG.md with an Unreleased section and notes for the latest release.",
|
|
129
|
+
"Update it as part of every release, not after.",
|
|
130
|
+
],
|
|
131
|
+
"release_tags": [
|
|
132
|
+
"Review the unreleased commits and group them into user-visible changes.",
|
|
133
|
+
"Update the changelog, tag a release, and publish release notes.",
|
|
134
|
+
],
|
|
135
|
+
"security_policy": [
|
|
136
|
+
"Add a SECURITY.md explaining how to report a vulnerability privately.",
|
|
137
|
+
"State which versions receive fixes.",
|
|
138
|
+
],
|
|
139
|
+
"contributing": [
|
|
140
|
+
"Add a CONTRIBUTING.md covering setup, tests, and how to propose a change.",
|
|
141
|
+
"Link it from the README so contributors find it.",
|
|
142
|
+
],
|
|
143
|
+
"docs": [
|
|
144
|
+
"Create a docs/ directory or documentation site for anything the README cannot hold.",
|
|
145
|
+
"Link the documentation from the README.",
|
|
146
|
+
],
|
|
147
|
+
"examples": [
|
|
148
|
+
"Add an examples/ directory with at least one small, runnable example.",
|
|
149
|
+
"Reference the examples from the README.",
|
|
150
|
+
],
|
|
151
|
+
"issue_templates": [
|
|
152
|
+
"Add .github/ISSUE_TEMPLATE forms for bug reports and feature requests.",
|
|
153
|
+
"Ask for reproduction steps and environment details in the bug template.",
|
|
154
|
+
],
|
|
155
|
+
"pr_template": [
|
|
156
|
+
"Add .github/pull_request_template.md asking what changed, why, and how it was tested.",
|
|
157
|
+
],
|
|
158
|
+
"code_of_conduct": [
|
|
159
|
+
"Add a CODE_OF_CONDUCT.md (the Contributor Covenant is a common default).",
|
|
160
|
+
],
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def build_repo_audit(repo: Path, generated_at: str | None = None) -> RepoAudit:
|
|
165
|
+
generated_at = generated_at or today_iso()
|
|
166
|
+
readme_text = _readme_text(repo)
|
|
167
|
+
|
|
168
|
+
checks = [
|
|
169
|
+
_readme_check(repo, readme_text),
|
|
170
|
+
_license_check(repo),
|
|
171
|
+
_install_check(readme_text),
|
|
172
|
+
_quickstart_check(readme_text),
|
|
173
|
+
_docs_check(repo, readme_text),
|
|
174
|
+
_examples_check(repo, readme_text),
|
|
175
|
+
_community_file_check(repo, "contributing", "community", "Contributing guide", "CONTRIBUTING.md"),
|
|
176
|
+
_community_file_check(repo, "code_of_conduct", "community", "Code of conduct", "CODE_OF_CONDUCT.md"),
|
|
177
|
+
_community_file_check(repo, "security_policy", "community", "Security policy", "SECURITY.md"),
|
|
178
|
+
_issue_templates_check(repo),
|
|
179
|
+
_pr_template_check(repo),
|
|
180
|
+
_changelog_check(repo),
|
|
181
|
+
_ci_check(repo),
|
|
182
|
+
_release_tags_check(repo),
|
|
183
|
+
]
|
|
184
|
+
|
|
185
|
+
counted = [check for check in checks if check.status != "skip"]
|
|
186
|
+
passed = sum(1 for check in counted if check.status == "pass")
|
|
187
|
+
score = {
|
|
188
|
+
"pass": passed,
|
|
189
|
+
"warn": sum(1 for check in counted if check.status == "warn"),
|
|
190
|
+
"fail": sum(1 for check in counted if check.status == "fail"),
|
|
191
|
+
"skipped": len(checks) - len(counted),
|
|
192
|
+
"total": len(counted),
|
|
193
|
+
"percent": round(100 * passed / len(counted)) if counted else 0,
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return RepoAudit(
|
|
197
|
+
ok=not any(check.status == "fail" for check in checks),
|
|
198
|
+
generated_at=generated_at,
|
|
199
|
+
repo=str(repo),
|
|
200
|
+
project_name=repo.name or str(repo),
|
|
201
|
+
score=score,
|
|
202
|
+
checks=checks,
|
|
203
|
+
recommended_action=_recommended_action(checks),
|
|
204
|
+
warnings=[f"{check.name}: {check.detail}" for check in checks if check.status in {"warn", "fail"}],
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def render_audit_markdown(audit: RepoAudit) -> str:
|
|
209
|
+
lines = [
|
|
210
|
+
"# Repository Audit",
|
|
211
|
+
"",
|
|
212
|
+
"A zero-config maintainer-readiness check. It reads only files the repository already has; no analytics exports are required.",
|
|
213
|
+
"",
|
|
214
|
+
"## Scorecard",
|
|
215
|
+
"",
|
|
216
|
+
*key_value_table(
|
|
217
|
+
[
|
|
218
|
+
("Repository", audit.project_name),
|
|
219
|
+
("Generated at", audit.generated_at),
|
|
220
|
+
("Overall status", status_label("fail" if audit.score["fail"] else ("warn" if audit.score["warn"] else "pass"))),
|
|
221
|
+
("Score", f"{audit.score['percent']}% ({audit.score['pass']} of {audit.score['total']} checks pass)"),
|
|
222
|
+
("Warnings", audit.score["warn"]),
|
|
223
|
+
("Failures", audit.score["fail"]),
|
|
224
|
+
("Skipped", audit.score["skipped"]),
|
|
225
|
+
]
|
|
226
|
+
),
|
|
227
|
+
"",
|
|
228
|
+
"## Checks",
|
|
229
|
+
"",
|
|
230
|
+
*markdown_table(
|
|
231
|
+
["Check", "Category", "Status", "Detail"],
|
|
232
|
+
[(check.name, check.category, status_label(check.status), check.detail) for check in audit.checks],
|
|
233
|
+
),
|
|
234
|
+
"",
|
|
235
|
+
"## Recommended Next Action",
|
|
236
|
+
"",
|
|
237
|
+
]
|
|
238
|
+
action = audit.recommended_action
|
|
239
|
+
if action is None:
|
|
240
|
+
lines.extend(
|
|
241
|
+
[
|
|
242
|
+
"All audited surfaces look healthy. The next signal-driven step is the data loop:",
|
|
243
|
+
"",
|
|
244
|
+
"1. Run `ogl init` to create the local data files.",
|
|
245
|
+
"2. Drop in a Search Console export and aggregate event counts.",
|
|
246
|
+
"3. Run `ogl plan` for one conservative, evidence-backed action.",
|
|
247
|
+
]
|
|
248
|
+
)
|
|
249
|
+
else:
|
|
250
|
+
lines.extend(
|
|
251
|
+
key_value_table(
|
|
252
|
+
[
|
|
253
|
+
("Action", action.title),
|
|
254
|
+
("Why now", action.reason),
|
|
255
|
+
("Confidence", action.confidence),
|
|
256
|
+
]
|
|
257
|
+
)
|
|
258
|
+
)
|
|
259
|
+
lines.extend(["", "Steps:", ""])
|
|
260
|
+
lines.extend(f"- [ ] {step}" for step in action.next_steps)
|
|
261
|
+
lines.extend(["", *collapsible_section("Codex-ready prompt for this action", ["```text", *render_audit_prompt(audit).splitlines(), "```"])])
|
|
262
|
+
lines.extend(
|
|
263
|
+
[
|
|
264
|
+
"",
|
|
265
|
+
"## Going Deeper",
|
|
266
|
+
"",
|
|
267
|
+
"The audit covers repository hygiene. To plan work from real usage signals, add local CSV exports and run the full loop: `ogl init`, `ogl validate`, `ogl plan`.",
|
|
268
|
+
"",
|
|
269
|
+
]
|
|
270
|
+
)
|
|
271
|
+
return "\n".join(lines)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def render_audit_prompt(audit: RepoAudit, action: AuditAction | None = None) -> str:
|
|
275
|
+
action = action or audit.recommended_action
|
|
276
|
+
if action is None:
|
|
277
|
+
return ""
|
|
278
|
+
steps = "\n".join(f"- {step}" for step in action.next_steps)
|
|
279
|
+
return f"""You are helping maintain an open-source project.
|
|
280
|
+
|
|
281
|
+
Work from this single repository-audit action:
|
|
282
|
+
|
|
283
|
+
Repository: {audit.project_name}
|
|
284
|
+
Action: {action.title}
|
|
285
|
+
Why now: {action.reason}
|
|
286
|
+
Confidence: {action.confidence}
|
|
287
|
+
|
|
288
|
+
Constraints:
|
|
289
|
+
- Make one focused, reviewable change.
|
|
290
|
+
- Do not invent analytics or adoption claims.
|
|
291
|
+
- Match the project's existing tone and conventions.
|
|
292
|
+
- Add or update tests/docs when the change affects maintainer workflows.
|
|
293
|
+
|
|
294
|
+
Steps:
|
|
295
|
+
{steps}
|
|
296
|
+
"""
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def write_audit_reports(audit: RepoAudit, out_dir: Path) -> tuple[Path, Path, Path, Path]:
|
|
300
|
+
md_path, md_history = write_text_report(out_dir / "latest-audit.md", render_audit_markdown(audit))
|
|
301
|
+
json_path, json_history = write_json_report(out_dir / "latest-audit.json", asdict(audit))
|
|
302
|
+
return md_path, md_history, json_path, json_history
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def action_from_check(check: AuditCheck) -> AuditAction:
|
|
306
|
+
return AuditAction(
|
|
307
|
+
check_id=check.id,
|
|
308
|
+
title=check.recommendation or check.name,
|
|
309
|
+
reason=check.detail,
|
|
310
|
+
confidence="high" if check.status == "fail" else "medium",
|
|
311
|
+
next_steps=list(NEXT_STEPS.get(check.id, [])),
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _recommended_action(checks: list[AuditCheck]) -> AuditAction | None:
|
|
316
|
+
by_id = {check.id: check for check in checks}
|
|
317
|
+
for check_id in RECOMMENDATION_ORDER:
|
|
318
|
+
check = by_id.get(check_id)
|
|
319
|
+
if check is None or check.status in {"pass", "skip"}:
|
|
320
|
+
continue
|
|
321
|
+
return action_from_check(check)
|
|
322
|
+
return None
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _readme_text(repo: Path) -> str:
|
|
326
|
+
for name in README_NAMES:
|
|
327
|
+
path = repo / name
|
|
328
|
+
if path.is_file():
|
|
329
|
+
try:
|
|
330
|
+
return path.read_text(encoding="utf-8", errors="replace")
|
|
331
|
+
except OSError:
|
|
332
|
+
return ""
|
|
333
|
+
return ""
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _readme_check(repo: Path, readme_text: str) -> AuditCheck:
|
|
337
|
+
if not any((repo / name).is_file() for name in README_NAMES):
|
|
338
|
+
return AuditCheck("readme", "essentials", "README", "fail", "No README file was found.", "Add a README that explains what the project does and how to use it.")
|
|
339
|
+
if len(readme_text.strip()) < THIN_README_CHARS:
|
|
340
|
+
return AuditCheck(
|
|
341
|
+
"readme",
|
|
342
|
+
"essentials",
|
|
343
|
+
"README",
|
|
344
|
+
"warn",
|
|
345
|
+
f"README is under {THIN_README_CHARS} characters; a visitor cannot judge the project from it.",
|
|
346
|
+
"Expand the README with a one-line pitch, install command, and usage example.",
|
|
347
|
+
)
|
|
348
|
+
return AuditCheck("readme", "essentials", "README", "pass", "README is present with enough content to evaluate the project.", "")
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _license_check(repo: Path) -> AuditCheck:
|
|
352
|
+
if any(path.is_file() and (path.name.upper().startswith(LICENSE_PREFIXES) or path.name.upper() == "UNLICENSE") for path in repo.iterdir()):
|
|
353
|
+
return AuditCheck("license", "essentials", "License", "pass", "A license file is present.", "")
|
|
354
|
+
return AuditCheck(
|
|
355
|
+
"license",
|
|
356
|
+
"essentials",
|
|
357
|
+
"License",
|
|
358
|
+
"fail",
|
|
359
|
+
"No license file was found; without one, others cannot legally reuse the project.",
|
|
360
|
+
"Add a LICENSE file so the project is actually open source.",
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _install_check(readme_text: str) -> AuditCheck:
|
|
365
|
+
if INSTALL_PATTERN.search(readme_text) or INSTALL_HEADING_PATTERN.search(readme_text):
|
|
366
|
+
return AuditCheck("install", "onboarding", "Install instructions", "pass", "README shows how to install the project.", "")
|
|
367
|
+
return AuditCheck(
|
|
368
|
+
"install",
|
|
369
|
+
"onboarding",
|
|
370
|
+
"Install instructions",
|
|
371
|
+
"warn",
|
|
372
|
+
"README has no recognizable install command or Install section.",
|
|
373
|
+
"Add an Install section with the exact command a new user runs.",
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _quickstart_check(readme_text: str) -> AuditCheck:
|
|
378
|
+
code_blocks = readme_text.count("```") // 2
|
|
379
|
+
if code_blocks >= 1 and QUICKSTART_HEADING_PATTERN.search(readme_text):
|
|
380
|
+
return AuditCheck("quickstart", "onboarding", "Quickstart", "pass", "README has a usage section with at least one code example.", "")
|
|
381
|
+
return AuditCheck(
|
|
382
|
+
"quickstart",
|
|
383
|
+
"onboarding",
|
|
384
|
+
"Quickstart",
|
|
385
|
+
"warn",
|
|
386
|
+
"README has no usage section with a copy-pasteable example.",
|
|
387
|
+
"Add a Quickstart section with one runnable example and its expected output.",
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _docs_check(repo: Path, readme_text: str) -> AuditCheck:
|
|
392
|
+
for name in ("docs", "doc"):
|
|
393
|
+
directory = repo / name
|
|
394
|
+
if directory.is_dir() and any(directory.iterdir()):
|
|
395
|
+
return AuditCheck("docs", "onboarding", "Documentation", "pass", f"A {name}/ directory is present.", "")
|
|
396
|
+
if DOCS_LINK_PATTERN.search(readme_text):
|
|
397
|
+
return AuditCheck("docs", "onboarding", "Documentation", "pass", "README links to documentation.", "")
|
|
398
|
+
return AuditCheck(
|
|
399
|
+
"docs",
|
|
400
|
+
"onboarding",
|
|
401
|
+
"Documentation",
|
|
402
|
+
"warn",
|
|
403
|
+
"No docs/ directory or documentation link was found.",
|
|
404
|
+
"Add a docs/ directory or link a documentation site from the README.",
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _examples_check(repo: Path, readme_text: str) -> AuditCheck:
|
|
409
|
+
for name in ("examples", "example"):
|
|
410
|
+
directory = repo / name
|
|
411
|
+
if directory.is_dir() and any(directory.iterdir()):
|
|
412
|
+
return AuditCheck("examples", "onboarding", "Examples", "pass", f"An {name}/ directory is present.", "")
|
|
413
|
+
if QUICKSTART_HEADING_PATTERN.search(readme_text) and readme_text.count("```") // 2 >= 2:
|
|
414
|
+
return AuditCheck("examples", "onboarding", "Examples", "pass", "README contains multiple worked examples.", "")
|
|
415
|
+
return AuditCheck(
|
|
416
|
+
"examples",
|
|
417
|
+
"onboarding",
|
|
418
|
+
"Examples",
|
|
419
|
+
"warn",
|
|
420
|
+
"No examples/ directory or worked README examples were found.",
|
|
421
|
+
"Add at least one small, runnable example.",
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _community_file_check(repo: Path, check_id: str, category: str, name: str, filename: str) -> AuditCheck:
|
|
426
|
+
for directory in COMMUNITY_DIRS:
|
|
427
|
+
path = repo / directory / filename if directory else repo / filename
|
|
428
|
+
if path.is_file():
|
|
429
|
+
return AuditCheck(check_id, category, name, "pass", f"{filename} is present.", "")
|
|
430
|
+
return AuditCheck(
|
|
431
|
+
check_id,
|
|
432
|
+
category,
|
|
433
|
+
name,
|
|
434
|
+
"warn",
|
|
435
|
+
f"No {filename} was found in the repository root, .github/, or docs/.",
|
|
436
|
+
f"Add a {filename}.",
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _issue_templates_check(repo: Path) -> AuditCheck:
|
|
441
|
+
template_dir = repo / ".github" / "ISSUE_TEMPLATE"
|
|
442
|
+
if template_dir.is_dir() and any(template_dir.iterdir()):
|
|
443
|
+
return AuditCheck("issue_templates", "community", "Issue templates", "pass", "Issue templates are present.", "")
|
|
444
|
+
return AuditCheck(
|
|
445
|
+
"issue_templates",
|
|
446
|
+
"community",
|
|
447
|
+
"Issue templates",
|
|
448
|
+
"warn",
|
|
449
|
+
"No .github/ISSUE_TEMPLATE directory was found.",
|
|
450
|
+
"Add bug-report and feature-request issue templates.",
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def _pr_template_check(repo: Path) -> AuditCheck:
|
|
455
|
+
candidates = [
|
|
456
|
+
repo / ".github" / "pull_request_template.md",
|
|
457
|
+
repo / ".github" / "PULL_REQUEST_TEMPLATE.md",
|
|
458
|
+
repo / "pull_request_template.md",
|
|
459
|
+
repo / "PULL_REQUEST_TEMPLATE.md",
|
|
460
|
+
]
|
|
461
|
+
if any(path.is_file() for path in candidates):
|
|
462
|
+
return AuditCheck("pr_template", "community", "Pull request template", "pass", "A pull request template is present.", "")
|
|
463
|
+
return AuditCheck(
|
|
464
|
+
"pr_template",
|
|
465
|
+
"community",
|
|
466
|
+
"Pull request template",
|
|
467
|
+
"warn",
|
|
468
|
+
"No pull request template was found.",
|
|
469
|
+
"Add a pull request template asking what changed, why, and how it was tested.",
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def _changelog_check(repo: Path) -> AuditCheck:
|
|
474
|
+
if any((repo / name).is_file() for name in CHANGELOG_NAMES):
|
|
475
|
+
return AuditCheck("changelog", "release", "Changelog", "pass", "A changelog file is present.", "")
|
|
476
|
+
return AuditCheck(
|
|
477
|
+
"changelog",
|
|
478
|
+
"release",
|
|
479
|
+
"Changelog",
|
|
480
|
+
"warn",
|
|
481
|
+
"No changelog file was found; users cannot see what changed between releases.",
|
|
482
|
+
"Add a CHANGELOG.md and keep it updated with every release.",
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def _ci_check(repo: Path) -> AuditCheck:
|
|
487
|
+
workflows = repo / ".github" / "workflows"
|
|
488
|
+
if workflows.is_dir() and any(path.suffix in {".yml", ".yaml"} for path in workflows.iterdir() if path.is_file()):
|
|
489
|
+
return AuditCheck("ci", "automation", "Continuous integration", "pass", "GitHub Actions workflows are present.", "")
|
|
490
|
+
if any((repo / path).exists() for path in CI_PATHS):
|
|
491
|
+
return AuditCheck("ci", "automation", "Continuous integration", "pass", "A CI configuration is present.", "")
|
|
492
|
+
return AuditCheck(
|
|
493
|
+
"ci",
|
|
494
|
+
"automation",
|
|
495
|
+
"Continuous integration",
|
|
496
|
+
"warn",
|
|
497
|
+
"No CI configuration was found; contributors cannot see whether tests pass.",
|
|
498
|
+
"Add a CI workflow that installs the project and runs its tests.",
|
|
499
|
+
)
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def _release_tags_check(repo: Path) -> AuditCheck:
|
|
503
|
+
if not (repo / ".git").exists():
|
|
504
|
+
return AuditCheck("release_tags", "release", "Release tags", "skip", "Not a git repository; tag history was not checked.", "")
|
|
505
|
+
|
|
506
|
+
latest_tag = _git(repo, "describe", "--tags", "--abbrev=0")
|
|
507
|
+
if latest_tag is None:
|
|
508
|
+
commits = _git_int(repo, "rev-list", "--count", "HEAD")
|
|
509
|
+
if commits is None:
|
|
510
|
+
return AuditCheck("release_tags", "release", "Release tags", "skip", "Git history could not be read; tag history was not checked.", "")
|
|
511
|
+
if commits >= NO_TAG_COMMIT_WARNING:
|
|
512
|
+
return AuditCheck(
|
|
513
|
+
"release_tags",
|
|
514
|
+
"release",
|
|
515
|
+
"Release tags",
|
|
516
|
+
"warn",
|
|
517
|
+
f"No tagged release yet after {commits} commits.",
|
|
518
|
+
"Tag a first release so users can pin a known-good version.",
|
|
519
|
+
)
|
|
520
|
+
return AuditCheck("release_tags", "release", "Release tags", "pass", f"Early history ({commits} commit(s)); no tagged release expected yet.", "")
|
|
521
|
+
|
|
522
|
+
commits_since = _git_int(repo, "rev-list", "--count", f"{latest_tag}..HEAD")
|
|
523
|
+
if commits_since is not None and commits_since >= COMMITS_SINCE_TAG_WARNING:
|
|
524
|
+
return AuditCheck(
|
|
525
|
+
"release_tags",
|
|
526
|
+
"release",
|
|
527
|
+
"Release tags",
|
|
528
|
+
"warn",
|
|
529
|
+
f"{commits_since} commits since {latest_tag}; unreleased work is piling up.",
|
|
530
|
+
"Update the changelog and tag a release.",
|
|
531
|
+
)
|
|
532
|
+
return AuditCheck("release_tags", "release", "Release tags", "pass", f"Latest tag is {latest_tag}.", "")
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def _git_int(repo: Path, *args: str) -> int | None:
|
|
536
|
+
value = _git(repo, *args)
|
|
537
|
+
if value is None:
|
|
538
|
+
return None
|
|
539
|
+
try:
|
|
540
|
+
return int(value)
|
|
541
|
+
except ValueError:
|
|
542
|
+
return None
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def _git(repo: Path, *args: str) -> str | None:
|
|
546
|
+
try:
|
|
547
|
+
result = subprocess.run(
|
|
548
|
+
["git", "-C", str(repo), *args],
|
|
549
|
+
capture_output=True,
|
|
550
|
+
text=True,
|
|
551
|
+
timeout=10,
|
|
552
|
+
check=False,
|
|
553
|
+
)
|
|
554
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
555
|
+
return None
|
|
556
|
+
if result.returncode != 0:
|
|
557
|
+
return None
|
|
558
|
+
return result.stdout.strip() or None
|