1#!/usr/bin/env bash
2#
3# Create a draft documentation PR by auto-applying batched suggestions.
4#
5# Usage:
6# script/docs-suggest-publish [--dry-run] [--model MODEL]
7#
8# This script:
9# 1. Reads pending suggestions from the docs/suggestions-pending branch
10# 2. Uses Droid to apply suggestions in batches (default 10 per batch)
11# 3. Runs docs formatting
12# 4. Validates docs build (action references, JSON schemas, links)
13# 5. Creates a draft PR for human review/merge
14# 6. Optionally resets the suggestions branch after successful PR creation
15#
16# Options:
17# --dry-run Show what would be done without creating PR
18# --keep-queue Don't reset the suggestions branch after PR creation
19# --model MODEL Override Droid model used for auto-apply
20# --batch-size N Suggestions per Droid invocation (default: 10)
21# --skip-validation Skip the docs build validation step
22# --verbose Show detailed progress
23#
24# Run this as part of the preview release workflow.
25
26set -euo pipefail
27
28DRY_RUN=false
29KEEP_QUEUE=false
30VERBOSE=false
31SKIP_VALIDATION=false
32BATCH_SIZE=10
33MODEL="${DROID_MODEL:-claude-sonnet-4-5-latest}"
34
35SUGGESTIONS_BRANCH="docs/suggestions-pending"
36
37# Colors
38RED='\033[0;31m'
39GREEN='\033[0;32m'
40YELLOW='\033[0;33m'
41BLUE='\033[0;34m'
42NC='\033[0m'
43
44log() {
45 if [[ "$VERBOSE" == "true" ]]; then
46 echo -e "${BLUE}[docs-publish]${NC} $*" >&2
47 fi
48}
49
50error() {
51 echo -e "${RED}Error:${NC} $*" >&2
52 exit 1
53}
54
55# Parse arguments
56while [[ $# -gt 0 ]]; do
57 case $1 in
58 --dry-run)
59 DRY_RUN=true
60 shift
61 ;;
62 --keep-queue)
63 KEEP_QUEUE=true
64 shift
65 ;;
66 --verbose)
67 VERBOSE=true
68 shift
69 ;;
70 --model)
71 MODEL="$2"
72 shift 2
73 ;;
74 --batch-size)
75 BATCH_SIZE="$2"
76 shift 2
77 ;;
78 --skip-validation)
79 SKIP_VALIDATION=true
80 shift
81 ;;
82 -h|--help)
83 head -30 "$0" | tail -28
84 exit 0
85 ;;
86 *)
87 error "Unknown option: $1"
88 ;;
89 esac
90done
91
92# Get repo root
93REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
94cd "$REPO_ROOT"
95
96# Check if suggestions branch exists
97log "Checking for suggestions branch..."
98if ! git ls-remote --exit-code --heads origin "$SUGGESTIONS_BRANCH" > /dev/null 2>&1; then
99 echo "No pending suggestions found (branch $SUGGESTIONS_BRANCH doesn't exist)."
100 echo "Suggestions are queued automatically when PRs are merged to main."
101 exit 0
102fi
103
104# Fetch the suggestions branch
105log "Fetching suggestions branch..."
106git fetch origin "$SUGGESTIONS_BRANCH"
107
108# Check for manifest
109if ! git show "origin/$SUGGESTIONS_BRANCH:manifest.json" > /dev/null 2>&1; then
110 echo "No manifest found on suggestions branch."
111 exit 0
112fi
113
114# Read manifest
115MANIFEST=$(git show "origin/$SUGGESTIONS_BRANCH:manifest.json")
116SUGGESTION_COUNT=$(echo "$MANIFEST" | jq '.suggestions | length')
117
118if [[ "$SUGGESTION_COUNT" -eq 0 ]]; then
119 echo "No pending suggestions in queue."
120 exit 0
121fi
122
123echo "Found $SUGGESTION_COUNT pending suggestion(s):"
124echo ""
125echo "$MANIFEST" | jq -r '.suggestions[] | " PR #\(.pr): \(.title)"'
126echo ""
127
128if [[ "$DRY_RUN" == "true" ]]; then
129 echo -e "${YELLOW}=== DRY RUN ===${NC}"
130 echo ""
131 echo "Would auto-apply suggestions to docs via Droid and create a draft PR."
132 echo "Model: $MODEL"
133 echo ""
134
135 # Show each suggestion file
136 for file in $(echo "$MANIFEST" | jq -r '.suggestions[].file'); do
137 echo "--- $file ---"
138 git show "origin/$SUGGESTIONS_BRANCH:$file" 2>/dev/null || echo "(file not found)"
139 echo ""
140 done
141
142 echo -e "${YELLOW}=== END DRY RUN ===${NC}"
143 echo ""
144 echo "Run without --dry-run to create the PR."
145 exit 0
146fi
147
148# Ensure clean working state (ignore untracked files with grep -v '??')
149if [[ -n "$(git status --porcelain | grep -v '^??' || true)" ]]; then
150 error "Working directory has uncommitted changes. Please commit or stash first."
151fi
152
153REQUIRED_COMMANDS=(git gh jq droid)
154if [[ "$SKIP_VALIDATION" != "true" ]]; then
155 REQUIRED_COMMANDS+=(mdbook)
156fi
157for command in "${REQUIRED_COMMANDS[@]}"; do
158 if ! command -v "$command" > /dev/null 2>&1; then
159 error "Required command not found: $command"
160 fi
161done
162
163# Remember current branch
164ORIGINAL_BRANCH=$(git branch --show-current)
165log "Current branch: $ORIGINAL_BRANCH"
166
167# Create new branch for docs PR from latest main
168git fetch origin main
169DOCS_BRANCH="docs/preview-auto-$(date +%Y-%m-%d-%H%M%S)"
170log "Creating docs branch: $DOCS_BRANCH"
171
172git checkout -b "$DOCS_BRANCH" origin/main
173
174TMPDIR=$(mktemp -d)
175trap 'rm -rf "$TMPDIR"' EXIT
176
177APPLY_SUMMARY_FILE="$TMPDIR/apply-summary.md"
178touch "$APPLY_SUMMARY_FILE"
179
180# Collect suggestion files into an array
181SUGGESTION_FILES=()
182while IFS= read -r file; do
183 SUGGESTION_FILES+=("$file")
184done < <(echo "$MANIFEST" | jq -r '.suggestions[].file')
185
186# Determine which PRs are already in the latest stable release.
187# Suggestions queued with --preview may reference features that shipped in stable
188# by the time this script runs, so their Preview callouts should be stripped.
189STABLE_PRS=()
190STABLE_TAG=$(git tag -l 'v*' --sort=-v:refname | grep -v 'pre' | head -1 || true)
191if [[ -n "$STABLE_TAG" ]]; then
192 log "Latest stable release tag: $STABLE_TAG"
193 for file in "${SUGGESTION_FILES[@]}"; do
194 pr_num=$(echo "$MANIFEST" | jq -r --arg f "$file" '.suggestions[] | select(.file == $f) | .pr')
195 # Find the merge commit for this PR
196 merge_sha=$(gh pr view "$pr_num" --json mergeCommit --jq '.mergeCommit.oid' 2>/dev/null || true)
197 if [[ -n "$merge_sha" ]] && git merge-base --is-ancestor "$merge_sha" "$STABLE_TAG" 2>/dev/null; then
198 STABLE_PRS+=("$pr_num")
199 log "PR #$pr_num is in stable ($STABLE_TAG)"
200 fi
201 done
202 if [[ ${#STABLE_PRS[@]} -gt 0 ]]; then
203 echo -e "${YELLOW}Note:${NC} ${#STABLE_PRS[@]} suggestion(s) are for PRs already in stable ($STABLE_TAG)."
204 echo " Preview callouts will be stripped for: ${STABLE_PRS[*]}"
205 echo ""
206 fi
207else
208 log "No stable release tag found, treating all suggestions as preview-only"
209fi
210
211# Determine which PRs touch code gated behind feature flags.
212# Features behind flags aren't generally available and shouldn't be documented yet.
213FLAGGED_PRS=()
214FLAGS_FILE="$REPO_ROOT/crates/feature_flags/src/flags.rs"
215if [[ -f "$FLAGS_FILE" ]]; then
216 # Extract feature flag struct names (e.g. SubagentsFeatureFlag, GitGraphFeatureFlag)
217 FLAG_NAMES=$(grep -oE 'pub struct \w+FeatureFlag' "$FLAGS_FILE" | awk '{print $3}')
218 if [[ -n "$FLAG_NAMES" ]]; then
219 FLAG_PATTERN=$(echo "$FLAG_NAMES" | tr '\n' '|' | sed 's/|$//')
220 log "Feature flags found: $(echo "$FLAG_NAMES" | tr '\n' ' ')"
221 for file in "${SUGGESTION_FILES[@]}"; do
222 pr_num=$(echo "$MANIFEST" | jq -r --arg f "$file" '.suggestions[] | select(.file == $f) | .pr')
223 # Skip PRs already marked as stable (no need to double-check)
224 is_already_stable=false
225 for stable_pr in "${STABLE_PRS[@]+"${STABLE_PRS[@]}"}"; do
226 if [[ "$stable_pr" == "$pr_num" ]]; then
227 is_already_stable=true
228 break
229 fi
230 done
231 if [[ "$is_already_stable" == "true" ]]; then
232 continue
233 fi
234 # Check if the PR diff references any feature flag
235 pr_diff=$(gh pr diff "$pr_num" 2>/dev/null || true)
236 if [[ -n "$pr_diff" ]] && echo "$pr_diff" | grep -qE "$FLAG_PATTERN"; then
237 matched_flags=$(echo "$pr_diff" | grep -oE "$FLAG_PATTERN" | sort -u | tr '\n' ', ' | sed 's/,$//')
238 FLAGGED_PRS+=("$pr_num")
239 log "PR #$pr_num is behind feature flag(s): $matched_flags"
240 fi
241 done
242 if [[ ${#FLAGGED_PRS[@]} -gt 0 ]]; then
243 echo -e "${YELLOW}Note:${NC} ${#FLAGGED_PRS[@]} suggestion(s) are for features behind feature flags."
244 echo " These will be skipped: ${FLAGGED_PRS[*]}"
245 echo ""
246 fi
247 fi
248else
249 log "Feature flags file not found, skipping flag detection"
250fi
251
252# Split into batches
253TOTAL=${#SUGGESTION_FILES[@]}
254BATCH_COUNT=$(( (TOTAL + BATCH_SIZE - 1) / BATCH_SIZE ))
255
256if [[ "$BATCH_COUNT" -gt 1 ]]; then
257 echo "Processing $TOTAL suggestions in $BATCH_COUNT batches of up to $BATCH_SIZE..."
258else
259 echo "Processing $TOTAL suggestions..."
260fi
261echo ""
262
263for (( batch=0; batch<BATCH_COUNT; batch++ )); do
264 START=$(( batch * BATCH_SIZE ))
265 END=$(( START + BATCH_SIZE ))
266 if [[ "$END" -gt "$TOTAL" ]]; then
267 END=$TOTAL
268 fi
269
270 BATCH_NUM=$(( batch + 1 ))
271 BATCH_SUGGESTIONS_FILE="$TMPDIR/batch-${BATCH_NUM}-suggestions.md"
272 BATCH_PROMPT_FILE="$TMPDIR/batch-${BATCH_NUM}-prompt.md"
273 BATCH_SUMMARY_FILE="$TMPDIR/batch-${BATCH_NUM}-summary.md"
274
275 echo -e "${BLUE}Batch $BATCH_NUM/$BATCH_COUNT${NC} (suggestions $(( START + 1 ))-$END of $TOTAL)"
276
277 # Combine suggestion files for this batch, skipping flagged PRs and annotating stable PRs
278 BATCH_HAS_SUGGESTIONS=false
279 for (( i=START; i<END; i++ )); do
280 file="${SUGGESTION_FILES[$i]}"
281 pr_num=$(echo "$MANIFEST" | jq -r --arg f "$file" '.suggestions[] | select(.file == $f) | .pr')
282
283 # Skip PRs behind feature flags entirely
284 is_flagged=false
285 for flagged_pr in "${FLAGGED_PRS[@]+"${FLAGGED_PRS[@]}"}"; do
286 if [[ "$flagged_pr" == "$pr_num" ]]; then
287 is_flagged=true
288 break
289 fi
290 done
291 if [[ "$is_flagged" == "true" ]]; then
292 log "Skipping PR #$pr_num (behind feature flag)"
293 {
294 echo "### Skipped: PR #$pr_num"
295 echo ""
296 echo "This PR is behind a feature flag and was not applied."
297 echo ""
298 } >> "$APPLY_SUMMARY_FILE"
299 continue
300 fi
301
302 BATCH_HAS_SUGGESTIONS=true
303
304 # Check if PR is already in stable
305 is_stable=false
306 for stable_pr in "${STABLE_PRS[@]+"${STABLE_PRS[@]}"}"; do
307 if [[ "$stable_pr" == "$pr_num" ]]; then
308 is_stable=true
309 break
310 fi
311 done
312 {
313 echo "## Source: $file"
314 if [[ "$is_stable" == "true" ]]; then
315 echo ""
316 echo "> **ALREADY IN STABLE**: PR #$pr_num shipped in $STABLE_TAG."
317 echo "> Do NOT add Preview or Changed-in-Preview callouts for this suggestion."
318 echo "> Apply the documentation content only, without any preview-related callouts."
319 fi
320 echo ""
321 git show "origin/$SUGGESTIONS_BRANCH:$file" 2>/dev/null || error "Suggestion file missing: $file"
322 echo ""
323 echo "---"
324 echo ""
325 } >> "$BATCH_SUGGESTIONS_FILE"
326 done
327
328 # Skip this batch if all its suggestions were flagged
329 if [[ "$BATCH_HAS_SUGGESTIONS" == "false" ]]; then
330 echo -e " ${YELLOW}Batch $BATCH_NUM skipped (all suggestions behind feature flags)${NC}"
331 continue
332 fi
333
334 # Build auto-apply prompt for this batch
335 cat > "$BATCH_PROMPT_FILE" << 'EOF'
336# Documentation Auto-Apply Request (Preview Release)
337
338Apply all queued documentation suggestions below directly to docs files in this repository.
339
340Before making edits, read and follow these rule files:
341- `.rules`
342- `docs/.rules`
343
344## Required behavior
345
3461. Apply concrete documentation edits (not suggestion text) to the appropriate files.
3472. Edit only docs content files under `docs/src/` unless a suggestion explicitly requires another docs path.
3483. For every docs file you modify, run a full-file brand voice pass (entire file, not only edited sections).
3494. Enforce the brand rubric exactly; final file content must score 4+ on every criterion:
350 - Technical Grounding
351 - Natural Syntax
352 - Quiet Confidence
353 - Developer Respect
354 - Information Priority
355 - Specificity
356 - Voice Consistency
357 - Earned Claims
3585. Keep SEO/frontmatter/linking requirements from the suggestions where applicable.
3596. Keep preview callout semantics correct:
360 - Additive features: `> **Preview:** ...`
361 - Behavior modifications: `> **Changed in Preview (vX.XXX).** ...`
362 - **Exception**: Suggestions marked "ALREADY IN STABLE" must NOT get any preview callouts.
363 These features already shipped in a stable release. Apply the content changes only.
364 - Suggestions for features behind feature flags have been pre-filtered and excluded.
365 If you encounter references to feature-flagged functionality, do not document it.
3667. If a suggestion is too ambiguous to apply safely, skip it and explain why in the summary.
3678. **Do not invent `{#kb}` or `{#action}` references.** Only use action names that already
368 appear in the existing docs files you are editing. If unsure whether an action name is
369 valid, use plain text instead. The docs build validates all action references against
370 the compiled binary and will reject unknown names.
371
372## Output format (after making edits)
373
374Return markdown with:
375
376- `## Applied Suggestions`
377- `## Skipped Suggestions`
378- `## Files Updated`
379- `## Brand Voice Verification` (one line per updated file confirming full-file pass)
380
381Do not include a patch in the response; apply edits directly to files.
382
383## Queued Suggestions
384
385EOF
386
387 cat "$BATCH_SUGGESTIONS_FILE" >> "$BATCH_PROMPT_FILE"
388
389 log "Running Droid auto-apply (batch $BATCH_NUM) with model: $MODEL"
390 if ! droid exec -m "$MODEL" -f "$BATCH_PROMPT_FILE" --auto high > "$BATCH_SUMMARY_FILE" 2>&1; then
391 echo "Droid exec output (batch $BATCH_NUM):"
392 cat "$BATCH_SUMMARY_FILE"
393 error "Droid exec failed on batch $BATCH_NUM. See output above."
394 fi
395
396 # Append batch summary
397 {
398 echo "### Batch $BATCH_NUM"
399 echo ""
400 cat "$BATCH_SUMMARY_FILE"
401 echo ""
402 } >> "$APPLY_SUMMARY_FILE"
403
404 echo -e " ${GREEN}Batch $BATCH_NUM complete${NC}"
405done
406echo ""
407
408log "All batches completed, checking results..."
409
410if [[ -n "$(git status --porcelain | grep -v '^??' | grep -vE '^.. docs/' || true)" ]]; then
411 error "Auto-apply modified non-doc files. Revert and re-run."
412fi
413
414if [[ -z "$(git status --porcelain docs/ | grep '^.. docs/src/' || true)" ]]; then
415 error "Auto-apply produced no docs/src changes."
416fi
417
418log "Running docs formatter"
419./script/prettier --write
420
421if [[ -z "$(git status --porcelain docs/ | grep '^.. docs/src/' || true)" ]]; then
422 error "No docs/src changes remain after formatting; aborting PR creation."
423fi
424
425# Validate docs build before creating PR
426if [[ "$SKIP_VALIDATION" != "true" ]]; then
427 echo "Validating docs build..."
428 log "Generating action metadata..."
429 if ! ./script/generate-action-metadata > /dev/null 2>&1; then
430 echo -e "${YELLOW}Warning:${NC} Could not generate action metadata (cargo build may have failed)."
431 echo "Skipping docs build validation. CI will still catch errors."
432 else
433 VALIDATION_DIR="$TMPDIR/docs-validation"
434 if ! mdbook build ./docs --dest-dir="$VALIDATION_DIR" 2>"$TMPDIR/validation-errors.txt"; then
435 echo ""
436 echo -e "${RED}Docs build validation failed:${NC}"
437 cat "$TMPDIR/validation-errors.txt"
438 echo ""
439 error "Fix the errors above and re-run, or use --skip-validation to bypass."
440 fi
441 echo -e "${GREEN}Docs build validation passed.${NC}"
442 fi
443 echo ""
444fi
445
446# Build PR body from suggestions
447PR_BODY_FILE="$TMPDIR/pr-body.md"
448cat > "$PR_BODY_FILE" << 'EOF'
449# Documentation Updates for Preview Release
450
451This draft PR auto-applies queued documentation suggestions collected from
452recently merged PRs.
453
454## How to Use This PR
455
4561. Review the applied changes file-by-file.
4572. Verify brand voice on each touched file as a full-file pass.
4583. Ensure docs use the correct callout type:
459 - Additive features: Preview callout
460 - Behavior modifications: Changed in Preview callout
4614. Merge when ready.
462
463## Auto-Apply Summary
464
465EOF
466
467cat "$APPLY_SUMMARY_FILE" >> "$PR_BODY_FILE"
468
469cat >> "$PR_BODY_FILE" << 'EOF'
470
471## Preview Callouts
472
473Use the Preview callout for new/additive features:
474
475```markdown
476> **Preview:** This feature is available in Zed Preview. It will be included in the next Stable release.
477```
478
479Use this callout for behavior modifications to existing functionality:
480
481```markdown
482> **Changed in Preview (v0.XXX).** See [release notes](/releases#0.XXX).
483```
484
485---
486
487## Pending Suggestions
488
489EOF
490
491# Append each suggestion to PR body
492for file in $(echo "$MANIFEST" | jq -r '.suggestions[].file'); do
493 log "Adding $file to PR body..."
494 echo "" >> "$PR_BODY_FILE"
495 git show "origin/$SUGGESTIONS_BRANCH:$file" >> "$PR_BODY_FILE" 2>/dev/null || true
496 echo "" >> "$PR_BODY_FILE"
497 echo "---" >> "$PR_BODY_FILE"
498done
499
500# Add tracking info
501cat >> "$PR_BODY_FILE" << EOF
502
503## PRs Included
504
505EOF
506
507echo "$MANIFEST" | jq -r '.suggestions[] | "- [PR #\(.pr)](\(.file)): \(.title)"' >> "$PR_BODY_FILE"
508
509cat >> "$PR_BODY_FILE" << 'EOF'
510
511Release Notes:
512
513- N/A
514EOF
515
516git add docs/
517git commit -m "docs: Auto-apply preview release suggestions
518
519Auto-applied queued documentation suggestions from:
520$(echo "$MANIFEST" | jq -r '.suggestions[] | "- PR #\(.pr)"')
521
522Generated with script/docs-suggest-publish for human review in draft PR."
523
524# Push and create PR
525log "Pushing branch..."
526git push -u origin "$DOCS_BRANCH"
527
528log "Creating PR..."
529PR_URL=$(gh pr create \
530 --draft \
531 --title "docs: Apply preview release suggestions" \
532 --body-file "$PR_BODY_FILE")
533
534echo ""
535echo -e "${GREEN}PR created:${NC} $PR_URL"
536
537# Reset suggestions branch if not keeping
538if [[ "$KEEP_QUEUE" != "true" ]]; then
539 echo ""
540 echo "Resetting suggestions queue..."
541
542 git checkout --orphan "${SUGGESTIONS_BRANCH}-reset"
543 git rm -rf . > /dev/null 2>&1 || true
544
545 cat > README.md << 'EOF'
546# Documentation Suggestions Queue
547
548This branch contains batched documentation suggestions for the next Preview release.
549
550Each file represents suggestions from a merged PR. At preview branch cut time,
551run `script/docs-suggest-publish` to create a documentation PR from these suggestions.
552
553## Structure
554
555- `suggestions/PR-XXXXX.md` - Suggestions for PR #XXXXX
556- `manifest.json` - Index of all pending suggestions
557
558## Workflow
559
5601. PRs merged to main trigger documentation analysis
5612. Suggestions are committed here as individual files
5623. At preview release, suggestions are collected into a docs PR
5634. After docs PR is created, this branch is reset
564EOF
565
566 mkdir -p suggestions
567 echo '{"suggestions":[]}' > manifest.json
568 git add README.md suggestions manifest.json
569 git commit -m "Reset documentation suggestions queue
570
571Previous suggestions published in: $PR_URL"
572
573 # Force push required: replacing the orphan suggestions branch with a clean slate
574 git push -f origin "${SUGGESTIONS_BRANCH}-reset:$SUGGESTIONS_BRANCH"
575 git checkout "$ORIGINAL_BRANCH"
576 git branch -D "${SUGGESTIONS_BRANCH}-reset"
577
578 echo "Suggestions queue reset."
579else
580 git checkout "$ORIGINAL_BRANCH"
581 echo ""
582 echo "Suggestions queue kept (--keep-queue). Remember to reset manually after PR is merged."
583fi
584
585echo ""
586echo -e "${GREEN}Done!${NC}"
587echo ""
588echo "Next steps:"
589echo "1. Review the draft PR and verify all auto-applied docs changes"
590echo "2. Confirm full-file brand voice pass for each touched docs file"
591echo "3. Mark ready for review and merge"