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
 45permissions:
 46  contents: write
 47  pull-requests: write
 48
 49env:
 50  DROID_MODEL: claude-sonnet-4-5-20250929
 51  SUGGESTIONS_BRANCH: docs/suggestions-pending
 52
 53jobs:
 54  # Job for PRs merged to main - batch suggestions to branch
 55  # Only runs for PRs from the same repo (not forks) since secrets aren't available for fork PRs
 56  batch-suggestions:
 57    runs-on: ubuntu-latest
 58    timeout-minutes: 10
 59    if: |
 60      (github.event_name == 'pull_request' && 
 61       github.event.pull_request.merged == true &&
 62       github.event.pull_request.base.ref == 'main' &&
 63       github.event.pull_request.head.repo.full_name == github.repository) ||
 64      (github.event_name == 'workflow_dispatch' && inputs.mode == 'batch')
 65    
 66    steps:
 67      - name: Checkout repository
 68        uses: actions/checkout@v4
 69        with:
 70          fetch-depth: 0
 71          token: ${{ secrets.GITHUB_TOKEN }}
 72
 73      - name: Install Droid CLI
 74        run: |
 75          # Retry with exponential backoff for transient network/auth issues
 76          MAX_RETRIES=3
 77          for i in $(seq 1 "$MAX_RETRIES"); do
 78            echo "Attempt $i of $MAX_RETRIES to install Droid CLI..."
 79            if curl -fsSL https://app.factory.ai/cli | sh; then
 80              echo "Droid CLI installed successfully"
 81              break
 82            fi
 83            if [ "$i" -eq "$MAX_RETRIES" ]; then
 84              echo "Failed to install Droid CLI after $MAX_RETRIES attempts"
 85              exit 1
 86            fi
 87            sleep $((i * 5))
 88          done
 89          echo "${HOME}/.local/bin" >> "$GITHUB_PATH"
 90        env:
 91          FACTORY_API_KEY: ${{ secrets.FACTORY_API_KEY }}
 92
 93      - name: Get PR info
 94        id: pr
 95        run: |
 96          if [ -n "${{ inputs.pr_number }}" ]; then
 97            PR_NUM="${{ inputs.pr_number }}"
 98          else
 99            PR_NUM="${{ github.event.pull_request.number }}"
