jekyll-theme-zer0 1.19.1 → 1.21.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 +404 -0
- data/README.md +27 -19
- data/_data/authors.yml +154 -5
- data/_data/backlog.yml +12 -5
- data/_data/content_statistics.yml +273 -297
- data/_data/features.yml +4 -25
- data/_data/navigation/README.md +24 -0
- data/_data/navigation/about.yml +2 -0
- data/_data/navigation/main.yml +2 -7
- data/_data/roadmap.yml +86 -12
- data/_data/roadmap_plan.yml +37 -0
- data/_data/routing.yml +73 -0
- data/_includes/components/author-avatar-url.html +28 -0
- data/_includes/components/author-bio.html +86 -0
- data/_includes/components/author-card.html +184 -121
- data/_includes/components/author-eeat.html +10 -4
- data/_includes/components/info-section.html +1 -1
- data/_includes/components/mermaid.html +0 -3
- data/_includes/components/post-card.html +19 -9
- data/_includes/content/giscus.html +3 -2
- data/_includes/core/footer-fabs.html +28 -0
- data/_includes/core/footer.html +7 -17
- data/_includes/core/head.html +2 -2
- data/_includes/navigation/breadcrumbs.html +20 -2
- data/_includes/navigation/local-graph.html +18 -2
- data/_includes/obsidian/full-graph.html +4 -6
- data/_layouts/article.html +44 -74
- data/_layouts/author.html +274 -0
- data/_layouts/authors.html +55 -0
- data/_layouts/news.html +3 -3
- data/_layouts/note.html +21 -6
- data/_layouts/notebook.html +21 -6
- data/_layouts/root.html +31 -17
- data/_layouts/section.html +3 -3
- data/_plugins/author_pages_generator.rb +121 -0
- data/_sass/components/_author.scss +219 -0
- data/_sass/components/_content-tables.scss +16 -1
- data/_sass/components/_notes-index.scss +102 -0
- data/_sass/components/_search-modal.scss +40 -0
- data/_sass/components/_ui-enhancements.scss +570 -0
- data/_sass/core/_docs-code-examples.scss +463 -0
- data/_sass/core/_docs-layout.scss +0 -453
- data/_sass/core/_navbar.scss +253 -0
- data/_sass/core/_sidebar-extras.scss +79 -0
- data/_sass/core/_toc.scss +87 -0
- data/_sass/core/_variables.scss +7 -142
- data/_sass/custom.scss +24 -1122
- data/_sass/layouts/_global-chrome.scss +59 -0
- data/assets/css/main.scss +19 -2
- data/assets/js/author-profile.js +190 -0
- data/assets/js/modules/navigation/navbar.js +104 -0
- data/assets/js/obsidian-graph.js +2 -2
- data/assets/js/obsidian-local-graph.js +11 -5
- data/assets/vendor/cytoscape/cytoscape.min.js +32 -0
- data/scripts/README.md +39 -0
- data/scripts/bin/validate +11 -1
- data/scripts/dev/css-diff.sh +49 -0
- data/scripts/dev/shot.js +37 -0
- data/scripts/features/generate-preview-images +110 -6
- data/scripts/features/pixelate-preview-images +126 -0
- data/scripts/features/pixelate_images.py +662 -0
- data/scripts/github-setup.sh +0 -0
- data/scripts/lib/preview_generator.py +47 -3
- data/scripts/pixelate-preview-images.sh +12 -0
- data/scripts/sync-backlog.rb +149 -26
- data/scripts/sync-plan.rb +164 -0
- data/scripts/sync-plan.sh +21 -0
- data/scripts/test/integration/auto-version +10 -8
- data/scripts/test/lib/run_tests.sh +2 -0
- data/scripts/test/lib/test_content_review.sh +205 -0
- data/scripts/test/lib/test_pixelate_images.sh +108 -0
- metadata +29 -20
- data/_data/hub.yml +0 -68
- data/_data/hub_index.yml +0 -203
- data/_data/navigation/hub.yml +0 -110
- data/assets/vendor/font-awesome/css/all.min.css +0 -9
- data/assets/vendor/font-awesome/webfonts/fa-brands-400.ttf +0 -0
- data/assets/vendor/font-awesome/webfonts/fa-brands-400.woff2 +0 -0
- data/assets/vendor/font-awesome/webfonts/fa-regular-400.ttf +0 -0
- data/assets/vendor/font-awesome/webfonts/fa-regular-400.woff2 +0 -0
- data/assets/vendor/font-awesome/webfonts/fa-solid-900.ttf +0 -0
- data/assets/vendor/font-awesome/webfonts/fa-solid-900.woff2 +0 -0
- data/assets/vendor/font-awesome/webfonts/fa-v4compatibility.ttf +0 -0
- data/assets/vendor/font-awesome/webfonts/fa-v4compatibility.woff2 +0 -0
- data/assets/vendor/jquery/jquery-3.7.1.min.js +0 -2
- data/scripts/lib/hub.rb +0 -208
- data/scripts/provision-org-sites.rb +0 -252
- data/scripts/provision-org-sites.sh +0 -23
- data/scripts/sync-hub-metadata.rb +0 -184
- data/scripts/sync-hub-metadata.sh +0 -22
|
@@ -466,10 +466,42 @@ class PreviewGenerator:
|
|
|
466
466
|
|
|
467
467
|
# Rate limiter for API calls
|
|
468
468
|
self.rate_limiter = RateLimiter(requests_per_minute=rate_limit)
|
|
469
|
-
|
|
469
|
+
|
|
470
|
+
# Author data (for per-author art-style overrides). Loaded once and read
|
|
471
|
+
# (never mutated) per file, so it is safe to share across worker threads.
|
|
472
|
+
self.authors = self._load_authors()
|
|
473
|
+
|
|
470
474
|
# Ensure output directory exists
|
|
471
475
|
if not dry_run:
|
|
472
476
|
self.output_dir.mkdir(parents=True, exist_ok=True)
|
|
477
|
+
|
|
478
|
+
def _load_authors(self) -> Dict[str, Any]:
|
|
479
|
+
"""Load _data/authors.yml so posts can override the art style per author."""
|
|
480
|
+
authors_file = self.project_root / '_data' / 'authors.yml'
|
|
481
|
+
try:
|
|
482
|
+
data = yaml.safe_load(authors_file.read_text(encoding='utf-8'))
|
|
483
|
+
return data if isinstance(data, dict) else {}
|
|
484
|
+
except Exception:
|
|
485
|
+
return {}
|
|
486
|
+
|
|
487
|
+
def author_preview_overrides(self, author_key: Optional[str]) -> Dict[str, Any]:
|
|
488
|
+
"""Return the `preview:` override block for an author key (or {}).
|
|
489
|
+
|
|
490
|
+
A post whose `author:` references an entry in _data/authors.yml that
|
|
491
|
+
carries a `preview:` block gets that block's settings — they win over the
|
|
492
|
+
site-wide style for that post's banner (e.g. AI personas cassandra/vega).
|
|
493
|
+
"""
|
|
494
|
+
# Jekyll allows `author:` as a string, a list (multi-author), or a mapping
|
|
495
|
+
# (jekyll-seo-tag E-E-A-T). Only a hashable string can index authors.yml;
|
|
496
|
+
# anything else simply has no per-author override (and must not crash the
|
|
497
|
+
# run — keeping this path as resilient as the rest of the file).
|
|
498
|
+
if not isinstance(author_key, str) or not author_key:
|
|
499
|
+
return {}
|
|
500
|
+
author = self.authors.get(author_key)
|
|
501
|
+
if not isinstance(author, dict):
|
|
502
|
+
return {}
|
|
503
|
+
preview = author.get('preview')
|
|
504
|
+
return preview if isinstance(preview, dict) else {}
|
|
473
505
|
|
|
474
506
|
def debug(self, msg: str):
|
|
475
507
|
"""Print debug message if verbose mode is enabled."""
|
|
@@ -584,14 +616,26 @@ class PreviewGenerator:
|
|
|
584
616
|
clean_content = re.sub(r'\n+', ' ', clean_content)
|
|
585
617
|
prompt_parts.append(f"Key themes: {clean_content}")
|
|
586
618
|
|
|
619
|
+
# Per-author art-style override (e.g. AI personas) wins over the
|
|
620
|
+
# configured style for this post's banner. Computed per-call from the
|
|
621
|
+
# document's own front matter, so it is thread-safe under parallel workers.
|
|
622
|
+
effective_style = self.image_style
|
|
623
|
+
style_modifiers = ""
|
|
624
|
+
overrides = self.author_preview_overrides(content.front_matter.get('author'))
|
|
625
|
+
if overrides.get('style'):
|
|
626
|
+
effective_style = str(overrides['style']).strip()
|
|
627
|
+
self.debug(f"Author '{content.front_matter.get('author')}' style override applied")
|
|
628
|
+
if overrides.get('style_modifiers'):
|
|
629
|
+
style_modifiers = f" Additional style: {str(overrides['style_modifiers']).strip()}."
|
|
630
|
+
|
|
587
631
|
# Add style instructions
|
|
588
632
|
prompt_parts.extend([
|
|
589
|
-
f"Style: {
|
|
633
|
+
f"Style: {effective_style}.{style_modifiers}",
|
|
590
634
|
"The image should be suitable as a blog header/preview image.",
|
|
591
635
|
"Clean composition, professional look, visually appealing.",
|
|
592
636
|
"No text or letters in the image.",
|
|
593
637
|
])
|
|
594
|
-
|
|
638
|
+
|
|
595
639
|
return ' '.join(prompt_parts)
|
|
596
640
|
|
|
597
641
|
def generate_filename(self, title: str) -> str:
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
|
|
3
|
+
# ============================================================================
|
|
4
|
+
# WRAPPER: This script forwards to scripts/features/pixelate-preview-images
|
|
5
|
+
#
|
|
6
|
+
# The canonical location is scripts/features/pixelate-preview-images. This
|
|
7
|
+
# wrapper exists for backward compatibility and discoverability alongside the
|
|
8
|
+
# other scripts/*.sh entry points.
|
|
9
|
+
# ============================================================================
|
|
10
|
+
|
|
11
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
12
|
+
exec "$SCRIPT_DIR/features/pixelate-preview-images" "$@"
|
data/scripts/sync-backlog.rb
CHANGED
|
@@ -89,6 +89,7 @@ def validate(data)
|
|
|
89
89
|
return ['Missing or empty `tasks:` list.'] unless tasks.is_a?(Array) && !tasks.empty?
|
|
90
90
|
|
|
91
91
|
seen_ids = {}
|
|
92
|
+
seen_links = {}
|
|
92
93
|
tasks.each_with_index do |task, i|
|
|
93
94
|
where = "tasks[#{i}]"
|
|
94
95
|
unless task.is_a?(Hash)
|
|
@@ -114,6 +115,30 @@ def validate(data)
|
|
|
114
115
|
unless task['acceptance'].is_a?(Array) && !task['acceptance'].empty?
|
|
115
116
|
errors << "#{where}: `acceptance` must be a non-empty list."
|
|
116
117
|
end
|
|
118
|
+
|
|
119
|
+
# Adoption guard: an existing GitHub issue may be claimed by at most one task,
|
|
120
|
+
# so two tasks can never fight over (or duplicate) the same issue.
|
|
121
|
+
lnk = task.dig('links', 'issue')
|
|
122
|
+
if lnk
|
|
123
|
+
unless lnk.is_a?(Integer) || lnk.to_s =~ /\A\d+\z/
|
|
124
|
+
errors << "#{where}: `links.issue` must be an issue number (got #{lnk.inspect})."
|
|
125
|
+
end
|
|
126
|
+
key = lnk.to_s
|
|
127
|
+
if seen_links[key]
|
|
128
|
+
errors << "#{where}: `links.issue` ##{key} already claimed by #{seen_links[key]}."
|
|
129
|
+
else
|
|
130
|
+
seen_links[key] = (id || where)
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
# Optional routing/dependency metadata (consumed by /issue-implement + /issue-plan).
|
|
134
|
+
if task.key?('route') && !task['route'].is_a?(String)
|
|
135
|
+
errors << "#{where}: `route` must be a string."
|
|
136
|
+
end
|
|
137
|
+
if task.key?('depends_on') &&
|
|
138
|
+
!(task['depends_on'].is_a?(Array) &&
|
|
139
|
+
task['depends_on'].all? { |d| d.is_a?(String) && d =~ /\AT-\d{3,}\z/ })
|
|
140
|
+
errors << "#{where}: `depends_on` must be a list of T-NNN ids."
|
|
141
|
+
end
|
|
117
142
|
end
|
|
118
143
|
errors
|
|
119
144
|
end
|
|
@@ -133,6 +158,52 @@ def marker(id)
|
|
|
133
158
|
"<!-- backlog-id: #{id} -->"
|
|
134
159
|
end
|
|
135
160
|
|
|
161
|
+
# Delimiters around the sync-owned section of an ADOPTED human issue. Text ABOVE
|
|
162
|
+
# the start delimiter (the author's original report) is never touched; only this
|
|
163
|
+
# block is upserted on each sync, so adoption is non-destructive.
|
|
164
|
+
MANAGED_START = '<!-- backlog-managed:start -->'
|
|
165
|
+
MANAGED_END = '<!-- backlog-managed:end -->'
|
|
166
|
+
|
|
167
|
+
def render_managed_block(task)
|
|
168
|
+
accept = (task['acceptance'] || []).map { |a| "- [ ] #{a}" }.join("\n")
|
|
169
|
+
roadmap = task.dig('links', 'roadmap')
|
|
170
|
+
meta_row = [
|
|
171
|
+
"**Priority:** #{task['priority']}",
|
|
172
|
+
"**Area:** #{task['area']}",
|
|
173
|
+
"**Risk:** #{task['risk']}",
|
|
174
|
+
"**Effort:** #{task['effort']}",
|
|
175
|
+
"**Source:** #{task['source']}"
|
|
176
|
+
].join(' · ')
|
|
177
|
+
|
|
178
|
+
<<~BLOCK.strip
|
|
179
|
+
#{MANAGED_START}
|
|
180
|
+
#{marker(task['id'])}
|
|
181
|
+
> Tracked from [`_data/backlog.yml`](../blob/main/_data/backlog.yml) by `scripts/sync-backlog.rb`.
|
|
182
|
+
> The report above is the author's; this block is auto-managed — edit the backlog, not here.
|
|
183
|
+
|
|
184
|
+
#{meta_row}#{roadmap ? " · **Roadmap:** v#{roadmap}" : ''}
|
|
185
|
+
|
|
186
|
+
## Acceptance criteria
|
|
187
|
+
|
|
188
|
+
#{accept}
|
|
189
|
+
|
|
190
|
+
Picked up by the IMPLEMENT routine; see [`docs/systems/continuous-evolution.md`](../blob/main/docs/systems/continuous-evolution.md).
|
|
191
|
+
#{MANAGED_END}
|
|
192
|
+
BLOCK
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Upsert the managed block into a (human-authored) issue body, preserving the
|
|
196
|
+
# author's text. Idempotent: replaces an existing block, else appends one.
|
|
197
|
+
def upsert_managed_block(body, task)
|
|
198
|
+
body = body.to_s
|
|
199
|
+
block = render_managed_block(task)
|
|
200
|
+
if body.include?(MANAGED_START) && body.include?(MANAGED_END)
|
|
201
|
+
body.sub(/#{Regexp.escape(MANAGED_START)}.*#{Regexp.escape(MANAGED_END)}/m, block)
|
|
202
|
+
else
|
|
203
|
+
"#{body.rstrip}\n\n#{block}\n"
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
136
207
|
def render_body(task)
|
|
137
208
|
accept = (task['acceptance'] || []).map { |a| "- [ ] #{a}" }.join("\n")
|
|
138
209
|
roadmap = task.dig('links', 'roadmap')
|
|
@@ -202,26 +273,52 @@ def ensure_labels(gh)
|
|
|
202
273
|
VALID_RISK.each { |r| gh.write(['label', 'create', "risk:#{r}", '--color', RISK_COLOR, '--force']) }
|
|
203
274
|
end
|
|
204
275
|
|
|
205
|
-
#
|
|
206
|
-
|
|
276
|
+
# Returns [index, link_state]:
|
|
277
|
+
# index — backlog id -> {number,state,labels,body,marker_id} for every
|
|
278
|
+
# marker-bearing agent-ready issue (the already-managed ones).
|
|
279
|
+
# link_state — issue number -> same record, for each issue referenced by a task
|
|
280
|
+
# `links.issue`. A human issue may carry neither the agent-ready
|
|
281
|
+
# label nor a marker yet, so it is fetched by number for adoption +
|
|
282
|
+
# provenance checks (is it already claimed by a foreign marker?).
|
|
283
|
+
def existing_issues(gh, link_numbers = [])
|
|
284
|
+
index = {}
|
|
285
|
+
link_state = {}
|
|
286
|
+
to_rec = lambda do |issue|
|
|
287
|
+
body = issue['body'].to_s
|
|
288
|
+
rec = {
|
|
289
|
+
'number' => issue['number'],
|
|
290
|
+
'state' => issue['state'].to_s.downcase,
|
|
291
|
+
'labels' => (issue['labels'] || []).map { |l| l['name'] },
|
|
292
|
+
'body' => body,
|
|
293
|
+
'marker_id' => body[/<!-- backlog-id: (T-\d+) -->/, 1]
|
|
294
|
+
}
|
|
295
|
+
index[rec['marker_id']] = rec if rec['marker_id']
|
|
296
|
+
rec
|
|
297
|
+
end
|
|
298
|
+
|
|
207
299
|
raw = gh.read(
|
|
208
300
|
['issue', 'list', '--label', 'agent-ready', '--state', 'all', '--limit', '500',
|
|
209
301
|
'--json', 'number,body,state,labels'],
|
|
210
302
|
default: '[]'
|
|
211
303
|
)
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
304
|
+
begin
|
|
305
|
+
JSON.parse(raw).each { |issue| to_rec.call(issue) }
|
|
306
|
+
rescue JSON::ParserError
|
|
307
|
+
# leave index empty; adoption/create still work off link_state
|
|
308
|
+
end
|
|
215
309
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
310
|
+
link_numbers.compact.map(&:to_i).uniq.each do |n|
|
|
311
|
+
raw1 = gh.read(['issue', 'view', n.to_s, '--json', 'number,body,state,labels'], default: '')
|
|
312
|
+
next if raw1.strip.empty?
|
|
313
|
+
|
|
314
|
+
begin
|
|
315
|
+
link_state[n] = to_rec.call(JSON.parse(raw1))
|
|
316
|
+
rescue JSON::ParserError
|
|
317
|
+
next
|
|
318
|
+
end
|
|
221
319
|
end
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
{}
|
|
320
|
+
|
|
321
|
+
[index, link_state]
|
|
225
322
|
end
|
|
226
323
|
|
|
227
324
|
# ---------------------------------------------------------------------------
|
|
@@ -241,29 +338,55 @@ end
|
|
|
241
338
|
|
|
242
339
|
def sync(data, gh)
|
|
243
340
|
ensure_labels(gh)
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
341
|
+
tasks = data['tasks'] || []
|
|
342
|
+
link_numbers = tasks.map { |t| t.dig('links', 'issue') }.compact
|
|
343
|
+
index, link_state = existing_issues(gh, link_numbers)
|
|
344
|
+
created = adopted = updated = closed = 0
|
|
345
|
+
|
|
346
|
+
tasks.each do |task|
|
|
347
|
+
id = task['id']
|
|
348
|
+
title = task['title']
|
|
251
349
|
want_open = OPEN_STATUSES.include?(task['status'])
|
|
252
|
-
|
|
350
|
+
lnk = task.dig('links', 'issue')
|
|
351
|
+
issue = index[id] # already marker-tracked?
|
|
352
|
+
|
|
353
|
+
# Adoption: task points at an existing issue not yet marker-tracked. Adopt it
|
|
354
|
+
# (inject the managed block) instead of creating a duplicate — unless the
|
|
355
|
+
# issue already carries a DIFFERENT marker (foreign provenance → refuse).
|
|
356
|
+
if issue.nil? && lnk
|
|
357
|
+
ls = link_state[lnk.to_i]
|
|
358
|
+
if ls.nil?
|
|
359
|
+
warn "task #{id}: links.issue ##{lnk} not found — creating a new issue instead."
|
|
360
|
+
elsif ls['marker_id'] && ls['marker_id'] != id
|
|
361
|
+
warn "task #{id}: refusing to adopt ##{lnk} — already marked #{ls['marker_id']} (foreign). Skipping."
|
|
362
|
+
next
|
|
363
|
+
else
|
|
364
|
+
issue = ls.merge('adopt' => true)
|
|
365
|
+
end
|
|
366
|
+
end
|
|
253
367
|
|
|
254
368
|
if issue.nil?
|
|
255
369
|
next unless want_open # never create an issue for an already-done task
|
|
256
370
|
|
|
257
|
-
args = ['issue', 'create', '--title', title, '--body',
|
|
371
|
+
args = ['issue', 'create', '--title', title, '--body', render_body(task)]
|
|
258
372
|
managed_labels(task).each { |l| args.push('--label', l) }
|
|
259
373
|
created += 1 if gh.write(args)
|
|
260
374
|
next
|
|
261
375
|
end
|
|
262
376
|
|
|
263
377
|
number = issue['number'].to_s
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
378
|
+
if lnk
|
|
379
|
+
# Human-authored (adopted) issue: NEVER overwrite its title/body — only
|
|
380
|
+
# upsert the managed block (preserving the author's report) + labels.
|
|
381
|
+
gh.write(['issue', 'edit', number, '--body', upsert_managed_block(issue['body'], task)] +
|
|
382
|
+
label_args(managed_labels(task), issue['labels']))
|
|
383
|
+
issue['adopt'] ? (adopted += 1) : (updated += 1)
|
|
384
|
+
else
|
|
385
|
+
# Bot-created issue: fully sync-owned title + body.
|
|
386
|
+
gh.write(['issue', 'edit', number, '--title', title, '--body', render_body(task)] +
|
|
387
|
+
label_args(managed_labels(task), issue['labels']))
|
|
388
|
+
updated += 1
|
|
389
|
+
end
|
|
267
390
|
|
|
268
391
|
if want_open && issue['state'] != 'open'
|
|
269
392
|
gh.write(['issue', 'reopen', number])
|
|
@@ -273,7 +396,7 @@ def sync(data, gh)
|
|
|
273
396
|
end
|
|
274
397
|
end
|
|
275
398
|
|
|
276
|
-
puts "Backlog sync complete: #{created} created, #{updated} updated, #{closed} closed."
|
|
399
|
+
puts "Backlog sync complete: #{created} created, #{adopted} adopted, #{updated} updated, #{closed} closed."
|
|
277
400
|
0
|
|
278
401
|
end
|
|
279
402
|
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# =============================================================================
|
|
5
|
+
# sync-plan.rb
|
|
6
|
+
# =============================================================================
|
|
7
|
+
#
|
|
8
|
+
# Validates (and optionally mirrors) `_data/roadmap_plan.yml` — the ORDER-ONLY
|
|
9
|
+
# plan artifact produced by the /issue-plan committee. The plan sequences open
|
|
10
|
+
# backlog tasks into batches; it must NEVER re-encode anything the backlog owns
|
|
11
|
+
# (risk / priority / area / status / effort), which would create a second source
|
|
12
|
+
# of truth that drifts.
|
|
13
|
+
#
|
|
14
|
+
# ruby scripts/sync-plan.rb --check # validate the plan vs the backlog (CI/PR gate; stdlib only)
|
|
15
|
+
# ruby scripts/sync-plan.rb --dry-run # print the intended pinned-issue upsert
|
|
16
|
+
# ruby scripts/sync-plan.rb # upsert one pinned tracking issue via `gh`
|
|
17
|
+
#
|
|
18
|
+
# --check asserts:
|
|
19
|
+
# * every batch task id exists in _data/backlog.yml and is OPEN (not done),
|
|
20
|
+
# * batch `depends_on` references existing batch ids and the batch DAG is acyclic,
|
|
21
|
+
# * no plan entry carries a backlog-owned field (order-only invariant).
|
|
22
|
+
# =============================================================================
|
|
23
|
+
|
|
24
|
+
require 'yaml'
|
|
25
|
+
require 'date'
|
|
26
|
+
require 'json'
|
|
27
|
+
require 'optparse'
|
|
28
|
+
require 'open3'
|
|
29
|
+
|
|
30
|
+
ROOT = File.expand_path('..', __dir__)
|
|
31
|
+
PLAN_FILE = File.join(ROOT, '_data', 'roadmap_plan.yml')
|
|
32
|
+
BACKLOG = File.join(ROOT, '_data', 'backlog.yml')
|
|
33
|
+
|
|
34
|
+
PIN_MARKER = '<!-- roadmap-plan:pinned -->'
|
|
35
|
+
BACKLOG_OWNED = %w[risk priority area status effort source acceptance title].freeze
|
|
36
|
+
OPEN_TASK_STATUS = %w[open in-progress blocked].freeze
|
|
37
|
+
|
|
38
|
+
def load_yaml(path)
|
|
39
|
+
YAML.load_file(path, permitted_classes: [Date, Time])
|
|
40
|
+
rescue ArgumentError
|
|
41
|
+
YAML.safe_load(File.read(path, encoding: 'UTF-8'), permitted_classes: [Date, Time], aliases: false)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def backlog_status
|
|
45
|
+
tasks = (load_yaml(BACKLOG)['tasks'] || [])
|
|
46
|
+
tasks.each_with_object({}) { |t, h| h[t['id']] = t['status'] }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def validate(plan, statuses)
|
|
50
|
+
errors = []
|
|
51
|
+
unless plan.is_a?(Hash) && plan['batches'].is_a?(Array)
|
|
52
|
+
return ['plan must have a `batches:` list.']
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
batch_ids = plan['batches'].map { |b| b['id'] }.compact
|
|
56
|
+
seen = {}
|
|
57
|
+
batch_ids.each { |bid| seen[bid] ? (errors << "duplicate batch id #{bid}.") : (seen[bid] = true) }
|
|
58
|
+
|
|
59
|
+
plan['batches'].each_with_index do |batch, i|
|
|
60
|
+
where = batch['id'] ? "batch #{batch['id']}" : "batches[#{i}]"
|
|
61
|
+
errors << "#{where}: missing `id`." if batch['id'].to_s.empty?
|
|
62
|
+
|
|
63
|
+
# Order-only invariant: a batch (or its task entries) may not carry a field
|
|
64
|
+
# the backlog owns. Tasks must be bare id strings.
|
|
65
|
+
(BACKLOG_OWNED & batch.keys).each { |k| errors << "#{where}: must not carry backlog-owned field `#{k}`." }
|
|
66
|
+
Array(batch['tasks']).each do |t|
|
|
67
|
+
unless t.is_a?(String) && t =~ /\AT-\d{3,}\z/
|
|
68
|
+
errors << "#{where}: task entries must be bare T-NNN ids (got #{t.inspect})."
|
|
69
|
+
next
|
|
70
|
+
end
|
|
71
|
+
if !statuses.key?(t)
|
|
72
|
+
errors << "#{where}: task #{t} is not in the backlog."
|
|
73
|
+
elsif !OPEN_TASK_STATUS.include?(statuses[t])
|
|
74
|
+
errors << "#{where}: task #{t} is #{statuses[t]} (only open tasks may be planned)."
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
Array(batch['depends_on']).each do |dep|
|
|
79
|
+
errors << "#{where}: depends_on references unknown batch #{dep}." unless seen[dep]
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
errors.concat(cycle_errors(plan['batches']))
|
|
84
|
+
errors
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Kahn's algorithm over the batch DAG; any leftover nodes => a cycle.
|
|
88
|
+
def cycle_errors(batches)
|
|
89
|
+
deps = {}
|
|
90
|
+
batches.each { |b| deps[b['id']] = Array(b['depends_on']).dup }
|
|
91
|
+
indeg = Hash.new(0)
|
|
92
|
+
deps.each { |_n, ds| ds.each { |d| indeg[d] += 1 if deps.key?(d) } }
|
|
93
|
+
# Edge dep -> node (dep must precede node); compute order by repeatedly removing
|
|
94
|
+
# nodes whose deps are all satisfied.
|
|
95
|
+
remaining = deps.keys
|
|
96
|
+
progress = true
|
|
97
|
+
while progress
|
|
98
|
+
progress = false
|
|
99
|
+
ready = remaining.select { |n| deps[n].all? { |d| !remaining.include?(d) } }
|
|
100
|
+
unless ready.empty?
|
|
101
|
+
remaining -= ready
|
|
102
|
+
progress = true
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
remaining.empty? ? [] : ["batch dependency cycle among: #{remaining.sort.join(', ')}."]
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def pinned_body(plan)
|
|
109
|
+
lines = ["#{PIN_MARKER}", '> Auto-managed by `scripts/sync-plan.rb` from `_data/roadmap_plan.yml`.',
|
|
110
|
+
'> Order only — risk/priority/area come from `_data/backlog.yml`.', '']
|
|
111
|
+
plan['batches'].each do |b|
|
|
112
|
+
lines << "### #{b['id']} — #{b['goal']}"
|
|
113
|
+
lines << "Tasks: #{Array(b['tasks']).join(', ')}"
|
|
114
|
+
lines << "Depends on: #{Array(b['depends_on']).join(', ')}" unless Array(b['depends_on']).empty?
|
|
115
|
+
lines << "Test framework: #{b['test_framework']}" if b['test_framework']
|
|
116
|
+
lines << ''
|
|
117
|
+
end
|
|
118
|
+
lines.join("\n").strip
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def upsert_pinned(plan, dry_run:)
|
|
122
|
+
title = '📋 Roadmap plan (auto-managed)'
|
|
123
|
+
body = pinned_body(plan)
|
|
124
|
+
found = `gh issue list --search #{PIN_MARKER.inspect}\\ in:body --state open --json number --jq '.[0].number' 2>/dev/null`.strip
|
|
125
|
+
args =
|
|
126
|
+
if found.empty?
|
|
127
|
+
['issue', 'create', '--title', title, '--body', body, '--label', 'agent-hold']
|
|
128
|
+
else
|
|
129
|
+
['issue', 'edit', found, '--title', title, '--body', body]
|
|
130
|
+
end
|
|
131
|
+
if dry_run
|
|
132
|
+
puts "DRY-RUN gh #{args.join(' ')}"
|
|
133
|
+
return
|
|
134
|
+
end
|
|
135
|
+
_o, err, st = Open3.capture3('gh', *args)
|
|
136
|
+
warn "gh failed: #{err}" unless st.success?
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def main
|
|
140
|
+
mode = :sync
|
|
141
|
+
OptionParser.new do |o|
|
|
142
|
+
o.on('--check') { mode = :check }
|
|
143
|
+
o.on('--dry-run') { mode = :dry_run }
|
|
144
|
+
end.parse!
|
|
145
|
+
|
|
146
|
+
return 0 unless File.exist?(PLAN_FILE) # no plan yet => nothing to validate
|
|
147
|
+
|
|
148
|
+
plan = load_yaml(PLAN_FILE)
|
|
149
|
+
errors = validate(plan, backlog_status)
|
|
150
|
+
unless errors.empty?
|
|
151
|
+
warn '✗ _data/roadmap_plan.yml failed validation:'
|
|
152
|
+
errors.each { |e| warn " - #{e}" }
|
|
153
|
+
return 1
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
if mode == :check
|
|
157
|
+
puts "✓ _data/roadmap_plan.yml is valid (#{plan['batches'].size} batches)."
|
|
158
|
+
return 0
|
|
159
|
+
end
|
|
160
|
+
upsert_pinned(plan, dry_run: mode == :dry_run)
|
|
161
|
+
0
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
exit main if $PROGRAM_NAME == __FILE__
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# =============================================================================
|
|
3
|
+
# sync-plan.sh
|
|
4
|
+
# =============================================================================
|
|
5
|
+
#
|
|
6
|
+
# Thin wrapper around scripts/sync-plan.rb.
|
|
7
|
+
#
|
|
8
|
+
# Validates (and optionally mirrors) `_data/roadmap_plan.yml` — the order-only
|
|
9
|
+
# plan artifact produced by the /issue-plan committee — against the backlog.
|
|
10
|
+
#
|
|
11
|
+
# Usage:
|
|
12
|
+
# ./scripts/sync-plan.sh # upsert the pinned tracking issue via gh
|
|
13
|
+
# ./scripts/sync-plan.sh --check # validate plan vs backlog (CI/PR gate)
|
|
14
|
+
# ./scripts/sync-plan.sh --dry-run # print the intended gh call only
|
|
15
|
+
#
|
|
16
|
+
# =============================================================================
|
|
17
|
+
|
|
18
|
+
set -euo pipefail
|
|
19
|
+
|
|
20
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
21
|
+
exec ruby "${SCRIPT_DIR}/sync-plan.rb" "$@"
|
|
@@ -21,12 +21,14 @@ log_warning() { warn "$@"; }
|
|
|
21
21
|
log_error() { error "$@"; }
|
|
22
22
|
log_test() { step "$@"; }
|
|
23
23
|
|
|
24
|
-
# Paths under test (
|
|
25
|
-
#
|
|
24
|
+
# Paths under test (commit analysis lives in scripts/utils/ with the
|
|
25
|
+
# analyze-commits.sh wrapper; releases go through scripts/bin/release). The CI
|
|
26
|
+
# release path is the release-please pipeline in .github/workflows/release.yml,
|
|
27
|
+
# which replaced the retired version-bump.yml.
|
|
26
28
|
ANALYZE_WRAPPER="$SCRIPTS_ROOT/analyze-commits.sh"
|
|
27
29
|
ANALYZE_IMPL="$SCRIPTS_ROOT/utils/analyze-commits"
|
|
28
30
|
RELEASE_BIN="$SCRIPTS_ROOT/bin/release"
|
|
29
|
-
WORKFLOW_FILE="$PROJECT_ROOT/.github/workflows/
|
|
31
|
+
WORKFLOW_FILE="$PROJECT_ROOT/.github/workflows/release.yml"
|
|
30
32
|
|
|
31
33
|
# Test counter
|
|
32
34
|
TESTS_RUN=0
|
|
@@ -114,14 +116,14 @@ test_release_command() {
|
|
|
114
116
|
test_workflow_syntax() {
|
|
115
117
|
log_info "Testing GitHub Actions workflow syntax..."
|
|
116
118
|
|
|
117
|
-
run_test "
|
|
119
|
+
run_test "release workflow exists" "test -f '$WORKFLOW_FILE'"
|
|
118
120
|
|
|
119
121
|
# Check if yamllint is available
|
|
120
122
|
if command -v yamllint >/dev/null 2>&1; then
|
|
121
|
-
run_test "
|
|
123
|
+
run_test "release workflow syntax" "yamllint -c '$PROJECT_ROOT/.github/config/.yamllint.yml' '$WORKFLOW_FILE'"
|
|
122
124
|
else
|
|
123
125
|
# Basic YAML syntax check with Ruby (always present for this repo)
|
|
124
|
-
run_test "
|
|
126
|
+
run_test "release workflow syntax" "ruby -ryaml -e 'YAML.safe_load_file(\"$WORKFLOW_FILE\", aliases: true)'"
|
|
125
127
|
fi
|
|
126
128
|
}
|
|
127
129
|
|
|
@@ -129,7 +131,7 @@ test_workflow_syntax() {
|
|
|
129
131
|
test_integration() {
|
|
130
132
|
log_info "Testing integration between components..."
|
|
131
133
|
|
|
132
|
-
#
|
|
134
|
+
# Validate that the commit-analysis wrapper returns a usable bump type
|
|
133
135
|
if git rev-list --count HEAD >/dev/null 2>&1; then
|
|
134
136
|
local commit_count=$(git rev-list --count HEAD)
|
|
135
137
|
if [[ $commit_count -gt 0 ]]; then
|
|
@@ -195,7 +197,7 @@ DESCRIPTION:
|
|
|
195
197
|
- Commit analysis functionality (scripts/analyze-commits.sh wrapper
|
|
196
198
|
and scripts/utils/analyze-commits implementation)
|
|
197
199
|
- Release command compatibility (scripts/bin/release)
|
|
198
|
-
- Workflow file syntax validation (.github/workflows/
|
|
200
|
+
- Workflow file syntax validation (.github/workflows/release.yml)
|
|
199
201
|
- Integration between components
|
|
200
202
|
- Conventional commit detection
|
|
201
203
|
|
|
@@ -129,6 +129,8 @@ main() {
|
|
|
129
129
|
source "$TEST_DIR/test_analyze_commits.sh"
|
|
130
130
|
source "$TEST_DIR/test_locale_independence.sh"
|
|
131
131
|
source "$TEST_DIR/test_migrate.sh"
|
|
132
|
+
source "$TEST_DIR/test_pixelate_images.sh"
|
|
133
|
+
source "$TEST_DIR/test_content_review.sh"
|
|
132
134
|
|
|
133
135
|
# Summary
|
|
134
136
|
echo -e "\n${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|