docs_suggestions.yml

  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"