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