diff --git a/.github/workflows/pr-review-requested.yml b/.github/workflows/pr-review-requested.yml index e4a8cee76c..3463894f76 100644 --- a/.github/workflows/pr-review-requested.yml +++ b/.github/workflows/pr-review-requested.yml @@ -20,9 +20,9 @@ jobs: ref: ${{ github.event.pull_request.base.sha }} persist-credentials: false - name: Send Slack notification on PR review requested - if: github.event.requested_reviewer.login == 'danblackadder' || github.event.requested_reviewer.login == 'aterga' || github.event.requested_reviewer.login == 'sea-snake' || github.event.requested_reviewer.login == 'lmuntaner' + if: github.event.requested_reviewer.login == 'aterga' || github.event.requested_reviewer.login == 'sea-snake' uses: ./.github/actions/slack with: WEBHOOK_URL: ${{ secrets.SLACK_PRIVATE_IDENTITY_WEBHOOK_URL }} MESSAGE: | - <@${{ github.event.requested_reviewer.login == 'danblackadder' && 'U07FDBKDHEH' || github.event.requested_reviewer.login == 'aterga' && 'U02EAPEDT3J' || github.event.requested_reviewer.login == 'sea-snake' && 'U07QU79GX0T' || github.event.requested_reviewer.login == 'lmuntaner' && 'U02TEQHKV35' }}>: New PR ready for review: <${{ github.event.pull_request.html_url }}|${{ github.event.pull_request.title }}>. + <@${{ github.event.requested_reviewer.login == 'aterga' && 'U02EAPEDT3J' || github.event.requested_reviewer.login == 'sea-snake' && 'U07QU79GX0T' }}>: New PR ready for review: <${{ github.event.pull_request.html_url }}|${{ github.event.pull_request.title }}>. diff --git a/.github/workflows/translations-check.yml b/.github/workflows/translations-check.yml new file mode 100644 index 0000000000..7ece931be5 --- /dev/null +++ b/.github/workflows/translations-check.yml @@ -0,0 +1,173 @@ +name: Translations Check + +# Creates translation PRs for languages that have missing `msgstr` entries +# or are affected by newly-added rules in src/frontend/src/lib/locales/CLAUDE.md. +on: + push: + branches: [main] + paths: + - "src/frontend/src/lib/locales/**.po" + - "src/frontend/src/lib/locales/CLAUDE.md" + - "src/frontend/src/lib/locales/rules/**.md" + workflow_dispatch: + +permissions: + contents: read + pull-requests: read + +concurrency: + group: translations-check + cancel-in-progress: false + +jobs: + precheck: + runs-on: ubuntu-latest + outputs: + needs_run: ${{ steps.decide.outputs.needs_run }} + rule_diff_range: ${{ steps.decide.outputs.rule_diff_range }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Decide whether to invoke Claude + id: decide + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + EVENT_NAME: ${{ github.event_name }} + EVENT_BEFORE: ${{ github.event.before }} + EVENT_AFTER: ${{ github.event.after }} + run: | + set -euo pipefail + + # Trigger 1: rule file(s) changed in the pushed commit range (new rule merged)? + # EVENT_BEFORE is the all-zero SHA on branch creation / force pushes + # from non-existent history; guard against that and verify both ends + # resolve before asking git to diff them. + rule_diff_range="" + zero_sha="0000000000000000000000000000000000000000" + if [ "$EVENT_NAME" = "push" ] \ + && [ -n "${EVENT_BEFORE:-}" ] && [ "$EVENT_BEFORE" != "$zero_sha" ] \ + && [ -n "${EVENT_AFTER:-}" ] && [ "$EVENT_AFTER" != "$zero_sha" ] \ + && git cat-file -e "$EVENT_BEFORE^{commit}" 2>/dev/null \ + && git cat-file -e "$EVENT_AFTER^{commit}" 2>/dev/null; then + rule_diff_range="$EVENT_BEFORE..$EVENT_AFTER" + if git diff --quiet "$EVENT_BEFORE" "$EVENT_AFTER" -- src/frontend/src/lib/locales/CLAUDE.md src/frontend/src/lib/locales/rules/; then + rules_changed=false + else + rules_changed=true + fi + else + rules_changed=false + fi + + # Trigger 2: any .po file on main has empty msgstr and no open PR for it? + open_pr_branches=$(gh pr list --repo "$GITHUB_REPOSITORY" --state open --search "chore(fe): update" --json headRefName --jq '.[].headRefName' 2>/dev/null || true) + + has_missing=false + for po in src/frontend/src/lib/locales/*.po; do + lang=$(basename "$po" .po) + [ "$lang" = "en" ] && continue + if echo "$open_pr_branches" | grep -qx "chore/translate-$lang"; then + continue + fi + # Skip the header entry (msgid "") then look for empty msgstr + if awk '/^msgid ""$/{skip=1;next}/^msgid /{skip=0}{if(!skip)print}' "$po" | grep -q '^msgstr ""$'; then + has_missing=true + break + fi + done + + # Always run on workflow_dispatch + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + needs_run=true + elif [ "$rules_changed" = true ] || [ "$has_missing" = true ]; then + needs_run=true + else + needs_run=false + fi + + echo "rules_changed=$rules_changed" + echo "has_missing=$has_missing" + echo "needs_run=$needs_run" + echo "rule_diff_range=$rule_diff_range" + echo "needs_run=$needs_run" >> "$GITHUB_OUTPUT" + echo "rule_diff_range=$rule_diff_range" >> "$GITHUB_OUTPUT" + + run-claude: + needs: precheck + if: needs.precheck.outputs.needs_run == 'true' + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - uses: actions/checkout@v4 + with: + token: ${{ secrets.GIX_BOT_PAT }} + fetch-depth: 0 + + - uses: ./.github/actions/setup-node + + - run: npm ci + + - name: Configure git identity + run: | + git config --global user.name "gix-bot" + git config --global user.email "gix-bot@users.noreply.github.com" + + - name: Install Claude Code CLI + run: npm install -g @anthropic-ai/claude-code@2.1.116 + + - name: Snapshot branch refs before Claude runs + run: git for-each-ref --format='%(refname:short)=%(objectname)' refs/heads/ > "$RUNNER_TEMP/pre-refs.txt" + + - name: Run Claude + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + GH_TOKEN: ${{ secrets.GIX_BOT_PAT }} + RULE_DIFF_RANGE: ${{ needs.precheck.outputs.rule_diff_range }} + run: | + prompt=$(envsubst '${RULE_DIFF_RANGE}' < src/frontend/src/lib/locales/prompts/check.md) + claude -p "$prompt" \ + --dangerously-skip-permissions \ + --allowedTools "Bash(git:*),Bash(gh pr:*),Bash(gh api repos/dfinity/internet-identity/:*),Bash(gh api graphql:*),Bash(npm:*),Bash(awk:*),Bash(grep:*),Bash(sed:*),Bash(cat:*),Bash(basename:*),Bash(echo:*),Bash(jq:*),Bash(wc:*),Bash(head:*),Bash(tail:*),Bash(date:*),Bash(find:*),Bash(ls:*),Bash(mkdir:*),Bash(cd:*),Bash(envsubst:*),Bash(printf:*),Bash(sort:*),Bash(uniq:*),Bash(tr:*),Read,Write,Edit,Glob,Grep,Agent" + + - name: Audit Claude changes + if: always() + env: + ALLOWED_PATHS_REGEX: '^src/frontend/src/lib/locales/[a-z][a-z-]*\.po$' + run: | + set -euo pipefail + violations=() + while IFS='=' read -r ref sha; do + pre_sha=$(grep "^${ref}=" "$RUNNER_TEMP/pre-refs.txt" 2>/dev/null | cut -d= -f2 || true) + if [ -z "$pre_sha" ]; then + base="origin/main" + elif [ "$pre_sha" != "$sha" ]; then + base="$pre_sha" + else + continue + fi + files=$(git diff --name-only "$base..$sha" 2>/dev/null || true) + [ -z "$files" ] && continue + while IFS= read -r f; do + if ! echo "$f" | grep -qE "$ALLOWED_PATHS_REGEX"; then + violations+=("$ref: $f") + fi + done <<< "$files" + done < <(git for-each-ref --format='%(refname:short)=%(objectname)' refs/heads/) + + if [ ${#violations[@]} -gt 0 ]; then + echo "::error::Claude modified files outside the allowed path set for this workflow:" + printf ' %s\n' "${violations[@]}" + exit 1 + fi + echo "Audit passed — all changed files are within the allowed path set." + + - name: Notify Slack on failure + if: ${{ failure() }} + uses: ./.github/actions/slack + with: + WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + MESSAGE: "Translations check failed: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" diff --git a/.github/workflows/translations-context-audit.yml b/.github/workflows/translations-context-audit.yml new file mode 100644 index 0000000000..1e4ea69217 --- /dev/null +++ b/.github/workflows/translations-context-audit.yml @@ -0,0 +1,99 @@ +name: Translations Context Audit + +# Scans Svelte sources for translatable strings that are ambiguous without a +# translator-facing `context` annotation (e.g. short labels like "Cancel" used +# in several places with different meanings) and opens a single sweep PR that +# converts them to `$t({ message, context })` form. +# +# Runs weekly to pick up newly-added ambiguous strings. `workflow_dispatch` is +# also available for on-demand sweeps. Reviewer feedback on the resulting PR is +# handled by the translations-feedback workflow. + +on: + schedule: + # Mondays at 10:00 UTC + - cron: "0 10 * * 1" + workflow_dispatch: + +permissions: + contents: read + pull-requests: read + +concurrency: + group: translations-context-audit + cancel-in-progress: false + +jobs: + run-claude: + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - uses: actions/checkout@v4 + with: + token: ${{ secrets.GIX_BOT_PAT }} + fetch-depth: 0 + + - uses: ./.github/actions/setup-node + + - run: npm ci + + - name: Configure git identity + run: | + git config --global user.name "gix-bot" + git config --global user.email "gix-bot@users.noreply.github.com" + + - name: Install Claude Code CLI + run: npm install -g @anthropic-ai/claude-code@2.1.116 + + - name: Snapshot branch refs before Claude runs + run: git for-each-ref --format='%(refname:short)=%(objectname)' refs/heads/ > "$RUNNER_TEMP/pre-refs.txt" + + - name: Run Claude + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + GH_TOKEN: ${{ secrets.GIX_BOT_PAT }} + run: | + claude -p "$(cat src/frontend/src/lib/locales/prompts/context-audit.md)" \ + --dangerously-skip-permissions \ + --allowedTools "Bash(git:*),Bash(gh pr:*),Bash(gh api repos/dfinity/internet-identity/:*),Bash(gh api graphql:*),Bash(npm:*),Bash(awk:*),Bash(grep:*),Bash(sed:*),Bash(cat:*),Bash(basename:*),Bash(echo:*),Bash(jq:*),Bash(wc:*),Bash(head:*),Bash(tail:*),Bash(date:*),Bash(find:*),Bash(ls:*),Bash(mkdir:*),Bash(cd:*),Bash(envsubst:*),Bash(printf:*),Bash(sort:*),Bash(uniq:*),Bash(tr:*),Read,Write,Edit,Glob,Grep,Agent" + + - name: Audit Claude changes + if: always() + env: + ALLOWED_PATHS_REGEX: '^src/frontend/src/lib/locales/[a-z][a-z-]*\.po$|^src/frontend/src/(lib|routes)/.*\.svelte$' + run: | + set -euo pipefail + violations=() + while IFS='=' read -r ref sha; do + pre_sha=$(grep "^${ref}=" "$RUNNER_TEMP/pre-refs.txt" 2>/dev/null | cut -d= -f2 || true) + if [ -z "$pre_sha" ]; then + base="origin/main" + elif [ "$pre_sha" != "$sha" ]; then + base="$pre_sha" + else + continue + fi + files=$(git diff --name-only "$base..$sha" 2>/dev/null || true) + [ -z "$files" ] && continue + while IFS= read -r f; do + if ! echo "$f" | grep -qE "$ALLOWED_PATHS_REGEX"; then + violations+=("$ref: $f") + fi + done <<< "$files" + done < <(git for-each-ref --format='%(refname:short)=%(objectname)' refs/heads/) + + if [ ${#violations[@]} -gt 0 ]; then + echo "::error::Claude modified files outside the allowed path set for this workflow:" + printf ' %s\n' "${violations[@]}" + exit 1 + fi + echo "Audit passed — all changed files are within the allowed path set." + + - name: Notify Slack on failure + if: ${{ failure() }} + uses: ./.github/actions/slack + with: + WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + MESSAGE: "Translations context audit failed: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" diff --git a/.github/workflows/translations-feedback.yml b/.github/workflows/translations-feedback.yml new file mode 100644 index 0000000000..c87dca022f --- /dev/null +++ b/.github/workflows/translations-feedback.yml @@ -0,0 +1,208 @@ +name: Translations Feedback + +# Applies reviewer feedback on translation PRs and (for broad feedback) +# opens rule-proposal PRs that, once merged, trigger Translations Check. +on: + pull_request_review: + types: [submitted] + pull_request_review_comment: + types: [created] + issue_comment: + types: [created] + +permissions: + contents: read + pull-requests: read + +concurrency: + group: translations-feedback-pr-${{ github.event.pull_request.number || github.event.issue.number }} + cancel-in-progress: false + +jobs: + gate: + runs-on: ubuntu-latest + outputs: + run_claude: ${{ steps.gate.outputs.run_claude }} + pr_number: ${{ steps.gate.outputs.pr_number }} + pr_head_ref: ${{ steps.gate.outputs.pr_head_ref }} + pr_head_repo: ${{ steps.gate.outputs.pr_head_repo }} + steps: + - name: Gate + id: gate + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + EVENT_NAME: ${{ github.event_name }} + COMMENT_USER: ${{ github.event.comment.user.login || github.event.review.user.login }} + ISSUE_PR_URL: ${{ github.event.issue.pull_request.url }} + PR_NUMBER_DIRECT: ${{ github.event.pull_request.number }} + ISSUE_NUMBER: ${{ github.event.issue.number }} + REVIEW_BODY: ${{ github.event.review.body }} + REVIEW_STATE: ${{ github.event.review.state }} + run: | + set -euo pipefail + + # issue_comment fires for both issues and PRs — require pull_request presence. + if [ "$EVENT_NAME" = "issue_comment" ] && [ -z "${ISSUE_PR_URL:-}" ]; then + echo "run_claude=false" >> "$GITHUB_OUTPUT" + echo "Not a PR comment — skipping" + exit 0 + fi + + # pull_request_review fires for approvals and dismissals too. Only + # proceed if the review body carries feedback text — inline review + # comments arrive separately via pull_request_review_comment. + if [ "$EVENT_NAME" = "pull_request_review" ] && [ -z "${REVIEW_BODY:-}" ]; then + echo "run_claude=false" >> "$GITHUB_OUTPUT" + echo "Review has no body (state='${REVIEW_STATE:-}') — skipping" + exit 0 + fi + + # Self-trigger guard: gix-bot's own comments must never kick off another + # run, even if the allowlist below were ever broadened to include it. + if [ "$COMMENT_USER" = "gix-bot" ]; then + echo "run_claude=false" >> "$GITHUB_OUTPUT" + echo "Self-reply — skipping" + exit 0 + fi + + # Allowlist (same as the previous launchd scripts). + case "$COMMENT_USER" in + AntonioVentilii|aterga|sea-snake|marc0olo|mducroux|copilot-pull-request-reviewer|copilot-pull-request-reviewer\[bot\]|Copilot) ;; + *) + echo "run_claude=false" >> "$GITHUB_OUTPUT" + echo "Commenter '$COMMENT_USER' not in allowlist — skipping" + exit 0 + ;; + esac + + pr_number="${PR_NUMBER_DIRECT:-$ISSUE_NUMBER}" + if [ -z "$pr_number" ]; then + echo "run_claude=false" >> "$GITHUB_OUTPUT" + echo "No PR number resolvable — skipping" + exit 0 + fi + + pr_json=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$pr_number") + pr_title=$(echo "$pr_json" | jq -r .title) + pr_head_ref=$(echo "$pr_json" | jq -r .head.ref) + pr_head_repo=$(echo "$pr_json" | jq -r .head.repo.full_name) + pr_state=$(echo "$pr_json" | jq -r .state) + pr_merged=$(echo "$pr_json" | jq -r .merged) + + # Reject fork PRs before any secret-bearing work. The gix-bot PAT + # cannot push to forks, and running Claude on a fork-origin PR would + # expose write-scoped secrets to a workflow influenced by untrusted + # head-repo content. + if [ "$pr_head_repo" != "$GITHUB_REPOSITORY" ]; then + echo "run_claude=false" >> "$GITHUB_OUTPUT" + echo "PR head repo '$pr_head_repo' is a fork — skipping" + exit 0 + fi + + # Only run on translation PRs. + if ! echo "$pr_title" | grep -q '^chore(fe):'; then + echo "run_claude=false" >> "$GITHUB_OUTPUT" + echo "PR title '$pr_title' is not a translation PR — skipping" + exit 0 + fi + + # For closed PRs, only consider those merged within the last 14 days + # (matching the previous launchd behaviour). + if [ "$pr_state" = "closed" ] && [ "$pr_merged" = "true" ]; then + merged_at=$(echo "$pr_json" | jq -r .merged_at) + cutoff=$(date -u -d '14 days ago' +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u -v-14d +%Y-%m-%dT%H:%M:%SZ) + if [ "$merged_at" \< "$cutoff" ]; then + echo "run_claude=false" >> "$GITHUB_OUTPUT" + echo "PR merged before $cutoff — skipping" + exit 0 + fi + elif [ "$pr_state" = "closed" ]; then + echo "run_claude=false" >> "$GITHUB_OUTPUT" + echo "PR is closed without merge — skipping" + exit 0 + fi + + echo "run_claude=true" >> "$GITHUB_OUTPUT" + echo "pr_number=$pr_number" >> "$GITHUB_OUTPUT" + echo "pr_head_ref=$pr_head_ref" >> "$GITHUB_OUTPUT" + echo "pr_head_repo=$pr_head_repo" >> "$GITHUB_OUTPUT" + + run-claude: + needs: gate + if: needs.gate.outputs.run_claude == 'true' + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - uses: actions/checkout@v4 + with: + token: ${{ secrets.GIX_BOT_PAT }} + fetch-depth: 0 + + - uses: ./.github/actions/setup-node + + - run: npm ci + + - name: Configure git identity + run: | + git config --global user.name "gix-bot" + git config --global user.email "gix-bot@users.noreply.github.com" + + - name: Install Claude Code CLI + run: npm install -g @anthropic-ai/claude-code@2.1.116 + + - name: Snapshot branch refs before Claude runs + run: git for-each-ref --format='%(refname:short)=%(objectname)' refs/heads/ > "$RUNNER_TEMP/pre-refs.txt" + + - name: Run Claude + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + GH_TOKEN: ${{ secrets.GIX_BOT_PAT }} + PR_NUMBER: ${{ needs.gate.outputs.pr_number }} + PR_HEAD_REF: ${{ needs.gate.outputs.pr_head_ref }} + PR_HEAD_REPO: ${{ needs.gate.outputs.pr_head_repo }} + run: | + prompt=$(envsubst '${PR_NUMBER} ${PR_HEAD_REF} ${PR_HEAD_REPO}' < src/frontend/src/lib/locales/prompts/feedback.md) + claude -p "$prompt" \ + --dangerously-skip-permissions \ + --allowedTools "Bash(git:*),Bash(gh pr:*),Bash(gh api repos/dfinity/internet-identity/:*),Bash(gh api graphql:*),Bash(npm:*),Bash(awk:*),Bash(grep:*),Bash(sed:*),Bash(cat:*),Bash(basename:*),Bash(echo:*),Bash(jq:*),Bash(wc:*),Bash(head:*),Bash(tail:*),Bash(date:*),Bash(find:*),Bash(ls:*),Bash(mkdir:*),Bash(cd:*),Bash(envsubst:*),Bash(printf:*),Bash(sort:*),Bash(uniq:*),Bash(tr:*),Read,Write,Edit,Glob,Grep,Agent" + + - name: Audit Claude changes + if: always() + env: + ALLOWED_PATHS_REGEX: '^src/frontend/src/lib/locales/([a-z][a-z-]*\.po|CLAUDE\.md|rules/[a-zA-Z0-9_-]+\.md)$|^src/frontend/src/(lib|routes)/.*\.svelte$' + run: | + set -euo pipefail + violations=() + while IFS='=' read -r ref sha; do + pre_sha=$(grep "^${ref}=" "$RUNNER_TEMP/pre-refs.txt" 2>/dev/null | cut -d= -f2 || true) + if [ -z "$pre_sha" ]; then + base="origin/main" + elif [ "$pre_sha" != "$sha" ]; then + base="$pre_sha" + else + continue + fi + files=$(git diff --name-only "$base..$sha" 2>/dev/null || true) + [ -z "$files" ] && continue + while IFS= read -r f; do + if ! echo "$f" | grep -qE "$ALLOWED_PATHS_REGEX"; then + violations+=("$ref: $f") + fi + done <<< "$files" + done < <(git for-each-ref --format='%(refname:short)=%(objectname)' refs/heads/) + + if [ ${#violations[@]} -gt 0 ]; then + echo "::error::Claude modified files outside the allowed path set for this workflow:" + printf ' %s\n' "${violations[@]}" + exit 1 + fi + echo "Audit passed — all changed files are within the allowed path set." + + - name: Notify Slack on failure + if: ${{ failure() }} + uses: ./.github/actions/slack + with: + WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + MESSAGE: "Translations feedback failed on PR #${{ needs.gate.outputs.pr_number }}: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" diff --git a/src/frontend/src/lib/locales/CLAUDE.md b/src/frontend/src/lib/locales/CLAUDE.md new file mode 100644 index 0000000000..6e7cf52dcc --- /dev/null +++ b/src/frontend/src/lib/locales/CLAUDE.md @@ -0,0 +1,13 @@ +# Translation rules + +This file is the index. Individual rule sets live in [`rules/`](./rules/) and are imported below. Add a new rule by appending to the relevant `rules/.md` file — or creating a new one and adding an `@rules/...` line here. + +Rules are added via "rule proposal" pull requests (title prefix `chore(fe): propose translation rule`). Merging such a PR signals approval and triggers a sweep of existing translations against the new rule. + +## General (applies to all languages) + +@rules/general.md + +## Language-specific + +@rules/russian.md diff --git a/src/frontend/src/lib/locales/prompts/check.md b/src/frontend/src/lib/locales/prompts/check.md new file mode 100644 index 0000000000..627ac7c448 --- /dev/null +++ b/src/frontend/src/lib/locales/prompts/check.md @@ -0,0 +1,91 @@ +You are running in CI. Check this repo (`dfinity/internet-identity`) for missing translations and/or newly-added rules, then create pull requests as needed. + +Follow the rules in `src/frontend/src/lib/locales/CLAUDE.md`. Read that file before you start translating. + +You are committing as `gix-bot` — replies to comments and all git commits must be authored by that identity. "Already addressed" checks must look for responses from `gix-bot`, not from any human reviewer. + +## Step 1: Detect newly-added rules + +Rules live under `src/frontend/src/lib/locales/rules/` — one file per topic, imported from `src/frontend/src/lib/locales/CLAUDE.md`. `rules/general.md` applies to all languages; `rules/.md` (e.g. `rules/russian.md`, `rules/dutch.md`) applies to one language. + +If the environment variable `$RULE_DIFF_RANGE` is non-empty (e.g. `abc123..def456`), run: + +``` +git diff $RULE_DIFF_RANGE -- src/frontend/src/lib/locales/CLAUDE.md src/frontend/src/lib/locales/rules/ +``` + +to see which rules the triggering push added or modified. If rules were added/changed, treat every language affected by them as requiring a sweep — even if that language has no missing `msgstr` entries. A diff that only touches `CLAUDE.md` imports (no change to the rule content itself) does not require a sweep. + +If `$RULE_DIFF_RANGE` is empty (manual `workflow_dispatch` run), skip this step and handle only missing translations in Step 2. + +## Step 2: Detect missing translations + +For each `.po` file in `src/frontend/src/lib/locales/` (skip `en.po`), check whether it has any entries with empty `msgstr ""` (excluding the header). Fetch the raw file content directly; you do not need to check out main in a worktree just for this step. + +If there are no new rules from Step 1 AND no missing translations, stop here and report "Nothing to do". + +## Step 3: Skip languages with existing open PRs + +For each candidate language, skip it if there is already an open PR with branch `chore/translate-`. Check with: + +``` +gh pr list --repo dfinity/internet-identity --state open --search "chore(fe): update" --json number,headRefName +``` + +## Step 4: Create one PR per remaining language + +For each language, do the following in an isolated git worktree: + +1. Create a branch `chore/translate-` (e.g. `chore/translate-de`). +2. Run `npm run extract` to ensure `.po` files reflect the latest source strings. +3. Translate any entries where `msgstr` is empty. +4. If this language is affected by a newly-added rule from Step 1, also review existing translations in that file and update any that do not comply with the new rule. +5. Run `npm run extract` again to clean up, then `npm run format`. +6. Stage only the specific `.po` file for this language (e.g. `git add src/frontend/src/lib/locales/de.po`). `npm run extract`/`npm run format` may touch other `.po` files — do not include those. +7. Commit and push. +8. Open a PR with `gh pr create`: + + Title: `chore(fe): update translations` + + Body (follows the repo PR template — no Motivation heading, Tests section only if there are tests): + + ``` + New translations were missing for `.po`. This PR adds the missing translations. + + # Changes + + - Translated missing entries in `src/frontend/src/lib/locales/.po` + ``` + + If the PR is also applying a newly-added rule, adjust the body: + + ``` + This PR updates `.po` to comply with the translation rule introduced in #, and adds any missing translations. + + # Changes + + - Applied translation rule: + - Translated missing entries in `src/frontend/src/lib/locales/.po` (if applicable) + ``` + +## Step 5: Add reviewers + +After opening each PR, run `gh pr edit --add-reviewer `. + +Always request review from `aterga` AND `sea-snake`. In addition, if the language has a language-specific reviewer from the mapping below, request review from them too and leave a comment tagging them: + +> @ this PR may already be merged by the time you see it, but if you spot any translation mistakes feel free to leave comments or suggestions here — they'll be picked up by AI in a future run. Besides specific fixes, broader feedback is also welcome (e.g., tone, terminology preferences, style guidelines) — these will be reviewed and applied across all future translations. + +Language-specific reviewers (in addition to `aterga` and `sea-snake`): + +- Italian (`it.po`) → `AntonioVentilii` +- French (`fr.po`) → `mducroux` +- German (`de.po`) → `marc0olo` + +Dutch (`nl.po`) has no separate language-specific reviewer — `sea-snake` is already covered by the default pair. + +## Important + +- One PR per language, never one combined PR. +- Do not touch files for languages that have all translations filled in and are not affected by a newly-added rule. +- Skip `en.po` — it is the source language. diff --git a/src/frontend/src/lib/locales/prompts/context-audit.md b/src/frontend/src/lib/locales/prompts/context-audit.md new file mode 100644 index 0000000000..267b03b11d --- /dev/null +++ b/src/frontend/src/lib/locales/prompts/context-audit.md @@ -0,0 +1,103 @@ +You are running in CI to audit translatable strings in Svelte files. Your goal is to add translator-facing `context` annotations _only where ambiguity is real_, and to keep `.po` bloat minimal by reusing existing context phrases whenever they semantically match. + +Follow the rules in `src/frontend/src/lib/locales/CLAUDE.md`. Read that file before starting. + +You are committing as `gix-bot` — git commits must be authored by that identity. + +## Step 1: Skip if a context-audit PR is already open + +Check for an existing open context-audit PR: + +``` +gh pr list --repo dfinity/internet-identity --state open --head chore/translation-context --json number,title +``` + +If there is already an open PR on that branch, stop and report "A context-audit PR is already open." Reviewer feedback on that PR is handled by the separate feedback flow, not here. + +## Step 2: Build a context inventory + +Before touching anything, scan all `.svelte` files under `src/frontend/src/lib` and `src/frontend/src/routes` and build a map of: + +- **Already-annotated entries**: every `$t({ message: "...", context: "..." })` occurrence → `{msgid, context, file:line, surrounding UI role}`. +- **Unannotated entries**: every `$t\`...\``and`$t({ message: "..." })`without`context`→`{msgid, file:line, surrounding UI role}`. + +The inventory drives the rest of the sweep. Each `.po` file entry is keyed by `(msgid, msgctxt)` — reusing the _exact same_ context string across call sites collapses them into a single entry, which is what keeps translation files small. + +## Step 3: Decide what needs a context + +Ambiguity criteria, in priority order: + +1. **Same msgid used in ≥2 places with divergent meanings** (e.g. `Cancel` as a modal dismiss vs. `Cancel` to abort reauthentication). +2. **Commonly ambiguous short words** (≤3 words): `Right`, `Left`, `Close`, `Open`, `Back`, `Save`, `Cancel`, `Continue`, `Skip`, `Done`, `New`, `Edit`, `View`, `Clear`, `Run`, `Set`, `Show`, `Hide`, `OK`, `Remove`, `Reset`, `Retry`, `Verify`, `Default`, `Upgrade`. +3. **Generic labels** that could refer to multiple kinds of things: `Name`, `Type`, `Status`. + +Skip in all other cases. Long phrases (>5 words), file names, URLs, identifiers, and anything that's clearly self-disambiguating from the msgid alone are NOT candidates. + +**When in doubt, do nothing.** A missing annotation is cheap to add in a later sweep; an unnecessary one permanently bloats `.po` files across every supported language. + +## Step 4: Pick a context, preferring reuse + +For each string you decided to annotate, pick a context phrase using this decision tree: + +1. **Does an already-annotated call site of the same msgid have a context whose description matches the UI role at the new call site?** If yes, reuse the existing phrase _byte-for-byte_ (copy-paste it). Do not rephrase, punctuate, or "improve" it — any deviation creates a separate `.po` entry. +2. **Are there multiple unannotated call sites of the same msgid that share the same UI role?** Group them and assign them all the same new context phrase. +3. **Only invent a new context phrase when no existing one fits.** Use concise, translator-facing descriptions: `"button label: dismiss dialog"`, `"menu item: delete passkey"`, `"direction"`, `"heading"`. Prefer reusing words already present in the existing inventory over minting synonyms ("dismiss modal" vs. "dismiss dialog" generate two entries). + +If during scanning you notice two existing annotations whose contexts are semantically equivalent but textually different (e.g. `"dismiss modal"` and `"close dialog"` both used for modal-dismiss), normalize them to the same phrase as part of this sweep. Note each normalization in the PR body. + +## Step 5: Apply the conversions + +For each call site chosen in Step 3 with its phrase from Step 4: + +1. Convert `$t\`X\``to`$t({ message: "X", context: "" })`. Preserve any variables — `$t\`Hello ${name}\`` becomes `$t({ message: \`Hello ${name}\`, context: "..." })`(template literal retained for`message` when variables are present). +2. For `$t({ message: "X" })` with no context, just add the `context` field. + +Do not change anything else — no copy changes, no translation changes, no unrelated refactors. + +### Budget + +Apply **no more than 40 annotations per sweep**. If the inventory turns up more candidates than 40, pick the highest-impact ones (strings used in the most places, most-likely-mistranslated words from the list in Step 3). Remaining candidates will be picked up in a future sweep. If you're about to exceed 40, stop and note the carry-over in the PR body. + +## Step 6: Extract and format + +Run `npm run extract`, then `npm run format`. Existing translations for the now-context-annotated msgids will be moved/cleared by Lingui; new `(msgid, msgctxt)` pairs appear with empty `msgstr` and will be picked up by the `translations-check` workflow to produce per-language translation PRs once this PR merges. + +## Step 7: Open the PR + +1. Branch: `chore/translation-context` (branch from latest `main`). +2. Stage BOTH the `.svelte` source changes AND the `src/frontend/src/lib/locales/*.po` changes. +3. Commit and push. +4. Count `.po` entries before and after the sweep. Before: total entries on `main` (e.g. `grep -c '^msgid ' src/frontend/src/lib/locales/en.po`). After: same on your branch. Report both numbers in the PR body. +5. Open the PR with `gh pr create`: + + Title: `chore(fe): add translation context` + + Body (follows the repo PR template — no Motivation heading, Tests section only if there are tests): + + ``` + Adds translator-facing `context` annotations to ambiguous translatable strings so translators can diverge translations per UI role. + + # Changes + + - Converted occurrences across files to `$t({ message, context })` + - Reused existing context phrases for of them (no new `.po` entries) + - Introduced new context phrases + - `.po` entries before → after: (+) + + - Normalized <"old phrase" → "new phrase"> to collapse semantically-equivalent contexts + + - Budget reached at 40 annotations; additional candidates deferred to next sweep + + # Examples + + - `` → distinct contexts (list them briefly) + ``` + +6. Add reviewers with `gh pr edit --add-reviewer aterga,sea-snake`. Do not add language-specific reviewers here — context phrasing is language-agnostic; per-language translation PRs will come later via the check workflow. + +## Important + +- Do not change translations or user-visible copy — only add `context` annotations to sources. +- Reuse existing context phrases byte-for-byte whenever the UI role matches; this is the primary lever for keeping `.po` files small. +- When in doubt, skip the annotation. +- One PR for the whole sweep. diff --git a/src/frontend/src/lib/locales/prompts/feedback.md b/src/frontend/src/lib/locales/prompts/feedback.md new file mode 100644 index 0000000000..04245479da --- /dev/null +++ b/src/frontend/src/lib/locales/prompts/feedback.md @@ -0,0 +1,113 @@ +You are running in CI, triggered by a reviewer comment or review on a pull request. Address the feedback on PR `#$PR_NUMBER` in the `dfinity/internet-identity` repository. + +Follow the rules in `src/frontend/src/lib/locales/CLAUDE.md`. Read that file before applying any fixes. + +You are committing as `gix-bot` — replies to comments and all git commits must be authored by that identity. "Already replied" checks must look for responses from `gix-bot`. + +## Step 0: Determine PR kind + +Fetch `gh pr view $PR_NUMBER --repo dfinity/internet-identity --json title,state,mergedAt,merged,headRefName` and decide: + +- **Rule-proposal PR** — title starts with `chore(fe): propose translation rule`. Skip every `.po` / `npm run extract` / `npm run format` step below. Apply requested text edits directly to the proposed rule file under `src/frontend/src/lib/locales/rules/` on the PR branch, commit and push. If the reviewer is asking for the rule to be moved to a different file (e.g. rescoped from general to language-specific), do that and update the `@rules/...` import in `src/frontend/src/lib/locales/CLAUDE.md` accordingly. Still reply to every allowed-user comment. +- **Translation PR (open)** — title matches `chore(fe): update translations` and PR is open. Push fixes to the PR branch (Step 2a). +- **Translation PR (merged within 14 days)** — same title, PR is merged. Open a NEW translation PR on a fresh branch with the fixes (Step 2b), then reply on the merged PR with a link. +- **Context-audit PR (open)** — title starts with `chore(fe): add translation context` and PR is open. Refinements here are changes to the `context` values in `.svelte` files (e.g. a reviewer suggests a clearer context phrase, or flags a missed ambiguous string). Follow Step 2a, but note: edits go to `.svelte` sources, and you must re-run `npm run extract` and `npm run format` so the `.po` files stay in sync. Stage both the `.svelte` changes and the updated `.po` files. Do not modify translations in this flow. +- **Context-audit PR (merged within 14 days)** — rare; if a reviewer flags an issue on a merged context-audit PR, reply with a link to the `translations-context-audit` workflow and ask a maintainer to re-dispatch it so a fresh sweep can incorporate the feedback. Do not open a one-off follow-up PR. + +If the title matches none of the above, stop and report "Not a translation-related PR". + +## Step 1: Fetch feedback + +Consider only comments from these users (ignore everyone else, including yourself): + +`AntonioVentilii`, `aterga`, `sea-snake`, `marc0olo`, `mducroux`, `copilot-pull-request-reviewer`, `copilot-pull-request-reviewer[bot]`, `Copilot`. + +Pull all three streams and treat each entry as a "comment": + +- Inline review comments: `gh api repos/dfinity/internet-identity/pulls/$PR_NUMBER/comments` +- Issue-style PR comments: `gh api repos/dfinity/internet-identity/issues/$PR_NUMBER/comments` +- Review bodies: `gh api repos/dfinity/internet-identity/pulls/$PR_NUMBER/reviews` — each review's `body` field (when non-empty) is itself a comment to address. Reply to it with `gh api repos/dfinity/internet-identity/issues/$PR_NUMBER/comments -X POST -f body=...`, referencing the review id in the text. + +A comment is **unaddressed** if there is no reply from `gix-bot` or a bot acknowledging the fix. + +Every comment from the allowed users MUST get a reply — even a simple acknowledgement when no code change is needed. Always respond to feedback. + +If none of the recent comments are unaddressed, stop here and report "No unaddressed feedback". + +## Step 2a: Apply fixes to an open translation PR + +For each unaddressed comment on an open translation PR: + +1. Fetch the PR branch and check it out in a worktree: + + ``` + git fetch origin + git worktree add ../feedback- + cd ../feedback- + ``` + +2. Apply the requested translation fix to the relevant `.po` file. +3. Run `npm run extract` to clean up, then `npm run format`. +4. Stage only the relevant `.po` file — do not stage other files that `npm run extract`/`npm run format` may have modified. +5. Commit and push to the PR branch. +6. Reply to the comment confirming the fix was applied. +7. If the comment is from `copilot-pull-request-reviewer`, `copilot-pull-request-reviewer[bot]`, or `Copilot`, resolve the review thread after replying (use `gh api repos/dfinity/internet-identity/pulls/comments//resolve -X PUT` or the GraphQL `resolveReviewThread` mutation). + +## Step 2b: Create a new PR for fixes to a merged translation PR + +You cannot push to a merged branch. Instead, open a new translation PR: + +1. From the latest `main`, create a branch `chore/translate-` (where `` is the language of the merged PR). If that branch already exists as an open PR, add the fix to that existing PR instead of opening another. +2. Apply the requested translation fix(es) to the `.po` file. +3. Run `npm run extract` then `npm run format`, stage only the relevant `.po` file, commit and push. +4. Open the PR with the body format from the check prompt, noting the source: `- Fixed translations based on reviewer feedback from #`. +5. Add reviewers per the language mapping in the check prompt (always `aterga`, plus language-specific where applicable). +6. Reply to each original feedback comment on the merged PR with a link to the new PR (e.g. "Fixed in #1234"). +7. Resolve Copilot threads as in Step 2a. + +## Step 3: Classify each comment + +- **Narrow**: a fix to a specific translation. Nothing further. +- **Broad**: feedback that implies a pattern or rule applicable to future translations (e.g. "always use formal 'you' in German", "prefer X terminology over Y", "don't translate brand names"). + +Skip this step when handling comments on a rule-proposal PR — broad/narrow distinction does not apply there. + +## Step 4: Rule-proposal PR for broad feedback + +For each broad comment, create one rule-proposal PR (branched from latest `main`, not from the feedback PR): + +1. Create a branch `chore/translation-rule-`. +2. Rules live under `src/frontend/src/lib/locales/rules/` — one file per topic, imported by `src/frontend/src/lib/locales/CLAUDE.md`. Choose the target file: + - **General rule** (applies to all languages) → append to `rules/general.md`. + - **Language-specific rule** → append to `rules/.md` (e.g. `rules/russian.md`, `rules/dutch.md`). If that file does not exist yet, create it and add a new `@rules/.md` line under the "Language-specific" section of `CLAUDE.md`. +3. The rule text should be self-contained and prescriptive — do not include metadata like dates or source links; that lives in the PR description and git history. +4. Commit and push. When creating a new `rules/.md`, stage both the new file and the `CLAUDE.md` import update in the same commit. +5. Open a PR with `gh pr create`: + + Title: `chore(fe): propose translation rule — ` + + Body: + + ``` + Rule proposed based on reviewer feedback from #. + + # Changes + + - Added translation rule to `src/frontend/src/lib/locales/CLAUDE.md` + + # Context + + Reviewer: @ + Original comment: + ``` + +6. Request review from `aterga` and `sea-snake` with `gh pr edit --add-reviewer aterga,sea-snake`. This triggers the existing Slack notification workflow. +7. Reply to the original feedback comment with a link to the rule-proposal PR, e.g. "Proposed as a general rule in #." + +Merging a rule-proposal PR signals approval and will trigger a sweep of existing translations. + +## Important + +- Only fix what the reviewer asked for — do not add new translations or make unrelated changes. +- Preserve all variables (like `{variable}`) and tags (like `<0>...`). +- Always reply to every comment from allowed users. diff --git a/src/frontend/src/lib/locales/rules/general.md b/src/frontend/src/lib/locales/rules/general.md new file mode 100644 index 0000000000..4fabd9d228 --- /dev/null +++ b/src/frontend/src/lib/locales/rules/general.md @@ -0,0 +1,8 @@ +Rules that apply to every language when translating `.po` files in this directory. + +- Analyze existing translations in the file first to determine the established tone (e.g., formal/informal "you"), style, and terminology, and stay consistent with them. +- Preserve all variables (like `{variable}`) and tags (like `<0>...`). +- Even for RTL languages, keep tags in the same order as the source text — do not reverse them. +- Apply the correct ICU plural categories for the target language (e.g. `one`/`other`, `one`/`few`/`many`/`other`, or just `other`). `other` is mandatory; additional categories are optional. If a target-language category is not explicitly present, ICU falls back to `other`. +- If the source plural has fewer categories than the target language, add target-language categories when needed for natural grammar. It is acceptable to keep fewer categories (such as only `one` + `other`) when that remains grammatically natural. +- Do not translate brand names. diff --git a/src/frontend/src/lib/locales/rules/russian.md b/src/frontend/src/lib/locales/rules/russian.md new file mode 100644 index 0000000000..140969c0b0 --- /dev/null +++ b/src/frontend/src/lib/locales/rules/russian.md @@ -0,0 +1,8 @@ +Rules specific to `ru.po`. + +## Distinguish "identity" vs "account" terminology + +- **"Identity"** (the user's Internet Identity — their profile/unique ID on the II platform) → **учётная запись** +- **"Account"** (an app-specific login created within II, or a third-party account like Google/Apple) → **аккаунт** + +Do not use идентификатор for "identity". Do not use учётная запись for "account".