feat(dashboard): auto-translate foreign-language GitHub issues on import (#2141)
## Why The Import Tasks panel routinely lists issues in languages the operator cannot read. Translation already shipped in #2128, but deliberately **opt-in and preview-only** — its header comment read *"Translation is opt-in (never automatic) so import provenance stays faithful until the operator asks."* This reverses that decision **behind a default-off setting**, so operators who never opt in keep byte-faithful import provenance. The superseded comment is kept and annotated rather than deleted, so the reason the rule changed stays in the code. ### The structural gap #2128 left `POST /github/issues/import` accepts only `{owner, repo, issueNumber}` and **re-fetches the issue server-side**. A translation held in React state could never reach the created task, and the in-memory cache died with the modal. That is why the cache here is server-side rather than in the hook — it's what makes "imported issues carry the translated version" actually true. ## What operators get Auto-translate is **off by default**. When enabled: - The **50 most recent OPEN** foreign-language issues translate on panel load — **list titles**, not just the preview, so the list reads in your language before you click anything. - Translations show **by default**, with a toggle back to the original (hover a translated list title to see the original). - Translations **persist until the issue closes**, so re-opening the panel neither waits nor re-bills. - **Both single and batch import** carry the translation, so the created task reads like the preview you approved. - A **target language** setting (unset = follow the dashboard language) and a dedicated **model lane**, so you can pin a cheap/fast model without dragging the summarization lane onto it. ## Notable decisions | Decision | Why | |---|---| | Detect **before** the model | An issue already in the target language is never sent. Without this, an English repo with the setting on would bill every issue to return its input unchanged. | | Detection moved to `@fusion/core` | The panel and the server must not disagree about which issues are foreign; two copies of a heuristic drift. | | Own rate-limit budget | Translation shared a 10/hour budget with refine/goal-draft. Fanning out per-issue would fail partway **and** starve refine for the hour. | | Cache keyed on a **source hash** | An edited issue misses the cache and re-translates instead of serving stale prose. | | Import is **cache-read only** | A miss imports the original. Import must never block on, or fail because of, translation. | | `project_id` leads the cache PK + full RLS contract | All projects share one flat `project` schema. `verification_cache`'s PK predates that discipline; this table does not copy that mistake. | ## Verification - ✅ `pnpm lint`, `@fusion/core` + `@fusion/dashboard` typecheck - ✅ `pnpm verify:fast` — build + scoped typecheck + real boot smoke (`/api/health`) - ✅ `pnpm test:gate` — 479 tests - ✅ 19 new tests covering the billing invariants (off/closed/same-language ⇒ **no model call**), cache hit/miss-on-edit, the 50 cap, and per-item fail-soft - ✅ `schema-applier` real-Postgres suite (46 tests) exercises migration `0010` and its isolation invariant **Pre-existing failures NOT touched** (confirmed red on `HEAD` before this branch): `AppearanceSection`'s task-popup test, and two PG-cutover keys (`sqliteMigrationNotice`, `postgresMigrationInboxMessageSentAt`) missing description mappings. I left the latter rather than guess an allowlist entry that could mask a real coverage gap. ## Reviewer notes - Short Latin-script prose (a one-line Spanish title) rates only *medium* confidence and won't auto-translate — the existing heuristic is deliberately conservative so English issues are never billed. CJK detects regardless of length. The threshold is the knob if you'd rather bias toward translating. - The RLS/isolation contract in migration `0010` is the part most worth a careful look. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2765,10 +2765,15 @@ export function apiFetchGitHubIssues(
|
||||
}
|
||||
|
||||
/** Import a specific GitHub issue as a fn task */
|
||||
export function apiImportGitHubIssue(owner: string, repo: string, issueNumber: number, projectId?: string): Promise<Task> {
|
||||
/*
|
||||
FNXC:GitHubImportTranslate 2026-07-15-14:10:
|
||||
`targetLocale` forwards the panel's ACTIVE locale so an imported task carries the same translation the operator previewed.
|
||||
The server also falls back to the global `language` setting, so this argument is not load-bearing for the common case — it exists for the one case the server cannot know: a surface whose locale was browser-detected while global `language` is unset (PR #2141 review, P1).
|
||||
*/
|
||||
export function apiImportGitHubIssue(owner: string, repo: string, issueNumber: number, projectId?: string, targetLocale?: string): Promise<Task> {
|
||||
return api<Task>(withProjectId("/github/issues/import", projectId), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ owner, repo, issueNumber }),
|
||||
body: JSON.stringify({ owner, repo, issueNumber, ...(targetLocale ? { targetLocale } : {}) }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2788,11 +2793,13 @@ export function apiBatchImportGitHubIssues(
|
||||
repo: string,
|
||||
issueNumbers: number[],
|
||||
delayMs?: number,
|
||||
projectId?: string
|
||||
projectId?: string,
|
||||
/** See apiImportGitHubIssue: batch import must carry translations identically. */
|
||||
targetLocale?: string,
|
||||
): Promise<{ results: BatchImportResult[] }> {
|
||||
return api<{ results: BatchImportResult[] }>(withProjectId("/github/issues/batch-import", projectId), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ owner, repo, issueNumbers, delayMs }),
|
||||
body: JSON.stringify({ owner, repo, issueNumbers, delayMs, ...(targetLocale ? { targetLocale } : {}) }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6224,6 +6231,42 @@ export async function translateImportContent(
|
||||
return response.fields;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:GitHubImportTranslate 2026-07-15-09:30:
|
||||
Auto-translate the visible import list in ONE request. The server reads through its durable cache, so a repeat load of the same repo returns instantly and bills nothing; the same cache is what the import path reads, so an imported task carries the translation shown here.
|
||||
The server enforces the auto-translate setting and the 50-issue cap itself and echoes `enabled`/`capped` back, so the client never has to duplicate that policy.
|
||||
*/
|
||||
export interface AutoTranslateImportItem {
|
||||
number: number;
|
||||
title: string;
|
||||
body: string | null;
|
||||
state?: "open" | "closed";
|
||||
}
|
||||
|
||||
export interface AutoTranslateImportResponse {
|
||||
translations: Record<number, { title: string; body: string }>;
|
||||
enabled: boolean;
|
||||
targetLocale: string | null;
|
||||
/** True when more foreign issues existed than the per-load cap. */
|
||||
capped: boolean;
|
||||
}
|
||||
|
||||
export async function autoTranslateImportIssues(
|
||||
owner: string,
|
||||
repo: string,
|
||||
items: AutoTranslateImportItem[],
|
||||
targetLocale: string,
|
||||
projectId?: string,
|
||||
): Promise<AutoTranslateImportResponse> {
|
||||
return api<AutoTranslateImportResponse>(
|
||||
withProjectId("/github/issues/auto-translate", projectId),
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ owner, repo, items, targetLocale }),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** User-facing error copy for translateImportContent failures (toast/banner). */
|
||||
export const TRANSLATE_ERROR_MESSAGES = {
|
||||
RATE_LIMIT: "Too many translation requests. Please wait an hour.",
|
||||
|
||||
Reference in New Issue
Block a user