jekyll-theme-zer0 1.21.0 → 1.23.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.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +57 -0
  3. data/_data/ai.yml +10 -0
  4. data/_data/backlog.yml +173 -23
  5. data/_data/navigation/docs.yml +4 -0
  6. data/_data/theme_backgrounds.yml +24 -0
  7. data/_includes/components/component-showcase.html +83 -14
  8. data/_includes/components/js-cdn.html +10 -2
  9. data/_includes/components/post-card.html +18 -4
  10. data/_includes/components/search-modal.html +28 -4
  11. data/_includes/content/backlinks.html +47 -18
  12. data/_includes/content/giscus.html +30 -44
  13. data/_includes/core/color-mode-init.html +35 -0
  14. data/_includes/core/footer-fabs.html +5 -2
  15. data/_includes/core/head.html +9 -0
  16. data/_includes/core/tokens-inline.html +5 -0
  17. data/_includes/navigation/local-graph-fab.html +1 -1
  18. data/_includes/navigation/local-graph.html +4 -3
  19. data/_includes/navigation/section-sidebar.html +10 -3
  20. data/_includes/obsidian/full-graph.html +7 -5
  21. data/_layouts/article.html +23 -6
  22. data/_layouts/author.html +22 -1
  23. data/_layouts/root.html +15 -1
  24. data/_plugins/obsidian_links.rb +84 -15
  25. data/_sass/core/_obsidian.scss +59 -8
  26. data/assets/data/wiki-index.json +50 -3
  27. data/assets/js/obsidian-graph.js +66 -21
  28. data/assets/js/obsidian-local-graph.js +125 -32
  29. data/assets/js/obsidian-wiki-links.js +118 -21
  30. data/assets/js/search-modal.js +45 -9
  31. data/scripts/bin/giscus-discussions +509 -0
  32. data/scripts/bin/validate +1 -0
  33. data/scripts/ci/classify_changes.py +129 -0
  34. data/scripts/issues/dispatch.py +478 -0
  35. data/scripts/issues/test_verify_close.py +118 -0
  36. data/scripts/issues/triage.py +1046 -0
  37. data/scripts/issues/verify_close.py +179 -0
  38. metadata +10 -2
