feat(medium): Refactor page readiness logic and consolidate CI scripts #3643
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # .github/workflows/pr-enrichment.yml | |
| # Enriches pull request titles and descriptions with contextual information | |
| # based on the files changed, scope of changes, and related issues/PRs. | |
| # | |
| # Configuration: | |
| # Set the following in your repository settings to disable/customize: | |
| # - DISABLE_PR_ENRICHMENT=true - Completely disable PR enrichment | |
| # - DISABLE_TITLE_ENHANCEMENT=true - Only disable title enhancement | |
| name: PR Enrichment | |
| on: | |
| pull_request: | |
| types: [opened, reopened, synchronize] | |
| workflow_call: | |
| inputs: | |
| pr_number: | |
| description: 'PR number to enrich' | |
| required: true | |
| type: string | |
| permissions: | |
| contents: read | |
| pull-requests: write | |
| issues: write | |
| jobs: | |
| enrich-pr: | |
| name: Enrich PR Title and Description | |
| runs-on: ubuntu-latest | |
| # Explicitly define GH_TOKEN at the job level to ensure it's available for all steps. | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| steps: | |
| - uses: actions/checkout@v4 | |
| with: | |
| ref: refs/pull/${{ inputs.pr_number || github.event.pull_request.number }}/head | |
| fetch-depth: 0 | |
| - name: Setup Environment | |
| uses: ./.github/actions/setup-env | |
| - name: Check if PR Enrichment is Disabled | |
| id: check-disabled | |
| env: | |
| PR_TITLE_EVENT: ${{ github.event.pull_request.title }} | |
| HEAD_REF_EVENT: ${{ github.event.pull_request.head.ref || github.head_ref }} | |
| run: | | |
| # 1. Check for manual disable toggle | |
| if [ "${DISABLE_PR_ENRICHMENT:-false}" == "true" ]; then | |
| echo "disabled=true" >> $GITHUB_OUTPUT | |
| echo "::notice::PR enrichment is disabled (DISABLE_PR_ENRICHMENT=true)" | |
| exit 0 | |
| fi | |
| # 2. Automatically disable for E2E tests to prevent interfering with test expectations | |
| # Consistent with pr-orchestrator.yml bypass logic. | |
| if [[ "$PR_TITLE_EVENT" == *"E2E Test PR"* ]] || [[ "$HEAD_REF_EVENT" == "e2e-test-"* ]]; then | |
| echo "disabled=true" >> $GITHUB_OUTPUT | |
| echo "::notice::PR enrichment is disabled for E2E Test PR (Title: '$PR_TITLE_EVENT', Branch: '$HEAD_REF_EVENT')" | |
| exit 0 | |
| fi | |
| echo "disabled=false" >> $GITHUB_OUTPUT | |
| - name: Setup PR Context | |
| id: pr_context | |
| env: | |
| PR_NUMBER_INPUT: ${{ inputs.pr_number }} | |
| PR_NUMBER_EVENT: ${{ github.event.pull_request.number }} | |
| run: | | |
| source scripts/ci/github-utils.sh | |
| if [ -n "$PR_NUMBER_INPUT" ]; then | |
| PR_NUMBER="$PR_NUMBER_INPUT" | |
| else | |
| PR_NUMBER="$PR_NUMBER_EVENT" | |
| fi | |
| echo "PR_NUMBER=$PR_NUMBER" >> $GITHUB_ENV | |
| # Fetch PR details immediately (polling removed). | |
| # Validation ensures both title and head SHA are available. | |
| # REDUCED REDUNDANCY: Metrics (additions, deletions, files) are handled in the Analyze step. | |
| FIELDS="title,body,baseRefName,headRefName,baseRefOid,headRefOid,author" | |
| JQ_FILTER='(. // {}) | {title: (.title // ""), body: (.body // ""), base_ref: (.baseRefName // ""), head_ref: (.headRefName // ""), base_sha: (.baseRefOid // ""), head_sha: (.headRefOid // ""), author: (.author.login // "")}' | |
| VALIDATION='.title != "" and .head_sha != ""' | |
| PR_DATA=$(poll_pr_view "$PR_NUMBER" 12 10 "$FIELDS" "$JQ_FILTER" "$VALIDATION") | |
| if [ -z "$PR_DATA" ]; then | |
| echo "::error::Could not retrieve PR details from GitHub API after retries." | |
| exit 1 | |
| fi | |
| # Securely write PR metadata to GITHUB_ENV using EOF delimiters to prevent command injection. | |
| echo "PR_TITLE<<EOF" >> $GITHUB_ENV | |
| echo "$PR_DATA" | jq -r '.title // "Untitled PR"' >> $GITHUB_ENV | |
| echo "EOF" >> $GITHUB_ENV | |
| echo "PR_AUTHOR<<EOF" >> $GITHUB_ENV | |
| echo "$PR_DATA" | jq -r '.author // "unknown"' >> $GITHUB_ENV | |
| echo "EOF" >> $GITHUB_ENV | |
| echo "BASE_SHA=$(echo "$PR_DATA" | jq -r '.base_sha')" >> $GITHUB_ENV | |
| echo "BASE_REF_NAME=$(echo "$PR_DATA" | jq -r '.base_ref')" >> $GITHUB_ENV | |
| echo "HEAD_SHA=$(echo "$PR_DATA" | jq -r '.head_sha')" >> $GITHUB_ENV | |
| # Securely write multiline body to GITHUB_ENV | |
| echo "PR_BODY<<EOF" >> $GITHUB_ENV | |
| echo "$PR_DATA" | jq -r '.body' >> $GITHUB_ENV | |
| echo "EOF" >> $GITHUB_ENV | |
| - name: Analyze PR Changes | |
| if: steps.check-disabled.outputs.disabled == 'false' | |
| id: analyze | |
| run: | | |
| source scripts/ci/github-utils.sh | |
| PR_NUMBER=${{ env.PR_NUMBER }} | |
| # Retrieval for PR metrics using GitHub API only. | |
| # poll_pr_metrics encapsulates the logic for cross-referencing metadata and diffs (polling removed). | |
| METRICS_JSON=$(poll_pr_metrics "$PR_NUMBER" 18 10) | |
| if [ -z "$METRICS_JSON" ]; then | |
| echo "::error::Could not retrieve PR metrics after retries." | |
| exit 1 | |
| fi | |
| FILES=$(echo "$METRICS_JSON" | jq -r '.files') | |
| FILE_COUNT=$(echo "$METRICS_JSON" | jq -r '.file_count') | |
| ADDITIONS=$(echo "$METRICS_JSON" | jq -r '.additions') | |
| DELETIONS=$(echo "$METRICS_JSON" | jq -r '.deletions') | |
| echo "FILES<<EOF" >> $GITHUB_ENV | |
| echo "$FILES" >> $GITHUB_ENV | |
| echo "EOF" >> $GITHUB_ENV | |
| # Categorize changes | |
| CATEGORIES=() | |
| if echo "$FILES" | grep -qE '^app/|^components/|^lib/'; then | |
| CATEGORIES+=("features") | |
| fi | |
| if echo "$FILES" | grep -qE '^tests/|\.test\.|\.spec\.'; then | |
| CATEGORIES+=("testing") | |
| fi | |
| if echo "$FILES" | grep -qE '^\.github/workflows/|scripts/'; then | |
| CATEGORIES+=("ci") | |
| fi | |
| if echo "$FILES" | grep -qE '\.md$|docs/'; then | |
| CATEGORIES+=("docs") | |
| fi | |
| if echo "$FILES" | grep -qE 'package\.json|tsconfig|eslint|prettier'; then | |
| CATEGORIES+=("build-config") | |
| fi | |
| if echo "$FILES" | grep -qE '\.css$|theme/|styles'; then | |
| CATEGORIES+=("styling") | |
| fi | |
| # Store categories | |
| echo "CATEGORIES=${CATEGORIES[*]}" >> $GITHUB_ENV | |
| # Export metrics | |
| echo "FILE_COUNT=$FILE_COUNT" >> $GITHUB_ENV | |
| echo "ADDITIONS=$ADDITIONS" >> $GITHUB_ENV | |
| echo "DELETIONS=$DELETIONS" >> $GITHUB_ENV | |
| # Determine change scope | |
| if [ "$FILE_COUNT" -le 3 ] && [ "${ADDITIONS:-0}" -le 100 ]; then | |
| SCOPE="small" | |
| elif [ "$FILE_COUNT" -le 10 ] && [ "${ADDITIONS:-0}" -le 500 ]; then | |
| SCOPE="medium" | |
| else | |
| SCOPE="large" | |
| fi | |
| echo "SCOPE=$SCOPE" >> $GITHUB_ENV | |
| - name: Generate PR Description | |
| id: generate-description | |
| if: steps.check-disabled.outputs.disabled == 'false' | |
| env: | |
| GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} | |
| PR_TEMPLATE_PATH: .github/PULL_REQUEST_TEMPLATE.md | |
| run: | | |
| PROMPT_FILE=$(mktemp) | |
| SCRIPT_FILE=$(mktemp) | |
| # Use a here-document to write the Node.js script to a temporary file. | |
| cat <<'EOF' > "$SCRIPT_FILE" | |
| const fs = require("fs"); | |
| // Read environment variables inside the Node.js script | |
| const templatePath = process.env.PR_TEMPLATE_PATH; | |
| const prTitle = process.env.PR_TITLE; | |
| const prAuthor = process.env.PR_AUTHOR; | |
| const prBody = process.env.PR_BODY; | |
| const fileCount = process.env.FILE_COUNT; | |
| const additions = process.env.ADDITIONS; | |
| const deletions = process.env.DELETIONS; | |
| const categories = process.env.CATEGORIES; | |
| const template = fs.readFileSync(templatePath, "utf8"); | |
| const prompt = `You are an AI assistant. Your task is to generate a pull request description based on the provided template and PR data. | |
| ### PR Template | |
| ${template} | |
| ### PR Data | |
| PR Title: ${prTitle} | |
| PR Author: ${prAuthor} | |
| PR Body: ${prBody} | |
| Files Changed: ${fileCount} | |
| Lines Added: ${additions} | |
| Lines Deleted: ${deletions} | |
| Impact Areas: ${categories} | |
| ### Instructions | |
| Your primary task is to populate the provided pull request template using the PR data. | |
| The final output MUST be a valid JSON object containing a single key, "description". | |
| The value of "description" should be a complete markdown string based on the template. | |
| **Crucial Formatting Rule for 'Change Type':** | |
| Inside the markdown string you generate for the "description" field, you MUST format the "Change Type" section as a single line. | |
| - **Correct format:** \`## Change Type: 🐛 Bug fix (non-breaking change fixing an issue)\` | |
| - **Incorrect format:** Do NOT include the list of other options or checkboxes (\`- [ ] ...\`). | |
| ### Output Format | |
| You MUST return a valid JSON object. Do not include markdown formatting like \`\`\`json. | |
| { | |
| "description": "The full, populated pull request description as a markdown string." | |
| } | |
| `; | |
| fs.writeFileSync(process.argv[2], prompt); | |
| EOF | |
| # Execute the script, passing the prompt file path as an argument. | |
| node "$SCRIPT_FILE" "$PROMPT_FILE" | |
| npx tsx scripts/gemini-client.ts --task-file "$PROMPT_FILE" --output "pr_description.json" | |
| - name: Update PR Description | |
| if: steps.check-disabled.outputs.disabled == 'false' | |
| uses: actions/github-script@v7 | |
| env: | |
| PR_NUMBER: ${{ env.PR_NUMBER }} | |
| with: | |
| github-token: ${{ secrets.GITHUB_TOKEN }} | |
| script: | | |
| const fs = require('fs'); | |
| try { | |
| const rawData = fs.readFileSync('pr_description.json', 'utf8'); | |
| const parseGeminiOutput = require('./.github/scripts/parse-gemini-output.cjs'); | |
| let result; | |
| try { | |
| result = parseGeminiOutput(rawData); | |
| } catch (e) { | |
| throw new Error(`Failed to parse PR description: ${e.message}`); | |
| } | |
| // Support both 'description' (normal) and 'reviewComment' (fallback for errors) | |
| const newBody = result.description || result.reviewComment; | |
| if (newBody) { | |
| const prNumber = parseInt(process.env.PR_NUMBER); | |
| const pr = await github.rest.pulls.get({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| pull_number: prNumber | |
| }); | |
| const currentBody = pr.data.body || ''; | |
| // 1. Find the Jules task line anywhere in the current body to ensure it's always found. | |
| const julesTaskRegex = /^PR created automatically by Jules for task .*$/m; | |
| const julesTaskMatch = currentBody.match(julesTaskRegex); | |
| const julesTaskLine = julesTaskMatch ? julesTaskMatch[0] : ''; | |
| // 2. Determine the "true" original body content for the details block. | |
| // Using a more flexible regex to handle variations in whitespace or line endings. | |
| let trueOriginalBody = ''; | |
| const detailsRegex = /<details>\s*<summary>\s*Original PR Body\s*<\/summary>([\s\S]*?)<\/details>/i; | |
| const detailsMatch = currentBody.match(detailsRegex); | |
| if (detailsMatch) { | |
| // On a re-run, the true original body is inside the existing details block. | |
| trueOriginalBody = detailsMatch[1].trim(); | |
| } else { | |
| // On the first run, the entire body is the original body. | |
| trueOriginalBody = currentBody.trim(); | |
| } | |
| // 3. Clean the original body by removing the Jules task line from it. | |
| const cleanedOriginalBody = trueOriginalBody.replace(julesTaskRegex, '').trim(); | |
| // 4. Build the final body. | |
| let finalBody = ''; | |
| if (julesTaskLine) { | |
| finalBody += julesTaskLine + '\n\n'; | |
| } | |
| finalBody += newBody; | |
| finalBody += '\n\n<details>\n<summary>Original PR Body</summary>\n\n' + cleanedOriginalBody + '\n</details>'; | |
| await github.rest.pulls.update({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| pull_number: prNumber, | |
| body: finalBody.trim(), | |
| }); | |
| } else { | |
| core.setFailed('Generated description is empty or missing from the JSON output.'); | |
| } | |
| } catch (error) { | |
| console.error('Error processing description result:', error); | |
| console.error('Raw data:', fs.readFileSync('pr_description.json', 'utf8')); | |
| core.setFailed('Failed to update PR description: ' + error.message); | |
| } | |
| - name: Cleanup | |
| if: always() | |
| run: rm -f pr_description.json | |
| - name: Enhance PR Title | |
| if: steps.check-disabled.outputs.disabled == 'false' && env.DISABLE_TITLE_ENHANCEMENT != 'true' | |
| run: | | |
| CURRENT_TITLE="$PR_TITLE" | |
| # Define Regex Patterns for Conventional Commits | |
| readonly CONVENTIONAL_REGEX='^(feat|fix|docs|style|refactor|test|chore|ci)\(' | |
| readonly GENERIC_SCOPE_REGEX='^[a-zA-Z]+\(' | |
| # Only enhance if title doesn't have a conventional commit prefix | |
| if [[ ! "$CURRENT_TITLE" =~ $CONVENTIONAL_REGEX ]]; then | |
| # Determine prefix based on categories | |
| CATEGORIES="${{ env.CATEGORIES }}" | |
| if echo "$CATEGORIES" | grep -q "features"; then | |
| PREFIX="feat" | |
| elif echo "$CATEGORIES" | grep -q "ci"; then | |
| PREFIX="ci" | |
| elif echo "$CATEGORIES" | grep -q "docs"; then | |
| PREFIX="docs" | |
| elif echo "$CATEGORIES" | grep -q "testing"; then | |
| PREFIX="test" | |
| elif echo "$CATEGORIES" | grep -q "styling"; then | |
| PREFIX="style" | |
| else | |
| PREFIX="chore" | |
| fi | |
| # Determine scope | |
| SCOPE="${{ env.SCOPE }}" | |
| # Create enhanced title only if it doesn't already have a generic scope format | |
| if [[ ! "$CURRENT_TITLE" =~ $GENERIC_SCOPE_REGEX ]]; then | |
| NEW_TITLE="$PREFIX($SCOPE): $CURRENT_TITLE" | |
| echo "Updating title from: $CURRENT_TITLE" | |
| echo "Updating title to: $NEW_TITLE" | |
| REPO_URL="https://api.github.com/repos/${{ github.repository }}" | |
| PR_API_URL="$REPO_URL/pulls/$PR_NUMBER" | |
| curl -X PATCH -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \ | |
| -H "Content-Type: application/json" \ | |
| -d "{\"title\": \"$NEW_TITLE\"}" \ | |
| "$PR_API_URL" || (echo "::error::Could not update PR title" && exit 1) | |
| fi | |
| fi | |
| - name: Log PR Enrichment | |
| run: | | |
| echo "## PR Enrichment Summary" | |
| echo "" | |
| echo "**Scope**: ${{ env.SCOPE }}" | |
| echo "**Files Changed**: ${{ env.FILE_COUNT }}" | |
| echo "**Lines Added**: ${{ env.ADDITIONS }}" | |
| echo "**Lines Deleted**: ${{ env.DELETIONS }}" | |
| echo "**Categories**: ${{ env.CATEGORIES }}" |