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
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# =============================================================================
|
|
3
|
+
# usage_report.rb — publish a job's AI usage records (summary, artifact, PR comment)
|
|
4
|
+
# -----------------------------------------------------------------------------
|
|
5
|
+
# The reporting half of AI metering. usage.rb captured one JSONL record per AI
|
|
6
|
+
# call into AI_USAGE_DIR/records.jsonl; this script, run at the end of the
|
|
7
|
+
# job (the claude-run composite calls it automatically), does four things:
|
|
8
|
+
#
|
|
9
|
+
# 1. ATTRIBUTE — resolve which PR the spend belongs to: an explicit --pr,
|
|
10
|
+
# the pull_request event payload, or the pr-result.txt file
|
|
11
|
+
# the factory/fleet agents write after opening a PR (that
|
|
12
|
+
# run's records become the PR's CREATION cost).
|
|
13
|
+
# 2. CONSUME — move records.jsonl to a reported-*.jsonl file (so a second
|
|
14
|
+
# AI step in the same job never double-reports) and print
|
|
15
|
+
# `file=` / `name=` to $GITHUB_OUTPUT for the artifact upload.
|
|
16
|
+
# 3. SUMMARIZE — append a per-call table to $GITHUB_STEP_SUMMARY.
|
|
17
|
+
# 4. COMMENT — upsert ONE sticky "AI usage & cost" comment on the PR,
|
|
18
|
+
# found by its <!-- lh-ai-usage --> marker (never
|
|
19
|
+
# `--edit-last`, which grabs whatever the bot said last).
|
|
20
|
+
# The comment embeds its own base64 data blob, so each run
|
|
21
|
+
# merges records by id — cumulative, idempotent, and safe to
|
|
22
|
+
# re-run. Concurrent jobs can still race the read-merge-write
|
|
23
|
+
# (last writer wins for the VIEW); the artifacts + nightly
|
|
24
|
+
# ledger remain the source of truth.
|
|
25
|
+
#
|
|
26
|
+
# Every dollar figure is API-equivalent: what the tokens would bill at list
|
|
27
|
+
# prices. Subscription (OAuth) runs cost $0 marginal — the label says so.
|
|
28
|
+
# Best-effort by design: metering must never fail the job. Stdlib + `gh` only.
|
|
29
|
+
#
|
|
30
|
+
# ruby scripts/ai/usage_report.rb [--pr N] [--pr-result pr-result.txt]
|
|
31
|
+
# =============================================================================
|
|
32
|
+
require 'json'
|
|
33
|
+
require 'time'
|
|
34
|
+
require 'digest'
|
|
35
|
+
require 'securerandom'
|
|
36
|
+
require_relative 'usage'
|
|
37
|
+
|
|
38
|
+
MARKER = '<!-- lh-ai-usage -->'.freeze
|
|
39
|
+
DATA_HEAD = '<!-- lh-ai-usage-data:'.freeze
|
|
40
|
+
MAX_BLOB_RECORDS = 150 # older records fold into a rollup so the comment stays < 64KB
|
|
41
|
+
MAX_TABLE_ROWS = 30
|
|
42
|
+
|
|
43
|
+
pr_arg = nil
|
|
44
|
+
pr_result_file = 'pr-result.txt'
|
|
45
|
+
args = ARGV.dup
|
|
46
|
+
until args.empty?
|
|
47
|
+
case (a = args.shift)
|
|
48
|
+
when '--pr' then pr_arg = args.shift.to_i
|
|
49
|
+
when '--pr-result' then pr_result_file = args.shift.to_s
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# --- 1. load this job's records ------------------------------------------------
|
|
54
|
+
src = AIUsage.records_path
|
|
55
|
+
records = File.exist?(src) ? File.read(src, encoding: 'UTF-8').split("\n").map { |l| JSON.parse(l) rescue nil }.compact : []
|
|
56
|
+
if records.empty?
|
|
57
|
+
warn '[usage_report] no AI usage records this job — nothing to report.'
|
|
58
|
+
File.open(ENV['GITHUB_OUTPUT'], 'a') { |io| io.puts('file='); io.puts('name=') } if ENV['GITHUB_OUTPUT']
|
|
59
|
+
exit 0
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# --- 2. attribute to a PR --------------------------------------------------------
|
|
63
|
+
pr = nil
|
|
64
|
+
pr_source = nil
|
|
65
|
+
if pr_arg && pr_arg > 0
|
|
66
|
+
pr = pr_arg
|
|
67
|
+
pr_source = 'event'
|
|
68
|
+
elsif ENV['GITHUB_EVENT_PATH'] && File.exist?(ENV['GITHUB_EVENT_PATH'])
|
|
69
|
+
ev = JSON.parse(File.read(ENV['GITHUB_EVENT_PATH'])) rescue {}
|
|
70
|
+
n = ev.dig('pull_request', 'number')
|
|
71
|
+
if n
|
|
72
|
+
pr = n.to_i
|
|
73
|
+
pr_source = 'event'
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
if pr.nil? && File.exist?(pr_result_file)
|
|
77
|
+
# The factory/fleet convention: the agent writes the PR/issue URL(s) it opened
|
|
78
|
+
# to pr-result.txt. The first pull URL is the PR this run CREATED — its spend
|
|
79
|
+
# is that PR's creation cost.
|
|
80
|
+
if (m = File.read(pr_result_file, encoding: 'UTF-8')[%r{/pull/(\d+)}, 1])
|
|
81
|
+
pr = m.to_i
|
|
82
|
+
pr_source = 'created'
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
records.each { |r| r['pr'] ||= pr; r['pr_source'] ||= pr_source if pr }
|
|
86
|
+
|
|
87
|
+
# --- 3. consume: move to a reported file, hand the path to the uploader ----------
|
|
88
|
+
reported = File.join(AIUsage.dir, "reported-#{Time.now.utc.strftime('%H%M%S')}-#{SecureRandom.hex(3)}.jsonl")
|
|
89
|
+
File.open(reported, 'w') { |io| records.each { |r| io.puts(JSON.generate(r)) } }
|
|
90
|
+
File.delete(src)
|
|
91
|
+
if ENV['GITHUB_OUTPUT']
|
|
92
|
+
artifact = "ai-usage-#{ENV['GITHUB_RUN_ID'] || 'local'}-#{(ENV['GITHUB_JOB'] || 'job').gsub(/[^A-Za-z0-9_-]/, '_')}-#{SecureRandom.hex(3)}"
|
|
93
|
+
File.open(ENV['GITHUB_OUTPUT'], 'a') { |io| io.puts("file=#{reported}"); io.puts("name=#{artifact}") }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
fmt_usd = ->(v) { format('$%.4f', v.to_f) }
|
|
97
|
+
fmt_tok = ->(v) { v.to_i >= 10_000 ? "#{(v.to_i / 1000.0).round}k" : v.to_i.to_s }
|
|
98
|
+
|
|
99
|
+
# --- 4. step summary --------------------------------------------------------------
|
|
100
|
+
if ENV['GITHUB_STEP_SUMMARY']
|
|
101
|
+
total = records.sum { |r| r['cost_usd'].to_f }
|
|
102
|
+
lines = []
|
|
103
|
+
lines << '## 🤖 AI usage (this job)'
|
|
104
|
+
lines << ''
|
|
105
|
+
lines << '| role | model | turns | in | out | cache r/w | cost (API-equiv) | via |'
|
|
106
|
+
lines << '|---|---|---|---|---|---|---|---|'
|
|
107
|
+
records.each do |r|
|
|
108
|
+
t = r['tokens'] || {}
|
|
109
|
+
lines << "| #{r['agent'].to_s.empty? ? '—' : r['agent']} | #{r['model']} | #{r['num_turns'] || '—'} " \
|
|
110
|
+
"| #{fmt_tok.call(t['input'])} | #{fmt_tok.call(t['output'])} " \
|
|
111
|
+
"| #{fmt_tok.call(t['cache_read'])}/#{fmt_tok.call(t['cache_creation'])} " \
|
|
112
|
+
"| #{fmt_usd.call(r['cost_usd'])}#{r['cost_source'] == 'estimated' ? '*' : ''} | #{r['auth']} |"
|
|
113
|
+
end
|
|
114
|
+
lines << ''
|
|
115
|
+
# A failed call bills nothing, so it is invisible in the table above — spell
|
|
116
|
+
# out what the model refused, right where the operator is already looking.
|
|
117
|
+
records.select { |r| r['error'] }.each do |r|
|
|
118
|
+
e = r['error']
|
|
119
|
+
lines << "> ❌ **#{r['agent'].to_s.empty? ? 'AI call' : r['agent']} failed** " \
|
|
120
|
+
"(`#{e['subtype'].to_s.empty? ? "exit #{e['exit_code']}" : e['subtype']}`): #{e['message']}"
|
|
121
|
+
lines << ''
|
|
122
|
+
end
|
|
123
|
+
lines << "**Job total: #{fmt_usd.call(total)}** (API-equivalent#{records.any? { |r| r['cost_source'] == 'estimated' } ? '; * = estimated from _data/ai_pricing.yml' : ''})."
|
|
124
|
+
lines << ''
|
|
125
|
+
File.open(ENV['GITHUB_STEP_SUMMARY'], 'a') { |io| io.puts(lines.join("\n")) }
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# --- 5. sticky PR comment (best-effort) --------------------------------------------
|
|
129
|
+
repo = ENV['GITHUB_REPOSITORY'].to_s
|
|
130
|
+
exit 0 if pr.nil? || repo.empty?
|
|
131
|
+
unless system('gh --version > /dev/null 2>&1') && !(ENV['GH_TOKEN'].to_s + ENV['GITHUB_TOKEN'].to_s).empty?
|
|
132
|
+
warn '[usage_report] no gh/token — skipping the PR comment (records still in the artifact).'
|
|
133
|
+
exit 0
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def gh_json(args)
|
|
137
|
+
out = IO.popen(['gh'] + args, err: %i[child out], &:read)
|
|
138
|
+
return nil unless $?.success?
|
|
139
|
+
JSON.parse(out)
|
|
140
|
+
rescue StandardError
|
|
141
|
+
nil
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Find the existing sticky comment by MARKER (any author, any position).
|
|
145
|
+
existing = nil
|
|
146
|
+
page = 1
|
|
147
|
+
loop do
|
|
148
|
+
batch = gh_json(['api', "repos/#{repo}/issues/#{pr}/comments?per_page=100&page=#{page}"])
|
|
149
|
+
break unless batch.is_a?(Array)
|
|
150
|
+
existing = batch.find { |c| c['body'].to_s.start_with?(MARKER) }
|
|
151
|
+
break if existing || batch.size < 100 || page >= 10
|
|
152
|
+
page += 1
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Merge this job's records into the comment's embedded blob (dedup by id).
|
|
156
|
+
blob = { 'records' => [], 'folded' => nil }
|
|
157
|
+
if existing && (m = existing['body'].to_s[/#{Regexp.escape(DATA_HEAD)}([A-Za-z0-9+\/=]+) -->/, 1])
|
|
158
|
+
blob = JSON.parse(m.unpack1('m')) rescue { 'records' => [], 'folded' => nil }
|
|
159
|
+
end
|
|
160
|
+
compact = ->(r) do
|
|
161
|
+
t = r['tokens'] || {}
|
|
162
|
+
{ 'i' => r['id'], 't' => r['ts'], 'w' => r['workflow'], 'j' => r['job'], 'r' => r['run_id'],
|
|
163
|
+
'a' => r['agent'], 'm' => r['model'], 'ti' => t['input'].to_i, 'to' => t['output'].to_i,
|
|
164
|
+
'tr' => t['cache_read'].to_i, 'tc' => t['cache_creation'].to_i,
|
|
165
|
+
'c' => r['cost_usd'].to_f.round(6), 's' => r['cost_source'], 'au' => r['auth'],
|
|
166
|
+
'st' => r['status'], 'ps' => r['pr_source'] }
|
|
167
|
+
end
|
|
168
|
+
known = blob['records'].map { |r| r['i'] }
|
|
169
|
+
records.each { |r| blob['records'] << compact.call(r) unless known.include?(r['id']) }
|
|
170
|
+
blob['records'].sort_by! { |r| r['t'].to_s }
|
|
171
|
+
while blob['records'].size > MAX_BLOB_RECORDS
|
|
172
|
+
old = blob['records'].shift
|
|
173
|
+
f = blob['folded'] ||= { 'n' => 0, 'c' => 0.0, 'ti' => 0, 'to' => 0 }
|
|
174
|
+
f['n'] += 1
|
|
175
|
+
f['c'] = (f['c'] + old['c'].to_f).round(6)
|
|
176
|
+
f['ti'] += old['ti'].to_i
|
|
177
|
+
f['to'] += old['to'].to_i
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
all = blob['records']
|
|
181
|
+
folded = blob['folded']
|
|
182
|
+
total = all.sum { |r| r['c'].to_f } + (folded ? folded['c'].to_f : 0.0)
|
|
183
|
+
creation = all.select { |r| r['ps'] == 'created' }.sum { |r| r['c'].to_f }
|
|
184
|
+
downstream = total - creation
|
|
185
|
+
estimated = all.any? { |r| r['s'] == 'estimated' }
|
|
186
|
+
oauth_only = all.all? { |r| r['au'] == 'oauth' }
|
|
187
|
+
|
|
188
|
+
body = []
|
|
189
|
+
body << MARKER
|
|
190
|
+
body << '## 🤖 AI usage & cost for this PR'
|
|
191
|
+
body << ''
|
|
192
|
+
body << "**Total: #{fmt_usd.call(total)} API-equivalent** across #{all.size + (folded ? folded['n'] : 0)} AI call(s) — " \
|
|
193
|
+
"creation #{fmt_usd.call(creation)}, reviews/fixes/checks #{fmt_usd.call(downstream)}."
|
|
194
|
+
body << ''
|
|
195
|
+
body << '| when (UTC) | workflow · job | role | model | out tok | cost |'
|
|
196
|
+
body << '|---|---|---|---|---|---|'
|
|
197
|
+
body << "| _earlier_ | _#{folded['n']} older call(s), folded_ | | | #{fmt_tok.call(folded['to'])} | #{fmt_usd.call(folded['c'])} |" if folded
|
|
198
|
+
all.last(MAX_TABLE_ROWS).each do |r|
|
|
199
|
+
run_link = r['r'].to_s.empty? ? (r['w'].to_s.empty? ? 'local' : r['w']) : "[#{r['w']} · #{r['j']}](https://github.com/#{repo}/actions/runs/#{r['r']})"
|
|
200
|
+
body << "| #{r['t'].to_s[5, 11]} | #{run_link} | #{r['a'].to_s.empty? ? '—' : r['a']}#{r['ps'] == 'created' ? ' 🌱' : ''} " \
|
|
201
|
+
"| #{r['m']} | #{fmt_tok.call(r['to'])} | #{fmt_usd.call(r['c'])}#{r['s'] == 'estimated' ? '*' : ''} |"
|
|
202
|
+
end
|
|
203
|
+
body << "| | _…#{all.size - MAX_TABLE_ROWS} more in the ledger_ | | | | |" if all.size > MAX_TABLE_ROWS
|
|
204
|
+
body << ''
|
|
205
|
+
notes = ['🌱 = the run that opened this PR (creation cost).']
|
|
206
|
+
notes << '\\* = estimated from `_data/ai_pricing.yml` (that path reports tokens, not dollars).' if estimated
|
|
207
|
+
notes << (oauth_only ? 'All calls ran on Claude Code subscription auth (OAuth) — $0 marginal spend; the figure is what these tokens would bill at API list prices.' \
|
|
208
|
+
: 'Some calls used a metered API key — those dollars are real.')
|
|
209
|
+
notes << 'Updated automatically after every AI job; full history at [/docs/ai-usage/](https://lifehacker.dev/docs/ai-usage/).'
|
|
210
|
+
body << notes.map { |n| "_#{n}_" }.join(' ')
|
|
211
|
+
body << ''
|
|
212
|
+
body << "#{DATA_HEAD}#{[JSON.generate(blob)].pack('m0')} -->"
|
|
213
|
+
|
|
214
|
+
payload = JSON.generate('body' => body.join("\n"))
|
|
215
|
+
tmp = File.join(AIUsage.dir, 'comment-payload.json')
|
|
216
|
+
File.write(tmp, payload)
|
|
217
|
+
ok =
|
|
218
|
+
if existing
|
|
219
|
+
system('gh', 'api', '-X', 'PATCH', "repos/#{repo}/issues/comments/#{existing['id']}", '--input', tmp, out: File::NULL, err: %i[child out])
|
|
220
|
+
else
|
|
221
|
+
system('gh', 'api', "repos/#{repo}/issues/#{pr}/comments", '--input', tmp, out: File::NULL, err: %i[child out])
|
|
222
|
+
end
|
|
223
|
+
warn(ok ? "[usage_report] PR ##{pr} cost comment #{existing ? 'updated' : 'created'} (total #{fmt_usd.call(total)})." \
|
|
224
|
+
: "[usage_report] PR ##{pr} comment update failed (non-fatal; records are in the artifact).")
|
|
225
|
+
exit 0
|
data/scripts/bin/audit-consumer
CHANGED
|
@@ -118,14 +118,35 @@ done
|
|
|
118
118
|
|
|
119
119
|
CONSUMER_PATH="$(cd "$CONSUMER_PATH" && pwd)"
|
|
120
120
|
|
|
121
|
+
# In --format json, stdout must be PURE JSON so `jq` / `json.load` can consume
|
|
122
|
+
# it directly. The human-readable log lines are still useful, so send them to
|
|
123
|
+
# stderr rather than dropping them. (Left on stdout, they made every --format
|
|
124
|
+
# json run unparseable even after the trailing-comma fix.)
|
|
125
|
+
if [[ "$FORMAT" == "json" ]]; then
|
|
126
|
+
info() { echo -e "${BLUE}[INFO]${NC} $1" >&2; }
|
|
127
|
+
step() { echo -e "${CYAN}[STEP]${NC} $1" >&2; }
|
|
128
|
+
success() { echo -e "${GREEN}[SUCCESS]${NC} $1" >&2; }
|
|
129
|
+
warn() { echo -e "${YELLOW}[WARNING]${NC} $1" >&2; }
|
|
130
|
+
fi
|
|
131
|
+
|
|
121
132
|
# ---------------------------------------------------------------------------
|
|
122
133
|
step "Auditing consumer: $CONSUMER_PATH"
|
|
123
134
|
|
|
124
|
-
#
|
|
135
|
+
# CONSUMER_ROOT holds the config/override files; CONSUMER_PATH is where the
|
|
136
|
+
# theme-shadowing files actually live. They differ when the consumer sets
|
|
137
|
+
# Jekyll's `source:` (zer0-pages keeps its whole site tree under `pages/`).
|
|
138
|
+
CONSUMER_ROOT="$CONSUMER_PATH"
|
|
139
|
+
CONSUMER_SOURCE_REL=$(detect_consumer_source "$CONSUMER_ROOT")
|
|
140
|
+
if [[ -n "$CONSUMER_SOURCE_REL" && -d "$CONSUMER_ROOT/$CONSUMER_SOURCE_REL" ]]; then
|
|
141
|
+
CONSUMER_PATH="$CONSUMER_ROOT/$CONSUMER_SOURCE_REL"
|
|
142
|
+
info "Jekyll source: $CONSUMER_SOURCE_REL/ — scanning there, not the repo root"
|
|
143
|
+
fi
|
|
144
|
+
|
|
145
|
+
# Detect or confirm mode (from the repo root, where _config.yml lives)
|
|
125
146
|
if [[ -n "$MODE_OVERRIDE" ]]; then
|
|
126
147
|
MODE="$MODE_OVERRIDE"
|
|
127
148
|
else
|
|
128
|
-
MODE=$(detect_consumer_mode "$
|
|
149
|
+
MODE=$(detect_consumer_mode "$CONSUMER_ROOT")
|
|
129
150
|
info "Detected mode: $MODE"
|
|
130
151
|
fi
|
|
131
152
|
|
|
@@ -133,7 +154,7 @@ fi
|
|
|
133
154
|
if [[ -n "$THEME_PATH_OVERRIDE" ]]; then
|
|
134
155
|
RESOLVED_THEME="$THEME_PATH_OVERRIDE"
|
|
135
156
|
else
|
|
136
|
-
RESOLVED_THEME=$(resolve_theme_path "$MODE" "$
|
|
157
|
+
RESOLVED_THEME=$(resolve_theme_path "$MODE" "$CONSUMER_ROOT" "$THEME_ROOT")
|
|
137
158
|
fi
|
|
138
159
|
|
|
139
160
|
if [[ -z "$RESOLVED_THEME" || ! -d "$RESOLVED_THEME" ]]; then
|
|
@@ -149,7 +170,10 @@ if [[ ! -f "$MANIFEST" ]]; then
|
|
|
149
170
|
fi
|
|
150
171
|
|
|
151
172
|
# Load overrides manifest from consumer
|
|
152
|
-
|
|
173
|
+
# Declared overrides sit next to _config.yml at the repo root; fall back to the
|
|
174
|
+
# source dir so either placement works.
|
|
175
|
+
OVERRIDES_FILE="$CONSUMER_ROOT/.theme-overrides.yml"
|
|
176
|
+
[[ -f "$OVERRIDES_FILE" ]] || OVERRIDES_FILE="$CONSUMER_PATH/.theme-overrides.yml"
|
|
153
177
|
OVERRIDE_PATHS_TMP=$(mktemp)
|
|
154
178
|
trap 'rm -f "$OVERRIDE_PATHS_TMP"' EXIT
|
|
155
179
|
|
|
@@ -190,8 +214,14 @@ if [[ ${#THEMABLE_DIRS[@]} -eq 0 ]]; then
|
|
|
190
214
|
THEMABLE_DIRS=("_layouts" "_includes" "_sass" "assets/css" "assets/js")
|
|
191
215
|
fi
|
|
192
216
|
if [[ ${#REQUIRED_PLUGIN_FILES[@]} -eq 0 && ${#OPTIONAL_PLUGIN_FILES[@]} -eq 0 ]]; then
|
|
193
|
-
|
|
194
|
-
|
|
217
|
+
# No manifest at all. Nothing is genuinely required (GitHub Pages loads no
|
|
218
|
+
# local plugins), so every known plugin is offered as optional.
|
|
219
|
+
OPTIONAL_PLUGIN_FILES=(
|
|
220
|
+
"_plugins/obsidian_links.rb"
|
|
221
|
+
"_plugins/admin_page_urls.rb"
|
|
222
|
+
"_plugins/content_statistics_generator.rb"
|
|
223
|
+
"_plugins/theme_version.rb"
|
|
224
|
+
)
|
|
195
225
|
fi
|
|
196
226
|
|
|
197
227
|
# ---------------------------------------------------------------------------
|
|
@@ -316,7 +346,9 @@ fi
|
|
|
316
346
|
# ---------------------------------------------------------------------------
|
|
317
347
|
# Summary
|
|
318
348
|
# ---------------------------------------------------------------------------
|
|
319
|
-
|
|
349
|
+
# Entries are printed without a trailing newline (see print_classification),
|
|
350
|
+
# so close the array on its own line.
|
|
351
|
+
[[ "$FORMAT" == "json" ]] && printf '\n]}\n'
|
|
320
352
|
|
|
321
353
|
if [[ "$FORMAT" == "text" ]]; then
|
|
322
354
|
echo ""
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
# `giscus-conversation` skill drives to build conversations from page comments.
|
|
7
7
|
#
|
|
8
8
|
# Subcommands:
|
|
9
|
+
# doctor Verify the comment chain end-to-end (app installed, IDs match)
|
|
9
10
|
# categories List the repo's discussion categories + node IDs (setup helper)
|
|
10
11
|
# list List discussions in the configured Giscus category
|
|
11
12
|
# thread Print a discussion's full conversation (comments + replies)
|
|
@@ -72,14 +73,22 @@ resolve_category() {
|
|
|
72
73
|
owner_of() { printf '%s' "${1%%/*}"; }
|
|
73
74
|
name_of() { printf '%s' "${1##*/}"; }
|
|
74
75
|
|
|
75
|
-
# Normalize a page path
|
|
76
|
+
# Normalize a page path into the *Giscus term* — the string Giscus actually
|
|
77
|
+
# searches discussion titles for under data-mapping="pathname".
|
|
78
|
+
#
|
|
79
|
+
# Giscus uses location.pathname with the leading slash STRIPPED. Verified live:
|
|
80
|
+
# /posts/2025/01/21/remote-work-revolution/ produces a widget iframe with
|
|
81
|
+
# term=posts%2F2025%2F01%2F21%2Fremote-work-revolution%2F
|
|
82
|
+
# This previously prepended a slash instead, so `seed` created titles Giscus
|
|
83
|
+
# would never match, and `--page` lookups missed every thread Giscus itself
|
|
84
|
+
# created. Callers may still pass a leading slash or a full URL.
|
|
76
85
|
normalize_page() {
|
|
77
86
|
local p="$1"
|
|
78
87
|
if [[ "$p" == *"://"* ]]; then
|
|
79
88
|
p="${p#*://}" # strip scheme
|
|
80
|
-
p="
|
|
89
|
+
p="${p#*/}" # strip host, keep the path
|
|
81
90
|
fi
|
|
82
|
-
|
|
91
|
+
p="${p#/}" # Giscus's pathname term carries no leading slash
|
|
83
92
|
printf '%s' "$p"
|
|
84
93
|
}
|
|
85
94
|
|
|
@@ -95,15 +104,26 @@ repo_node_id() {
|
|
|
95
104
|
--jq '.data.repository.id'
|
|
96
105
|
}
|
|
97
106
|
|
|
98
|
-
# Find a discussion number whose title
|
|
99
|
-
#
|
|
107
|
+
# Find a discussion number whose title matches the page. Echoes the number, or
|
|
108
|
+
# nothing if not found.
|
|
109
|
+
#
|
|
110
|
+
# Matches four title spellings so lookups work against BOTH threads Giscus
|
|
111
|
+
# created (its own term: no leading slash) and any hand-made or legacy thread
|
|
112
|
+
# titled with a leading slash. Paginates: the previous version declared $after
|
|
113
|
+
# and pageInfo but never used them, so on a site with more than 100 discussions
|
|
114
|
+
# it silently failed to find anything past the first page.
|
|
100
115
|
discussion_number_for_page() {
|
|
101
|
-
local
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
116
|
+
local term; term="$(normalize_page "$1")"
|
|
117
|
+
# Match four spellings so lookups work against BOTH threads Giscus created
|
|
118
|
+
# (its own term: no leading slash) and hand-made/legacy threads titled with
|
|
119
|
+
# one. --paginate walks every page: the previous version declared $after and
|
|
120
|
+
# pageInfo but never used them, so on a site with more than 100 discussions
|
|
121
|
+
# it silently failed to find anything past the first page.
|
|
122
|
+
local matches
|
|
123
|
+
matches="$(gh api graphql --paginate -f query='
|
|
124
|
+
query($owner:String!,$name:String!,$categoryId:ID,$endCursor:String){
|
|
105
125
|
repository(owner:$owner,name:$name){
|
|
106
|
-
discussions(first:100,after:$
|
|
126
|
+
discussions(first:100,after:$endCursor,categoryId:$categoryId){
|
|
107
127
|
nodes{ number title }
|
|
108
128
|
pageInfo{ hasNextPage endCursor }
|
|
109
129
|
}
|
|
@@ -111,8 +131,16 @@ discussion_number_for_page() {
|
|
|
111
131
|
}' -F owner="$(owner_of "$REPO_NWO")" -F name="$(name_of "$REPO_NWO")" \
|
|
112
132
|
-f categoryId="$CATEGORY_ID" \
|
|
113
133
|
--jq ".data.repository.discussions.nodes[]
|
|
114
|
-
| select(.title == \"$
|
|
115
|
-
|
|
134
|
+
| select(.title == \"${term}\"
|
|
135
|
+
or .title == \"${term%/}\"
|
|
136
|
+
or .title == \"/${term}\"
|
|
137
|
+
or .title == \"/${term%/}\")
|
|
138
|
+
| .number" 2>/dev/null)"
|
|
139
|
+
# First match only. Deliberately NOT `| head -1` on the pipeline: head
|
|
140
|
+
# closes the pipe early, gh dies on SIGPIPE, and `set -o pipefail` then
|
|
141
|
+
# aborts the whole script inside the surrounding command substitution.
|
|
142
|
+
local nl=$'\n'
|
|
143
|
+
printf '%s' "${matches%%${nl}*}"
|
|
116
144
|
}
|
|
117
145
|
|
|
118
146
|
discussion_node_id() {
|
|
@@ -260,7 +288,12 @@ resolve_number_arg() {
|
|
|
260
288
|
elif [[ -n "$page" ]]; then
|
|
261
289
|
resolve_category
|
|
262
290
|
NUMBER="$(discussion_number_for_page "$page")"
|
|
263
|
-
[[
|
|
291
|
+
# `if`, not `[[ ... ]] && error`: as the last statement in this branch a
|
|
292
|
+
# false test returns 1, which `set -e` turns into a silent script abort
|
|
293
|
+
# on the SUCCESS path.
|
|
294
|
+
if [[ -z "$NUMBER" ]]; then
|
|
295
|
+
error "No discussion found for page '$page'. Seed one with: $0 seed --page '$page' --title ... --body ..."
|
|
296
|
+
fi
|
|
264
297
|
else
|
|
265
298
|
error "Provide --number N, --url URL, or --page PATH."
|
|
266
299
|
fi
|
|
@@ -401,7 +434,9 @@ cmd_post() {
|
|
|
401
434
|
elif [[ -n "$page" ]]; then
|
|
402
435
|
resolve_category
|
|
403
436
|
NUMBER="$(discussion_number_for_page "$page")"
|
|
404
|
-
[[ -z "$NUMBER" ]]
|
|
437
|
+
if [[ -z "$NUMBER" ]]; then
|
|
438
|
+
error "No discussion for page '$page'. Seed one first: $0 seed --page '$page' ..."
|
|
439
|
+
fi
|
|
405
440
|
else
|
|
406
441
|
error "post: provide --number N, --url URL, or --page PATH."
|
|
407
442
|
fi
|
|
@@ -448,6 +483,167 @@ cmd_post() {
|
|
|
448
483
|
success "Comment posted to discussion #$NUMBER"
|
|
449
484
|
}
|
|
450
485
|
|
|
486
|
+
# ---------------------------------------------------------------------------
|
|
487
|
+
# Subcommand: doctor (verify the whole comment chain actually works)
|
|
488
|
+
# ---------------------------------------------------------------------------
|
|
489
|
+
|
|
490
|
+
# Why this exists: every other check in this repo is static. The core test
|
|
491
|
+
# validates _config.yml keys and the include's Liquid; the Playwright spec stubs
|
|
492
|
+
# giscus.app out entirely. All of them pass with a 100% broken widget, because
|
|
493
|
+
# the two things that actually break comments in practice live on GitHub's side,
|
|
494
|
+
# not in any file here:
|
|
495
|
+
# 1. the giscus GitHub App not being installed on the repo
|
|
496
|
+
# 2. a data-repo-id / data-category-id that doesn't belong to this repo
|
|
497
|
+
# `doctor` asks Giscus itself, which is the only authoritative answer.
|
|
498
|
+
|
|
499
|
+
# Read a scalar from the giscus block of _config.yml (same block-scoping as
|
|
500
|
+
# config_category_id, which only handles data-category-id).
|
|
501
|
+
config_giscus_key() {
|
|
502
|
+
local key="$1"
|
|
503
|
+
[[ -f "$CONFIG_FILE" ]] || return 0
|
|
504
|
+
# Block-scoped to the top-level `giscus:` mapping, then strip the key, any
|
|
505
|
+
# trailing comment, and surrounding quotes (\047 is awk for a single quote).
|
|
506
|
+
awk -v k="${key}:" '
|
|
507
|
+
/^giscus:/ { f=1; next }
|
|
508
|
+
f && /^[^[:space:]#]/ { f=0 }
|
|
509
|
+
f && $1 == k {
|
|
510
|
+
sub(/^[[:space:]]*[^:]+:[[:space:]]*/, "")
|
|
511
|
+
sub(/[[:space:]]+#.*$/, "")
|
|
512
|
+
gsub(/^["\047]+|["\047]+[[:space:]]*$/, "")
|
|
513
|
+
print
|
|
514
|
+
exit
|
|
515
|
+
}' "$CONFIG_FILE"
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
# Non-fatal error: report and let the caller accumulate failures. common.sh's
|
|
519
|
+
# error() exits, which would stop doctor at the first problem instead of
|
|
520
|
+
# reporting every one.
|
|
521
|
+
doctor_fail() { printf '\033[0;31m[FAIL]\033[0m %s\n' "$*" >&2; }
|
|
522
|
+
|
|
523
|
+
# One graphql call, one field out — avoids needing a standalone jq.
|
|
524
|
+
gql_repo_field() {
|
|
525
|
+
gh api graphql -f query="$1" \
|
|
526
|
+
-F owner="$(owner_of "$REPO_NWO")" -F name="$(name_of "$REPO_NWO")" \
|
|
527
|
+
--jq "$2" 2>/dev/null
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
REPO_Q='
|
|
531
|
+
query($owner:String!,$name:String!){
|
|
532
|
+
repository(owner:$owner,name:$name){
|
|
533
|
+
id isPrivate hasDiscussionsEnabled
|
|
534
|
+
discussionCategories(first:50){ nodes{ id name } }
|
|
535
|
+
}
|
|
536
|
+
}'
|
|
537
|
+
|
|
538
|
+
cmd_doctor() {
|
|
539
|
+
local category_name=""
|
|
540
|
+
while [[ $# -gt 0 ]]; do
|
|
541
|
+
case "$1" in
|
|
542
|
+
--repo) REPO_NWO="$2"; shift 2 ;;
|
|
543
|
+
--category-id) CATEGORY_ID="$2"; shift 2 ;;
|
|
544
|
+
--category) category_name="$2"; shift 2 ;;
|
|
545
|
+
*) error "doctor: unknown option '$1'" ;;
|
|
546
|
+
esac
|
|
547
|
+
done
|
|
548
|
+
resolve_repo
|
|
549
|
+
require_command gh "install: https://cli.github.com/"
|
|
550
|
+
require_command curl "install: https://curl.se/"
|
|
551
|
+
|
|
552
|
+
local failed=0
|
|
553
|
+
info "Checking the Giscus comment chain for $REPO_NWO"
|
|
554
|
+
|
|
555
|
+
# --- 1. Repo-side facts, straight from the GitHub API -------------------
|
|
556
|
+
local real_repo_id is_private discussions_on
|
|
557
|
+
real_repo_id="$(gql_repo_field "$REPO_Q" '.data.repository.id')"
|
|
558
|
+
[[ -z "$real_repo_id" ]] && error "Could not query $REPO_NWO via gh. Check the repo name and \`gh auth status\`."
|
|
559
|
+
is_private="$(gql_repo_field "$REPO_Q" '.data.repository.isPrivate')"
|
|
560
|
+
discussions_on="$(gql_repo_field "$REPO_Q" '.data.repository.hasDiscussionsEnabled')"
|
|
561
|
+
|
|
562
|
+
if [[ "$is_private" == "false" ]]; then
|
|
563
|
+
success "Repository is public"
|
|
564
|
+
else
|
|
565
|
+
doctor_fail "Repository is PRIVATE — visitors cannot read the discussions"; failed=1
|
|
566
|
+
fi
|
|
567
|
+
|
|
568
|
+
if [[ "$discussions_on" == "true" ]]; then
|
|
569
|
+
success "Discussions are enabled"
|
|
570
|
+
else
|
|
571
|
+
doctor_fail "Discussions are NOT enabled (Settings → General → Features → Discussions)"; failed=1
|
|
572
|
+
fi
|
|
573
|
+
|
|
574
|
+
# --- 2. Config IDs must belong to THIS repo ----------------------------
|
|
575
|
+
local cfg_repo_id cfg_category_id cfg_enabled
|
|
576
|
+
cfg_repo_id="$(config_giscus_key 'data-repo-id')"
|
|
577
|
+
cfg_category_id="${CATEGORY_ID:-$(config_giscus_key 'data-category-id')}"
|
|
578
|
+
cfg_enabled="$(config_giscus_key 'enabled')"
|
|
579
|
+
|
|
580
|
+
if [[ "$cfg_enabled" == "true" ]]; then
|
|
581
|
+
success "_config.yml has giscus.enabled: true"
|
|
582
|
+
else
|
|
583
|
+
doctor_fail "_config.yml has giscus.enabled: '${cfg_enabled:-unset}' — comments will not render"
|
|
584
|
+
failed=1
|
|
585
|
+
fi
|
|
586
|
+
|
|
587
|
+
if [[ -z "$cfg_repo_id" ]]; then
|
|
588
|
+
doctor_fail "_config.yml is missing giscus.data-repo-id"; failed=1
|
|
589
|
+
elif [[ "$cfg_repo_id" == "$real_repo_id" ]]; then
|
|
590
|
+
success "data-repo-id matches $REPO_NWO ($real_repo_id)"
|
|
591
|
+
else
|
|
592
|
+
doctor_fail "data-repo-id '$cfg_repo_id' does NOT belong to $REPO_NWO (expected '$real_repo_id'). Forked without regenerating it?"
|
|
593
|
+
failed=1
|
|
594
|
+
fi
|
|
595
|
+
|
|
596
|
+
# Resolve the configured category id to a name (proves it exists on this repo).
|
|
597
|
+
local cat_name=""
|
|
598
|
+
if [[ -n "$cfg_category_id" ]]; then
|
|
599
|
+
cat_name="$(gql_repo_field "$REPO_Q" \
|
|
600
|
+
".data.repository.discussionCategories.nodes[] | select(.id == \"${cfg_category_id}\") | .name")"
|
|
601
|
+
fi
|
|
602
|
+
if [[ -n "$cat_name" ]]; then
|
|
603
|
+
success "data-category-id resolves to the '$cat_name' category"
|
|
604
|
+
else
|
|
605
|
+
doctor_fail "data-category-id '${cfg_category_id:-unset}' is not a category on $REPO_NWO (see: $0 categories)"
|
|
606
|
+
failed=1
|
|
607
|
+
fi
|
|
608
|
+
[[ -n "$category_name" ]] && cat_name="$category_name"
|
|
609
|
+
|
|
610
|
+
# --- 3. The authoritative check: ask Giscus ----------------------------
|
|
611
|
+
# Only Giscus knows whether its GitHub App is installed on the repo. No
|
|
612
|
+
# amount of config inspection can substitute for this call.
|
|
613
|
+
local api_out
|
|
614
|
+
api_out="$(curl -s -m 20 -G https://giscus.app/api/discussions \
|
|
615
|
+
--data-urlencode "repo=$REPO_NWO" \
|
|
616
|
+
--data-urlencode "category=${cat_name:-Announcements}" \
|
|
617
|
+
--data-urlencode "term=/" \
|
|
618
|
+
-d "number=0&strict=true&first=1" 2>/dev/null || true)"
|
|
619
|
+
|
|
620
|
+
if [[ -z "$api_out" ]]; then
|
|
621
|
+
warn "Could not reach giscus.app (offline?) — app-installation check SKIPPED"
|
|
622
|
+
elif printf '%s' "$api_out" | grep -q 'giscus is not installed'; then
|
|
623
|
+
doctor_fail "The giscus GitHub App is NOT installed on $REPO_NWO — comments are broken sitewide."
|
|
624
|
+
info " Install: https://github.com/apps/giscus → Configure → grant access to $REPO_NWO"
|
|
625
|
+
failed=1
|
|
626
|
+
elif printf '%s' "$api_out" | grep -qi '"error"'; then
|
|
627
|
+
local msg
|
|
628
|
+
msg="$(printf '%s' "$api_out" | sed -E 's/.*"error"[[:space:]]*:[[:space:]]*"([^"]*)".*/\1/')"
|
|
629
|
+
# "Discussion not found" is the healthy answer for a page nobody has
|
|
630
|
+
# commented on yet — Giscus creates the thread on the first comment.
|
|
631
|
+
if printf '%s' "$msg" | grep -qi 'not found'; then
|
|
632
|
+
success "Giscus app is installed (no thread for '/' yet — created on first comment)"
|
|
633
|
+
else
|
|
634
|
+
doctor_fail "Giscus reported: $msg"; failed=1
|
|
635
|
+
fi
|
|
636
|
+
else
|
|
637
|
+
success "Giscus app is installed and serving this repo"
|
|
638
|
+
fi
|
|
639
|
+
|
|
640
|
+
echo
|
|
641
|
+
if [[ "$failed" -ne 0 ]]; then
|
|
642
|
+
error "Giscus chain is BROKEN — comments will not work for visitors."
|
|
643
|
+
fi
|
|
644
|
+
success "Giscus chain is healthy — comments will render for visitors."
|
|
645
|
+
}
|
|
646
|
+
|
|
451
647
|
# ---------------------------------------------------------------------------
|
|
452
648
|
# Usage / dispatch
|
|
453
649
|
# ---------------------------------------------------------------------------
|
|
@@ -460,6 +656,7 @@ USAGE:
|
|
|
460
656
|
./scripts/bin/giscus-discussions <subcommand> [options]
|
|
461
657
|
|
|
462
658
|
SUBCOMMANDS:
|
|
659
|
+
doctor Verify the whole chain works (app installed, IDs match)
|
|
463
660
|
categories List discussion categories + node IDs (setup helper)
|
|
464
661
|
list [--json] List discussions in the Giscus category
|
|
465
662
|
thread <target> [--json] Show a discussion's full conversation
|
|
@@ -481,6 +678,7 @@ ENVIRONMENT:
|
|
|
481
678
|
GISCUS_CATEGORY_ID Default --category-id
|
|
482
679
|
|
|
483
680
|
EXAMPLES:
|
|
681
|
+
./scripts/bin/giscus-discussions doctor
|
|
484
682
|
./scripts/bin/giscus-discussions categories
|
|
485
683
|
./scripts/bin/giscus-discussions list
|
|
486
684
|
./scripts/bin/giscus-discussions thread --page /posts/2025/01/21/remote-work-revolution/
|
|
@@ -497,6 +695,7 @@ main() {
|
|
|
497
695
|
[[ $# -gt 0 ]] && shift || true
|
|
498
696
|
case "$sub" in
|
|
499
697
|
categories) cmd_categories "$@" ;;
|
|
698
|
+
doctor) cmd_doctor "$@" ;;
|
|
500
699
|
list) cmd_list "$@" ;;
|
|
501
700
|
thread) cmd_thread "$@" ;;
|
|
502
701
|
draft) cmd_draft "$@" ;;
|