@@ -0,0 +1,509 @@
1
+ #!/bin/bash
2
+
3
+ # giscus-discussions — read, draft, and post the GitHub Discussions that back
4
+ # the site's Giscus comment threads. This is the engine the Claude Code
5
+ # `giscus-conversation` skill drives to build conversations from page comments.
6
+ #
7
+ # Subcommands:
8
+ # categories List the repo's discussion categories + node IDs (setup helper)
9
+ # list List discussions in the configured Giscus category
10
+ # thread Print a discussion's full conversation (comments + replies)
11
+ # draft Write a Markdown draft scaffold (thread context + reply slot)
12
+ # seed Create a new discussion for a page (starts a conversation)
13
+ # post Add a comment or reply to an existing discussion
14
+ #
15
+ # Reads owner/repo from `gh repo view` and the Giscus category from
16
+ # _config.yml (giscus.data-category-id). Both are overridable via flags/env.
17
+ #
18
+ # Usage: ./scripts/bin/giscus-discussions <subcommand> [options]
19
+ # Requires: gh (authenticated). Writes (seed/post) honor --dry-run.
20
+
21
+ set -euo pipefail
22
+
23
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
24
+ LIB_DIR="$SCRIPT_DIR/../lib"
25
+ REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
26
+
27
+ # shellcheck source=/dev/null
28
+ source "$LIB_DIR/common.sh"
29
+
30
+ # ---------------------------------------------------------------------------
31
+ # Configuration resolution
32
+ # ---------------------------------------------------------------------------
33
+
34
+ CONFIG_FILE="$REPO_ROOT/_config.yml"
35
+
36
+ # Owner/repo: env GISCUS_REPO or --repo override; else derive from gh.
37
+ REPO_NWO="${GISCUS_REPO:-}"
38
+ # Giscus category node id: env GISCUS_CATEGORY_ID or --category-id; else config.
39
+ CATEGORY_ID="${GISCUS_CATEGORY_ID:-}"
40
+
41
+ resolve_repo() {
42
+ if [[ -n "$REPO_NWO" ]]; then
43
+ return 0
44
+ fi
45
+ require_command gh "install: https://cli.github.com/"
46
+ REPO_NWO="$(gh repo view --json nameWithOwner -q .nameWithOwner 2>/dev/null || true)"
47
+ if [[ -z "$REPO_NWO" ]]; then
48
+ error "Could not determine repository. Pass --repo owner/name or set GISCUS_REPO."
49
+ fi
50
+ }
51
+
52
+ # Extract giscus.data-category-id from _config.yml (block-scoped).
53
+ config_category_id() {
54
+ [[ -f "$CONFIG_FILE" ]] || return 0
55
+ awk '/^giscus:/{f=1;next} f&&/^[^[:space:]#]/{f=0} f' "$CONFIG_FILE" \
56
+ | grep -E '^[[:space:]]+data-category-id:' \
57
+ | head -1 \
58
+ | sed -E 's/^[[:space:]]+data-category-id:[[:space:]]*//; s/^"//; s/"[[:space:]]*$//; s/^'"'"'//; s/'"'"'[[:space:]]*$//'
59
+ }
60
+
61
+ resolve_category() {
62
+ if [[ -n "$CATEGORY_ID" ]]; then
63
+ return 0
64
+ fi
65
+ CATEGORY_ID="$(config_category_id)"
66
+ if [[ -z "$CATEGORY_ID" ]]; then
67
+ error "No category id. Pass --category-id, set GISCUS_CATEGORY_ID, or fix giscus.data-category-id in _config.yml (see: $0 categories)."
68
+ fi
69
+ }
70
+
71
+ owner_of() { printf '%s' "${1%%/*}"; }
72
+ name_of() { printf '%s' "${1##*/}"; }
73
+
74
+ # Normalize a page path so it matches a Giscus pathname-mapped discussion title.
75
+ normalize_page() {
76
+ local p="$1"
77
+ if [[ "$p" == *"://"* ]]; then
78
+ p="${p#*://}" # strip scheme
79
+ p="/${p#*/}" # strip host, keep path with a leading slash
80
+ fi
81
+ [[ "$p" != /* ]] && p="/$p"
82
+ printf '%s' "$p"
83
+ }
84
+
85
+ # ---------------------------------------------------------------------------
86
+ # GraphQL helpers
87
+ # ---------------------------------------------------------------------------
88
+
89
+ repo_node_id() {
90
+ gh api graphql -f query='
91
+ query($owner:String!,$name:String!){
92
+ repository(owner:$owner,name:$name){ id }
93
+ }' -F owner="$(owner_of "$REPO_NWO")" -F name="$(name_of "$REPO_NWO")" \
94
+ --jq '.data.repository.id'
95
+ }
96
+
97
+ # Find a discussion number whose title equals the (normalized) page path.
98
+ # Echoes the number, or nothing if not found.
99
+ discussion_number_for_page() {
100
+ local page; page="$(normalize_page "$1")"
101
+ local page_noslash="${page%/}"
102
+ gh api graphql -f query='
103
+ query($owner:String!,$name:String!,$categoryId:ID,$after:String){
104
+ repository(owner:$owner,name:$name){
105
+ discussions(first:100,after:$after,categoryId:$categoryId){
106
+ nodes{ number title }
107
+ pageInfo{ hasNextPage endCursor }
108
+ }
109
+ }
110
+ }' -F owner="$(owner_of "$REPO_NWO")" -F name="$(name_of "$REPO_NWO")" \
111
+ -f categoryId="$CATEGORY_ID" \
112
+ --jq ".data.repository.discussions.nodes[]
113
+ | select(.title == \"$page\" or .title == \"$page_noslash\")
114
+ | .number" | head -1
115
+ }
116
+
117
+ discussion_node_id() {
118
+ local number="$1"
119
+ gh api graphql -f query='
120
+ query($owner:String!,$name:String!,$number:Int!){
121
+ repository(owner:$owner,name:$name){ discussion(number:$number){ id } }
122
+ }' -F owner="$(owner_of "$REPO_NWO")" -F name="$(name_of "$REPO_NWO")" \
123
+ -F number="$number" \
124
+ --jq '.data.repository.discussion.id'
125
+ }
126
+
127
+ # ---------------------------------------------------------------------------
128
+ # Subcommand: categories
129
+ # ---------------------------------------------------------------------------
130
+
131
+ cmd_categories() {
132
+ resolve_repo
133
+ info "Discussion categories for $REPO_NWO:"
134
+ gh api graphql -f query='
135
+ query($owner:String!,$name:String!){
136
+ repository(owner:$owner,name:$name){
137
+ hasDiscussionsEnabled
138
+ discussionCategories(first:30){ nodes{ id name slug } }
139
+ }
140
+ }' -F owner="$(owner_of "$REPO_NWO")" -F name="$(name_of "$REPO_NWO")" \
141
+ --jq '.data.repository as $r
142
+ | if $r.hasDiscussionsEnabled then empty
143
+ else "WARNING: Discussions are NOT enabled on this repo." end,
144
+ ($r.discussionCategories.nodes[] | " \(.id)\t\(.name)")'
145
+ }
146
+
147
+ # ---------------------------------------------------------------------------
148
+ # Subcommand: list
149
+ # ---------------------------------------------------------------------------
150
+
151
+ cmd_list() {
152
+ local as_json=false
153
+ while [[ $# -gt 0 ]]; do
154
+ case "$1" in
155
+ --repo) REPO_NWO="$2"; shift 2 ;;
156
+ --category-id) CATEGORY_ID="$2"; shift 2 ;;
157
+ --json) as_json=true; shift ;;
158
+ *) error "list: unknown option '$1'" ;;
159
+ esac
160
+ done
161
+ resolve_repo; resolve_category
162
+ local owner name; owner="$(owner_of "$REPO_NWO")"; name="$(name_of "$REPO_NWO")"
163
+ local query='
164
+ query($owner:String!,$name:String!,$categoryId:ID){
165
+ repository(owner:$owner,name:$name){
166
+ discussions(first:100,categoryId:$categoryId,orderBy:{field:UPDATED_AT,direction:DESC}){
167
+ nodes{ number title url updatedAt comments{ totalCount } author{ login } }
168
+ }
169
+ }
170
+ }'
171
+ if [[ "$as_json" == true ]]; then
172
+ gh api graphql -f query="$query" -F owner="$owner" -F name="$name" \
173
+ -f categoryId="$CATEGORY_ID" --jq '.data.repository.discussions.nodes'
174
+ return 0
175
+ fi
176
+ local out
177
+ out="$(gh api graphql -f query="$query" -F owner="$owner" -F name="$name" \
178
+ -f categoryId="$CATEGORY_ID" \
179
+ --jq '.data.repository.discussions.nodes[]
180
+ | "#\(.number)\t\(.comments.totalCount) comment(s)\t\(.title)\n\t\(.url)"')"
181
+ if [[ -z "$out" ]]; then
182
+ info "No discussions yet in the Giscus category for $REPO_NWO."
183
+ info "Giscus creates one on the first visitor comment, or seed one:"
184
+ info " $0 seed --page /path/ --title ... --body ..."
185
+ return 0
186
+ fi
187
+ info "Discussions in the Giscus category for $REPO_NWO:"
188
+ printf '%s\n' "$out"
189
+ }
190
+
191
+ # ---------------------------------------------------------------------------
192
+ # Subcommand: thread
193
+ # ---------------------------------------------------------------------------
194
+
195
+ THREAD_QUERY='
196
+ query($owner:String!,$name:String!,$number:Int!){
197
+ repository(owner:$owner,name:$name){
198
+ discussion(number:$number){
199
+ number title url body createdAt id
200
+ author{ login }
201
+ comments(first:100){
202
+ totalCount
203
+ nodes{
204
+ id body createdAt author{ login }
205
+ replies(first:100){ nodes{ id body createdAt author{ login } } }
206
+ }
207
+ }
208
+ }
209
+ }
210
+ }'
211
+
212
+ # jq program that renders a discussion thread as readable Markdown.
213
+ THREAD_MD_JQ='
214
+ .data.repository.discussion as $d
215
+ | "# \($d.title)\n\n- URL: \($d.url)\n- Discussion: #\($d.number) (node: \($d.id))\n- Opened by: @\($d.author.login // "unknown") on \($d.createdAt)\n\n## Original post\n\n\($d.body)\n\n## Comments (\($d.comments.totalCount))\n"
216
+ + ( [ $d.comments.nodes[]
217
+ | "\n### @\(.author.login // "unknown") — \(.createdAt)\n_comment id: `\(.id)`_\n\n\(.body)\n"
218
+ + ( if (.replies.nodes | length) > 0
219
+ then "\n" + ( [ .replies.nodes[]
220
+ | " - **@\(.author.login // "unknown")** (\(.createdAt)) _reply id: `\(.id)`_\n\n \(.body | gsub("\n"; "\n "))" ] | join("\n") ) + "\n"
221
+ else "" end )
222
+ ] | join("\n---\n") )'
223
+
224
+ # Render a thread as Markdown (uses gh's bundled jq — no external jq needed).
225
+ fetch_thread_md() {
226
+ local number="$1"
227
+ gh api graphql -f query="$THREAD_QUERY" \
228
+ -F owner="$(owner_of "$REPO_NWO")" -F name="$(name_of "$REPO_NWO")" \
229
+ -F number="$number" --jq "$THREAD_MD_JQ"
230
+ }
231
+
232
+ # Fetch the raw discussion JSON.
233
+ fetch_thread_raw() {
234
+ local number="$1"
235
+ gh api graphql -f query="$THREAD_QUERY" \
236
+ -F owner="$(owner_of "$REPO_NWO")" -F name="$(name_of "$REPO_NWO")" \
237
+ -F number="$number"
238
+ }
239
+
240
+ resolve_number_arg() {
241
+ # Sets NUMBER from --number, --url, or --page. Used by thread/draft/post.
242
+ local number="" url="" page=""
243
+ REMAINING_ARGS=()
244
+ while [[ $# -gt 0 ]]; do
245
+ case "$1" in
246
+ --number) number="$2"; shift 2 ;;
247
+ --url) url="$2"; shift 2 ;;
248
+ --page) page="$2"; shift 2 ;;
249
+ --repo) REPO_NWO="$2"; shift 2 ;;
250
+ --category-id) CATEGORY_ID="$2"; shift 2 ;;
251
+ *) REMAINING_ARGS+=("$1"); shift ;;
252
+ esac
253
+ done
254
+ resolve_repo
255
+ if [[ -n "$number" ]]; then
256
+ NUMBER="$number"
257
+ elif [[ -n "$url" ]]; then
258
+ NUMBER="${url##*/}"
259
+ elif [[ -n "$page" ]]; then
260
+ resolve_category
261
+ NUMBER="$(discussion_number_for_page "$page")"
262
+ [[ -z "$NUMBER" ]] && error "No discussion found for page '$page'. Seed one with: $0 seed --page '$page' --title ... --body ..."
263
+ else
264
+ error "Provide --number N, --url URL, or --page PATH."
265
+ fi
266
+ }
267
+
268
+ cmd_thread() {
269
+ local as_json=false
270
+ local args=()
271
+ for a in "$@"; do
272
+ [[ "$a" == "--json" ]] && { as_json=true; continue; }
273
+ args+=("$a")
274
+ done
275
+ resolve_number_arg "${args[@]}"
276
+ if [[ "$as_json" == true ]]; then
277
+ fetch_thread_raw "$NUMBER"
278
+ else
279
+ fetch_thread_md "$NUMBER"
280
+ fi
281
+ }
282
+
283
+ # ---------------------------------------------------------------------------
284
+ # Subcommand: draft
285
+ # ---------------------------------------------------------------------------
286
+
287
+ cmd_draft() {
288
+ local out=""
289
+ local args=()
290
+ while [[ $# -gt 0 ]]; do
291
+ case "$1" in
292
+ --out) out="$2"; shift 2 ;;
293
+ *) args+=("$1"); shift ;;
294
+ esac
295
+ done
296
+ resolve_number_arg "${args[@]}"
297
+ [[ -z "$out" ]] && out="$REPO_ROOT/giscus-draft-${NUMBER}.md"
298
+ {
299
+ echo "<!-- Giscus reply draft. Edit the REPLY section, then post with:"
300
+ echo " ./scripts/bin/giscus-discussions post --number $NUMBER --body-file \"$out\" [--reply-to <comment id>] [--dry-run]"
301
+ echo " Everything above the REPLY marker is context and is NOT posted. -->"
302
+ echo
303
+ echo "## Conversation context"
304
+ echo
305
+ fetch_thread_md "$NUMBER"
306
+ echo
307
+ echo "<!-- ===== REPLY (everything below this line is your draft) ===== -->"
308
+ echo
309
+ echo "_Draft your reply here._"
310
+ echo
311
+ } > "$out"
312
+ success "Draft scaffold written: $out"
313
+ info "Fill in the REPLY section, then: $0 post --number $NUMBER --body-file \"$out\" --reply-to <id> --dry-run"
314
+ }
315
+
316
+ # Extract just the reply portion (everything below the REPLY marker) from a
317
+ # draft file produced by `draft`, dropping leading blank lines.
318
+ extract_reply_body() {
319
+ awk 'f{print} /===== REPLY/{f=1}' "$1" | sed '/./,$!d'
320
+ }
321
+
322
+ # ---------------------------------------------------------------------------
323
+ # Subcommand: seed (create a new discussion for a page)
324
+ # ---------------------------------------------------------------------------
325
+
326
+ cmd_seed() {
327
+ local page="" title="" body="" body_file=""
328
+ while [[ $# -gt 0 ]]; do
329
+ case "$1" in
330
+ --page) page="$2"; shift 2 ;;
331
+ --title) title="$2"; shift 2 ;;
332
+ --body) body="$2"; shift 2 ;;
333
+ --body-file) body_file="$2"; shift 2 ;;
334
+ --repo) REPO_NWO="$2"; shift 2 ;;
335
+ --category-id) CATEGORY_ID="$2"; shift 2 ;;
336
+ --dry-run) DRY_RUN=true; shift ;;
337
+ *) error "seed: unknown option '$1'" ;;
338
+ esac
339
+ done
340
+ resolve_repo; resolve_category
341
+ [[ -z "$page" ]] && error "seed: --page PATH is required (Giscus maps the discussion title to the page path)."
342
+ page="$(normalize_page "$page")"
343
+ [[ -z "$title" ]] && title="$page"
344
+ if [[ -n "$body_file" ]]; then
345
+ require_file "$body_file" "body file"
346
+ body="$(cat "$body_file")"
347
+ fi
348
+ [[ -z "$body" ]] && error "seed: provide --body TEXT or --body-file FILE."
349
+
350
+ local existing; existing="$(discussion_number_for_page "$page" || true)"
351
+ if [[ -n "$existing" ]]; then
352
+ error "A discussion already exists for '$page' (#$existing). Use: $0 post --number $existing ..."
353
+ fi
354
+
355
+ if [[ "$DRY_RUN" == "true" ]]; then
356
+ info "[DRY RUN] Would create discussion in $REPO_NWO (category $CATEGORY_ID):"
357
+ info " title: $title"
358
+ info " body: $(printf '%s' "$body" | head -c 200)"
359
+ return 0
360
+ fi
361
+
362
+ local repo_id; repo_id="$(repo_node_id)"
363
+ gh api graphql -f query='
364
+ mutation($repoId:ID!,$categoryId:ID!,$title:String!,$body:String!){
365
+ createDiscussion(input:{repositoryId:$repoId,categoryId:$categoryId,title:$title,body:$body}){
366
+ discussion{ number url }
367
+ }
368
+ }' -F repoId="$repo_id" -F categoryId="$CATEGORY_ID" \
369
+ -F title="$title" -F body="$body" \
370
+ --jq '.data.createDiscussion.discussion | "Created discussion #\(.number): \(.url)"'
371
+ success "Discussion created for $page"
372
+ }
373
+
374
+ # ---------------------------------------------------------------------------
375
+ # Subcommand: post (comment on / reply to an existing discussion)
376
+ # ---------------------------------------------------------------------------
377
+
378
+ cmd_post() {
379
+ local number="" url="" page="" body="" body_file="" reply_to=""
380
+ while [[ $# -gt 0 ]]; do
381
+ case "$1" in
382
+ --number) number="$2"; shift 2 ;;
383
+ --url) url="$2"; shift 2 ;;
384
+ --page) page="$2"; shift 2 ;;
385
+ --body) body="$2"; shift 2 ;;
386
+ --body-file) body_file="$2"; shift 2 ;;
387
+ --reply-to) reply_to="$2"; shift 2 ;;
388
+ --repo) REPO_NWO="$2"; shift 2 ;;
389
+ --category-id) CATEGORY_ID="$2"; shift 2 ;;
390
+ --dry-run) DRY_RUN=true; shift ;;
391
+ *) error "post: unknown option '$1'" ;;
392
+ esac
393
+ done
394
+ resolve_repo
395
+ # Resolve the target discussion number.
396
+ if [[ -n "$number" ]]; then
397
+ NUMBER="$number"
398
+ elif [[ -n "$url" ]]; then
399
+ NUMBER="${url##*/}"
400
+ elif [[ -n "$page" ]]; then
401
+ resolve_category
402
+ NUMBER="$(discussion_number_for_page "$page")"
403
+ [[ -z "$NUMBER" ]] && error "No discussion for page '$page'. Seed one first: $0 seed --page '$page' ..."
404
+ else
405
+ error "post: provide --number N, --url URL, or --page PATH."
406
+ fi
407
+
408
+ if [[ -n "$body_file" ]]; then
409
+ require_file "$body_file" "body file"
410
+ # If it's a draft scaffold, post only the REPLY section; else the whole file.
411
+ if grep -q '===== REPLY' "$body_file"; then
412
+ body="$(extract_reply_body "$body_file")"
413
+ else
414
+ body="$(cat "$body_file")"
415
+ fi
416
+ fi
417
+ # Trim leading/trailing blank lines.
418
+ body="$(printf '%s' "$body" | sed -e 's/[[:space:]]*$//')"
419
+ [[ -z "${body//[[:space:]]/}" ]] && error "post: empty body. Provide --body TEXT or --body-file FILE with content under the REPLY marker."
420
+
421
+ if [[ "$DRY_RUN" == "true" ]]; then
422
+ info "[DRY RUN] Would post to $REPO_NWO discussion #$NUMBER${reply_to:+ (reply to $reply_to)}:"
423
+ printf '%s\n' "$body" | sed 's/^/ | /'
424
+ return 0
425
+ fi
426
+
427
+ local disc_id; disc_id="$(discussion_node_id "$NUMBER")"
428
+ [[ -z "$disc_id" || "$disc_id" == "null" ]] && error "Could not resolve discussion #$NUMBER on $REPO_NWO."
429
+
430
+ if [[ -n "$reply_to" ]]; then
431
+ gh api graphql -f query='
432
+ mutation($discussionId:ID!,$body:String!,$replyToId:ID!){
433
+ addDiscussionComment(input:{discussionId:$discussionId,body:$body,replyToId:$replyToId}){
434
+ comment{ url }
435
+ }
436
+ }' -F discussionId="$disc_id" -F body="$body" -F replyToId="$reply_to" \
437
+ --jq '"Posted reply: \(.data.addDiscussionComment.comment.url)"'
438
+ else
439
+ gh api graphql -f query='
440
+ mutation($discussionId:ID!,$body:String!){
441
+ addDiscussionComment(input:{discussionId:$discussionId,body:$body}){
442
+ comment{ url }
443
+ }
444
+ }' -F discussionId="$disc_id" -F body="$body" \
445
+ --jq '"Posted comment: \(.data.addDiscussionComment.comment.url)"'
446
+ fi
447
+ success "Comment posted to discussion #$NUMBER"
448
+ }
449
+
450
+ # ---------------------------------------------------------------------------
451
+ # Usage / dispatch
452
+ # ---------------------------------------------------------------------------
453
+
454
+ show_usage() {
455
+ cat << 'EOF'
456
+ giscus-discussions — read, draft, and post the GitHub Discussions behind Giscus comments
457
+
458
+ USAGE:
459
+ ./scripts/bin/giscus-discussions <subcommand> [options]
460
+
461
+ SUBCOMMANDS:
462
+ categories List discussion categories + node IDs (setup helper)
463
+ list [--json] List discussions in the Giscus category
464
+ thread <target> [--json] Show a discussion's full conversation
465
+ draft <target> [--out F] Write a Markdown reply-draft scaffold
466
+ seed --page P --title T (--body B | --body-file F) [--dry-run]
467
+ Create a new discussion for a page
468
+ post <target> (--body B | --body-file F) [--reply-to ID] [--dry-run]
469
+ Add a comment / reply to an existing discussion
470
+
471
+ <target> is one of: --number N | --url URL | --page /path/
472
+
473
+ COMMON OPTIONS:
474
+ --repo owner/name Override repository (default: gh repo view)
475
+ --category-id ID Override Giscus category (default: _config.yml)
476
+ --dry-run Preview writes (seed/post) without calling the API
477
+
478
+ ENVIRONMENT:
479
+ GISCUS_REPO Default --repo
480
+ GISCUS_CATEGORY_ID Default --category-id
481
+
482
+ EXAMPLES:
483
+ ./scripts/bin/giscus-discussions categories
484
+ ./scripts/bin/giscus-discussions list
485
+ ./scripts/bin/giscus-discussions thread --page /posts/2025/01/21/remote-work-revolution/
486
+ ./scripts/bin/giscus-discussions draft --number 7 --out /tmp/reply.md
487
+ ./scripts/bin/giscus-discussions post --number 7 --body-file /tmp/reply.md --reply-to DC_xxx --dry-run
488
+ ./scripts/bin/giscus-discussions seed --page /posts/new/ --title "/posts/new/" --body "Discussion thread." --dry-run
489
+
490
+ Requires an authenticated GitHub CLI (`gh auth login`).
491
+ EOF
492
+ }
493
+
494
+ main() {
495
+ local sub="${1:-}"
496
+ [[ $# -gt 0 ]] && shift || true
497
+ case "$sub" in
498
+ categories) cmd_categories "$@" ;;
499
+ list) cmd_list "$@" ;;
500
+ thread) cmd_thread "$@" ;;
501
+ draft) cmd_draft "$@" ;;
502
+ seed) cmd_seed "$@" ;;
503
+ post) cmd_post "$@" ;;
504
+ -h|--help|help|"") show_usage ;;
505
+ *) error "Unknown subcommand '$sub' (try: $0 --help)" ;;
506
+ esac
507
+ }
508
+
509
+ main "$@"
data/scripts/bin/validate CHANGED
@@ -413,6 +413,7 @@ classified_files = %w[
413
413
  _config_secrets_local.yml
414
414
  pages/_about/settings/_config.yml
415
415
  .github/ISSUE_TEMPLATE/config.yml
416
+ .issues/config.yml
416
417
  ]
