GPT Translate by PR #8
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
| name: GPT Translate by PR | |
| on: | |
| workflow_dispatch: | |
| inputs: | |
| pr: | |
| description: "Pull request URL or number (e.g., 123 or https://github.com/org/repo/pull/123)" | |
| required: true | |
| permissions: | |
| id-token: write | |
| pull-requests: write | |
| checks: write | |
| statuses: write | |
| contents: write | |
| jobs: | |
| gpt_translate: | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Parse PR input | |
| id: pr | |
| uses: actions/github-script@v7 | |
| env: | |
| PR_INPUT: ${{ inputs.pr }} | |
| with: | |
| script: | | |
| const raw = process.env.PR_INPUT || ''; | |
| if (!raw || raw.trim() === '') { | |
| throw new Error('PR input is required.'); | |
| } | |
| const trimmed = raw.trim(); | |
| let prNumber = null; | |
| if (/^\d+$/.test(trimmed)) { | |
| prNumber = parseInt(trimmed, 10); | |
| } else { | |
| const matches = trimmed.match(/\d+/g); | |
| if (matches && matches.length > 0) { | |
| prNumber = parseInt(matches[matches.length - 1], 10); | |
| } | |
| } | |
| if (!prNumber || Number.isNaN(prNumber)) { | |
| throw new Error(`Unable to extract pull request number from input: ${trimmed}`); | |
| } | |
| const { data: pr } = await github.rest.pulls.get({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| pull_number: prNumber, | |
| }); | |
| core.setOutput('pr_number', String(prNumber)); | |
| core.setOutput('head_ref', pr.head.ref); | |
| core.setOutput('head_sha', pr.head.sha); | |
| core.setOutput('base_ref', pr.base.ref); | |
| core.setOutput('title', pr.title); | |
| core.setOutput('html_url', pr.html_url); | |
| - name: Collect changed documentation files | |
| id: collect | |
| uses: actions/github-script@v7 | |
| env: | |
| PR_NUMBER: ${{ steps.pr.outputs.pr_number }} | |
| with: | |
| script: | | |
| const rawPrNumber = process.env.PR_NUMBER || ''; | |
| const prNumber = Number.parseInt(rawPrNumber, 10); | |
| if (!prNumber) { | |
| throw new Error(`PR number is missing or invalid: "${rawPrNumber}"`); | |
| } | |
| const isDoc = (path) => | |
| path.startsWith('docs/en/') && | |
| (path.endsWith('.md') || path.endsWith('.json')); | |
| const toCnPath = (path) => | |
| `docs/cn/${path.slice('docs/en/'.length)}`; | |
| const files = await github.paginate( | |
| github.rest.pulls.listFiles, | |
| { | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| pull_number: prNumber, | |
| per_page: 100, | |
| } | |
| ); | |
| const inputSet = new Set(); | |
| const removedSet = new Set(); | |
| for (const file of files) { | |
| const { filename, status, previous_filename: prev } = file; | |
| if (status === 'removed' && isDoc(filename)) { | |
| removedSet.add(toCnPath(filename)); | |
| continue; | |
| } | |
| if (status === 'renamed' && prev && isDoc(prev)) { | |
| removedSet.add(toCnPath(prev)); | |
| } | |
| if (isDoc(filename) && status !== 'removed') { | |
| inputSet.add(`./${filename}`); | |
| } | |
| } | |
| core.setOutput('input_files', Array.from(inputSet).join(' ')); | |
| core.setOutput('removed_cn', Array.from(removedSet).join(' ')); | |
| core.setOutput('has_inputs', inputSet.size > 0 ? 'true' : 'false'); | |
| core.setOutput('has_removals', removedSet.size > 0 ? 'true' : 'false'); | |
| - name: Exit if no documentation changes | |
| if: steps.collect.outputs.has_inputs != 'true' && steps.collect.outputs.has_removals != 'true' | |
| run: | | |
| echo "No English documentation additions, updates, or deletions detected for PR #${{ steps.pr.outputs.pr_number }} (${{ steps.pr.outputs.html_url }})." | |
| exit 0 | |
| - name: Checkout repository | |
| uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| ref: main | |
| - name: Populate PR content | |
| if: steps.collect.outputs.has_inputs == 'true' | |
| env: | |
| PR: ${{ steps.pr.outputs.pr_number }} | |
| INPUT_FILES: ${{ steps.collect.outputs.input_files }} | |
| run: | | |
| set -euo pipefail | |
| git fetch --no-tags --depth=1 origin pull/${PR}/head:refs/remotes/origin/pr-${PR} | |
| for file in $INPUT_FILES; do | |
| clean_path=${file#./} | |
| mkdir -p "$(dirname "$clean_path")" | |
| git show origin/pr-${PR}:"$clean_path" > "$clean_path" | |
| done | |
| - name: Snapshot existing translation branches | |
| if: steps.collect.outputs.has_inputs == 'true' | |
| id: snapshot | |
| run: | | |
| git ls-remote --heads origin 'translation-*' | awk '{print $2}' | sed 's#refs/heads/##' | sort > /tmp/translation-branches-before.txt | |
| echo "before=/tmp/translation-branches-before.txt" >> "$GITHUB_OUTPUT" | |
| - name: Run GPT Translate | |
| if: steps.collect.outputs.has_inputs == 'true' | |
| uses: BohuTANG/[email protected] | |
| with: | |
| github_token: ${{ secrets.GITHUB_TOKEN }} | |
| api_key: ${{ secrets.API_KEY }} | |
| base_url: ${{ secrets.BASE_URL }} | |
| ai_model: ${{ secrets.LLM_MODEL }} | |
| refine_ai_model: ${{ secrets.REFINE_LLM_MODEL }} | |
| target_lang: "Simplified-Chinese" | |
| system_prompt: ".github/workflows/prompt.txt" | |
| refine_system_prompt: ".github/workflows/refine_prompt.txt" | |
| temperature: ${{ secrets.TEMPERATURE }} | |
| refine_temperature: ${{ secrets.REFINE_TEMPERATURE }} | |
| input_files: "${{ steps.collect.outputs.input_files }}" | |
| output_files: "docs/cn/**/*.{md,json}" | |
| pr_title: "Add LLM Translations V2 for PR #${{ steps.pr.outputs.pr_number }}" | |
| - name: Identify translation branch | |
| if: steps.collect.outputs.has_inputs == 'true' | |
| id: branch | |
| env: | |
| SNAPSHOT_FILE: ${{ steps.snapshot.outputs.before }} | |
| run: | | |
| git ls-remote --heads origin 'translation-*' | awk '{print $2}' | sed 's#refs/heads/##' | sort > /tmp/translation-branches-after.txt | |
| comm -13 "$SNAPSHOT_FILE" /tmp/translation-branches-after.txt > /tmp/new-translation-branches.txt | |
| branch=$(tail -n 1 /tmp/new-translation-branches.txt) | |
| if [ -n "$branch" ]; then | |
| echo "Discovered translation branch: $branch" | |
| echo "branch=$branch" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "Unable to determine translation branch created by GPT workflow." | |
| echo "branch=" >> "$GITHUB_OUTPUT" | |
| fi | |
| - name: Annotate translation PR | |
| if: steps.branch.outputs.branch != '' | |
| uses: actions/github-script@v7 | |
| env: | |
| SOURCE_PR: ${{ steps.pr.outputs.pr_number }} | |
| SOURCE_PR_URL: ${{ steps.pr.outputs.html_url }} | |
| REMOVED_FILES: ${{ steps.collect.outputs.removed_cn }} | |
| with: | |
| script: | | |
| const branch = '${{ steps.branch.outputs.branch }}'; | |
| if (!branch) { | |
| core.info('No translation branch detected, skipping annotation.'); | |
| return; | |
| } | |
| const owner = context.repo.owner; | |
| const repo = context.repo.repo; | |
| const prs = await github.paginate(github.rest.pulls.list, { | |
| owner, | |
| repo, | |
| head: `${owner}:${branch}`, | |
| state: 'open', | |
| per_page: 100, | |
| }); | |
| if (prs.length === 0) { | |
| core.warning(`Unable to find translation PR for branch ${branch}`); | |
| return; | |
| } | |
| const translationPr = prs[0]; | |
| const sourcePrNumber = process.env.SOURCE_PR; | |
| const sourcePrUrl = process.env.SOURCE_PR_URL; | |
| const removedFilesRaw = (process.env.REMOVED_FILES || '').trim(); | |
| const removedFilesList = removedFilesRaw | |
| ? removedFilesRaw.split(/\s+/).map((file) => `- \`${file}\``).join('\n') | |
| : '- *(none)*'; | |
| const annotationBlock = [ | |
| '---', | |
| '### 🔗 Source Pull Request', | |
| `- PR #${sourcePrNumber}`, | |
| `- ${sourcePrUrl}`, | |
| '', | |
| '### 🗑️ Synchronized CN Deletions', | |
| removedFilesList, | |
| '---', | |
| ].join('\n'); | |
| let updatedBody = translationPr.body || ''; | |
| const marker = '\n---\n### 🔗 Source Pull Request'; | |
| if (updatedBody.includes(marker)) { | |
| updatedBody = updatedBody.replace(/---\n### 🔗 Source Pull Request[\s\S]*?---/m, annotationBlock); | |
| } else { | |
| updatedBody = `${updatedBody.trim()}\n\n${annotationBlock}`.trim(); | |
| } | |
| const updatedTitle = translationPr.title.includes(`#${sourcePrNumber}`) | |
| ? translationPr.title | |
| : `${translationPr.title} (from #${sourcePrNumber})`; | |
| await github.rest.pulls.update({ | |
| owner, | |
| repo, | |
| pull_number: translationPr.number, | |
| title: updatedTitle, | |
| body: updatedBody, | |
| }); | |
| - name: Apply deletions to translation branch | |
| if: > | |
| steps.collect.outputs.has_inputs == 'true' && | |
| steps.collect.outputs.has_removals == 'true' && | |
| steps.branch.outputs.branch != '' | |
| env: | |
| REMOVED_FILES: ${{ steps.collect.outputs.removed_cn }} | |
| TRANSLATION_BRANCH: ${{ steps.branch.outputs.branch }} | |
| run: | | |
| set -euo pipefail | |
| git fetch origin "$TRANSLATION_BRANCH" | |
| git checkout "$TRANSLATION_BRANCH" | |
| if command -v sudo >/dev/null 2>&1; then | |
| sudo chown -R "$(id -u)":"$(id -g)" .git docs || true | |
| fi | |
| for file in $REMOVED_FILES; do | |
| if [ -f "$file" ]; then | |
| rm -f "$file" | |
| echo "Removed $file" | |
| fi | |
| done | |
| find docs/cn -mindepth 1 -type d -empty -print -delete | |
| if git status --porcelain | grep .; then | |
| git config user.name "github-actions[bot]" | |
| git config user.email "github-actions[bot]@users.noreply.github.com" | |
| git add -A | |
| git commit -m "chore: sync deletions for PR #${{ steps.pr.outputs.pr_number }}" | |
| git push origin "$TRANSLATION_BRANCH" | |
| else | |
| echo "No deletions to commit." | |
| fi | |
| - name: Prepare deletion-only changes | |
| if: steps.collect.outputs.has_inputs != 'true' && steps.collect.outputs.has_removals == 'true' | |
| env: | |
| REMOVED_FILES: ${{ steps.collect.outputs.removed_cn }} | |
| run: | | |
| set -euo pipefail | |
| for file in $REMOVED_FILES; do | |
| if [ -f "$file" ]; then | |
| rm -f "$file" | |
| echo "Removed $file" | |
| fi | |
| done | |
| find docs/cn -mindepth 1 -type d -empty -print -delete | |
| - name: Open deletion-only translation PR | |
| if: steps.collect.outputs.has_inputs != 'true' && steps.collect.outputs.has_removals == 'true' | |
| uses: peter-evans/create-pull-request@v6 | |
| with: | |
| token: ${{ secrets.GITHUB_TOKEN }} | |
| branch: translation-pr-${{ steps.pr.outputs.pr_number }} | |
| base: main | |
| commit-message: "chore: sync deletions for PR #${{ steps.pr.outputs.pr_number }}" | |
| title: "AI Translate cleanup for PR #${{ steps.pr.outputs.pr_number }}" | |
| body: | | |
| This automated PR removes translated files that no longer have an English source from PR #${{ steps.pr.outputs.pr_number }}. |