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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +57 -0
- data/_data/ai.yml +10 -0
- data/_data/backlog.yml +173 -23
- data/_data/navigation/docs.yml +4 -0
- data/_data/theme_backgrounds.yml +24 -0
- data/_includes/components/component-showcase.html +83 -14
- data/_includes/components/js-cdn.html +10 -2
- data/_includes/components/post-card.html +18 -4
- data/_includes/components/search-modal.html +28 -4
- data/_includes/content/backlinks.html +47 -18
- data/_includes/content/giscus.html +30 -44
- data/_includes/core/color-mode-init.html +35 -0
- data/_includes/core/footer-fabs.html +5 -2
- data/_includes/core/head.html +9 -0
- data/_includes/core/tokens-inline.html +5 -0
- data/_includes/navigation/local-graph-fab.html +1 -1
- data/_includes/navigation/local-graph.html +4 -3
- data/_includes/navigation/section-sidebar.html +10 -3
- data/_includes/obsidian/full-graph.html +7 -5
- data/_layouts/article.html +23 -6
- data/_layouts/author.html +22 -1
- data/_layouts/root.html +15 -1
- data/_plugins/obsidian_links.rb +84 -15
- data/_sass/core/_obsidian.scss +59 -8
- data/assets/data/wiki-index.json +50 -3
- data/assets/js/obsidian-graph.js +66 -21
- data/assets/js/obsidian-local-graph.js +125 -32
- data/assets/js/obsidian-wiki-links.js +118 -21
- data/assets/js/search-modal.js +45 -9
- data/scripts/bin/giscus-discussions +509 -0
- data/scripts/bin/validate +1 -0
- data/scripts/ci/classify_changes.py +129 -0
- data/scripts/issues/dispatch.py +478 -0
- data/scripts/issues/test_verify_close.py +118 -0
- data/scripts/issues/triage.py +1046 -0
- data/scripts/issues/verify_close.py +179 -0
- 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
|
@@ -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())
|