417
418
 
418
419
  # T-018: the admin config page renders pages/_about/settings/_config.yml via
@@ -0,0 +1,129 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ classify_changes.py — the auto-merge smuggle guard.
4
+
5
+ Reads a list of changed file paths (one per line on stdin, e.g. from
6
+ `gh pr diff <n> --name-only`) and prints the distinct CATEGORIES present, one
7
+ per line, from:
8
+
9
+ content — pages/**, _data/{quests,navigation,...}, assets/** (the site)
10
+ infra — .github/**, scripts/**, Dockerfile, docker-compose, Gemfile*, Makefile
11
+ config — _config*.yml, _data/ai.yml, _data/brand/**, .cms/**, *.json config
12
+ data — other _data/**
13
+
14
+ The content auto-merge workflow refuses to merge any PR whose categories include
15
+ anything other than `content` — so an `auto:content` PR can never quietly carry a
16
+ workflow, dependency, brand-rule, or AI-config change to main without a human.
17
+ Unknown paths classify as `infra` (fail safe — when unsure, require a human).
18
+
19
+ The content category is deliberately broad (it includes theme files like
20
+ `_layouts/**`, `_includes/**`, `_sass/**`). A caller that needs a TIGHTER scope
21
+ than "any content" — e.g. the issue autopilot, whose resolver may only edit
22
+ `pages/**`, `assets/**`, `_data/quests/**` — adds `--allow-globs` to require that
23
+ every changed path also matches one of the given globs. `--content-only` and
24
+ `--allow-globs` compose: both must pass.
25
+
26
+ Usage:
27
+ gh pr diff 123 --name-only | python3 scripts/ci/classify_changes.py
28
+ python3 scripts/ci/classify_changes.py a.md b.yml # paths as args
29
+ ... | python3 scripts/ci/classify_changes.py --content-only # exit 0 iff content-only
30
+ ... | python3 scripts/ci/classify_changes.py --content-only \
31
+ --allow-globs 'pages/**' 'assets/**' '_data/quests/**' # tighter scope
32
+ """
33
+ from __future__ import annotations
34
+
35
+ import argparse
36
+ import sys
37
+ from fnmatch import fnmatch
38
+
39
+ # Order matters: first matching rule wins. Most specific first.
40
+ # THEME REPO: the theme itself (_layouts/_includes/_sass/_plugins/lib/assets) is
41
+ # the PRODUCT, so it is `infra` (never auto-mergeable) — only docs/pages Markdown
42
+ # counts as `content`. This is the key difference from a content site.
43
+ RULES = [
44
+ ("config", [
45
+ "_config.yml", "_config*.yml",
46
+ "_data/ai.yml",
47
+ ".github/config/*", ".github/config/**",
48
+ "frontmatter.json", ".frontmatter/*", ".frontmatter/**",
49
+ ]),
50
+ ("infra", [
51
+ ".github/*", ".github/**",
52
+ ".claude/*", ".claude/**",
53
+ "scripts/*", "scripts/**",
54
+ "lib/*", "lib/**",
55
+ "_layouts/*", "_layouts/**", "_includes/*", "_includes/**",
56
+ "_sass/*", "_sass/**", "_plugins/*", "_plugins/**",
57
+ "assets/*", "assets/**",
58
+ "test/*", "test/**", "templates/*", "templates/**",
59
+ "Dockerfile", "docker-compose*.yml", "docker-compose*.yaml",
60
+ "Gemfile", "Gemfile.lock", "Makefile", "Rakefile", "install.sh",
61
+ "*.gemspec", "package.json", "package-lock.json",
62
+ ]),
63
+ ("content", [
64
+ "docs/*", "docs/**",
65
+ "pages/*", "pages/**",
66
+ "index.html", "index.md", "*.md",
67
+ ]),
68
+ ]
69
+ FALLBACK = "infra" # unknown -> require a human (fail safe)
70
+
71
+
72
+ def classify_one(path: str) -> str:
73
+ p = path.strip()
74
+ if not p:
75
+ return ""
76
+ for category, patterns in RULES:
77
+ for pat in patterns:
78
+ if fnmatch(p, pat):
79
+ return category
80
+ return "data" if p.startswith("_data/") else FALLBACK
81
+
82
+
83
+ def main(argv=None) -> int:
84
+ ap = argparse.ArgumentParser(description="Classify changed paths for the auto-merge guard.")
85
+ ap.add_argument("paths", nargs="*", help="paths (else read stdin, one per line)")
86
+ ap.add_argument("--content-only", action="store_true",
87
+ help="exit 0 iff every path is content; exit 1 otherwise")
88
+ ap.add_argument("--allow-globs", nargs="*", default=None, metavar="GLOB",
89
+ help="require every changed path to match one of these globs "
90
+ "(tighter than --content-only); composes with it")
91
+ args = ap.parse_args(argv)
92
+
93
+ raw = [line for line in (args.paths if args.paths else sys.stdin.read().splitlines())
94
+ if line.strip()]
95
+ cats = []
96
+ for line in raw:
97
+ c = classify_one(line)
98
+ if c and c not in cats:
99
+ cats.append(c)
100
+
101
+ # When either gate is requested, ALL requested gates must pass (exit 0/1).
102
+ if args.content_only or args.allow_globs is not None:
103
+ ok = True
104
+ if args.content_only:
105
+ content_ok = bool(cats) and cats == ["content"]
106
+ if not content_ok:
107
+ others = [c for c in cats if c != "content"] or ["<empty diff>"]
108
+ print(f"not content-only: also touches {', '.join(others)}", file=sys.stderr)
109
+ ok = ok and content_ok
110
+ if args.allow_globs is not None:
111
+ offenders = [p.strip() for p in raw
112
+ if not any(fnmatch(p.strip(), g) for g in args.allow_globs)]
113
+ # An empty diff is never "allowed" — there is nothing legitimate to merge.
114
+ if not raw:
115
+ print("no changed paths — refusing (nothing to merge)", file=sys.stderr)
116
+ ok = False
117
+ elif offenders:
118
+ print(f"outside allowed scope ({', '.join(args.allow_globs)}): "
119
+ f"{', '.join(offenders)}", file=sys.stderr)
120
+ ok = False
121
+ return 0 if ok else 1
122
+
123
+ for c in cats:
124
+ print(c)
125
+ return 0
126
+
127
+
128
+ if __name__ == "__main__":
129
+ raise SystemExit(main())