jekyll-theme-zer0 1.28.0 → 1.30.0
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +1143 -13
- data/_data/README.md +2 -0
- data/_data/ai.yml +5 -3
- data/_data/ai_pricing.yml +36 -0
- data/_data/backlog.yml +507 -2
- data/_data/consumers.yml +157 -9
- data/_data/features.yml +303 -20
- data/_data/feedback_types.yml +17 -12
- data/_data/i18n/fr.yml +12 -7
- data/_data/i18n/manifest.yml +39 -12
- data/_data/ingredient_densities.yml +122 -0
- data/_data/landing.yml +5 -2
- data/_data/navigation/main.yml +16 -0
- data/_data/navigation/quickstart.yml +4 -0
- data/_data/recipe_courses.yml +64 -0
- data/_data/site_builder.yml +874 -0
- data/_data/theme-manifest.yml +160 -124
- data/_data/ui-text.yml +26 -0
- data/_includes/README.md +26 -2
- data/_includes/analytics/posthog.html +2 -2
- data/_includes/components/admin-links.html +2 -2
- data/_includes/components/admin-tabs.html +2 -2
- data/_includes/components/ai-chat.html +14 -11
- data/_includes/components/analytics-dashboard.html +8 -8
- data/_includes/components/author-bio.html +1 -1
- data/_includes/components/author-card.html +10 -2
- data/_includes/components/author-eeat.html +4 -4
- data/_includes/components/background-customizer.html +10 -10
- data/_includes/components/background-image.html +114 -0
- data/_includes/components/background-settings.html +28 -15
- data/_includes/components/collection-manager.html +5 -5
- data/_includes/components/component-showcase.html +13 -13
- data/_includes/components/config-editor.html +12 -12
- data/_includes/components/config-viewer.html +8 -8
- data/_includes/components/cookie-consent.html +15 -15
- data/_includes/components/cta-button.html +7 -2
- data/_includes/components/dev-shortcuts.html +7 -7
- data/_includes/components/env-dashboard.html +8 -8
- data/_includes/components/env-switcher.html +9 -9
- data/_includes/components/feature-card.html +2 -2
- data/_includes/components/halfmoon.html +2 -2
- data/_includes/components/info-section.html +42 -37
- data/_includes/components/js-cdn.html +15 -15
- data/_includes/components/language-toggle.html +168 -21
- data/_includes/components/mermaid.html +72 -435
- data/_includes/components/nanobar.html +5 -5
- data/_includes/components/nav-editor.html +2 -2
- data/_includes/components/nav-export.html +2 -2
- data/_includes/components/nav-overview.html +2 -2
- data/_includes/components/page-feedback.html +45 -30
- data/_includes/components/page-views-init.html +55 -0
- data/_includes/components/page-views.html +33 -0
- data/_includes/components/post-card.html +22 -22
- data/_includes/components/post-type-badge.html +2 -2
- data/_includes/components/powered-by.html +2 -2
- data/_includes/components/preview-image.html +6 -0
- data/_includes/components/quick-index.html +2 -2
- data/_includes/components/recipe-card.html +67 -0
- data/_includes/components/recipe-duration.html +50 -0
- data/_includes/components/recipe-grams.html +58 -0
- data/_includes/components/recipe-index.html +96 -0
- data/_includes/components/recipe-ingredients.html +90 -0
- data/_includes/components/recipe-meta.html +96 -0
- data/_includes/components/recipe-nutrition.html +57 -0
- data/_includes/components/recipe-qty.html +73 -0
- data/_includes/components/recipe-ratio.html +151 -0
- data/_includes/components/recipe-scaler.html +73 -0
- data/_includes/components/recipe-steps.html +86 -0
- data/_includes/components/recipe-temp.html +45 -0
- data/_includes/components/search-modal.html +29 -4
- data/_includes/components/searchbar.html +2 -2
- data/_includes/components/shortcuts-modal.html +3 -0
- data/_includes/components/svg-background.html +2 -2
- data/_includes/components/theme-customizer.html +2 -2
- data/_includes/components/theme-info.html +14 -7
- data/_includes/components/theme-preview-gallery.html +22 -22
- data/_includes/content/giscus.html +2 -2
- data/_includes/content/intro.html +8 -8
- data/_includes/content/jsonld-faq.html +2 -2
- data/_includes/content/jsonld-software.html +24 -5
- data/_includes/content/seo.html +4 -4
- data/_includes/content/sitemap.html +27 -27
- data/_includes/content/toc.html +183 -183
- data/_includes/core/branding.html +6 -6
- data/_includes/core/console-capture.html +32 -74
- data/_includes/core/favicon.html +49 -7
- data/_includes/core/footer-fabs.html +17 -3
- data/_includes/core/footer.html +49 -34
- data/_includes/core/head.html +110 -86
- data/_includes/core/header.html +76 -54
- data/_includes/docs/bootstrap-docs.html +8 -8
- data/_includes/landing/landing-install-cards.html +2 -2
- data/_includes/landing/landing-quick-links.html +1 -1
- data/_includes/navigation/admin-nav.html +2 -2
- data/_includes/navigation/nav-tree.html +8 -8
- data/_includes/navigation/navbar.html +12 -12
- data/_includes/navigation/section-sidebar.html +109 -27
- data/_includes/navigation/sidebar-config.html +36 -2
- data/_includes/navigation/sidebar-left.html +17 -16
- data/_includes/navigation/sidebar-right.html +8 -7
- data/_includes/obsidian/full-graph.html +2 -2
- data/_includes/setup/claude-session.html +72 -0
- data/_includes/setup/prereq-checklist.html +90 -0
- data/_includes/setup/wizard.html +924 -222
- data/_includes/stats/stats-categories.html +8 -8
- data/_includes/stats/stats-header.html +14 -14
- data/_includes/stats/stats-metrics.html +14 -14
- data/_includes/stats/stats-no-data.html +12 -12
- data/_includes/stats/stats-overview.html +6 -6
- data/_includes/stats/stats-tags.html +8 -8
- data/_layouts/404.html +38 -24
- data/_layouts/README.md +2 -0
- data/_layouts/admin.html +24 -24
- data/_layouts/article.html +43 -33
- data/_layouts/author.html +20 -20
- data/_layouts/authors.html +2 -2
- data/_layouts/book-abc.html +12 -12
- data/_layouts/book-story.html +15 -15
- data/_layouts/book.html +12 -12
- data/_layouts/collection.html +33 -33
- data/_layouts/cookbook.html +88 -0
- data/_layouts/default.html +31 -28
- data/_layouts/home.html +23 -23
- data/_layouts/index.html +10 -10
- data/_layouts/landing.html +17 -17
- data/_layouts/news.html +44 -44
- data/_layouts/note.html +38 -38
- data/_layouts/notebook.html +34 -34
- data/_layouts/recipe.html +274 -0
- data/_layouts/root.html +92 -55
- data/_layouts/section.html +62 -33
- data/_layouts/setup.html +3 -3
- data/_layouts/sitemap-collection.html +49 -49
- data/_layouts/stats.html +40 -40
- data/_layouts/tag.html +12 -12
- data/_layouts/welcome.html +21 -21
- data/_sass/components/_callout.scss +1 -1
- data/_sass/components/_footer.scss +37 -1
- data/_sass/components/_mermaid.scss +375 -0
- data/_sass/components/_page-views.scss +36 -0
- data/_sass/components/_recipe.scss +506 -0
- data/_sass/components/_setup-wizard.scss +764 -0
- data/_sass/components/_ui-enhancements.scss +6 -6
- data/_sass/core/_navbar.scss +261 -46
- data/_sass/layouts/_landing.scss +2 -2
- data/_sass/layouts/_navbar-extras.scss +14 -4
- data/_sass/tokens/_color.scss +6 -0
- data/_sass/tokens/_index.scss +2 -0
- data/_sass/tokens/_radius.scss +21 -0
- data/_sass/tokens/_typography.scss +4 -0
- data/_sass/utilities/_focus.scss +14 -0
- data/assets/css/main.scss +4 -0
- data/assets/js/ai-chat.js +47 -5
- data/assets/js/fleet-feedback-capture.js +124 -0
- data/assets/js/fleet-feedback.js +853 -0
- data/assets/js/mermaid-diagrams.js +1267 -0
- data/assets/js/modules/navigation/config.js +9 -6
- data/assets/js/modules/navigation/navbar.js +55 -0
- data/assets/js/modules/navigation/scroll-spy.js +315 -80
- data/assets/js/modules/theme/appearance.js +8 -2
- data/assets/js/obsidian-wiki-links.js +8 -3
- data/assets/js/page-feedback.js +125 -192
- data/assets/js/page-views.js +372 -0
- data/assets/js/recipe-scaler.js +501 -0
- data/assets/js/search-modal.js +36 -0
- data/assets/js/setup-wizard.js +2279 -226
- data/assets/js/site-builder.js +1834 -0
- data/assets/js/ui-enhancements.js +11 -3
- data/scripts/README.md +44 -0
- data/scripts/ai/README.md +38 -0
- data/scripts/ai/api_call.rb +124 -0
- data/scripts/ai/usage.rb +314 -0
- data/scripts/ai/usage_report.rb +225 -0
- data/scripts/bin/audit-consumer +39 -7
- data/scripts/bin/giscus-discussions +213 -14
- data/scripts/bin/manifest +35 -12
- data/scripts/ci/agent_review_result.py +164 -0
- data/scripts/ci/test_agent_review_result.py +172 -0
- data/scripts/ci/test_visual_evidence_autogen.py +341 -0
- data/scripts/ci/visual_evidence_autogen.py +1060 -0
- data/scripts/content-review.rb +20 -1
- data/scripts/design-system-check.rb +170 -0
- data/scripts/lib/audit.sh +42 -2
- data/scripts/lint-liquid-raw.rb +137 -0
- data/scripts/test/integration/mermaid +22 -8
- data/scripts/test/lib/run_tests.sh +3 -1
- data/scripts/test/lib/test_agent_review_result.sh +27 -0
- data/scripts/test/lib/test_visual_evidence_autogen.sh +24 -0
- data/scripts/translate.rb +94 -16
- metadata +48 -2
data/scripts/bin/manifest
CHANGED
|
@@ -67,19 +67,40 @@ THEME_DATA_PATHS=(
|
|
|
67
67
|
"_data/ui-text.yml"
|
|
68
68
|
)
|
|
69
69
|
|
|
70
|
-
# Plugins that remote_theme consumers MUST vendor locally
|
|
71
|
-
#
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
70
|
+
# Plugins that remote_theme consumers MUST vendor locally.
|
|
71
|
+
#
|
|
72
|
+
# Deliberately empty. Nothing the theme ships is mandatory for a stock
|
|
73
|
+
# remote_theme consumer: GitHub Pages builds in safe mode, so it does not load
|
|
74
|
+
# ANY local plugin — vendoring one there is inert, not required.
|
|
75
|
+
REQUIRED_PLUGIN_PATHS=()
|
|
75
76
|
|
|
76
|
-
# Optional plugins
|
|
77
|
+
# Optional plugins a consumer can vendor for enhanced functionality. These only
|
|
78
|
+
# execute on a self-built (vanilla Jekyll) site, never on default GitHub Pages.
|
|
79
|
+
#
|
|
80
|
+
# `obsidian_links.rb` sits here, not above, because obsidian.instructions.md is
|
|
81
|
+
# explicit that it is opt-in ("skipped under the github-pages gem"; "an
|
|
82
|
+
# SEO-quality enhancement for forks that self-build") and that the Liquid + JS
|
|
83
|
+
# path is the primary one. Listing it as required made the audit demand a file
|
|
84
|
+
# that cannot run, flagging MISSING_PLUGIN on seven fleet consumers that have
|
|
85
|
+
# no Obsidian content at all.
|
|
77
86
|
OPTIONAL_PLUGIN_PATHS=(
|
|
87
|
+
"_plugins/obsidian_links.rb"
|
|
78
88
|
"_plugins/admin_page_urls.rb"
|
|
79
89
|
"_plugins/content_statistics_generator.rb"
|
|
80
90
|
"_plugins/theme_version.rb"
|
|
81
91
|
)
|
|
82
92
|
|
|
93
|
+
# Emit YAML list items, or an empty flow sequence when there are none.
|
|
94
|
+
# `printf ' - %s\n'` with zero args would otherwise emit a bare ` - `, i.e. a
|
|
95
|
+
# phantom empty entry in the list.
|
|
96
|
+
yaml_list_items() {
|
|
97
|
+
if [[ $# -eq 0 ]]; then
|
|
98
|
+
echo " []"
|
|
99
|
+
else
|
|
100
|
+
printf ' - %s\n' "$@"
|
|
101
|
+
fi
|
|
102
|
+
}
|
|
103
|
+
|
|
83
104
|
# ---------------------------------------------------------------------------
|
|
84
105
|
# Argument parsing
|
|
85
106
|
DRY_RUN=false
|
|
@@ -153,17 +174,19 @@ $(printf ' - %s\n' "${THEMABLE_PATHS[@]}")
|
|
|
153
174
|
theme_data_paths:
|
|
154
175
|
$(printf ' - %s\n' "${THEME_DATA_PATHS[@]}")
|
|
155
176
|
|
|
156
|
-
# Plugin files
|
|
157
|
-
#
|
|
177
|
+
# Plugin files a consumer MUST vendor locally. Empty by design: GitHub Pages
|
|
178
|
+
# builds in safe mode and loads no local plugins at all, so nothing the theme
|
|
179
|
+
# ships is mandatory for a stock remote_theme consumer.
|
|
158
180
|
required_plugin_paths:
|
|
159
|
-
$(
|
|
160
|
-
# Optional plugins consumers can vendor for enhanced functionality.
|
|
181
|
+
$(yaml_list_items ${REQUIRED_PLUGIN_PATHS[@]+"${REQUIRED_PLUGIN_PATHS[@]}"})
|
|
182
|
+
# Optional plugins consumers can vendor for enhanced functionality. These run
|
|
183
|
+
# only on self-built (vanilla Jekyll) sites, never on default GitHub Pages.
|
|
161
184
|
optional_plugin_paths:
|
|
162
|
-
$(
|
|
185
|
+
$(yaml_list_items ${OPTIONAL_PLUGIN_PATHS[@]+"${OPTIONAL_PLUGIN_PATHS[@]}"})
|
|
163
186
|
|
|
164
187
|
# Legacy alias — all plugin paths combined (kept for backward compatibility).
|
|
165
188
|
plugin_paths:
|
|
166
|
-
$(
|
|
189
|
+
$(yaml_list_items ${REQUIRED_PLUGIN_PATHS[@]+"${REQUIRED_PLUGIN_PATHS[@]}"} ${OPTIONAL_PLUGIN_PATHS[@]+"${OPTIONAL_PLUGIN_PATHS[@]}"})
|
|
167
190
|
|
|
168
191
|
# Jekyll config keys this theme expects.
|
|
169
192
|
config_schema:
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Decide whether the Claude content review ACTUALLY ran, and fail loudly if not.
|
|
3
|
+
|
|
4
|
+
WHY THIS EXISTS (issue #418)
|
|
5
|
+
----------------------------
|
|
6
|
+
`.github/workflows/ai-content-review.yml`'s agent step used to capture the
|
|
7
|
+
Claude CLI's exit status, echo it, and then never act on it. Whatever landed on
|
|
8
|
+
stdout was posted verbatim under the `ai-content-review-agent` sticky marker and
|
|
9
|
+
the step exited 0.
|
|
10
|
+
|
|
11
|
+
So when the OAuth credential was revoked (2026-08-18 → 08-24) the CLI printed
|
|
12
|
+
|
|
13
|
+
Failed to authenticate. API Error: 401 OAuth access token has been revoked.
|
|
14
|
+
|
|
15
|
+
to STDOUT, that single line became the "review", and every job reported success.
|
|
16
|
+
Run 32618860829 concluded `success` while posting exactly that to PR #414; run
|
|
17
|
+
32656168775 shows all three agent steps green under the same conditions. Nothing
|
|
18
|
+
in the checks list, the run conclusion, or the annotations distinguished a
|
|
19
|
+
working editorial review from a dead one — for days, across several PRs.
|
|
20
|
+
|
|
21
|
+
Note what that rules out: a non-zero exit code is NOT sufficient to detect this.
|
|
22
|
+
The CLI reported the auth failure on stdout, and the step's own control flow
|
|
23
|
+
swallowed the status regardless. So all three ways the tier can be dead are
|
|
24
|
+
classified here:
|
|
25
|
+
|
|
26
|
+
1. the CLI exited non-zero;
|
|
27
|
+
2. it produced no output at all;
|
|
28
|
+
3. it produced only a failure notice (the observed 401 case).
|
|
29
|
+
|
|
30
|
+
On any of those this writes an explicit failure notice for the sticky comment,
|
|
31
|
+
emits a ``::error::`` annotation so the condition is visible in the checks UI,
|
|
32
|
+
and exits 1 so the job goes red. The workflow's `Post agent review` step is
|
|
33
|
+
`if: always()`, so the comment is still posted — the reader gets a notice that
|
|
34
|
+
says the review did not run, instead of a raw API error dressed as a review.
|
|
35
|
+
|
|
36
|
+
Usage:
|
|
37
|
+
python3 scripts/ci/agent_review_result.py \\
|
|
38
|
+
--status <claude-exit-code> \\
|
|
39
|
+
--stdout <file> [--stderr <file>] \\
|
|
40
|
+
--out <comment-body-file>
|
|
41
|
+
|
|
42
|
+
Exit: 0 the review is real · 1 the tier failed · 2 bad invocation
|
|
43
|
+
|
|
44
|
+
Tests: scripts/ci/test_agent_review_result.py (run in CI via
|
|
45
|
+
scripts/test/lib/test_agent_review_result.sh → ./scripts/bin/test).
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
from __future__ import annotations
|
|
49
|
+
|
|
50
|
+
import argparse
|
|
51
|
+
import re
|
|
52
|
+
import sys
|
|
53
|
+
from pathlib import Path
|
|
54
|
+
|
|
55
|
+
TITLE = "## 🤖 Claude Code Content Review"
|
|
56
|
+
|
|
57
|
+
# Failure signatures the Claude CLI prints INSTEAD of a review. Kept narrow on
|
|
58
|
+
# purpose: every entry is a string the CLI emits on its own.
|
|
59
|
+
FAILURE_SIGNATURES = re.compile(
|
|
60
|
+
r"Failed to authenticate"
|
|
61
|
+
r"|OAuth access token has been revoked"
|
|
62
|
+
r"|API Error: (?:401|403|429|5\d\d)"
|
|
63
|
+
r"|authentication_error"
|
|
64
|
+
r"|invalid_api_key"
|
|
65
|
+
r"|Invalid API key"
|
|
66
|
+
r"|Credit balance is too low"
|
|
67
|
+
r"|Please run [`'\"]?claude login",
|
|
68
|
+
re.IGNORECASE,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
# A genuine review is a structured Markdown document — a verdict, per-file
|
|
72
|
+
# findings, a summary. A failure notice is a line or two, and the failure is the
|
|
73
|
+
# FIRST thing on it. Requiring all three (short body, signature, signature near
|
|
74
|
+
# the top) stops a review that legitimately *discusses* an auth error from being
|
|
75
|
+
# thrown away as a failure — reviewing this repo's own credential-troubleshooting
|
|
76
|
+
# page would otherwise fail its own PR. Both directions are covered by the tests,
|
|
77
|
+
# and the bound is deliberately tight: an ambiguous LONG output containing an
|
|
78
|
+
# error is better shown to a human than silently discarded. A genuinely broken
|
|
79
|
+
# run that is also verbose almost always exits non-zero, which rule 1 catches.
|
|
80
|
+
MAX_FAILURE_NOTICE_LINES = 5
|
|
81
|
+
FAILURE_SIGNATURE_HEAD_LINES = 3
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def nonblank_lines(text: str) -> list[str]:
|
|
85
|
+
return [ln for ln in text.splitlines() if ln.strip()]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def classify(status: int, stdout: str) -> str | None:
|
|
89
|
+
"""Return a human-readable failure reason, or None if the review is real."""
|
|
90
|
+
lines = nonblank_lines(stdout)
|
|
91
|
+
if status != 0:
|
|
92
|
+
return f"the Claude CLI exited with status `{status}`"
|
|
93
|
+
if not lines:
|
|
94
|
+
return "the agent produced no output"
|
|
95
|
+
head = "\n".join(lines[:FAILURE_SIGNATURE_HEAD_LINES])
|
|
96
|
+
if len(lines) <= MAX_FAILURE_NOTICE_LINES and FAILURE_SIGNATURES.search(head):
|
|
97
|
+
return "the agent reported a credential / API failure instead of a review"
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _details(summary: str, body: str) -> list[str]:
|
|
102
|
+
tail = "\n".join(body.splitlines()[-20:])
|
|
103
|
+
return [f"<details><summary>{summary}</summary>", "", "```", tail, "```", "", "</details>", ""]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def render_failure(reason: str, status: int, stdout: str, stderr: str) -> str:
|
|
107
|
+
out = [
|
|
108
|
+
TITLE,
|
|
109
|
+
"",
|
|
110
|
+
"> [!CAUTION]",
|
|
111
|
+
f"> **The editorial review did not run** — {reason}.",
|
|
112
|
+
"> This comment is a failure notice, not a review. The job has been",
|
|
113
|
+
"> failed on purpose so the condition is visible on the checks list",
|
|
114
|
+
"> rather than only in this comment.",
|
|
115
|
+
"",
|
|
116
|
+
f"Claude CLI exit status: `{status}`",
|
|
117
|
+
"",
|
|
118
|
+
]
|
|
119
|
+
if stdout.strip():
|
|
120
|
+
out += _details("Agent stdout", stdout)
|
|
121
|
+
if stderr.strip():
|
|
122
|
+
out += _details("Agent stderr (tail)", stderr)
|
|
123
|
+
out += [
|
|
124
|
+
"If this is a credential problem, rotate `CLAUDE_CODE_OAUTH_TOKEN` for this",
|
|
125
|
+
"repository — see `docs/TOKEN-ROTATION.md` in bamr87/bamr87. The deterministic",
|
|
126
|
+
"tier (`scripts/content-review.rb`) is unaffected and posts separately.",
|
|
127
|
+
"",
|
|
128
|
+
]
|
|
129
|
+
return "\n".join(out)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def main(argv: list[str] | None = None) -> int:
|
|
133
|
+
p = argparse.ArgumentParser(description=__doc__)
|
|
134
|
+
p.add_argument("--status", type=int, required=True, help="claude CLI exit code")
|
|
135
|
+
p.add_argument("--stdout", required=True, help="file holding the agent's stdout")
|
|
136
|
+
p.add_argument("--stderr", default=None, help="file holding the agent's stderr")
|
|
137
|
+
p.add_argument("--out", required=True, help="file to write the sticky comment body to")
|
|
138
|
+
args = p.parse_args(argv)
|
|
139
|
+
|
|
140
|
+
stdout = Path(args.stdout).read_text(encoding="utf-8", errors="replace") \
|
|
141
|
+
if Path(args.stdout).exists() else ""
|
|
142
|
+
stderr = ""
|
|
143
|
+
if args.stderr and Path(args.stderr).exists():
|
|
144
|
+
stderr = Path(args.stderr).read_text(encoding="utf-8", errors="replace")
|
|
145
|
+
|
|
146
|
+
reason = classify(args.status, stdout)
|
|
147
|
+
out_path = Path(args.out)
|
|
148
|
+
|
|
149
|
+
if reason is None:
|
|
150
|
+
out_path.write_text(f"{TITLE}\n\n{stdout}", encoding="utf-8")
|
|
151
|
+
print(f"agent-review-result: review looks real "
|
|
152
|
+
f"({len(nonblank_lines(stdout))} non-blank lines).")
|
|
153
|
+
return 0
|
|
154
|
+
|
|
155
|
+
out_path.write_text(render_failure(reason, args.status, stdout, stderr), encoding="utf-8")
|
|
156
|
+
print(f"::error title=Claude content review did not run::{reason} "
|
|
157
|
+
f"(claude exit {args.status}). The sticky comment carries a failure notice, "
|
|
158
|
+
f"not a review.")
|
|
159
|
+
print(f"agent-review-result: FAILED — {reason}", file=sys.stderr)
|
|
160
|
+
return 1
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
if __name__ == "__main__":
|
|
164
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Unit tests for scripts/ci/agent_review_result.py (issue #418).
|
|
3
|
+
|
|
4
|
+
The bug these guard against: the Claude tier of ai-content-review.yml posted
|
|
5
|
+
|
|
6
|
+
Failed to authenticate. API Error: 401 OAuth access token has been revoked.
|
|
7
|
+
|
|
8
|
+
into PR #414 as its review, and the job still reported success. The credential
|
|
9
|
+
has since been rotated, but the SWALLOW is the durable defect — it would hide
|
|
10
|
+
the next revoked token exactly as well as it hid this one.
|
|
11
|
+
|
|
12
|
+
The first fixture below is that output verbatim, copied from the comment the
|
|
13
|
+
workflow actually posted (2026-08-23T04:51:47Z, run 32618860829). If the guard
|
|
14
|
+
ever stops failing on it, the regression is back.
|
|
15
|
+
|
|
16
|
+
Both directions are covered. A real review must NOT be flagged — including one
|
|
17
|
+
whose prose legitimately quotes an auth error, which is the false positive a
|
|
18
|
+
bare `grep 'Failed to authenticate'` would produce (and which would fire on this
|
|
19
|
+
repo's own troubleshooting docs).
|
|
20
|
+
|
|
21
|
+
Dependency-light on purpose: standard library only, so it runs anywhere the
|
|
22
|
+
workflow does.
|
|
23
|
+
|
|
24
|
+
python3 scripts/ci/test_agent_review_result.py
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import re
|
|
30
|
+
import sys
|
|
31
|
+
import tempfile
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
|
|
34
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
35
|
+
|
|
36
|
+
import agent_review_result as arr # noqa: E402
|
|
37
|
+
|
|
38
|
+
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
39
|
+
WORKFLOW = REPO_ROOT / ".github" / "workflows" / "ai-content-review.yml"
|
|
40
|
+
|
|
41
|
+
CHECKS: list[tuple[str, bool]] = []
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def check(label: str, ok: bool) -> None:
|
|
45
|
+
CHECKS.append((label, bool(ok)))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# The 401 exactly as the CLI printed it and the workflow posted it.
|
|
49
|
+
AUTH_401 = "Failed to authenticate. API Error: 401 OAuth access token has been revoked.\n"
|
|
50
|
+
|
|
51
|
+
REAL_REVIEW = """**Verdict:** approve with minor suggestions
|
|
52
|
+
|
|
53
|
+
### pages/_docs/getting-started.md
|
|
54
|
+
|
|
55
|
+
- The `description` front matter reads as a keyword list rather than a sentence.
|
|
56
|
+
Rewrite it as prose so search snippets are readable.
|
|
57
|
+
- The second H2 duplicates the page title; demote it to an H3 or cut it.
|
|
58
|
+
- Two code fences are missing a language tag, so they render unhighlighted.
|
|
59
|
+
- Alt text on the architecture diagram describes the file, not the picture.
|
|
60
|
+
|
|
61
|
+
### pages/_docs/configuration.md
|
|
62
|
+
|
|
63
|
+
- Consistent terminology: this page says "config file" where the rest of the
|
|
64
|
+
docs say `_config.yml`. Pick one.
|
|
65
|
+
- The table of collection defaults is missing the `books` collection added in
|
|
66
|
+
v1.19, so the page is incomplete rather than merely terse.
|
|
67
|
+
- Sentence length in "Advanced overrides" averages 38 words; split the longest.
|
|
68
|
+
|
|
69
|
+
### Summary
|
|
70
|
+
|
|
71
|
+
Both files are accurate and well-organised. The findings above are polish, not
|
|
72
|
+
correctness — nothing here blocks the merge.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
# A real review that TALKS about auth failures. Must not be mistaken for one.
|
|
76
|
+
REVIEW_ABOUT_AUTH = """**Verdict:** approve
|
|
77
|
+
|
|
78
|
+
### docs/troubleshooting/credentials.md
|
|
79
|
+
|
|
80
|
+
- The page explains what to do when the CLI prints "Failed to authenticate. API
|
|
81
|
+
Error: 401 OAuth access token has been revoked." — good, that is the exact
|
|
82
|
+
string users will search for, so keep it verbatim rather than paraphrasing.
|
|
83
|
+
- Add a cross-link to the rotation runbook; the reader is told to "rotate the
|
|
84
|
+
token" with no pointer to how.
|
|
85
|
+
- The `authentication_error` example block is missing a language tag.
|
|
86
|
+
- Consider noting that the deterministic review tier keeps working while the
|
|
87
|
+
credential is broken, since that surprised at least one reader.
|
|
88
|
+
- Front matter `lastmod` is stale by two releases.
|
|
89
|
+
|
|
90
|
+
### Summary
|
|
91
|
+
|
|
92
|
+
Accurate and genuinely useful. Only polish items above; the troubleshooting
|
|
93
|
+
steps themselves check out against the current workflow definition.
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def run_guard(status: int, stdout: str, stderr: str = "") -> tuple[int, str]:
|
|
98
|
+
"""Invoke the guard end-to-end; return (exit code, comment body)."""
|
|
99
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
100
|
+
d = Path(tmp)
|
|
101
|
+
(d / "out.txt").write_text(stdout, encoding="utf-8")
|
|
102
|
+
(d / "err.txt").write_text(stderr, encoding="utf-8")
|
|
103
|
+
rc = arr.main([
|
|
104
|
+
"--status", str(status),
|
|
105
|
+
"--stdout", str(d / "out.txt"),
|
|
106
|
+
"--stderr", str(d / "err.txt"),
|
|
107
|
+
"--out", str(d / "body.md"),
|
|
108
|
+
])
|
|
109
|
+
return rc, (d / "body.md").read_text(encoding="utf-8")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def main() -> int:
|
|
113
|
+
# --- the observed failure, verbatim ------------------------------------ #
|
|
114
|
+
rc, body = run_guard(0, AUTH_401)
|
|
115
|
+
check("the verbatim PR #414 401 fails the step (exit 1) even on claude exit 0", rc == 1)
|
|
116
|
+
check("the 401 comment body says the review did not run", "did not run" in body)
|
|
117
|
+
check("the raw 401 line is no longer posted as if it were the review",
|
|
118
|
+
not body.startswith(f"{arr.TITLE}\n\nFailed to authenticate"))
|
|
119
|
+
check("the failure notice is visibly a caution, not a review", "[!CAUTION]" in body)
|
|
120
|
+
|
|
121
|
+
rc, _ = run_guard(1, AUTH_401)
|
|
122
|
+
check("a non-zero claude exit fails the step", rc == 1)
|
|
123
|
+
|
|
124
|
+
# --- nothing came back -------------------------------------------------- #
|
|
125
|
+
rc, body = run_guard(0, "")
|
|
126
|
+
check("empty agent output fails the step", rc == 1)
|
|
127
|
+
check("the empty-output notice names the reason", "no output" in body)
|
|
128
|
+
|
|
129
|
+
rc, _ = run_guard(0, "\n \n\t\n")
|
|
130
|
+
check("whitespace-only agent output fails the step", rc == 1)
|
|
131
|
+
|
|
132
|
+
# --- other credential failures ------------------------------------------ #
|
|
133
|
+
rc, _ = run_guard(0, "API Error: 400 Credit balance is too low to access the API.\n")
|
|
134
|
+
check("a credit-balance failure fails the step", rc == 1)
|
|
135
|
+
rc, _ = run_guard(0, "Invalid API key · Please run `claude login`\n")
|
|
136
|
+
check("an invalid-key failure fails the step", rc == 1)
|
|
137
|
+
rc, _ = run_guard(0, "API Error: 429 rate limit exceeded\n")
|
|
138
|
+
check("a rate-limit failure fails the step", rc == 1)
|
|
139
|
+
|
|
140
|
+
# --- real reviews pass --------------------------------------------------- #
|
|
141
|
+
rc, body = run_guard(0, REAL_REVIEW)
|
|
142
|
+
check("a genuine multi-section review passes", rc == 0)
|
|
143
|
+
check("the real review is posted under the review heading", "**Verdict:**" in body)
|
|
144
|
+
|
|
145
|
+
rc, _ = run_guard(0, REVIEW_ABOUT_AUTH)
|
|
146
|
+
check("a long review that QUOTES an auth error is not mistaken for a failure", rc == 0)
|
|
147
|
+
|
|
148
|
+
# --- the workflow actually uses the guard -------------------------------- #
|
|
149
|
+
# Without this the fix is one deleted line away from returning, and nothing
|
|
150
|
+
# in the fixture tests above would notice.
|
|
151
|
+
wf = WORKFLOW.read_text(encoding="utf-8")
|
|
152
|
+
check("ai-content-review.yml invokes the guard",
|
|
153
|
+
"scripts/ci/agent_review_result.py" in wf)
|
|
154
|
+
check("the agent step no longer ends by piping stdout into the comment body",
|
|
155
|
+
not re.search(r"cat /tmp/agent-review\.md\s*\n\s*\}\s*>\s*/tmp/agent-review-final\.md", wf))
|
|
156
|
+
check("the AI gate no longer implies a present credential is a working one",
|
|
157
|
+
"not validity" in wf)
|
|
158
|
+
|
|
159
|
+
failed = [label for label, ok in CHECKS if not ok]
|
|
160
|
+
print()
|
|
161
|
+
for label, ok in CHECKS:
|
|
162
|
+
print(f" {'PASS' if ok else 'FAIL'} {label}")
|
|
163
|
+
print()
|
|
164
|
+
if failed:
|
|
165
|
+
print(f"FAILED ({len(failed)}/{len(CHECKS)})")
|
|
166
|
+
return 1
|
|
167
|
+
print(f"OK ({len(CHECKS)} checks)")
|
|
168
|
+
return 0
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
if __name__ == "__main__":
|
|
172
|
+
raise SystemExit(main())
|