name: Translate i18n Files with Claude Code

# Note: claude-code-action doesn't support push events directly.
# Push events are bridged by trigger-i18n-sync.yml via repository_dispatch.
on:
  repository_dispatch:
    types: [i18n-sync]
  workflow_dispatch:
    inputs:
      files:
        description: 'Specific files to translate (space-separated, e.g., "app common"). Required for full mode; leave empty in incremental mode to use en-US files changed since HEAD~1.'
        required: false
        type: string
      languages:
        description: 'Specific languages to translate (space-separated, e.g., "zh-Hans ja-JP"). Leave empty for all supported target languages except en-US.'
        required: false
        type: string
      mode:
        description: 'Sync mode: incremental (compare with previous en-US revision) or full (sync all keys in scope)'
        required: false
        default: incremental
        type: choice
        options:
          - incremental
          - full

permissions:
  contents: write
  pull-requests: write

concurrency:
  group: translate-i18n-${{ github.event_name }}-${{ github.ref }}
  cancel-in-progress: false

jobs:
  translate:
    if: github.repository == 'langgenius/dify'
    runs-on: depot-ubuntu-24.04
    timeout-minutes: 120

    steps:
      - name: Checkout repository
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          fetch-depth: 0
          token: ${{ secrets.GITHUB_TOKEN }}

      - name: Configure Git
        run: |
          git config --global user.name "github-actions[bot]"
          git config --global user.email "github-actions[bot]@users.noreply.github.com"

      - name: Setup web environment
        uses: ./.github/actions/setup-web

      - name: Prepare sync context
        id: context
        shell: bash
        run: |
          DEFAULT_TARGET_LANGS=$(awk "
            /value: '/ {
              value=\$2
              gsub(/[',]/, \"\", value)
            }
            /supported: true/ && value != \"en-US\" {
              printf \"%s \", value
            }
          " web/i18n-config/languages.ts | sed 's/[[:space:]]*$//')

          generate_changes_json() {
            node .github/scripts/generate-i18n-changes.mjs
          }

          if [ "${{ github.event_name }}" = "repository_dispatch" ]; then
            BASE_SHA="${{ github.event.client_payload.base_sha }}"
            HEAD_SHA="${{ github.event.client_payload.head_sha }}"
            CHANGED_FILES="${{ github.event.client_payload.changed_files }}"
            TARGET_LANGS="$DEFAULT_TARGET_LANGS"
            SYNC_MODE="${{ github.event.client_payload.sync_mode || 'incremental' }}"

            if [ -n "${{ github.event.client_payload.changes_base64 }}" ]; then
              printf '%s' '${{ github.event.client_payload.changes_base64 }}' | base64 -d > /tmp/i18n-changes.json
              CHANGES_AVAILABLE="true"
              CHANGES_SOURCE="embedded"
            elif [ -n "$BASE_SHA" ] && [ -n "$CHANGED_FILES" ]; then
              export BASE_SHA HEAD_SHA CHANGED_FILES
              generate_changes_json
              CHANGES_AVAILABLE="true"
              CHANGES_SOURCE="recomputed"
            else
              printf '%s' '{"baseSha":"","headSha":"","files":[],"changes":{}}' > /tmp/i18n-changes.json
              CHANGES_AVAILABLE="false"
              CHANGES_SOURCE="unavailable"
            fi
          else
            BASE_SHA=""
            HEAD_SHA=$(git rev-parse HEAD)
            if [ -n "${{ github.event.inputs.languages }}" ]; then
              TARGET_LANGS="${{ github.event.inputs.languages }}"
            else
              TARGET_LANGS="$DEFAULT_TARGET_LANGS"
            fi
            SYNC_MODE="${{ github.event.inputs.mode || 'incremental' }}"
            if [ -n "${{ github.event.inputs.files }}" ]; then
              CHANGED_FILES="${{ github.event.inputs.files }}"
            elif [ "$SYNC_MODE" = "incremental" ]; then
              BASE_SHA=$(git rev-parse HEAD~1 2>/dev/null || true)
              if [ -n "$BASE_SHA" ]; then
                CHANGED_FILES=$(git diff --name-only "$BASE_SHA" "$HEAD_SHA" -- 'web/i18n/en-US/*.json' 2>/dev/null | sed -n 's@^.*/@@p' | sed 's/\.json$//' | tr '\n' ' ' | sed 's/[[:space:]]*$//')
              else
                CHANGED_FILES=$(find web/i18n/en-US -maxdepth 1 -type f -name '*.json' -print | sed -n 's@^.*/@@p' | sed 's/\.json$//' | sort | tr '\n' ' ' | sed 's/[[:space:]]*$//')
              fi
            elif [ "$SYNC_MODE" = "full" ]; then
              echo "workflow_dispatch full mode requires the files input to stay within CI limits." >&2
              exit 1
            else
              CHANGED_FILES=""
            fi

            if [ "$SYNC_MODE" = "incremental" ] && [ -n "$CHANGED_FILES" ]; then
              export BASE_SHA HEAD_SHA CHANGED_FILES
              generate_changes_json
              CHANGES_AVAILABLE="true"
              CHANGES_SOURCE="local"
            else
              printf '%s' '{"baseSha":"","headSha":"","files":[],"changes":{}}' > /tmp/i18n-changes.json
              CHANGES_AVAILABLE="false"
              CHANGES_SOURCE="unavailable"
            fi
          fi

          FILE_ARGS=""
          if [ -n "$CHANGED_FILES" ]; then
            FILE_ARGS="--file $CHANGED_FILES"
          fi

          LANG_ARGS=""
          if [ -n "$TARGET_LANGS" ]; then
            LANG_ARGS="--lang $TARGET_LANGS"
          fi

          {
            echo "DEFAULT_TARGET_LANGS=$DEFAULT_TARGET_LANGS"
            echo "BASE_SHA=$BASE_SHA"
            echo "HEAD_SHA=$HEAD_SHA"
            echo "CHANGED_FILES=$CHANGED_FILES"
            echo "TARGET_LANGS=$TARGET_LANGS"
            echo "SYNC_MODE=$SYNC_MODE"
            echo "CHANGES_AVAILABLE=$CHANGES_AVAILABLE"
            echo "CHANGES_SOURCE=$CHANGES_SOURCE"
            echo "FILE_ARGS=$FILE_ARGS"
            echo "LANG_ARGS=$LANG_ARGS"
          } >> "$GITHUB_OUTPUT"

          echo "Files: ${CHANGED_FILES:-<none>}"
          echo "Languages: ${TARGET_LANGS:-<none>}"
          echo "Mode: $SYNC_MODE"

      - name: Run Claude Code for Translation Sync
        if: steps.context.outputs.CHANGED_FILES != ''
        uses: anthropics/claude-code-action@1dc994ee7a008f0ecc866d9ac23ef036b7229f84 # v1.0.127
        with:
          anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
          github_token: ${{ secrets.GITHUB_TOKEN }}
          allowed_bots: 'github-actions[bot]'
          show_full_output: ${{ github.event_name == 'workflow_dispatch' }}
          prompt: |
            You are the i18n sync agent for the Dify repository.
            Your job is to keep translations synchronized with the English source files under `${{ github.workspace }}/web/i18n/en-US/`.

            Use absolute paths at all times:
            - Repo root: `${{ github.workspace }}`
            - Web directory: `${{ github.workspace }}/web`
            - Language config: `${{ github.workspace }}/web/i18n-config/languages.ts`

            Inputs:
            - Files in scope: `${{ steps.context.outputs.CHANGED_FILES }}`
            - Target languages: `${{ steps.context.outputs.TARGET_LANGS }}`
            - Sync mode: `${{ steps.context.outputs.SYNC_MODE }}`
            - Base SHA: `${{ steps.context.outputs.BASE_SHA }}`
            - Head SHA: `${{ steps.context.outputs.HEAD_SHA }}`
            - Scoped file args: `${{ steps.context.outputs.FILE_ARGS }}`
            - Scoped language args: `${{ steps.context.outputs.LANG_ARGS }}`
            - Structured change set available: `${{ steps.context.outputs.CHANGES_AVAILABLE }}`
            - Structured change set source: `${{ steps.context.outputs.CHANGES_SOURCE }}`
            - Structured change set file: `/tmp/i18n-changes.json`

            Tool rules:
            - Use Read for repository files.
            - Use Edit for JSON updates.
            - Use Bash only for `vp`.
            - Do not use Bash for `git`, `gh`, or branch management.

            Required execution plan:
            1. Resolve target languages.
               - Use the provided `Target languages` value as the source of truth.
               - If it is unexpectedly empty, read `${{ github.workspace }}/web/i18n-config/languages.ts` and use every language with `supported: true` except `en-US`.
            2. Stay strictly in scope.
               - Only process the files listed in `Files in scope`.
               - Only process the resolved target languages, never `en-US`.
               - Do not touch unrelated i18n files.
               - Do not modify `${{ github.workspace }}/web/i18n/en-US/`.
            3. Resolve source changes.
               - If `Structured change set available` is `true`, read `/tmp/i18n-changes.json` and use it as the source of truth for file-level and key-level changes.
               - For each file entry:
                 - `added` contains new English keys that need translations.
                 - `updated` contains stale keys whose English source changed; re-translate using the `after` value.
                 - `deleted` contains keys that should be removed from locale files.
                 - `fileDeleted: true` means the English file no longer exists; remove the matching locale file if present.
               - Read the current English JSON file for any file that still exists so wording, placeholders, and surrounding terminology stay accurate.
               - If `Structured change set available` is `false`, treat this as a scoped full sync and use the current English files plus scoped checks as the source of truth.
            4. Run a scoped pre-check before editing:
               - `vp run dify-web#i18n:check ${{ steps.context.outputs.FILE_ARGS }} ${{ steps.context.outputs.LANG_ARGS }}`
               - Use this command as the source of truth for missing and extra keys inside the current scope.
            5. Apply translations.
               - For every target language and scoped file:
                 - If `fileDeleted` is `true`, remove the locale file if it exists and skip the rest of that file.
                 - If the locale file does not exist yet, create it with `Write` and then continue with `Edit` as needed.
                 - ADD missing keys.
                 - UPDATE stale translations when the English value changed.
                 - DELETE removed keys. Prefer `vp run dify-web#i18n:check ${{ steps.context.outputs.FILE_ARGS }} ${{ steps.context.outputs.LANG_ARGS }} --auto-remove` for extra keys so deletions stay in scope.
               - Preserve placeholders exactly: `{{variable}}`, `${variable}`, HTML tags, component tags, and variable names.
               - Match the existing terminology and register used by each locale.
               - Prefer one Edit per file when stable, but prioritize correctness over batching.
            6. Verify only the edited files.
               - Run `vp run dify-web#lint:fix --quiet -- <relative edited i18n file paths under web/>`
               - Run `vp run dify-web#i18n:check ${{ steps.context.outputs.FILE_ARGS }} ${{ steps.context.outputs.LANG_ARGS }}`
               - If verification fails, fix the remaining problems before continuing.
            7. Stop after the scoped locale files are updated and verification passes.
               - Do not create branches, commits, or pull requests.
          claude_args: |
            --max-turns 120
            --allowedTools "Read,Write,Edit,Bash(vp *),Bash(vp:*),Glob,Grep"

      - name: Prepare branch metadata
        id: pr_meta
        if: steps.context.outputs.CHANGED_FILES != ''
        shell: bash
        run: |
          if [ -z "$(git -C "${{ github.workspace }}" status --porcelain -- web/i18n/)" ]; then
            echo "has_changes=false" >> "$GITHUB_OUTPUT"
            exit 0
          fi

          SCOPE_HASH=$(printf '%s|%s|%s' "${{ steps.context.outputs.CHANGED_FILES }}" "${{ steps.context.outputs.TARGET_LANGS }}" "${{ steps.context.outputs.SYNC_MODE }}" | sha256sum | cut -c1-8)
          HEAD_SHORT=$(printf '%s' "${{ steps.context.outputs.HEAD_SHA }}" | cut -c1-12)
          BRANCH_NAME="chore/i18n-sync-${HEAD_SHORT}-${SCOPE_HASH}"

          {
            echo "has_changes=true"
            echo "branch_name=$BRANCH_NAME"
          } >> "$GITHUB_OUTPUT"

      - name: Commit translation changes
        if: steps.pr_meta.outputs.has_changes == 'true'
        shell: bash
        run: |
          git -C "${{ github.workspace }}" checkout -B "${{ steps.pr_meta.outputs.branch_name }}"
          git -C "${{ github.workspace }}" add web/i18n/
          git -C "${{ github.workspace }}" commit -m "chore(i18n): sync translations with en-US"

      - name: Push translation branch
        if: steps.pr_meta.outputs.has_changes == 'true'
        shell: bash
        run: |
          if git -C "${{ github.workspace }}" ls-remote --exit-code --heads origin "${{ steps.pr_meta.outputs.branch_name }}" >/dev/null 2>&1; then
            git -C "${{ github.workspace }}" push --force-with-lease origin "${{ steps.pr_meta.outputs.branch_name }}"
          else
            git -C "${{ github.workspace }}" push --set-upstream origin "${{ steps.pr_meta.outputs.branch_name }}"
          fi

      - name: Create or update translation PR
        if: steps.pr_meta.outputs.has_changes == 'true'
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          BRANCH_NAME: ${{ steps.pr_meta.outputs.branch_name }}
          FILES_IN_SCOPE: ${{ steps.context.outputs.CHANGED_FILES }}
          TARGET_LANGS: ${{ steps.context.outputs.TARGET_LANGS }}
          SYNC_MODE: ${{ steps.context.outputs.SYNC_MODE }}
          CHANGES_SOURCE: ${{ steps.context.outputs.CHANGES_SOURCE }}
          BASE_SHA: ${{ steps.context.outputs.BASE_SHA }}
          HEAD_SHA: ${{ steps.context.outputs.HEAD_SHA }}
          REPO_NAME: ${{ github.repository }}
        shell: bash
        run: |
          PR_BODY_FILE=/tmp/i18n-pr-body.md
          LANG_COUNT=$(printf '%s\n' "$TARGET_LANGS" | wc -w | tr -d ' ')
          if [ "$LANG_COUNT" = "0" ]; then
            LANG_COUNT="0"
          fi
          export LANG_COUNT

          node <<'NODE' > "$PR_BODY_FILE"
          const fs = require('node:fs')

          const changesPath = '/tmp/i18n-changes.json'
          const changes = fs.existsSync(changesPath)
            ? JSON.parse(fs.readFileSync(changesPath, 'utf8'))
            : { changes: {} }

          const filesInScope = (process.env.FILES_IN_SCOPE || '').split(/\s+/).filter(Boolean)
          const lines = [
            '## Summary',
            '',
            `- **Files synced**: \`${process.env.FILES_IN_SCOPE || '<none>'}\``,
            `- **Languages updated**: ${process.env.TARGET_LANGS || '<none>'} (${process.env.LANG_COUNT} languages)`,
            `- **Sync mode**: ${process.env.SYNC_MODE}${process.env.BASE_SHA ? ` (base: \`${process.env.BASE_SHA.slice(0, 10)}\`, head: \`${process.env.HEAD_SHA.slice(0, 10)}\`)` : ` (head: \`${process.env.HEAD_SHA.slice(0, 10)}\`)`}`,
            '',
            '### Key changes',
          ]

          for (const fileName of filesInScope) {
            const fileChange = changes.changes?.[fileName] || { added: {}, updated: {}, deleted: [], fileDeleted: false }
            const addedKeys = Object.keys(fileChange.added || {})
            const updatedKeys = Object.keys(fileChange.updated || {})
            const deletedKeys = fileChange.deleted || []
            lines.push(`- \`${fileName}\`: +${addedKeys.length} / ~${updatedKeys.length} / -${deletedKeys.length}${fileChange.fileDeleted ? ' (file deleted in en-US)' : ''}`)
          }

          lines.push(
            '',
            '## Verification',
            '',
            `- \`vp run dify-web#i18n:check --file ${process.env.FILES_IN_SCOPE} --lang ${process.env.TARGET_LANGS}\``,
            `- \`vp run dify-web#lint:fix --quiet -- <edited i18n files under web/>\``,
            '',
            '## Notes',
            '',
            '- This PR was generated from structured en-US key changes produced by `trigger-i18n-sync.yml`.',
            `- Structured change source: ${process.env.CHANGES_SOURCE || 'unknown'}.`,
            '- Branch name is deterministic for the head SHA and scope, so reruns update the same PR instead of opening duplicates.',
            '',
            '🤖 Generated with [Claude Code](https://claude.com/claude-code)'
          )

          process.stdout.write(lines.join('\n'))
          NODE

          EXISTING_PR_NUMBER=$(gh pr list --repo "$REPO_NAME" --head "$BRANCH_NAME" --state open --json number --jq '.[0].number')

          if [ -n "$EXISTING_PR_NUMBER" ] && [ "$EXISTING_PR_NUMBER" != "null" ]; then
            gh pr edit "$EXISTING_PR_NUMBER" --repo "$REPO_NAME" --title "chore(i18n): sync translations with en-US" --body-file "$PR_BODY_FILE"
          else
            gh pr create --repo "$REPO_NAME" --head "$BRANCH_NAME" --base main --title "chore(i18n): sync translations with en-US" --body-file "$PR_BODY_FILE"
          fi
