From 1c08d038260749f80ffb182114015f2f8c8bf97a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 20 Jun 2026 13:36:23 +0000 Subject: [PATCH 1/3] docs(i18n): keep auto-translate routine to a single open PR Switch the routine from a date-stamped branch (claude/translations-, which opened a new PR on every daily/push-triggered run) to a single long-lived claude/translations branch. Re-runs now locate the open PR, rebase the branch onto main, translate only the new gaps, and update the existing PR in place instead of opening another. Detection runs on the branch so already-translated units (and reviewer corrections) are not re-flagged. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01NQH6VBPhDr46yJMMH9dGcJ --- .claude/routines/translate.md | 116 +++++++++++++++++++++++++++------- docs/gotchas/i18n.md | 2 +- 2 files changed, 93 insertions(+), 25 deletions(-) diff --git a/.claude/routines/translate.md b/.claude/routines/translate.md index 4e7eabc0..d10d9f89 100644 --- a/.claude/routines/translate.md +++ b/.claude/routines/translate.md @@ -19,14 +19,22 @@ reasonable fix attempt. ### Goal -Detect every missing translation in the repo and translate it. Open one pull -request titled `chore(i18n): fill missing translations ( items)` from a -`claude/translations-` branch with `Closes` set to nothing (no -backing issue) and a body that lists each touched locale with a per-locale -gap count. - -If the detector reports zero gaps, exit cleanly with a short status message -and do **not** open a PR. +Detect every missing translation in the repo and translate it. Keep **exactly +one** open translation PR alive at any time, backed by a single long-lived +branch `claude/translations` (no date suffix). If that PR is already open, +**update it in place** — never open a second one. The PR is titled +`chore(i18n): fill missing translations ( items)`, has `Closes` set to +nothing (no backing issue), and a body that lists each touched locale with a +per-locale gap count. + +Exit decision after gap detection: + +- **No gaps and no open translation PR** → exit cleanly with a short status + message; do **not** open a PR. +- **No new gaps but a translation PR is already open** → leave it untouched + (it is just waiting to be merged); exit cleanly. Do **not** open a second PR. +- **Gaps to fill** → translate them, then create the PR (none open) or push to + `claude/translations` so the already-open PR updates. ### Source of truth @@ -47,7 +55,46 @@ reads this directly — do not hardcode the list). ### Steps -1. **Refresh i18n surface** so the detector sees the current state, not a +1. **Find the existing translation PR — there must only ever be one.** Look + for an **open** PR whose head branch is `claude/translations` (e.g. + `list_pull_requests` filtered to `head: claude/translations`, state + `open`), and check whether the branch exists on the remote: + + ```bash + git fetch origin + git ls-remote --heads origin claude/translations + ``` + + Note whether you are **reusing** an open PR/branch or **creating** a fresh + one — later steps depend on it. If you ever find more than one open + `chore(i18n): fill missing translations …` PR, keep the lowest-numbered one, + close the rest with a comment pointing at the survivor, and converge back to + a single PR. + +2. **Check out the single working branch `claude/translations`:** + + - **Reusing** (branch exists) — check it out and bring it up to date with + `main` so it does not go stale. Prefer a rebase; if it conflicts, fall + back to a hard reset onto `main` (the detector re-derives every + outstanding gap, so nothing is lost — it just re-translates): + + ```bash + git checkout -B claude/translations origin/claude/translations + git rebase origin/main || { git rebase --abort; git reset --hard origin/main; } + ``` + + - **Creating** (no branch yet) — start it from the tip of `main`: + + ```bash + git checkout -B claude/translations origin/main + ``` + + Running the detector on this branch (not on `main`) means units already + translated on the open PR keep `state="translated"` and are **not** + re-flagged — only genuinely new gaps get picked up, and human corrections + already pushed to the PR are preserved. + +3. **Refresh the i18n surface** so the detector sees the current state, not a stale snapshot: ```bash @@ -56,7 +103,7 @@ reads this directly — do not hardcode the list). node tools/src/sync-xliff-locales.mjs ``` -2. **Detect gaps**: +4. **Detect gaps**: ```bash mkdir -p .claude-translations @@ -70,10 +117,15 @@ reads this directly — do not hardcode the list). inventory and the hard rules. Treat that report as authoritative for the list of items to translate. - If `summary.env` shows `has_gaps=false`, post a one-line status and exit - without opening a PR. + Apply the exit decision from **Goal** using `summary.env`: -3. **Translate** from the German source (or, where missing, the English + - `has_gaps=false` **and no open translation PR** → post a one-line status + and exit without opening a PR. + - `has_gaps=false` **and a translation PR is already open** → leave it as + is and exit. Do **not** open a second PR or force-push an empty change. + - `has_gaps=true` → continue. + +5. **Translate** from the German source (or, where missing, the English sibling). Keep meaning, tone, brevity, and any HTML/placeholders intact. Hard rules — copied verbatim from the report so they bind even when this @@ -106,7 +158,7 @@ reads this directly — do not hardcode the list). every item in `instructions`, and every item in `tips`. Keep numeric/code values (`3×8`, `Tempo 3-1-1`) unchanged. -4. **Pre-push checks** (from `AGENTS.md` → Pre-Push Checklist): +6. **Pre-push checks** (from `AGENTS.md` → Pre-Push Checklist): ```bash pnpm nx affected -t=lint,test,build -c=production --parallel=3 @@ -116,22 +168,35 @@ reads this directly — do not hardcode the list). not push — write a status comment summarising the failure and stop. A broken `main` is worse than a delayed translation. -5. **Commit and push** to `claude/translations-`: +7. **Commit and push** to the single `claude/translations` branch: ```bash - git checkout -b claude/translations-$(date -u +%F) git add web/src/locale/messages.*.xlf \ content/blog content/wiki \ web/src/content # regenerated by generate-content, if applicable git commit -m "chore(i18n): fill missing translations ( items)" - git push -u origin "claude/translations-$(date -u +%F)" + # Reused branch (rebased/reset in step 2) → history was rewritten: + git push --force-with-lease -u origin claude/translations + # Freshly created branch → a plain push is enough: + # git push -u origin claude/translations ``` Stage files by name — never `git add -A` or `git add .`. The detector's artefacts under `.claude-translations/` are throwaway and must not be - committed. + committed. Use `--force-with-lease` (never plain `--force`) so a concurrent + push aborts the routine instead of being clobbered. + +8. **Open or update the single PR** against `main` — never create a second one: + + - **No PR open** → create it. Title `chore(i18n): fill missing translations + ( items)`, where `` is the number of translation items this + branch adds over `main` (XLIFF units flipped to `translated` + new + blog/wiki files). + - **PR already open** → update the existing PR's title and body in place + (e.g. `update_pull_request`) so the count and per-locale breakdown reflect + the new state. -6. **Open a PR** against `main`. Body must include: + The body (new or updated) must include: - One bullet per touched locale: `- : unit(s), blog post(s), wiki entry/entries`. @@ -140,9 +205,11 @@ reads this directly — do not hardcode the list). skipped list. - The exact `detect-translation-gaps.mjs` summary line for traceability. -7. **Subscribe to the PR's activity** via `subscribe_pr_activity` so CI - failures and review comments are picked up automatically — per - `AGENTS.md` → Pull Requests. +9. **Subscribe to the PR's activity** via `subscribe_pr_activity` (only when + you just created it) so CI failures and review comments are picked up + automatically — per `AGENTS.md` → Pull Requests. When the PR finally merges, + let it **delete** the `claude/translations` branch (squash + delete branch) + so the next run starts clean from `main`. ### Skipped section format @@ -179,8 +246,9 @@ This file is the prompt only. To wire it up: as an autonomous Claude Code routine` down to the trailing `---`). 4. **Repository:** `WolfSoko/pushup-stats-service`. Leave - **Allow unrestricted branch pushes** off — the routine pushes to - `claude/translations-` only. + **Allow unrestricted branch pushes** off — the routine only ever pushes to + the single `claude/translations` branch (including `--force-with-lease` + updates to it when an open PR is being refreshed). 5. **Environment:** Default (Trusted) is enough. No extra secrets needed — no service account, no PAT, no Copilot dispatch token (that's the whole point of replacing the workflow). diff --git a/docs/gotchas/i18n.md b/docs/gotchas/i18n.md index f0e1e259..4ec37a2e 100644 --- a/docs/gotchas/i18n.md +++ b/docs/gotchas/i18n.md @@ -10,7 +10,7 @@ Developers write **German only**. Every other locale (`en`, `fr`, `es`, `it`, `n - Missing `content/blog//.md` files. - Missing `content/wiki/pushup-types/..md` files. 3. The detector emits a Markdown brief listing every gap with the source German text and the hard rules (preserve XLIFF placeholders, flip `state` to `translated`, quote YAML list items containing `: `, run `tools:generate-content` after content edits). The routine reads this brief and does the translation work itself in the same session — no Copilot dispatch hop. -4. The routine opens a single PR titled `chore(i18n): fill missing translations ( items)` from `claude/translations-`. CI runs the normal lint/test/build pipeline on it. If the detector reports zero gaps, the routine exits without opening a PR. +4. The routine keeps **exactly one** open PR titled `chore(i18n): fill missing translations ( items)`, backed by the long-lived `claude/translations` branch. If that PR is already open it is **updated in place** (branch rebased onto `main`, re-pushed with `--force-with-lease`) instead of opening a second one. CI runs the normal lint/test/build pipeline on it. If there are no new gaps the routine opens nothing new — it exits when no PR is open, or leaves the already-open PR to merge. **When NOT to wait for the routine:** From b31cc3528dbe49a11a8f8de373e619779e0706d4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 20 Jun 2026 16:16:53 +0000 Subject: [PATCH 2/3] docs(i18n): merge (not rebase) to keep the single translation branch fresh Address PR review: the rebase + hard-reset-on-conflict fallback could silently discard reviewer corrections on the open PR, and the push guidance wrongly assumed history was always rewritten. Switch the reuse path to a non-rewriting merge of main into claude/translations (stop and report on conflict instead of discarding), so plain fast-forward pushes suffice everywhere and no force-push is needed. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01NQH6VBPhDr46yJMMH9dGcJ --- .claude/routines/translate.md | 48 +++++++++++++++++++++++------------ docs/gotchas/i18n.md | 2 +- 2 files changed, 33 insertions(+), 17 deletions(-) diff --git a/.claude/routines/translate.md b/.claude/routines/translate.md index d10d9f89..f089cf27 100644 --- a/.claude/routines/translate.md +++ b/.claude/routines/translate.md @@ -31,8 +31,10 @@ Exit decision after gap detection: - **No gaps and no open translation PR** → exit cleanly with a short status message; do **not** open a PR. -- **No new gaps but a translation PR is already open** → leave it untouched - (it is just waiting to be merged); exit cleanly. Do **not** open a second PR. +- **No new gaps but a translation PR is already open** → don't open a second + PR or add an empty commit; just keep the branch current — re-push the step-2 + merge only if `main` advanced — so the open PR stays mergeable, then exit + cleanly. - **Gaps to fill** → translate them, then create the PR (none open) or push to `claude/translations` so the already-open PR updates. @@ -74,13 +76,15 @@ reads this directly — do not hardcode the list). 2. **Check out the single working branch `claude/translations`:** - **Reusing** (branch exists) — check it out and bring it up to date with - `main` so it does not go stale. Prefer a rebase; if it conflicts, fall - back to a hard reset onto `main` (the detector re-derives every - outstanding gap, so nothing is lost — it just re-translates): + `main` by **merging** (never rebase or reset — a merge keeps every commit + already on the PR, including human review corrections). If the merge + conflicts, abort it and **stop and report** the conflict rather than + discarding history; a human resolves it on the PR: ```bash git checkout -B claude/translations origin/claude/translations - git rebase origin/main || { git rebase --abort; git reset --hard origin/main; } + git merge --no-edit origin/main \ + || { git merge --abort; echo "merge conflict on claude/translations — stop and report, do NOT discard the branch"; exit 1; } ``` - **Creating** (no branch yet) — start it from the tip of `main`: @@ -121,8 +125,21 @@ reads this directly — do not hardcode the list). - `has_gaps=false` **and no open translation PR** → post a one-line status and exit without opening a PR. - - `has_gaps=false` **and a translation PR is already open** → leave it as - is and exit. Do **not** open a second PR or force-push an empty change. + - `has_gaps=false` **and a translation PR is already open** → do **not** + open a second PR and do **not** create an empty translation commit. But + if the step-2 merge moved `claude/translations` past its remote tip (i.e. + `main` advanced), push the refreshed branch so the open PR is re-validated + against `main` and stays mergeable — otherwise do nothing: + + ```bash + git fetch origin claude/translations + if [ "$(git rev-parse HEAD)" != "$(git rev-parse origin/claude/translations)" ]; then + git push origin claude/translations + fi + ``` + + Then exit. When the branch already matches its remote tip (`main` did not + move) this is a no-op and the run costs nothing. - `has_gaps=true` → continue. 5. **Translate** from the German source (or, where missing, the English @@ -175,16 +192,15 @@ reads this directly — do not hardcode the list). content/blog content/wiki \ web/src/content # regenerated by generate-content, if applicable git commit -m "chore(i18n): fill missing translations ( items)" - # Reused branch (rebased/reset in step 2) → history was rewritten: - git push --force-with-lease -u origin claude/translations - # Freshly created branch → a plain push is enough: - # git push -u origin claude/translations + git push -u origin claude/translations ``` Stage files by name — never `git add -A` or `git add .`. The detector's artefacts under `.claude-translations/` are throwaway and must not be - committed. Use `--force-with-lease` (never plain `--force`) so a concurrent - push aborts the routine instead of being clobbered. + committed. A plain push suffices: step 2 only ever **merges** into the + branch (it never rewrites history), so your local tip stays ahead of the + remote. If the push is rejected as non-fast-forward, another run pushed + concurrently — stop and report instead of force-pushing. 8. **Open or update the single PR** against `main` — never create a second one: @@ -247,8 +263,8 @@ This file is the prompt only. To wire it up: `---`). 4. **Repository:** `WolfSoko/pushup-stats-service`. Leave **Allow unrestricted branch pushes** off — the routine only ever pushes to - the single `claude/translations` branch (including `--force-with-lease` - updates to it when an open PR is being refreshed). + the single `claude/translations` branch (plain fast-forward pushes; it + merges `main` in rather than rewriting history, so no force-push is needed). 5. **Environment:** Default (Trusted) is enough. No extra secrets needed — no service account, no PAT, no Copilot dispatch token (that's the whole point of replacing the workflow). diff --git a/docs/gotchas/i18n.md b/docs/gotchas/i18n.md index 4ec37a2e..f5f05fdc 100644 --- a/docs/gotchas/i18n.md +++ b/docs/gotchas/i18n.md @@ -10,7 +10,7 @@ Developers write **German only**. Every other locale (`en`, `fr`, `es`, `it`, `n - Missing `content/blog//.md` files. - Missing `content/wiki/pushup-types/..md` files. 3. The detector emits a Markdown brief listing every gap with the source German text and the hard rules (preserve XLIFF placeholders, flip `state` to `translated`, quote YAML list items containing `: `, run `tools:generate-content` after content edits). The routine reads this brief and does the translation work itself in the same session — no Copilot dispatch hop. -4. The routine keeps **exactly one** open PR titled `chore(i18n): fill missing translations ( items)`, backed by the long-lived `claude/translations` branch. If that PR is already open it is **updated in place** (branch rebased onto `main`, re-pushed with `--force-with-lease`) instead of opening a second one. CI runs the normal lint/test/build pipeline on it. If there are no new gaps the routine opens nothing new — it exits when no PR is open, or leaves the already-open PR to merge. +4. The routine keeps **exactly one** open PR titled `chore(i18n): fill missing translations ( items)`, backed by the long-lived `claude/translations` branch. If that PR is already open it is **updated in place** (branch merged up to date with `main`, then re-pushed) instead of opening a second one. CI runs the normal lint/test/build pipeline on it. If there are no new gaps the routine opens nothing new — when no PR is open it just exits; when a PR is already open it refreshes that branch onto `main` (only if `main` advanced, so an unchanged state still costs nothing) to keep the PR mergeable, then leaves it to merge. **When NOT to wait for the routine:** From afd58d9a882d853b1989586f4c19a331c9507184 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 20 Jun 2026 16:26:32 +0000 Subject: [PATCH 3/3] docs(i18n): specify how the PR is computed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address CodeRabbit review: the routine referenced a placeholder in the commit message and PR title without saying how to derive it. Make it explicit — count XLIFF units now translated plus new blog/wiki files in the diff against origin/main (not the detector's single-run gap_count, which undercounts an accumulated PR) — and point the PR title at the same value. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01NQH6VBPhDr46yJMMH9dGcJ --- .claude/routines/translate.md | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/.claude/routines/translate.md b/.claude/routines/translate.md index f089cf27..c97445b6 100644 --- a/.claude/routines/translate.md +++ b/.claude/routines/translate.md @@ -185,13 +185,26 @@ reads this directly — do not hardcode the list). not push — write a status comment summarising the failure and stop. A broken `main` is worse than a delayed translation. -7. **Commit and push** to the single `claude/translations` branch: +7. **Compute ``, then commit and push** to the single + `claude/translations` branch. + + `` is the number of translation items **this branch adds over + `main`** — derive it from the diff against `origin/main`, **not** from the + detector's `gap_count` (that counts only one run's new gaps and would + undercount a PR that accumulated work across several runs). It is the count + of XLIFF units now `state="translated"` that differ from `main`, plus the + new blog/wiki locale files: ```bash + git fetch origin main + xliff=$(git diff origin/main -- web/src/locale/messages.*.xlf | grep -c '^+.*state="translated"') + files=$(git diff --name-status origin/main -- content/blog content/wiki | grep -c '^A') + count=$((xliff + files)) # ← reuse $count in BOTH the commit message and the PR title + git add web/src/locale/messages.*.xlf \ content/blog content/wiki \ web/src/content # regenerated by generate-content, if applicable - git commit -m "chore(i18n): fill missing translations ( items)" + git commit -m "chore(i18n): fill missing translations ($count items)" git push -u origin claude/translations ``` @@ -205,9 +218,7 @@ reads this directly — do not hardcode the list). 8. **Open or update the single PR** against `main` — never create a second one: - **No PR open** → create it. Title `chore(i18n): fill missing translations - ( items)`, where `` is the number of translation items this - branch adds over `main` (XLIFF units flipped to `translated` + new - blog/wiki files). + ( items)`, reusing the `` computed in step 7. - **PR already open** → update the existing PR's title and body in place (e.g. `update_pull_request`) so the count and per-locale breakdown reflect the new state.