name: Documentation Suggestions # Stable release callout stripping plan (not wired yet): # 1. Add a separate stable-only workflow trigger on `release.published` # with `github.event.release.prerelease == false`. # 2. In that workflow, run `script/docs-strip-preview-callouts` on `main`. # 3. Open a PR with stripped preview callouts for human review. # 4. Fail loudly on script errors or when no callout changes are produced. # 5. Keep this workflow focused on suggestions only until that stable workflow is added. on: # Run when PRs are merged to main pull_request: types: [closed] branches: [main] paths: - 'crates/**/*.rs' - '!crates/**/*_test.rs' - '!crates/**/tests/**' # Run on cherry-picks to release branches pull_request_target: types: [opened, synchronize] branches: - 'v0.*' paths: - 'crates/**/*.rs' # Manual trigger for testing workflow_dispatch: inputs: pr_number: description: 'PR number to analyze' required: true type: string mode: description: 'Output mode' required: true type: choice options: - batch - immediate default: batch env: DROID_MODEL: claude-sonnet-4-5-20250929 SUGGESTIONS_BRANCH: docs/suggestions-pending jobs: # Job for PRs merged to main - batch suggestions to branch # Only runs for PRs from the same repo (not forks) since secrets aren't available for fork PRs batch-suggestions: runs-on: ubuntu-latest timeout-minutes: 10 permissions: contents: write pull-requests: read if: | (github.event_name == 'pull_request' && github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'main' && github.event.pull_request.head.repo.full_name == github.repository) || (github.event_name == 'workflow_dispatch' && inputs.mode == 'batch') steps: - name: Checkout repository uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: fetch-depth: 0 token: ${{ secrets.GITHUB_TOKEN }} - name: Install Droid CLI run: | # Retry with exponential backoff for transient network/auth issues MAX_RETRIES=3 for i in $(seq 1 "$MAX_RETRIES"); do echo "Attempt $i of $MAX_RETRIES to install Droid CLI..." if curl -fsSL https://app.factory.ai/cli | sh; then echo "Droid CLI installed successfully" break fi if [ "$i" -eq "$MAX_RETRIES" ]; then echo "Failed to install Droid CLI after $MAX_RETRIES attempts" exit 1 fi sleep $((i * 5)) done echo "${HOME}/.local/bin" >> "$GITHUB_PATH" env: FACTORY_API_KEY: ${{ secrets.FACTORY_API_KEY }} - name: Get PR info id: pr env: INPUT_PR_NUMBER: ${{ inputs.pr_number }} EVENT_PR_NUMBER: ${{ github.event.pull_request.number }} GH_TOKEN: ${{ github.token }} run: | if [ -n "$INPUT_PR_NUMBER" ]; then PR_NUM="$INPUT_PR_NUMBER" else PR_NUM="$EVENT_PR_NUMBER" fi if ! [[ "$PR_NUM" =~ ^[0-9]+$ ]]; then echo "::error::Invalid PR number: $PR_NUM" exit 1 fi echo "number=$PR_NUM" >> "$GITHUB_OUTPUT" PR_TITLE=$(gh pr view "$PR_NUM" --json title --jq '.title' | tr -d '\n\r' | head -c 200) EOF_MARKER="EOF_$(openssl rand -hex 8)" { echo "title<<$EOF_MARKER" echo "$PR_TITLE" echo "$EOF_MARKER" } >> "$GITHUB_OUTPUT" - name: Analyze PR for documentation needs id: analyze env: GH_TOKEN: ${{ github.token }} FACTORY_API_KEY: ${{ secrets.FACTORY_API_KEY }} PR_NUMBER: ${{ steps.pr.outputs.number }} run: | # Ensure gh CLI is authenticated (GH_TOKEN may not be auto-detected) # Unset GH_TOKEN first to allow gh auth login to store credentials echo "$GH_TOKEN" | (unset GH_TOKEN && gh auth login --with-token) OUTPUT_FILE=$(mktemp) # Retry with exponential backoff for transient Factory API failures MAX_RETRIES=3 for i in $(seq 1 "$MAX_RETRIES"); do echo "Attempt $i of $MAX_RETRIES to analyze PR..." if ./script/docs-suggest \ --pr "$PR_NUMBER" \ --immediate \ --preview \ --output "$OUTPUT_FILE" \ --verbose; then echo "Analysis completed successfully" break fi if [ "$i" -eq "$MAX_RETRIES" ]; then echo "Analysis failed after $MAX_RETRIES attempts" exit 1 fi echo "Retrying in $((i * 5)) seconds..." sleep $((i * 5)) done # Check if we got actionable suggestions (not "no updates needed") if grep -q "Documentation Suggestions" "$OUTPUT_FILE" && \ ! grep -q "No Documentation Updates Needed" "$OUTPUT_FILE"; then echo "has_suggestions=true" >> "$GITHUB_OUTPUT" echo "output_file=$OUTPUT_FILE" >> "$GITHUB_OUTPUT" else echo "has_suggestions=false" >> "$GITHUB_OUTPUT" echo "No actionable documentation suggestions for this PR" cat "$OUTPUT_FILE" fi - name: Commit suggestions to queue branch if: steps.analyze.outputs.has_suggestions == 'true' env: PR_NUM: ${{ steps.pr.outputs.number }} PR_TITLE: ${{ steps.pr.outputs.title }} OUTPUT_FILE: ${{ steps.analyze.outputs.output_file }} REPO: ${{ github.repository }} run: | set -euo pipefail # Configure git git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" # Retry loop for handling concurrent pushes MAX_RETRIES=3 for i in $(seq 1 "$MAX_RETRIES"); do echo "Attempt $i of $MAX_RETRIES" # Fetch and checkout suggestions branch (create if doesn't exist) if git ls-remote --exit-code --heads origin "$SUGGESTIONS_BRANCH" > /dev/null 2>&1; then git fetch origin "$SUGGESTIONS_BRANCH" git checkout -B "$SUGGESTIONS_BRANCH" "origin/$SUGGESTIONS_BRANCH" else # Create orphan branch for clean history git checkout --orphan "$SUGGESTIONS_BRANCH" git rm -rf . > /dev/null 2>&1 || true # Initialize with README cat > README.md << 'EOF' # Documentation Suggestions Queue This branch contains batched documentation suggestions for the next Preview release. Each file represents suggestions from a merged PR. At preview branch cut time, run `script/docs-suggest-publish` to create a documentation PR from these suggestions. ## Structure - `suggestions/PR-XXXXX.md` - Suggestions for PR #XXXXX - `manifest.json` - Index of all pending suggestions ## Workflow 1. PRs merged to main trigger documentation analysis 2. Suggestions are committed here as individual files 3. At preview release, suggestions are collected into a docs PR 4. After docs PR is created, this branch is reset EOF mkdir -p suggestions echo '{"suggestions":[]}' > manifest.json git add README.md suggestions manifest.json git commit -m "Initialize documentation suggestions queue" fi # Create suggestion file SUGGESTION_FILE="suggestions/PR-${PR_NUM}.md" { echo "# PR #${PR_NUM}: ${PR_TITLE}" echo "" echo "_Merged: $(date -u +%Y-%m-%dT%H:%M:%SZ)_" echo "_PR: https://github.com/${REPO}/pull/${PR_NUM}_" echo "" cat "$OUTPUT_FILE" } > "$SUGGESTION_FILE" # Update manifest MANIFEST=$(cat manifest.json) NEW_ENTRY="{\"pr\":${PR_NUM},\"title\":$(echo "$PR_TITLE" | jq -R .),\"file\":\"$SUGGESTION_FILE\",\"date\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}" # Add to manifest if not already present if ! echo "$MANIFEST" | jq -e ".suggestions[] | select(.pr == $PR_NUM)" > /dev/null 2>&1; then echo "$MANIFEST" | jq ".suggestions += [$NEW_ENTRY]" > manifest.json fi # Commit git add "$SUGGESTION_FILE" manifest.json git commit -m "docs: Add suggestions for PR #${PR_NUM} ${PR_TITLE} Auto-generated documentation suggestions for review at next preview release." # Try to push if git push origin "$SUGGESTIONS_BRANCH"; then echo "Successfully pushed suggestions" break else echo "Push failed, retrying..." if [ "$i" -eq "$MAX_RETRIES" ]; then echo "Failed after $MAX_RETRIES attempts" exit 1 fi sleep $((i * 2)) fi done - name: Summary if: always() env: HAS_SUGGESTIONS: ${{ steps.analyze.outputs.has_suggestions }} PR_NUM: ${{ steps.pr.outputs.number }} REPO: ${{ github.repository }} run: | { echo "## Documentation Suggestions" echo "" if [ "$HAS_SUGGESTIONS" == "true" ]; then echo "āœ… Suggestions queued for PR #${PR_NUM}" echo "" echo "View pending suggestions: [docs/suggestions-pending branch](https://github.com/${REPO}/tree/${SUGGESTIONS_BRANCH})" else echo "No documentation updates needed for this PR." fi } >> "$GITHUB_STEP_SUMMARY" # Job for cherry-picks to release branches - immediate output as PR comment cherry-pick-suggestions: runs-on: ubuntu-latest timeout-minutes: 10 permissions: contents: read pull-requests: write concurrency: group: docs-suggestions-${{ github.event.pull_request.number || inputs.pr_number || 'manual' }} cancel-in-progress: true if: | (github.event_name == 'pull_request_target' && startsWith(github.event.pull_request.base.ref, 'v0.') && contains(fromJSON('["MEMBER","OWNER"]'), github.event.pull_request.author_association)) || (github.event_name == 'workflow_dispatch' && inputs.mode == 'immediate') steps: - name: Checkout repository uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: fetch-depth: 0 ref: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.base.ref || '' }} persist-credentials: false - name: Install Droid CLI run: | # Retry with exponential backoff for transient network/auth issues MAX_RETRIES=3 for i in $(seq 1 "$MAX_RETRIES"); do echo "Attempt $i of $MAX_RETRIES to install Droid CLI..." if curl -fsSL https://app.factory.ai/cli | sh; then echo "Droid CLI installed successfully" break fi if [ "$i" -eq "$MAX_RETRIES" ]; then echo "Failed to install Droid CLI after $MAX_RETRIES attempts" exit 1 fi sleep $((i * 5)) done echo "${HOME}/.local/bin" >> "$GITHUB_PATH" env: FACTORY_API_KEY: ${{ secrets.FACTORY_API_KEY }} - name: Get PR number id: pr env: INPUT_PR_NUMBER: ${{ inputs.pr_number }} EVENT_PR_NUMBER: ${{ github.event.pull_request.number }} run: | if [ -n "$INPUT_PR_NUMBER" ]; then PR_NUM="$INPUT_PR_NUMBER" else PR_NUM="$EVENT_PR_NUMBER" fi if ! [[ "$PR_NUM" =~ ^[0-9]+$ ]]; then echo "::error::Invalid PR number: $PR_NUM" exit 1 fi echo "number=$PR_NUM" >> "$GITHUB_OUTPUT" - name: Analyze PR for documentation needs id: analyze env: GH_TOKEN: ${{ github.token }} FACTORY_API_KEY: ${{ secrets.FACTORY_API_KEY }} PR_NUMBER: ${{ steps.pr.outputs.number }} run: | # Ensure gh CLI is authenticated (GH_TOKEN may not be auto-detected) # Unset GH_TOKEN first to allow gh auth login to store credentials echo "$GH_TOKEN" | (unset GH_TOKEN && gh auth login --with-token) OUTPUT_FILE="${RUNNER_TEMP}/suggestions.md" # Cherry-picks don't get preview callout # Retry with exponential backoff for transient Factory API failures MAX_RETRIES=3 for i in $(seq 1 "$MAX_RETRIES"); do echo "Attempt $i of $MAX_RETRIES to analyze PR..." if ./script/docs-suggest \ --pr "$PR_NUMBER" \ --immediate \ --no-preview \ --output "$OUTPUT_FILE" \ --verbose; then echo "Analysis completed successfully" break fi if [ "$i" -eq "$MAX_RETRIES" ]; then echo "Analysis failed after $MAX_RETRIES attempts" exit 1 fi echo "Retrying in $((i * 5)) seconds..." sleep $((i * 5)) done # Check if we got actionable suggestions if [ -s "$OUTPUT_FILE" ] && \ grep -q "Documentation Suggestions" "$OUTPUT_FILE" && \ ! grep -q "No Documentation Updates Needed" "$OUTPUT_FILE"; then echo "has_suggestions=true" >> "$GITHUB_OUTPUT" echo "suggestions_file=$OUTPUT_FILE" >> "$GITHUB_OUTPUT" else echo "has_suggestions=false" >> "$GITHUB_OUTPUT" fi - name: Post suggestions as PR comment if: steps.analyze.outputs.has_suggestions == 'true' uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7 env: SUGGESTIONS_FILE: ${{ steps.analyze.outputs.suggestions_file }} PR_NUMBER: ${{ steps.pr.outputs.number }} with: script: | const fs = require('fs'); // Read suggestions from file const suggestionsRaw = fs.readFileSync(process.env.SUGGESTIONS_FILE, 'utf8'); // Sanitize AI-generated content let sanitized = suggestionsRaw // Strip HTML tags .replace(/<[^>]*>/g, '') // Strip markdown links but keep display text .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') // Strip raw URLs .replace(/https?:\/\/[^\s)>\]]+/g, '[link removed]') // Strip protocol-relative URLs .replace(/\/\/[^\s)>\]]+\.[^\s)>\]]+/g, '[link removed]') // Neutralize @-mentions (preserve JSDoc-style annotations) .replace(/@(?!param\b|returns?\b|throws?\b|typedef\b|type\b|see\b|example\b|since\b|deprecated\b|default\b)(\w+)/g, '`@$1`') // Strip cross-repo references that could be confused with real links .replace(/[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+#\d+/g, '[ref removed]'); // Truncate to 20,000 characters if (sanitized.length > 20000) { sanitized = sanitized.substring(0, 20000) + '\n\n…(truncated)'; } // Parse and validate PR number const prNumber = parseInt(process.env.PR_NUMBER, 10); if (isNaN(prNumber) || prNumber <= 0) { core.setFailed(`Invalid PR number: ${process.env.PR_NUMBER}`); return; } const body = `## šŸ“š Documentation Suggestions This cherry-pick contains changes that may need documentation updates. ${sanitized} --- > **Note:** This comment was generated automatically by an AI model analyzing > code changes. Suggestions may contain inaccuracies — please verify before acting.
About this comment This comment was generated automatically by analyzing code changes in this cherry-pick. Cherry-picks typically don't need new documentation since the feature was already documented when merged to main, but please verify.
`; // Find existing comment to update (avoid spam) const { data: comments } = await github.rest.issues.listComments({ owner: context.repo.owner, repo: context.repo.repo, issue_number: prNumber }); const botComment = comments.find(c => c.user.type === 'Bot' && c.body.includes('Documentation Suggestions') ); if (botComment) { await github.rest.issues.updateComment({ owner: context.repo.owner, repo: context.repo.repo, comment_id: botComment.id, body: body }); } else { await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, issue_number: prNumber, body: body }); } - name: Summary if: always() env: HAS_SUGGESTIONS: ${{ steps.analyze.outputs.has_suggestions }} PR_NUM: ${{ steps.pr.outputs.number }} run: | { echo "## šŸ“š Documentation Suggestions (Cherry-pick)" echo "" if [ "$HAS_SUGGESTIONS" == "true" ]; then echo "Suggestions posted as PR comment on #${PR_NUM}." else echo "No documentation suggestions for this cherry-pick." fi } >> "$GITHUB_STEP_SUMMARY"