mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-05 00:09:35 +00:00
The translation job ran on `push: main` and opened a separate `chore/update-translations` PR. That PR had no owner — it was never assigned to or reviewed by a human — and it left a window where main carried English strings with no translations. Move the trigger to `pull_request` and commit the generated locale files back onto the PR's own head branch, so the author who added the English strings also owns their translations and both land in one merge. Notes on the mechanics: - The commit is pushed with TRANSLATIONS_APPROVE_TOKEN, not GITHUB_TOKEN. GitHub does not trigger workflows for GITHUB_TOKEN pushes, so the new head SHA would carry no status checks and required checks could never pass — the PR would be wedged. Falls back to GITHUB_TOKEN if unset. - A guard step skips the run when the head commit is already the generated translation commit, so the PAT push cannot ping-pong. - Fork PRs are skipped: their branch lives in the contributor's repo and no token here can push to it. - concurrency flips to cancel-in-progress: true. The `false` was main-specific (merges landed faster than a run finished); on a PR branch nothing is committed until a run completes and the surviving run re-derives the pending set from .translation_state.json, so a cancel costs spend, never progress. - The push retries with a rebase in case the author pushes concurrently. Cost does not regress: re-translation across branches is prevented by the committed per-key hashes in .translation_state.json, not by the trigger, so each string is still translated once — on whichever branch first introduces it.
156 lines
5.2 KiB
Python
Executable File
156 lines
5.2 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
# Copyright 2026 OpenObserve Inc.
|
||
"""
|
||
Translation Generator for OpenObserve.
|
||
|
||
Generates translation files from en-US.json using DeepSeek (an LLM) via its
|
||
OpenAI-compatible API. Only newly added or modified English strings are
|
||
translated; already-translated, unchanged text is never re-sent (see
|
||
translator.py for the change-detection model).
|
||
|
||
Usage:
|
||
python main.py # translate all supported languages
|
||
python main.py fr-FR es-ES de-DE # translate specific languages (filename stems)
|
||
python main.py --check # report what is pending; translate nothing
|
||
|
||
Exit codes:
|
||
0 nothing to do, or everything translated
|
||
1 bad usage / missing source
|
||
2 --check only: translations are pending
|
||
3 at least one string failed validation (retried on the next run)
|
||
|
||
Environment:
|
||
DEEPSEEK_API_KEY Required. API key for https://api.deepseek.com.
|
||
DEEPSEEK_MODEL Model id (default "deepseek-v4-flash").
|
||
TRANSLATION_BATCH_SIZE Strings per API call (default 50).
|
||
TRANSLATION_CONCURRENCY Batches in flight per locale (default 4; 1 = serial).
|
||
"""
|
||
|
||
import sys
|
||
|
||
from translator import (
|
||
build_locale,
|
||
build_state,
|
||
collect_pending_leaves,
|
||
get_language_file_path,
|
||
get_supported_languages,
|
||
load_json,
|
||
load_source,
|
||
load_state,
|
||
new_counters,
|
||
save_state,
|
||
translate_pending,
|
||
write_json,
|
||
)
|
||
|
||
|
||
def run_check(locales):
|
||
"""Report every key still needing translation, without touching the API.
|
||
|
||
Pure comparison of en-US.json against the locale files and
|
||
.translation_state.json, so it costs nothing, needs no API key, and writes
|
||
nothing. This is what the merge-queue gate runs to prove a merge is not about
|
||
to land English-only strings on main.
|
||
"""
|
||
source = load_source()
|
||
if not source:
|
||
print("ERROR: en-US.json source is empty or missing.")
|
||
sys.exit(1)
|
||
|
||
state = load_state()
|
||
total = 0
|
||
for locale in locales:
|
||
existing = load_json(get_language_file_path(locale), {})
|
||
pending = collect_pending_leaves(source, existing, state)
|
||
if not pending:
|
||
continue
|
||
total += len(pending)
|
||
shown = [".".join(path) for path, _ in pending[:5]]
|
||
more = f" (+{len(pending) - len(shown)} more)" if len(pending) > len(shown) else ""
|
||
print(f" {locale}: {len(pending)} pending — {', '.join(shown)}{more}")
|
||
|
||
if total:
|
||
print(
|
||
f"\n{total} translation(s) pending across {len(locales)} language(s)."
|
||
)
|
||
sys.exit(2)
|
||
|
||
print(f"All {len(locales)} language(s) are up to date with en-US.json.")
|
||
|
||
|
||
def main():
|
||
supported = get_supported_languages()
|
||
|
||
args = sys.argv[1:]
|
||
# `--force` is accepted for backward compatibility but is now a no-op: there
|
||
# is no safety cap to bypass.
|
||
check_only = "--check" in args
|
||
requested = [a for a in args if not a.startswith("--")]
|
||
|
||
if requested:
|
||
invalid = [lang for lang in requested if lang not in supported]
|
||
if invalid:
|
||
print(f"WARNING: Unsupported language codes: {', '.join(invalid)}")
|
||
print(f"Supported languages: {', '.join(supported)}")
|
||
locales = [lang for lang in requested if lang in supported]
|
||
else:
|
||
locales = supported
|
||
|
||
if not locales:
|
||
print("ERROR: No valid languages to translate.")
|
||
sys.exit(1)
|
||
|
||
if check_only:
|
||
run_check(locales)
|
||
return
|
||
|
||
source = load_source()
|
||
if not source:
|
||
print("ERROR: en-US.json source is empty or missing.")
|
||
sys.exit(1)
|
||
|
||
state = load_state()
|
||
full_run = set(locales) == set(supported)
|
||
|
||
counters = new_counters()
|
||
locale_targets = {}
|
||
for locale in locales:
|
||
existing = load_json(get_language_file_path(locale), {})
|
||
pending = collect_pending_leaves(source, existing, state)
|
||
print(f"\nTranslating: {locale} ({len(pending)} strings pending)")
|
||
|
||
translated = translate_pending(pending, locale) if pending else {}
|
||
|
||
target = build_locale(source, existing, state, translated, counters)
|
||
# Flushed per locale (atomically), so a run that is cancelled or dies part
|
||
# way through still leaves every completed locale on disk for CI to commit.
|
||
write_json(get_language_file_path(locale), target)
|
||
locale_targets[locale] = target
|
||
|
||
# Advance shared state only on a full run, where every supported locale was
|
||
# processed and "present in all locales" is meaningful. Subset runs translate
|
||
# what they need but leave state untouched (safe: at worst a later full run
|
||
# re-checks those keys).
|
||
if full_run:
|
||
state = build_state(source, locale_targets, counters["failed_paths"])
|
||
save_state(state)
|
||
else:
|
||
print("\nℹ️ Subset run — state file not advanced (run all languages to persist state).")
|
||
|
||
print("\n" + "-" * 60)
|
||
print(
|
||
f"Done. translated={counters['translated']}, kept={counters['kept']}, "
|
||
f"failed={counters['failed']} across {len(locales)} language(s)."
|
||
)
|
||
|
||
if counters["failed"]:
|
||
print(
|
||
f"ERROR: {counters['failed']} translation(s) failed — they will retry "
|
||
f"next run."
|
||
)
|
||
sys.exit(3)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|