1name: Documentation Suggestions
2
3# Stable release callout stripping plan (not wired yet):
4# 1. Add a separate stable-only workflow trigger on `release.published`
5# with `github.event.release.prerelease == false`.
6# 2. In that workflow, run `script/docs-strip-preview-callouts` on `main`.
7# 3. Open a PR with stripped preview callouts for human review.
8# 4. Fail loudly on script errors or when no callout changes are produced.
9# 5. Keep this workflow focused on suggestions only until that stable workflow is added.
10
11on:
12 # Run when PRs are merged to main
13 pull_request:
14 types: [closed]
15 branches: [main]
16 paths:
17 - 'crates/**/*.rs'
18 - '!crates/**/*_test.rs'
19 - '!crates/**/tests/**'
20
21 # Run on cherry-picks to release branches
22 pull_request_target:
23 types: [opened, synchronize]
24 branches:
25 - 'v0.*'
26 paths:
27 - 'crates/**/*.rs'
28
29 # Manual trigger for testing
30 workflow_dispatch:
31 inputs:
32 pr_number:
33 description: 'PR number to analyze'
34 required: true
35 type: string
36 mode:
37 description: 'Output mode'
38 required: true
39 type: choice
40 options:
41 - batch
42 - immediate
43 default: batch
44
45env:
46 DROID_MODEL: claude-sonnet-4-5-20250929
47 SUGGESTIONS_BRANCH: docs/suggestions-pending
48
49jobs:
50 # Job for PRs merged to main - batch suggestions to branch
51 # Only runs for PRs from the same repo (not forks) since secrets aren't available for fork PRs
52 batch-suggestions:
53 runs-on: ubuntu-latest
54 timeout-minutes: 10
55 permissions:
56 contents: write
57 pull-requests: read
58 if: |
59 (github.event_name == 'pull_request' &&
60 github.event.pull_request.merged == true &&
61 github.event.pull_request.base.ref == 'main' &&
62 github.event.pull_request.head.repo.full_name == github.repository) ||
63 (github.event_name == 'workflow_dispatch' && inputs.mode == 'batch')
64
65 steps:
66 - name: Checkout repository
67 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
68 with:
69 fetch-depth: 0
70 token: ${{ secrets.GITHUB_TOKEN }}
71
72 - name: Install Droid CLI
73 run: |
74 # Retry with exponential backoff for transient network/auth issues
75 MAX_RETRIES=3
76 for i in $(seq 1 "$MAX_RETRIES"); do
77 echo "Attempt $i of $MAX_RETRIES to install Droid CLI..."
78 if curl -fsSL https://app.factory.ai/cli | sh; then
79 echo "Droid CLI installed successfully"
80 break
81 fi
82 if [ "$i" -eq "$MAX_RETRIES" ]; then
83 echo "Failed to install Droid CLI after $MAX_RETRIES attempts"
84 exit 1
85 fi
86 sleep $((i * 5))
87 done
88 echo "${HOME}/.local/bin" >> "$GITHUB_PATH"
89 env:
90 FACTORY_API_KEY: ${{ secrets.FACTORY_API_KEY }}
91
92 - name: Get PR info
93 id: pr
94 env:
95 INPUT_PR_NUMBER: ${{ inputs.pr_number }}
96 EVENT_PR_NUMBER: ${{ github.event.pull_request.number }}
97 GH_TOKEN: ${{ github.token }}
98 run: |
99 if [ -n "$INPUT_PR_NUMBER" ]; then
100 PR_NUM="$INPUT_PR_NUMBER"
101 else
102 PR_NUM="$EVENT_PR_NUMBER"
103 fi
104 if ! [[ "$PR_NUM" =~ ^[0-9]+$ ]]; then
105 echo "::error::Invalid PR number: $PR_NUM"
106 exit 1
107 fi
108 echo "number=$PR_NUM" >> "$GITHUB_OUTPUT"
109 PR_TITLE=$(gh pr view "$PR_NUM" --json title --jq '.title' | tr -d '\n\r' | head -c 200)
110 EOF_MARKER="EOF_$(openssl rand -hex 8)"
111 {
112 echo "title<<$EOF_MARKER"
113 echo "$PR_TITLE"
114 echo "$EOF_MARKER"
115 } >> "$GITHUB_OUTPUT"
116
117 - name: Analyze PR for documentation needs
118 id: analyze
119 env:
120 GH_TOKEN: ${{ github.token }}
121 FACTORY_API_KEY: ${{ secrets.FACTORY_API_KEY }}
122 PR_NUMBER: ${{ steps.pr.outputs.number }}
123 run: |
124 # Ensure gh CLI is authenticated (GH_TOKEN may not be auto-detected)
125 # Unset GH_TOKEN first to allow gh auth login to store credentials
126 echo "$GH_TOKEN" | (unset GH_TOKEN && gh auth login --with-token)
127
128 OUTPUT_FILE=$(mktemp)
129
130 # Retry with exponential backoff for transient Factory API failures
131 MAX_RETRIES=3
132 for i in $(seq 1 "$MAX_RETRIES"); do
133 echo "Attempt $i of $MAX_RETRIES to analyze PR..."
134 if ./script/docs-suggest \
135 --pr "$PR_NUMBER" \
136 --immediate \
137 --preview \
138 --output "$OUTPUT_FILE" \
139 --verbose; then
140 echo "Analysis completed successfully"
141 break
142 fi
143 if [ "$i" -eq "$MAX_RETRIES" ]; then
144 echo "Analysis failed after $MAX_RETRIES attempts"
145 exit 1
146 fi
147 echo "Retrying in $((i * 5)) seconds..."
148 sleep $((i * 5))
149 done
150
151 # Check if we got actionable suggestions (not "no updates needed")
152 if grep -q "Documentation Suggestions" "$OUTPUT_FILE" && \
153 ! grep -q "No Documentation Updates Needed" "$OUTPUT_FILE"; then
154 echo "has_suggestions=true" >> "$GITHUB_OUTPUT"
155 echo "output_file=$OUTPUT_FILE" >> "$GITHUB_OUTPUT"
156 else
157 echo "has_suggestions=false" >> "$GITHUB_OUTPUT"
158 echo "No actionable documentation suggestions for this PR"
159 cat "$OUTPUT_FILE"
160 fi
161
162 - name: Commit suggestions to queue branch
163 if: steps.analyze.outputs.has_suggestions == 'true'
164 env:
165 PR_NUM: ${{ steps.pr.outputs.number }}
166 PR_TITLE: ${{ steps.pr.outputs.title }}
167 OUTPUT_FILE: ${{ steps.analyze.outputs.output_file }}
168 REPO: ${{ github.repository }}
169 run: |
170 set -euo pipefail
171
172 # Configure git
173 git config user.name "github-actions[bot]"
174 git config user.email "github-actions[bot]@users.noreply.github.com"
175
176 # Retry loop for handling concurrent pushes
177 MAX_RETRIES=3
178 for i in $(seq 1 "$MAX_RETRIES"); do
179 echo "Attempt $i of $MAX_RETRIES"
180
181 # Fetch and checkout suggestions branch (create if doesn't exist)
182 if git ls-remote --exit-code --heads origin "$SUGGESTIONS_BRANCH" > /dev/null 2>&1; then
183 git fetch origin "$SUGGESTIONS_BRANCH"
184 git checkout -B "$SUGGESTIONS_BRANCH" "origin/$SUGGESTIONS_BRANCH"
185 else
186 # Create orphan branch for clean history
187 git checkout --orphan "$SUGGESTIONS_BRANCH"
188 git rm -rf . > /dev/null 2>&1 || true
189
190 # Initialize with README
191 cat > README.md << 'EOF'
192 # Documentation Suggestions Queue
193
194 This branch contains batched documentation suggestions for the next Preview release.
195
196 Each file represents suggestions from a merged PR. At preview branch cut time,
197 run `script/docs-suggest-publish` to create a documentation PR from these suggestions.
198
199 ## Structure
200
201 - `suggestions/PR-XXXXX.md` - Suggestions for PR #XXXXX
202 - `manifest.json` - Index of all pending suggestions
203
204 ## Workflow
205
206 1. PRs merged to main trigger documentation analysis
207 2. Suggestions are committed here as individual files
208 3. At preview release, suggestions are collected into a docs PR
209 4. After docs PR is created, this branch is reset
210 EOF
211
212 mkdir -p suggestions
213 echo '{"suggestions":[]}' > manifest.json
214 git add README.md suggestions manifest.json
215 git commit -m "Initialize documentation suggestions queue"
216 fi
217
218 # Create suggestion file
219 SUGGESTION_FILE="suggestions/PR-${PR_NUM}.md"
220
221 {
222 echo "# PR #${PR_NUM}: ${PR_TITLE}"
223 echo ""
224 echo "_Merged: $(date -u +%Y-%m-%dT%H:%M:%SZ)_"
225 echo "_PR: https://github.com/${REPO}/pull/${PR_NUM}_"
226 echo ""
227 cat "$OUTPUT_FILE"
228 } > "$SUGGESTION_FILE"
229
230 # Update manifest
231 MANIFEST=$(cat manifest.json)
232 NEW_ENTRY="{\"pr\":${PR_NUM},\"title\":$(echo "$PR_TITLE" | jq -R .),\"file\":\"$SUGGESTION_FILE\",\"date\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}"
233
234 # Add to manifest if not already present
235 if ! echo "$MANIFEST" | jq -e ".suggestions[] | select(.pr == $PR_NUM)" > /dev/null 2>&1; then
236 echo "$MANIFEST" | jq ".suggestions += [$NEW_ENTRY]" > manifest.json
237 fi
238
239 # Commit
240 git add "$SUGGESTION_FILE" manifest.json
241 git commit -m "docs: Add suggestions for PR #${PR_NUM}
242
243 ${PR_TITLE}
244
245 Auto-generated documentation suggestions for review at next preview release."
246
247 # Try to push
248 if git push origin "$SUGGESTIONS_BRANCH"; then
249 echo "Successfully pushed suggestions"
250 break
251 else
252 echo "Push failed, retrying..."
253 if [ "$i" -eq "$MAX_RETRIES" ]; then
254 echo "Failed after $MAX_RETRIES attempts"
255 exit 1
256 fi
257 sleep $((i * 2))
258 fi
259 done
260
261 - name: Summary
262 if: always()
263 env:
264 HAS_SUGGESTIONS: ${{ steps.analyze.outputs.has_suggestions }}
265 PR_NUM: ${{ steps.pr.outputs.number }}
266 REPO: ${{ github.repository }}
267 run: |
268 {
269 echo "## Documentation Suggestions"
270 echo ""
271 if [ "$HAS_SUGGESTIONS" == "true" ]; then
272 echo "ā
Suggestions queued for PR #${PR_NUM}"
273 echo ""
274 echo "View pending suggestions: [docs/suggestions-pending branch](https://github.com/${REPO}/tree/${SUGGESTIONS_BRANCH})"
275 else
276 echo "No documentation updates needed for this PR."
277 fi
278 } >> "$GITHUB_STEP_SUMMARY"
279
280 # Job for cherry-picks to release branches - immediate output as PR comment
281 cherry-pick-suggestions:
282 runs-on: ubuntu-latest
283 timeout-minutes: 10
284 permissions:
285 contents: read
286 pull-requests: write
287 concurrency:
288 group: docs-suggestions-${{ github.event.pull_request.number || inputs.pr_number || 'manual' }}
289 cancel-in-progress: true
290 if: |
291 (github.event_name == 'pull_request_target' &&
292 startsWith(github.event.pull_request.base.ref, 'v0.') &&
293 contains(fromJSON('["MEMBER","OWNER"]'),
294 github.event.pull_request.author_association)) ||
295 (github.event_name == 'workflow_dispatch' && inputs.mode == 'immediate')
296
297 steps:
298 - name: Checkout repository
299 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
300 with:
301 fetch-depth: 0
302 ref: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.base.ref || '' }}
303 persist-credentials: false
304
305 - name: Install Droid CLI
306 run: |
307 # Retry with exponential backoff for transient network/auth issues
308 MAX_RETRIES=3
309 for i in $(seq 1 "$MAX_RETRIES"); do
310 echo "Attempt $i of $MAX_RETRIES to install Droid CLI..."
311 if curl -fsSL https://app.factory.ai/cli | sh; then
312 echo "Droid CLI installed successfully"
313 break
314 fi
315 if [ "$i" -eq "$MAX_RETRIES" ]; then
316 echo "Failed to install Droid CLI after $MAX_RETRIES attempts"
317 exit 1
318 fi
319 sleep $((i * 5))
320 done
321 echo "${HOME}/.local/bin" >> "$GITHUB_PATH"
322 env:
323 FACTORY_API_KEY: ${{ secrets.FACTORY_API_KEY }}
324
325 - name: Get PR number
326 id: pr
327 env:
328 INPUT_PR_NUMBER: ${{ inputs.pr_number }}
329 EVENT_PR_NUMBER: ${{ github.event.pull_request.number }}
330 run: |
331 if [ -n "$INPUT_PR_NUMBER" ]; then
332 PR_NUM="$INPUT_PR_NUMBER"
333 else
334 PR_NUM="$EVENT_PR_NUMBER"
335 fi
336 if ! [[ "$PR_NUM" =~ ^[0-9]+$ ]]; then
337 echo "::error::Invalid PR number: $PR_NUM"
338 exit 1
339 fi
340 echo "number=$PR_NUM" >> "$GITHUB_OUTPUT"
341
342 - name: Analyze PR for documentation needs
343 id: analyze
344 env:
345 GH_TOKEN: ${{ github.token }}
346 FACTORY_API_KEY: ${{ secrets.FACTORY_API_KEY }}
347 PR_NUMBER: ${{ steps.pr.outputs.number }}
348 run: |
349 # Ensure gh CLI is authenticated (GH_TOKEN may not be auto-detected)
350 # Unset GH_TOKEN first to allow gh auth login to store credentials
351 echo "$GH_TOKEN" | (unset GH_TOKEN && gh auth login --with-token)
352
353 OUTPUT_FILE="${RUNNER_TEMP}/suggestions.md"
354
355 # Cherry-picks don't get preview callout
356 # Retry with exponential backoff for transient Factory API failures
357 MAX_RETRIES=3
358 for i in $(seq 1 "$MAX_RETRIES"); do
359 echo "Attempt $i of $MAX_RETRIES to analyze PR..."
360 if ./script/docs-suggest \
361 --pr "$PR_NUMBER" \
362 --immediate \
363 --no-preview \
364 --output "$OUTPUT_FILE" \
365 --verbose; then
366 echo "Analysis completed successfully"
367 break
368 fi
369 if [ "$i" -eq "$MAX_RETRIES" ]; then
370 echo "Analysis failed after $MAX_RETRIES attempts"
371 exit 1
372 fi
373 echo "Retrying in $((i * 5)) seconds..."
374 sleep $((i * 5))
375 done
376
377 # Check if we got actionable suggestions
378 if [ -s "$OUTPUT_FILE" ] && \
379 grep -q "Documentation Suggestions" "$OUTPUT_FILE" && \
380 ! grep -q "No Documentation Updates Needed" "$OUTPUT_FILE"; then
381 echo "has_suggestions=true" >> "$GITHUB_OUTPUT"
382 echo "suggestions_file=$OUTPUT_FILE" >> "$GITHUB_OUTPUT"
383 else
384 echo "has_suggestions=false" >> "$GITHUB_OUTPUT"
385 fi
386
387 - name: Post suggestions as PR comment
388 if: steps.analyze.outputs.has_suggestions == 'true'
389 uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
390 env:
391 SUGGESTIONS_FILE: ${{ steps.analyze.outputs.suggestions_file }}
392 PR_NUMBER: ${{ steps.pr.outputs.number }}
393 with:
394 script: |
395 const fs = require('fs');
396
397 // Read suggestions from file
398 const suggestionsRaw = fs.readFileSync(process.env.SUGGESTIONS_FILE, 'utf8');
399
400 // Sanitize AI-generated content
401 let sanitized = suggestionsRaw
402 // Strip HTML tags
403 .replace(/<[^>]*>/g, '')
404 // Strip markdown links but keep display text
405 .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
406 // Strip raw URLs
407 .replace(/https?:\/\/[^\s)>\]]+/g, '[link removed]')
408 // Strip protocol-relative URLs
409 .replace(/\/\/[^\s)>\]]+\.[^\s)>\]]+/g, '[link removed]')
410 // Neutralize @-mentions (preserve JSDoc-style annotations)
411 .replace(/@(?!param\b|returns?\b|throws?\b|typedef\b|type\b|see\b|example\b|since\b|deprecated\b|default\b)(\w+)/g, '`@$1`')
412 // Strip cross-repo references that could be confused with real links
413 .replace(/[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+#\d+/g, '[ref removed]');
414
415 // Truncate to 20,000 characters
416 if (sanitized.length > 20000) {
417 sanitized = sanitized.substring(0, 20000) + '\n\nā¦(truncated)';
418 }
419
420 // Parse and validate PR number
421 const prNumber = parseInt(process.env.PR_NUMBER, 10);
422 if (isNaN(prNumber) || prNumber <= 0) {
423 core.setFailed(`Invalid PR number: ${process.env.PR_NUMBER}`);
424 return;
425 }
426
427 const body = `## š Documentation Suggestions
428
429 This cherry-pick contains changes that may need documentation updates.
430
431 ${sanitized}
432
433 ---
434 > **Note:** This comment was generated automatically by an AI model analyzing
435 > code changes. Suggestions may contain inaccuracies ā please verify before acting.
436
437 <details>
438 <summary>About this comment</summary>
439
440 This comment was generated automatically by analyzing code changes in this cherry-pick.
441 Cherry-picks typically don't need new documentation since the feature was already
442 documented when merged to main, but please verify.
443
444 </details>`;
445
446 // Find existing comment to update (avoid spam)
447 const { data: comments } = await github.rest.issues.listComments({
448 owner: context.repo.owner,
449 repo: context.repo.repo,
450 issue_number: prNumber
451 });
452
453 const botComment = comments.find(c =>
454 c.user.type === 'Bot' &&
455 c.body.includes('Documentation Suggestions')
456 );
457
458 if (botComment) {
459 await github.rest.issues.updateComment({
460 owner: context.repo.owner,
461 repo: context.repo.repo,
462 comment_id: botComment.id,
463 body: body
464 });
465 } else {
466 await github.rest.issues.createComment({
467 owner: context.repo.owner,
468 repo: context.repo.repo,
469 issue_number: prNumber,
470 body: body
471 });
472 }
473
474 - name: Summary
475 if: always()
476 env:
477 HAS_SUGGESTIONS: ${{ steps.analyze.outputs.has_suggestions }}
478 PR_NUM: ${{ steps.pr.outputs.number }}
479 run: |
480 {
481 echo "## š Documentation Suggestions (Cherry-pick)"
482 echo ""
483 if [ "$HAS_SUGGESTIONS" == "true" ]; then
484 echo "Suggestions posted as PR comment on #${PR_NUM}."
485 else
486 echo "No documentation suggestions for this cherry-pick."
487 fi
488 } >> "$GITHUB_STEP_SUMMARY"