100          fi
101          echo "number=$PR_NUM" >> "$GITHUB_OUTPUT"
102          
103          # Get PR title
104          PR_TITLE=$(gh pr view "$PR_NUM" --json title --jq '.title')
105          echo "title=$PR_TITLE" >> "$GITHUB_OUTPUT"
106        env:
107          GH_TOKEN: ${{ github.token }}
108
109      - name: Analyze PR for documentation needs
110        id: analyze
111        run: |
112          # Ensure gh CLI is authenticated (GH_TOKEN may not be auto-detected)
113          # Unset GH_TOKEN first to allow gh auth login to store credentials
114          echo "$GH_TOKEN" | (unset GH_TOKEN && gh auth login --with-token)
115          
116          OUTPUT_FILE=$(mktemp)
117          
118          # Retry with exponential backoff for transient Factory API failures
119          MAX_RETRIES=3
120          for i in $(seq 1 "$MAX_RETRIES"); do
121            echo "Attempt $i of $MAX_RETRIES to analyze PR..."
122            if ./script/docs-suggest \
123              --pr "${{ steps.pr.outputs.number }}" \
124              --immediate \
125              --preview \
126              --output "$OUTPUT_FILE" \
127              --verbose; then
128              echo "Analysis completed successfully"
129              break
130            fi
131            if [ "$i" -eq "$MAX_RETRIES" ]; then
132              echo "Analysis failed after $MAX_RETRIES attempts"
133              exit 1
134            fi
135            echo "Retrying in $((i * 5)) seconds..."
136            sleep $((i * 5))
137          done
138          
139          # Check if we got actionable suggestions (not "no updates needed")
140          if grep -q "Documentation Suggestions" "$OUTPUT_FILE" && \
141             ! grep -q "No Documentation Updates Needed" "$OUTPUT_FILE"; then
142            echo "has_suggestions=true" >> "$GITHUB_OUTPUT"
143            echo "output_file=$OUTPUT_FILE" >> "$GITHUB_OUTPUT"
144          else
145            echo "has_suggestions=false" >> "$GITHUB_OUTPUT"
146            echo "No actionable documentation suggestions for this PR"
147            cat "$OUTPUT_FILE"
148          fi
149        env:
150          GH_TOKEN: ${{ github.token }}
151          FACTORY_API_KEY: ${{ secrets.FACTORY_API_KEY }}
152
153      - name: Commit suggestions to queue branch
154        if: steps.analyze.outputs.has_suggestions == 'true'
155        env:
156          PR_NUM: ${{ steps.pr.outputs.number }}
157          PR_TITLE: ${{ steps.pr.outputs.title }}
158          OUTPUT_FILE: ${{ steps.analyze.outputs.output_file }}
159        run: |
160          set -euo pipefail
161          
162          # Configure git
163          git config user.name "github-actions[bot]"
164          git config user.email "github-actions[bot]@users.noreply.github.com"
165          
166          # Retry loop for handling concurrent pushes
167          MAX_RETRIES=3
168          for i in $(seq 1 "$MAX_RETRIES"); do
169            echo "Attempt $i of $MAX_RETRIES"
170            
171            # Fetch and checkout suggestions branch (create if doesn't exist)
172            if git ls-remote --exit-code --heads origin "$SUGGESTIONS_BRANCH" > /dev/null 2>&1; then
173              git fetch origin "$SUGGESTIONS_BRANCH"
174              git checkout -B "$SUGGESTIONS_BRANCH" "origin/$SUGGESTIONS_BRANCH"
175            else
176              # Create orphan branch for clean history
177              git checkout --orphan "$SUGGESTIONS_BRANCH"
178              git rm -rf . > /dev/null 2>&1 || true
179              
180              # Initialize with README
181              cat > README.md << 'EOF'
182          # Documentation Suggestions Queue
183
184          This branch contains batched documentation suggestions for the next Preview release.
185
186          Each file represents suggestions from a merged PR. At preview branch cut time,
187          run `script/docs-suggest-publish` to create a documentation PR from these suggestions.
188
189          ## Structure
190
191          - `suggestions/PR-XXXXX.md` - Suggestions for PR #XXXXX
192          - `manifest.json` - Index of all pending suggestions
193
194          ## Workflow
195
196          1. PRs merged to main trigger documentation analysis
197          2. Suggestions are committed here as individual files
198          3. At preview release, suggestions are collected into a docs PR
199          4. After docs PR is created, this branch is reset
200          EOF
201              
202              mkdir -p suggestions
203              echo '{"suggestions":[]}' > manifest.json
204              git add README.md suggestions manifest.json
205              git commit -m "Initialize documentation suggestions queue"
206            fi
207            
208            # Create suggestion file
209            SUGGESTION_FILE="suggestions/PR-${PR_NUM}.md"
210            
211            {
212              echo "# PR #${PR_NUM}: ${PR_TITLE}"
213              echo ""
214              echo "_Merged: $(date -u +%Y-%m-%dT%H:%M:%SZ)_"
215              echo "_PR: https://github.com/${{ github.repository }}/pull/${PR_NUM}_"
216              echo ""
217              cat "$OUTPUT_FILE"
218            } > "$SUGGESTION_FILE"
219            
220            # Update manifest
221            MANIFEST=$(cat manifest.json)
222            NEW_ENTRY="{\"pr\":${PR_NUM},\"title\":$(echo "$PR_TITLE" | jq -R .),\"file\":\"$SUGGESTION_FILE\",\"date\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}"
223            
224            # Add to manifest if not already present
225            if ! echo "$MANIFEST" | jq -e ".suggestions[] | select(.pr == $PR_NUM)" > /dev/null 2>&1; then
226              echo "$MANIFEST" | jq ".suggestions += [$NEW_ENTRY]" > manifest.json
227            fi
228            
229            # Commit
230            git add "$SUGGESTION_FILE" manifest.json
231            git commit -m "docs: Add suggestions for PR #${PR_NUM}
232
233          ${PR_TITLE}
234
235          Auto-generated documentation suggestions for review at next preview release."
236            
237            # Try to push
238            if git push origin "$SUGGESTIONS_BRANCH"; then
239              echo "Successfully pushed suggestions"
240              break
241            else
242              echo "Push failed, retrying..."
243              if [ "$i" -eq "$MAX_RETRIES" ]; then
244                echo "Failed after $MAX_RETRIES attempts"
245                exit 1
246              fi
247              sleep $((i * 2))
248            fi
249          done
250
251      - name: Summary
252        if: always()
253        run: |
254          {
255            echo "## Documentation Suggestions"
256            echo ""
257            if [ "${{ steps.analyze.outputs.has_suggestions }}" == "true" ]; then
258              echo "✅ Suggestions queued for PR #${{ steps.pr.outputs.number }}"
259              echo ""
260              echo "View pending suggestions: [docs/suggestions-pending branch](https://github.com/${{ github.repository }}/tree/${{ env.SUGGESTIONS_BRANCH }})"
261            else
262              echo "No documentation updates needed for this PR."
263            fi
264          } >> "$GITHUB_STEP_SUMMARY"
265
266  # Job for cherry-picks to release branches - immediate output to step summary
267  cherry-pick-suggestions:
268    runs-on: ubuntu-latest
269    timeout-minutes: 10
270    if: |
271      (github.event_name == 'pull_request_target' && 
272       startsWith(github.event.pull_request.base.ref, 'v0.')) ||
273      (github.event_name == 'workflow_dispatch' && inputs.mode == 'immediate')
274    
275    steps:
276      - name: Checkout repository
277        uses: actions/checkout@v4
278        with:
279          fetch-depth: 0
280
281      - name: Install Droid CLI
282        run: |
283          # Retry with exponential backoff for transient network/auth issues
284          MAX_RETRIES=3
285          for i in $(seq 1 "$MAX_RETRIES"); do
286            echo "Attempt $i of $MAX_RETRIES to install Droid CLI..."
287            if curl -fsSL https://app.factory.ai/cli | sh; then
288              echo "Droid CLI installed successfully"
289              break
290            fi
291            if [ "$i" -eq "$MAX_RETRIES" ]; then
292              echo "Failed to install Droid CLI after $MAX_RETRIES attempts"
293              exit 1
294            fi
295            sleep $((i * 5))
296          done
297          echo "${HOME}/.local/bin" >> "$GITHUB_PATH"
298        env:
299          FACTORY_API_KEY: ${{ secrets.FACTORY_API_KEY }}
300
301      - name: Get PR number
302        id: pr
303        run: |
304          if [ -n "${{ inputs.pr_number }}" ]; then
305            echo "number=${{ inputs.pr_number }}" >> "$GITHUB_OUTPUT"
306          else
307            echo "number=${{ github.event.pull_request.number }}" >> "$GITHUB_OUTPUT"
308          fi
309
310      - name: Analyze PR for documentation needs
311        id: analyze
312        run: |
313          # Ensure gh CLI is authenticated (GH_TOKEN may not be auto-detected)
314          # Unset GH_TOKEN first to allow gh auth login to store credentials
315          echo "$GH_TOKEN" | (unset GH_TOKEN && gh auth login --with-token)
316          
317          OUTPUT_FILE="${RUNNER_TEMP}/suggestions.md"
318          
319          # Cherry-picks don't get preview callout
320          # Retry with exponential backoff for transient Factory API failures
321          MAX_RETRIES=3
322          for i in $(seq 1 "$MAX_RETRIES"); do
323            echo "Attempt $i of $MAX_RETRIES to analyze PR..."
324            if ./script/docs-suggest \
325              --pr "${{ steps.pr.outputs.number }}" \
326              --immediate \
327              --no-preview \
328              --output "$OUTPUT_FILE" \
329              --verbose; then
330              echo "Analysis completed successfully"
331              break
332            fi
333            if [ "$i" -eq "$MAX_RETRIES" ]; then
334              echo "Analysis failed after $MAX_RETRIES attempts"
335              exit 1
336            fi
337            echo "Retrying in $((i * 5)) seconds..."
338            sleep $((i * 5))
339          done
340          
341          # Check if we got actionable suggestions
342          if [ -s "$OUTPUT_FILE" ] && \
343             grep -q "Documentation Suggestions" "$OUTPUT_FILE" && \
344             ! grep -q "No Documentation Updates Needed" "$OUTPUT_FILE"; then
345            echo "has_suggestions=true" >> "$GITHUB_OUTPUT"
346            echo "suggestions_file=$OUTPUT_FILE" >> "$GITHUB_OUTPUT"
347          else
348            echo "has_suggestions=false" >> "$GITHUB_OUTPUT"
349          fi
350        env:
351          GH_TOKEN: ${{ github.token }}
352          FACTORY_API_KEY: ${{ secrets.FACTORY_API_KEY }}
353
354      - name: Post suggestions as PR comment
355        if: steps.analyze.outputs.has_suggestions == 'true'
356        uses: actions/github-script@v7
357        env:
358          SUGGESTIONS_FILE: ${{ steps.analyze.outputs.suggestions_file }}
359        with:
360          script: |
361            const fs = require('fs');
362            const suggestions = fs.readFileSync(process.env.SUGGESTIONS_FILE, 'utf8');
363            
364            const body = `## 📚 Documentation Suggestions
365
366            This cherry-pick contains changes that may need documentation updates.
367
368            ${suggestions}
369
370            ---
371            <details>
372            <summary>About this comment</summary>
373
374            This comment was generated automatically by analyzing code changes in this cherry-pick.
375            Cherry-picks typically don't need new documentation since the feature was already 
376            documented when merged to main, but please verify.
377
378            </details>`;
379            
380            // Find existing comment to update (avoid spam)
381            const { data: comments } = await github.rest.issues.listComments({
382              owner: context.repo.owner,
383              repo: context.repo.repo,
384              issue_number: ${{ steps.pr.outputs.number }}
385            });
386            
387            const botComment = comments.find(c => 
388              c.user.type === 'Bot' && 
389              c.body.includes('Documentation Suggestions')
390            );
391            
392            if (botComment) {
393              await github.rest.issues.updateComment({
394                owner: context.repo.owner,
395                repo: context.repo.repo,
396                comment_id: botComment.id,
397                body: body
398              });
399            } else {
400              await github.rest.issues.createComment({
401                owner: context.repo.owner,
402                repo: context.repo.repo,
403                issue_number: ${{ steps.pr.outputs.number }},
404                body: body
405              });
406            }
407
408      - name: Summary
409        if: always()
410        run: |
411          {
412            echo "## 📚 Documentation Suggestions (Cherry-pick)"
413            echo ""
414            if [ "${{ steps.analyze.outputs.has_suggestions }}" == "true" ]; then
415              echo "Suggestions posted as PR comment."
416              echo ""
417              cat "${{ steps.analyze.outputs.suggestions_file }}"
418            else
419              echo "No documentation suggestions for this cherry-pick."
420            fi
421          } >> "$GITHUB_STEP_SUMMARY"