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:
gsxdsm
2026-07-15 14:06:42 -07:00
committed by GitHub
parent 05151a25db
commit 0863c0fb58
32 changed files with 1669 additions and 267 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Auto-translate foreign-language GitHub issues in the Import Tasks panel, with a target language and model you choose.
category: feature
dev: New project settings `githubImportAutoTranslate` (default false) and `importTranslateTargetLocale`, plus an `import-translate` model lane (project `importTranslateProvider`/`importTranslateModelId`, global `importTranslateGlobalProvider`/`importTranslateGlobalModelId`) resolved by `resolveImportTranslateSettingsModel`. Translations persist in the new `project.import_translation_cache` table (migration 0010) keyed by project+repo+issue+locale+source hash, and are pruned when an issue closes. `POST /api/github/issues/auto-translate` translates the 50 most recent open foreign issues per load on its own rate-limit budget; both single and batch import read the cache so imported tasks carry the translated title/body. Language detection moved from the dashboard app to `@fusion/core` so the panel and server share one heuristic.

View File

@@ -122,6 +122,9 @@ Fusion automatically falls back to ntfy's JSON publish format when a notificatio
| `titleSummarizerGlobalProvider` | `string` | `undefined` | Global baseline provider for title summarization. Project `titleSummarizerProvider` overrides this. |
| `titleSummarizerGlobalModelId` | `string` | `undefined` | Global baseline model ID for title summarization. |
| `titleSummarizerGlobalThinkingLevel` | `ThinkingLevel` | `undefined` | Optional global summarization-lane thinking override. Inherits `defaultThinkingLevel` when unset. |
| `importTranslateGlobalProvider` | `string` | `undefined` | Global baseline provider for import auto-translation. Project `importTranslateProvider` overrides this. |
| `importTranslateGlobalModelId` | `string` | `undefined` | Global baseline model ID for import auto-translation. |
| `importTranslateGlobalThinkingLevel` | `ThinkingLevel` | `undefined` | Optional global import-translate-lane thinking override. Inherits `defaultThinkingLevel` when unset. |
| `daemonToken` | `string` | `undefined` | Daemon authentication token (`fn_<32 hex chars>`) used by CLI clients. |
| `daemonPort` | `number` | `4040` | Port for daemon/serve mode binding. |
| `daemonHost` | `string` | `"127.0.0.1"` | Host for daemon/serve mode binding. Defaults to localhost only; pass `"0.0.0.0"` to expose on all interfaces. |
@@ -635,6 +638,8 @@ Default notes:
| `githubTrackingEnabledByDefault` | `boolean` | `false` | Project-level default for enabling issue tracking on ordinary new tasks. When this is false, the Quick Entry GitHub toggle is disabled until tracking is enabled in Settings. Imported GitHub issues still follow this default unless `githubLinkImportedIssuesToTracking` is enabled. |
| `sessionAdvisorEnabledByDefault` | `boolean` | `false` | Project-level default for the session advisor (LLM overseer agent that reviews live executor transcripts). Off by default (opt-in). Quick Add exposes an eye toggle next to GitHub that inherits this default; each task can override via `sessionAdvisorEnabled`. Provider and model ids still come from workflow settings (`plannerOverseerAdvisorProvider` / `plannerOverseerAdvisorModelId`). Dashboard location: **Settings → Project → General → Session advisor (overseer agent)**. |
| `githubLinkImportedIssuesToTracking` | `boolean` | `false` | Project-scoped, import-only option. When enabled, GitHub issue imports from the dashboard, CLI, and extension tools persist `githubTracking: { enabled: true }` so Fusion adopts the imported source issue as the tracking issue without turning tracking on for ordinary new tasks. Duplicate/skipped imports do not create tasks or tracking metadata. |
| `githubImportAutoTranslate` | `boolean` | `false` | Project-scoped, import-only option. When enabled, the Import Tasks panel automatically translates foreign-language GitHub/GitLab issue titles and bodies into `importTranslateTargetLocale` and shows the translation by default (the original text stays one toggle away). Off by default so all-English projects never pay for a per-issue AI call. Dashboard location: **Settings → Project → General → GitHub Tracking**. |
| `importTranslateTargetLocale` | `Locale` | `undefined` | Target language for `githubImportAutoTranslate`. One of `SUPPORTED_LOCALES`. When unset, import translation follows the dashboard's own `language` setting. Dashboard location: **Settings → Project → General → GitHub Tracking**. |
| `githubTrackingDefaultRepo` | `string` | `undefined` | Project default issue-tracking repo (`owner/repo`) used before global fallback for tracked task creation (precedence: task override → project default → global default). In Settings UI this is a detected-remote dropdown with a Custom fallback for manual entry. This key is dual-scope: project saves go through `PUT /api/settings` (Settings → General → GitHub Tracking) while global saves go through `PUT /api/settings/global` (Settings → Global General). |
| `gitlabEnabled` | `boolean` | `undefined` (effective global fallback, then `true`) | Project GitLab integration enable switch. Explicit `false` disables outbound GitLab API imports, completion comments, close/reopen, source closed-at backfill, and tracking refresh side effects for this project without deleting saved URL/token fields. Dashboard location: **Settings → Project → General → GitLab Configuration** and **Settings → Project → Merge → GitLab Authentication** disclosure headers. |
| `gitlabInstanceUrl` | `string` | `undefined` (effective global fallback, then `https://gitlab.com`) | Project GitLab web instance URL for GitLab.com or self-managed GitLab. Blank/unset inherits global `gitlabInstanceUrl` and then defaults to GitLab.com. Values are trimmed and must be absolute `http://` or `https://` URLs without username/password userinfo; trailing slashes are normalized by `resolveGitlabConfig`. Dashboard location: **Settings → Project → General → GitLab Configuration**. |
@@ -1093,6 +1098,19 @@ Project-scoped model lane used for task title auto-summarization, GitHub trackin
If the configured title summarizer provider/model is stale and no longer exists in the pi model registry, title generation logs a warning with the stale id and retries once with automatic provider/model resolution. Other AI failures (auth, empty output, unavailable engine) still fail normally.
### Import auto-translation model
Dedicated model lane used by `githubImportAutoTranslate` to translate foreign-language GitHub/GitLab issue titles and bodies in the Import Tasks panel. Configurable under **Settings → Global Models** and **Settings → Project Models**. It is separate from the summarization lane because translation is one short, readonly, per-issue call with no repo context: operators can pin a cheap/fast model here without dragging the summarization lane (task titles, merge commit messages) onto that same model. It still falls back *through* summarization, so leaving it unset is a supported no-configuration path.
1. Project `importTranslateProvider` + `importTranslateModelId`
2. Global `importTranslateGlobalProvider` + `importTranslateGlobalModelId`
3. Summarization lane (project `titleSummarizerProvider` + `titleSummarizerModelId`, then global `titleSummarizerGlobalProvider` + `titleSummarizerGlobalModelId`)
4. Project `defaultProviderOverride` + `defaultModelIdOverride`
5. Global `defaultProvider` + `defaultModelId`
6. Automatic provider/model resolution
Thinking level for import translation: project `importTranslateThinkingLevel` → global `importTranslateGlobalThinkingLevel` → project `defaultThinkingLevelOverride` → global `defaultThinkingLevel`. Resolved by `resolveImportTranslateSettingsModel` (`@fusion/core`).
> **Note:** Runtime fallback precedence logic is implemented in engine and dashboard routes. The hierarchies above reflect current runtime behavior.
---

View File

@@ -24,6 +24,11 @@
"source": "./src/gh-cli.ts",
"import": "./dist/gh-cli.js"
},
"./detect-content-language": {
"types": "./src/detect-content-language.ts",
"source": "./src/detect-content-language.ts",
"import": "./dist/detect-content-language.js"
},
"./package.json": "./package.json"
},
"publishConfig": {

View File

@@ -40,6 +40,7 @@ import {
MONITOR_APPROVAL_ISOLATION_SCHEMA_VERSION,
MULTI_PROJECT_CUTOVER_SCHEMA_VERSION,
MISSION_FIX_IDEMPOTENCY_VERSION,
IMPORT_TRANSLATION_CACHE_VERSION,
PROJECT_OWNERSHIP_SCHEMA_VERSION,
SESSION_ADVISOR_ENABLED_SCHEMA_VERSION,
SQLITE_SCHEMA_PARITY_VERSION,
@@ -91,7 +92,14 @@ describe("schema-applier: immutable migration identities", () => {
it("keeps mission fix idempotency assigned to version 0009", () => {
expect(MISSION_FIX_IDEMPOTENCY_VERSION).toBe("0009");
expect(SCHEMA_BASELINE_VERSION).toBe(MISSION_FIX_IDEMPOTENCY_VERSION);
// FNXC:GitHubImportTranslate 2026-07-15-09:30: the baseline marker advanced to
// 0010; 0009 keeps its immutable identity so its migration cannot be skipped.
expect(Number(SCHEMA_BASELINE_VERSION)).toBeGreaterThanOrEqual(Number(MISSION_FIX_IDEMPOTENCY_VERSION));
});
it("keeps the import translation cache assigned to version 0010", () => {
expect(IMPORT_TRANSLATION_CACHE_VERSION).toBe("0010");
expect(SCHEMA_BASELINE_VERSION).toBe(IMPORT_TRANSLATION_CACHE_VERSION);
});
});
@@ -366,7 +374,7 @@ pgDescribe("schema-applier: VAL-SCHEMA-001 final-schema parity (table counts)",
ctx = null;
});
it("creates all 89 project tables, 17 central tables, 1 archive table", async () => {
it("creates all 90 project tables, 17 central tables, 1 archive table", async () => {
ctx = await setupFreshDb();
// FNXC:PostgresCutover 2026-07-05-15:55: apply the BASELINE only.
// applySchemaBaseline now runs the plugin schema-init hooks by default,
@@ -381,9 +389,10 @@ pgDescribe("schema-applier: VAL-SCHEMA-001 final-schema parity (table counts)",
GROUP BY table_schema
`)) as unknown as Array<{ table_schema: string; n: number }>;
const bySchema = Object.fromEntries(rows.map((r) => [r.table_schema, r.n]));
// Project: 87 typed core tables + 2 lossless legacy preservation tables.
// Project: 87 typed core tables + 2 lossless legacy preservation tables
// + 1 import_translation_cache (FNXC:GitHubImportTranslate 2026-07-15-09:30).
// Plugin tables are added separately by the hook.
expect(bySchema.project).toBe(89);
expect(bySchema.project).toBe(90);
expect(bySchema.central).toBe(17);
expect(bySchema.archive).toBe(1);
});
@@ -944,7 +953,7 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => {
const versions = (await ctx.db.execute(sql`
SELECT version FROM public.fusion_schema_migrations ORDER BY version
`)) as unknown as Array<{ version: string }>;
expect(versions.map(({ version }) => version)).toEqual(["0000", "0001", "0002", "0003", "0004", "0005", PROJECT_OWNERSHIP_SCHEMA_VERSION, SQLITE_SCHEMA_PARITY_VERSION, SESSION_ADVISOR_ENABLED_SCHEMA_VERSION, SCHEMA_BASELINE_VERSION]);
expect(versions.map(({ version }) => version)).toEqual(["0000", "0001", "0002", "0003", "0004", "0005", PROJECT_OWNERSHIP_SCHEMA_VERSION, SQLITE_SCHEMA_PARITY_VERSION, SESSION_ADVISOR_ENABLED_SCHEMA_VERSION, MISSION_FIX_IDEMPOTENCY_VERSION, SCHEMA_BASELINE_VERSION]);
expect((await applySchemaBaseline(ctx.db, { pluginHooks: [] })).applied).toBe(false);
});
@@ -968,7 +977,7 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => {
applySchemaBaseline(ctx.db, { pluginHooks: [] }),
]);
expect(results.filter(({ applied }) => applied)).toHaveLength(1);
expect(await getAppliedMigrations(ctx.db)).toEqual(["0000", "0001", "0002", "0003", "0004", "0005", PROJECT_OWNERSHIP_SCHEMA_VERSION, SQLITE_SCHEMA_PARITY_VERSION, SESSION_ADVISOR_ENABLED_SCHEMA_VERSION, SCHEMA_BASELINE_VERSION]);
expect(await getAppliedMigrations(ctx.db)).toEqual(["0000", "0001", "0002", "0003", "0004", "0005", PROJECT_OWNERSHIP_SCHEMA_VERSION, SQLITE_SCHEMA_PARITY_VERSION, SESSION_ADVISOR_ENABLED_SCHEMA_VERSION, MISSION_FIX_IDEMPOTENCY_VERSION, SCHEMA_BASELINE_VERSION]);
});
it("upgrades a 0001 database by backfilling analytics ownership", async () => {
@@ -1004,7 +1013,7 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => {
))) as unknown as Array<{ project_id: string }>;
expect(rows).toEqual([{ project_id: "project-a" }]);
}
expect(await getAppliedMigrations(ctx.db)).toEqual(["0000", "0001", "0002", "0003", "0004", "0005", "0006", "0007", "0008", "0009"]);
expect(await getAppliedMigrations(ctx.db)).toEqual(["0000", "0001", "0002", "0003", "0004", "0005", "0006", "0007", "0008", "0009", "0010"]);
});
/**
@@ -1042,7 +1051,7 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => {
))) as unknown as Array<{ project_id: string }>;
expect(rows).toEqual([{ project_id: "project-a" }]);
}
expect(await getAppliedMigrations(ctx.db)).toEqual(["0000", "0001", "0002", "0003", "0004", "0005", "0006", "0007", "0008", "0009"]);
expect(await getAppliedMigrations(ctx.db)).toEqual(["0000", "0001", "0002", "0003", "0004", "0005", "0006", "0007", "0008", "0009", "0010"]);
});
/*
@@ -1080,7 +1089,7 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => {
"project_auth_users",
"task_reviewer_runs",
]);
expect(await getAppliedMigrations(ctx.db)).toEqual(["0000", "0001", "0002", "0003", "0004", "0005", "0006", "0007", "0008", "0009"]);
expect(await getAppliedMigrations(ctx.db)).toEqual(["0000", "0001", "0002", "0003", "0004", "0005", "0006", "0007", "0008", "0009", "0010"]);
});
});

View File

@@ -0,0 +1,236 @@
/*
FNXC:GitHubImportTranslate 2026-07-15-09:30:
Detection lives in core, not the dashboard app bundle, because BOTH surfaces need the identical verdict: the browser decides whether to show the translate banner, and the SERVER decides whether an auto-translate run may skip an issue without spending a model call. Two copies of a heuristic would drift and the two surfaces would disagree about the same issue.
FNXC:GitHubImportTranslate 2026-07-14-12:00:
The GitHub (and GitLab) import preview must offer translation only when selected issue/PR content is in a different language than the active dashboard locale.
Client-side detection is heuristic (Unicode script counts + Latin stopword scoring) so the banner can appear without an AI round-trip; uncertain or same-family content stays silent rather than spamming a false-positive translate CTA.
*/
import type { Locale } from "./types.js";
import { SUPPORTED_LOCALES } from "./types.js";
/** Minimum alphabetic characters before we attempt language detection. */
export const MIN_DETECTABLE_CHARS = 24;
/**
* Script/language families used for mismatch decisions.
* zh-CN and zh-TW share `cjk` so Chinese content does not prompt translation when the UI is either Chinese locale.
* Latin locales (en/fr/es) share `latin` at the script layer and are disambiguated via stopword scores.
*/
export type LanguageFamily = "latin" | "cjk" | "hangul" | "other";
export type DetectedContentLanguage = {
/** Best-effort BCP-47-ish code among supported locales, or `unknown` when confidence is too low. */
locale: Locale | "unknown";
family: LanguageFamily;
/** Relative confidence of the best guess. */
confidence: "high" | "medium" | "low";
};
const LATIN_STOPWORDS: Record<"en" | "fr" | "es", readonly string[]> = {
en: [
"the", "and", "for", "that", "with", "this", "from", "have", "will", "are",
"not", "but", "you", "all", "can", "has", "was", "were", "been", "their",
"which", "when", "what", "into", "about", "would", "there", "should",
],
fr: [
"les", "des", "une", "est", "dans", "pour", "que", "qui", "sur", "avec",
"pas", "plus", "par", "sont", "cette", "aussi", "comme", "mais", "nous",
"vous", "être", "fait", "tout", "leur", "entre", "sans", "après",
],
es: [
"los", "las", "del", "una", "que", "por", "con", "para", "como", "más",
"este", "esta", "está", "son", "pero", "sus", "sobre", "entre", "cuando",
"también", "después", "desde", "hasta", "sin", "todos", "puede",
],
};
function countMatches(text: string, re: RegExp): number {
const matches = text.match(re);
return matches?.length ?? 0;
}
function familyForLocale(locale: Locale): LanguageFamily {
if (locale === "ko") return "hangul";
if (locale === "zh-CN" || locale === "zh-TW") return "cjk";
return "latin";
}
function isSupportedLocale(value: string): value is Locale {
return (SUPPORTED_LOCALES as readonly string[]).includes(value);
}
/**
* Score Latin text against en/fr/es stopword lists.
* Returns the best locale and a confidence derived from score separation.
*/
function scoreLatinLocale(text: string): { locale: Locale; confidence: DetectedContentLanguage["confidence"] } {
const tokens = text
.toLowerCase()
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.split(/[^a-zàâäéèêëïîôùûüçñ]+/i)
.filter((t) => t.length >= 2);
if (tokens.length < 6) {
return { locale: "en", confidence: "low" };
}
const scores: Record<"en" | "fr" | "es", number> = { en: 0, fr: 0, es: 0 };
for (const token of tokens) {
for (const locale of ["en", "fr", "es"] as const) {
if (LATIN_STOPWORDS[locale].includes(token)) {
scores[locale] += 1;
}
}
}
const ranked = (Object.entries(scores) as Array<["en" | "fr" | "es", number]>).sort(
(a, b) => b[1] - a[1],
);
const [best, second] = ranked;
const bestScore = best[1];
const secondScore = second[1];
if (bestScore === 0) {
return { locale: "en", confidence: "low" };
}
const ratio = secondScore === 0 ? Infinity : bestScore / secondScore;
const confidence: DetectedContentLanguage["confidence"] =
bestScore >= 4 && ratio >= 1.6 ? "high" : bestScore >= 2 && ratio >= 1.25 ? "medium" : "low";
return { locale: best[0], confidence };
}
/**
* Detect the likely language of free-form issue/PR content for import-preview translation gating.
* Intentionally conservative: short, code-heavy, or ambiguous samples return `unknown` / low confidence.
*/
export function detectContentLanguage(text: string): DetectedContentLanguage {
const sample = (text ?? "").trim();
if (!sample) {
return { locale: "unknown", family: "other", confidence: "low" };
}
// Strip fenced code, URLs, and GitHub usernames so detection focuses on prose.
const cleaned = sample
.replace(/```[\s\S]*?```/g, " ")
.replace(/`[^`]+`/g, " ")
.replace(/https?:\/\/\S+/gi, " ")
.replace(/@[\w-]+/g, " ")
.replace(/#\d+/g, " ");
const hangul = countMatches(cleaned, /[\uAC00-\uD7AF]/g);
const hiraganaKatakana = countMatches(cleaned, /[\u3040-\u30FF]/g);
const cjk = countMatches(cleaned, /[\u4E00-\u9FFF]/g);
const latin = countMatches(cleaned, /[A-Za-zÀ-ÖØ-öø-ÿ]/g);
const letters = hangul + hiraganaKatakana + cjk + latin;
if (letters < MIN_DETECTABLE_CHARS) {
return { locale: "unknown", family: "other", confidence: "low" };
}
const hangulShare = hangul / letters;
const cjkShare = cjk / letters;
const latinShare = latin / letters;
if (hangulShare >= 0.35) {
return {
locale: "ko",
family: "hangul",
confidence: hangulShare >= 0.55 ? "high" : "medium",
};
}
// Japanese (hiragana/katakana present) is not a dashboard locale — treat as non-matching CJK family.
if (hiraganaKatakana >= 8 || (hiraganaKatakana >= 3 && cjkShare >= 0.2)) {
return { locale: "unknown", family: "cjk", confidence: "high" };
}
if (cjkShare >= 0.35) {
// Cannot reliably split zh-CN vs zh-TW without a dictionary; either Chinese UI locale
// should suppress the translate CTA for CJK prose.
return {
locale: "zh-CN",
family: "cjk",
confidence: cjkShare >= 0.55 ? "high" : "medium",
};
}
if (latinShare >= 0.55) {
const latinGuess = scoreLatinLocale(cleaned);
return {
locale: latinGuess.locale,
family: "latin",
confidence: latinGuess.confidence,
};
}
return { locale: "unknown", family: "other", confidence: "low" };
}
/**
* Whether import-preview content should offer translation into `dashboardLocale`.
* Requires medium+ confidence and a family/locale mismatch so we do not nag same-language content.
*/
export function contentNeedsTranslation(
text: string,
dashboardLocale: Locale,
): { needed: boolean; detected: DetectedContentLanguage } {
const detected = detectContentLanguage(text);
if (detected.confidence === "low" || detected.locale === "unknown") {
// Still offer when family is clearly foreign (e.g. Japanese kana) even if locale is unknown.
if (detected.confidence === "high" && detected.family !== familyForLocale(dashboardLocale) && detected.family !== "other") {
return { needed: true, detected };
}
return { needed: false, detected };
}
if (detected.locale === dashboardLocale) {
return { needed: false, detected };
}
// Chinese UI locales treat Simplified/Traditional detection as same family.
if (
familyForLocale(dashboardLocale) === "cjk" &&
detected.family === "cjk" &&
isSupportedLocale(detected.locale) &&
familyForLocale(detected.locale) === "cjk"
) {
return { needed: false, detected };
}
// Latin locales that share the same stopword winner as dashboard.
if (detected.locale === dashboardLocale) {
return { needed: false, detected };
}
// Require medium+ confidence for same-script (latin) mismatches to limit false positives.
if (detected.family === familyForLocale(dashboardLocale) && detected.confidence !== "high") {
return { needed: false, detected };
}
return { needed: true, detected };
}
/** Human-readable endonym for a detected/source locale chip in the translate banner. */
export function localeDisplayName(locale: Locale | "unknown"): string {
switch (locale) {
case "en":
return "English";
case "zh-CN":
return "简体中文";
case "zh-TW":
return "繁體中文";
case "fr":
return "Français";
case "es":
return "Español";
case "ko":
return "한국어";
default:
return locale;
}
}

View File

@@ -1375,6 +1375,7 @@ export {
resolveTaskPlanningModel,
resolveTaskValidatorModel,
resolveTitleSummarizerSettingsModel,
resolveImportTranslateSettingsModel,
resolveValidatorSettingsModel,
TEST_MODE_RESOLVED,
routeTaskExecutionModel,

View File

@@ -1429,6 +1429,7 @@ export {
resolveTaskPlanningModel,
resolveTaskValidatorModel,
resolveTitleSummarizerSettingsModel,
resolveImportTranslateSettingsModel,
resolveValidatorSettingsModel,
TEST_MODE_RESOLVED,
routeTaskExecutionModel,
@@ -2397,3 +2398,15 @@ export {
// FNXC:SqliteRemoval 2026-07-14: Export async audit reader so engine tests can
// query run-audit events in backend mode (sync getRunAuditEvents returns [] in PG mode).
export { queryRunAuditEvents } from "./task-store/async-audit.js";
/*
FNXC:GitHubImportTranslate 2026-07-15-09:30:
Language detection is shared by the dashboard translate banner and the server-side auto-translate skip decision; exporting it from core keeps both surfaces on one heuristic.
*/
export {
MIN_DETECTABLE_CHARS,
detectContentLanguage,
contentNeedsTranslation,
localeDisplayName,
} from "./detect-content-language.js";
export type { LanguageFamily, DetectedContentLanguage } from "./detect-content-language.js";

View File

@@ -186,6 +186,37 @@ export function resolveTitleSummarizerSettingsModel(settings?: Partial<Settings>
);
}
/*
FNXC:GitHubImportTranslate 2026-07-15-09:30:
Import auto-translation resolves its own lane so operators can pin a cheap/fast translation model independently of summarization.
Hierarchy: project translate lane -> global translate lane -> summarization lane (nearest one-off readonly helper) -> project/global default.
Partial provider/model pairs are skipped by `pickFirstModelPair`, and test mode still forces mock like every other lane.
*/
export function resolveImportTranslateSettingsModel(settings?: Partial<Settings>): ResolvedModelSelection {
return applyTestModeOverrides(
pickFirstModelPair(
{
provider: settings?.importTranslateProvider,
modelId: settings?.importTranslateModelId,
},
{
provider: settings?.importTranslateGlobalProvider,
modelId: settings?.importTranslateGlobalModelId,
},
{
provider: settings?.titleSummarizerProvider,
modelId: settings?.titleSummarizerModelId,
},
{
provider: settings?.titleSummarizerGlobalProvider,
modelId: settings?.titleSummarizerGlobalModelId,
},
resolveProjectDefaultModel(settings),
),
settings,
);
}
/**
* FNXC:Settings-MergerModel 2026-07-13-07:52:
* Merger sessions resolve project merger lane → global merger lane → project/global default.

View File

@@ -0,0 +1,60 @@
/*
FNXC:GitHubImportTranslate 2026-07-15-09:30:
Import auto-translation persists one translation per (project, provider, repo, issue, target locale) so re-opening the Import Tasks panel never re-bills the AI helper for an issue already translated.
`source_hash` pins the translation to the ORIGINAL title+body, so an edited issue misses the cache instead of serving stale prose. Rows are written only for OPEN issues and pruned once an issue is observed closed.
FNXC:GitHubImportTranslate 2026-07-15-09:30:
Project isolation here is the SAME contract 0006 applies to every project-owned table, not merely a `project_id` column: RLS enabled, FORCE RLS (so even the table owner is filtered), a `fusion_project_isolation` policy honouring the `fusion.project_bypass` escape used by maintenance paths, and the `fusion_assign_project_id` trigger that stamps the column from `fusion.project_id`.
All projects share this one flat `project` schema, so a table that opts out of the contract would serve one project's translations to another. `schema-applier` verifies this invariant on boot and fails closed, so a new table MUST opt in here rather than rely on query-level predicates alone.
*/
CREATE TABLE IF NOT EXISTS project.import_translation_cache (
project_id text NOT NULL DEFAULT current_setting('fusion.project_id', true),
provider text NOT NULL,
repo_key text NOT NULL,
issue_number integer NOT NULL,
target_locale text NOT NULL,
source_hash text NOT NULL,
translated_title text NOT NULL,
translated_body text NOT NULL,
detected_locale text,
recorded_at text NOT NULL,
CONSTRAINT import_translation_cache_pkey
PRIMARY KEY (project_id, provider, repo_key, issue_number, target_locale)
);
DO $$
BEGIN
IF to_regclass('project.import_translation_cache') IS NULL THEN
RETURN;
END IF;
CREATE INDEX IF NOT EXISTS "idxImportTranslationCacheRecordedAt"
ON project.import_translation_cache (recorded_at);
ALTER TABLE project.import_translation_cache ENABLE ROW LEVEL SECURITY;
ALTER TABLE project.import_translation_cache FORCE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS fusion_project_isolation ON project.import_translation_cache;
CREATE POLICY fusion_project_isolation ON project.import_translation_cache
USING (
current_setting('fusion.project_bypass', true) = 'on'
OR project_id = current_setting('fusion.project_id', true)
)
WITH CHECK (
current_setting('fusion.project_bypass', true) = 'on'
OR project_id = current_setting('fusion.project_id', true)
);
-- Stamp project_id from the session setting, matching every other project table.
IF to_regprocedure('project.fusion_assign_project_id()') IS NOT NULL THEN
DROP TRIGGER IF EXISTS fusion_assign_project_id ON project.import_translation_cache;
CREATE TRIGGER fusion_assign_project_id
BEFORE INSERT OR UPDATE OF project_id ON project.import_translation_cache
FOR EACH ROW EXECUTE FUNCTION project.fusion_assign_project_id();
END IF;
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'fusion_runtime') THEN
GRANT SELECT, INSERT, UPDATE, DELETE ON project.import_translation_cache TO fusion_runtime;
END IF;
END
$$;

View File

@@ -27,7 +27,11 @@ import { sql } from "drizzle-orm";
import { runPluginSchemaInitHooks, DEFAULT_PLUGIN_SCHEMA_INIT_HOOKS, type PluginSchemaInitHook } from "./plugin-schema-hook.js";
/** The latest PostgreSQL schema version known to this applier. */
export const SCHEMA_BASELINE_VERSION = "0009";
/*
FNXC:GitHubImportTranslate 2026-07-15-09:30:
Advances to 0010 with the import-translation cache. Per-migration identities above stay fixed; only this latest-version marker moves.
*/
export const SCHEMA_BASELINE_VERSION = "0010";
const INITIAL_SCHEMA_VERSION = "0000";
const AUTOMATION_ISOLATION_SCHEMA_VERSION = "0001";
const ANALYTICS_ISOLATION_SCHEMA_VERSION = "0002";
@@ -47,6 +51,11 @@ export const SQLITE_SCHEMA_PARITY_VERSION = "0007";
*/
export const SESSION_ADVISOR_ENABLED_SCHEMA_VERSION = "0008";
export const MISSION_FIX_IDEMPOTENCY_VERSION = "0009";
/*
FNXC:GitHubImportTranslate 2026-07-15-09:30:
Import-translation cache advances to 0010. Migrations are registered here explicitly (not auto-discovered from the migrations dir), so a new .sql file that is not wired through a version constant + bookkeeping check silently never runs.
*/
export const IMPORT_TRANSLATION_CACHE_VERSION = "0010";
/** Bookkeeping table for the fresh Drizzle migration history. */
export const MIGRATION_BOOKKEEPING_TABLE = "fusion_schema_migrations";
@@ -98,6 +107,11 @@ const MISSION_FIX_IDEMPOTENCY_MIGRATION_PATH = join(
"migrations",
"0009_mission_fix_idempotency.sql",
);
const IMPORT_TRANSLATION_CACHE_MIGRATION_PATH = join(
__dirname,
"migrations",
"0010_import_translation_cache.sql",
);
/**
* Ensure the migration bookkeeping table exists. Lives in the public schema so
@@ -164,6 +178,7 @@ export async function applySchemaBaseline(
const sqliteSchemaParityAlreadyApplied = applied.includes(SQLITE_SCHEMA_PARITY_VERSION);
const sessionAdvisorEnabledAlreadyApplied = applied.includes(SESSION_ADVISOR_ENABLED_SCHEMA_VERSION);
const missionFixIdempotencyAlreadyApplied = applied.includes(MISSION_FIX_IDEMPOTENCY_VERSION);
const importTranslationCacheAlreadyApplied = applied.includes(IMPORT_TRANSLATION_CACHE_VERSION);
let schemaChanged = false;
if (!baselineAlreadyApplied) {
@@ -387,6 +402,19 @@ export async function applySchemaBaseline(
schemaChanged = true;
}
/*
FNXC:GitHubImportTranslate 2026-07-15-09:30:
Create the import-translation cache table independently of earlier schema versions so existing databases gain it on boot before any Import Tasks translate/import read runs against it.
*/
if (!importTranslationCacheAlreadyApplied) {
const importTranslationCacheSql = await readFile(IMPORT_TRANSLATION_CACHE_MIGRATION_PATH, "utf8");
await tx.execute(sql.raw(importTranslationCacheSql));
await tx.execute(
sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${IMPORT_TRANSLATION_CACHE_VERSION}) ON CONFLICT (version) DO NOTHING`,
);
schemaChanged = true;
}
return { applied: schemaChanged, pluginHooksRun: pluginHooks.length };
});
}

View File

@@ -1875,6 +1875,36 @@ export const verificationCache = projectSchema.table("verification_cache", {
index("idxVerificationCacheRecordedAt").on(t.recordedAt),
]);
/*
FNXC:GitHubImportTranslate 2026-07-15-09:30:
Import auto-translation must survive modal close and page reload — the operator should never re-bill the AI helper for an issue already translated. Cache one translation per (project, provider, repo, issue, target locale).
`sourceHash` is the hash of the original title+body: an edited issue produces a new hash so the stale translation is never served. Rows are only ever written for OPEN issues, and are pruned once an issue is observed closed, which is the requirement's natural expiry ("persist until the issue is closed").
`projectId` is part of the PK because all projects share one flat `project` schema — omitting it (as the older `verification_cache` PK does) would leak one project's translations into another.
*/
export const importTranslationCache = projectSchema.table("import_translation_cache", {
projectId: text("project_id").notNull().default(sql`current_setting('fusion.project_id', true)`),
/** Import source: "github" | "gitlab". */
provider: text("provider").notNull(),
/** Canonical repo identity, e.g. "owner/repo" (GitLab: project path). */
repoKey: text("repo_key").notNull(),
/** Issue/PR/MR number within the repo. */
issueNumber: integer("issue_number").notNull(),
/** BCP-47 target locale the cached fields were translated into. */
targetLocale: text("target_locale").notNull(),
/** Hash of the ORIGINAL title+body; a mismatch means the issue was edited. */
sourceHash: text("source_hash").notNull(),
translatedTitle: text("translated_title").notNull(),
translatedBody: text("translated_body").notNull(),
/** Detected source language, or null when detection was inconclusive. */
detectedLocale: text("detected_locale"),
recordedAt: text("recorded_at").notNull(),
}, (t) => [
primaryKey({
columns: [t.projectId, t.provider, t.repoKey, t.issueNumber, t.targetLocale],
}),
index("idxImportTranslationCacheRecordedAt").on(t.recordedAt),
]);
export const approvalRequests = projectSchema.table("approval_requests", {
id: text("id").primaryKey(),
status: text("status").notNull(),
@@ -1988,7 +2018,8 @@ export const projectTableNames = [
"agent_ratings", "chat_sessions", "cli_sessions", "chat_messages",
"run_audit_events", "mission_contract_assertions", "mission_feature_assertions",
"mission_validator_runs", "mission_validator_failures",
"mission_fix_feature_lineage", "verification_cache", "approval_requests",
"mission_fix_feature_lineage", "verification_cache", "import_translation_cache",
"approval_requests",
"approval_request_audit_events", "chat_rooms", "chat_room_members",
"chat_room_messages", "chat_token_usage",
] as const;

View File

@@ -205,6 +205,13 @@ export const DEFAULT_GLOBAL_SETTINGS = {
mergerGlobalProvider: undefined,
mergerGlobalModelId: undefined,
/*
FNXC:GitHubImportTranslate 2026-07-15-09:30:
Global import-translate baseline lane. Undefined falls through to the summarization lane then defaultProvider/defaultModelId at resolve time.
*/
importTranslateGlobalProvider: undefined,
importTranslateGlobalModelId: undefined,
importTranslateGlobalThinkingLevel: undefined,
/*
FNXC:Settings-ThinkingLevel 2026-07-10-00:00:
Global model lanes can override the default thinking effort independently. Undefined preserves the existing inheritance to `defaultThinkingLevel`.
*/
@@ -619,6 +626,15 @@ export const DEFAULT_PROJECT_SETTINGS = {
titleSummarizerFallbackModelId: undefined,
titleSummarizerFallbackThinkingLevel: undefined,
/*
FNXC:GitHubImportTranslate 2026-07-15-09:30:
Import auto-translation defaults OFF: operators who never opt in keep byte-faithful import provenance. Target locale undefined means "follow the active dashboard locale". Translate model lane stays project-scoped like the summarizer lane.
*/
githubImportAutoTranslate: false,
importTranslateTargetLocale: undefined,
importTranslateProvider: undefined,
importTranslateModelId: undefined,
importTranslateThinkingLevel: undefined,
/*
FNXC:Settings-MergerModel 2026-07-13-07:52:
Merger model lane stays project-scoped (not workflow-moved) like title summarizer: Settings → Project Models can override the global merger baseline without binding the choice to a workflow graph.
*/

View File

@@ -100,7 +100,7 @@ import { recordGoalCitationsImpl, insertTaskWithFtsRecoveryImpl2, assertTaskIdAv
import { applyLegacyWorkflowStepOverridesImpl, applyTaskPatchImpl, archiveDbImpl, assertNoDependencyCycleImpl, atomicCreateTaskJsonImpl, buildActiveTaskDependencyLookupImpl, buildArchivedAgentLogFieldsImpl, buildTaskIdIntegrityFallbackReportImpl, createBranchGroupImpl, dbImpl, detectAndCacheTaskIdIntegrityReportImpl, findLiveDependentsImpl, findLiveLineageChildrenImpl, getLegacyWorkflowStepSnapshotImpl, getMalformedTaskMetadataReasonImpl, getMergeQueuedTaskIdsAsyncImpl, insertRunAuditEventRowImpl, insertTaskImpl, invokeTaskCreatedHookImpl, isTaskArchivedImpl, isTaskIdPresentInArchivedTasksTableImpl, logTaskCreateConflictImpl, maybeResolveTombstonedTaskIdImpl, mergeTaskIdIntegrityReportsImpl, optionalGroupIdSetImpl, patchTaskRowInTransactionImpl, readConfigFastImpl, readConfigImpl, readPromptForArchiveImpl, readTaskFromDbImpl, reconcileDistributedTaskIdStateOnOpenImpl, recordActivityFromListenerImpl, recordDependencyCycleRejectedAuditImpl, refreshTaskIdIntegrityReportImpl, resolveLocalNodeIdForTaskAllocationImpl, runTaskFtsWriteWithRecoveryImpl, scanAndRecordCitationsImpl, taskIdExistsAnywhereImpl, throwSoftDeletedWriteBlockedImpl, toBuiltInWorkflowStepImpl, trackDeferredTaskCreatedWorkImpl, upsertTaskImpl, withConfigLockImpl, withTaskLockImpl, withWorktreeAllocationLockImpl } from "./task-store/remaining-ops-5.js";
import { clearNearDuplicateReferencesToFailSoftImpl, clearWorkflowRunStepInstancesImpl, computeMovedSettingsTargetWorkflowIdsImpl, ensureBranchGroupForSourceImpl, ensurePrEntityForSourceImpl, findRecentTasksByContentFingerprintImpl, getActiveMergingTaskImpl, getActivePrEntityBySourceImpl, getBranchGroupByBranchNameImpl, getBranchGroupBySourceImpl, getBranchGroupImpl, getBranchProgressByTaskImpl, getMutationsForRunImpl, getPrEntityByNumberImpl, getPrEntityImpl, getPrThreadStateImpl, getTasksByAssignedAgentImpl, getWorkflowPromptOverridesAsyncImpl, getWorkflowSettingValuesAsyncImpl, getWorkflowSettingValuesImpl, getWorkflowSettingsProjectIdImpl, getWorkflowWorkItemImpl, insertCompletionHandoffWorkflowWorkAuditImpl, listActivePrEntitiesImpl, listBranchGroupsImpl, listPrThreadStatesImpl, listTasksByBranchGroupImpl, listWorkflowSettingValuesForProjectImpl, loadWorkflowRunBranchesImpl, loadWorkflowRunStepInstancesImpl, mergeCustomFieldPatchImpl, normalizeMergeRequestStateImpl, normalizeWorkflowWorkItemKindImpl, normalizeWorkflowWorkItemStateImpl, parseWorkflowPromptOverrideJsonImpl, recordPrThreadOutcomeImpl, resetAllStepsToPendingImpl, resetPromptCheckboxesImpl, resolveWorkflowMoveActorImpl, resolveWorkflowSettingDeclarationsImpl, saveWorkflowRunStepInstanceImpl, transitionMergeRequestStateImpl, transitionWorkflowWorkItemSyncImpl, updateTaskImpl, updateWorkflowPromptOverridesImpl, upsertMergeRequestRecordImpl, workflowStateForMergeRequestStateImpl } from "./task-store/remaining-ops-6.js";
import { addPrInfoImpl, addSteeringCommentImpl, archiveAllDoneImpl, cleanupStaleMergeQueueRowsImpl, clearCompletionHandoffAcceptedMarkerImpl, clearDoneTransientFieldsImpl, clearStaleExecutionStartBranchReferencesImpl, computeWorkflowColumnsGraduationReportImpl, deleteTaskCommentImpl, deleteTaskDocumentImpl, emitUsageEventImpl, enqueueMergeQueueImpl, getAgentLogCountImpl, getAgentLogsImpl, getArtifactImpl, getArtifactsImpl, getAttachmentImpl, getCompletionHandoffAcceptedMarkerImpl, getTaskDocumentImpl, getTaskDocumentRevisionsImpl, getTaskDocumentsImpl, insertArtifactRowImpl, linkGithubIssueImpl, listWorkflowWorkItemsForTaskSyncImpl, moveToDoneImpl, parseDependenciesFromPromptImpl, parseFileScopeFromPromptImpl, parseStepsFromPromptImpl, peekMergeQueueHeadImpl, peekMergeQueueImpl, readPreArchiveColumnFromTaskFileImpl, recordPluginActivationImpl, recordRunAuditEventBackendImpl, removePrInfoByNumberImpl, resolvePrimaryPrInfoImpl, resolveUnarchiveTargetColumnImpl, rewriteLineageChildrenForRemovalImpl, runGitCommandImpl, stopWatchingImpl, syncAgentTaskLinkOnReassignmentImpl, updateArtifactImpl, updateGithubTrackingImpl, updatePrInfoByNumberImpl, updateTaskCommentImpl, upsertPrInfoByNumberImpl, writeArtifactDataImpl } from "./task-store/remaining-ops-7.js";
import { approveCliAutonomyImpl, approveWorkflowCliCommandImpl, cleanupOrphanedMaterializedStepsImpl, consumePluginGateVerdictsImpl, getAgentLogsByTimeRangeImpl, getDatabaseHealthImpl, getDistributedTaskIdAllocatorImpl, getExperimentSessionStoreImpl, getInReviewDurationEventsImpl, getMissionStoreImpl, getPluginStoreImpl, getSecretsStoreImpl, getSettingsSyncImpl, getTaskMergedTaskIdsImpl, getTaskWorkflowSelectionImpl, getVerificationCacheHitImpl, getWorkflowDefinitionImpl, healthCheckImpl, importLegacyAgentLogsOnceImpl, insertWorkflowDefinitionSyncImpl, isCliAutonomyApprovedImpl, isPluginInstalledImpl, isWorkflowCliCommandApprovedImpl, listWorkflowDefinitionsImpl, materializeExplicitWorkflowStepsImpl, materializeWorkflowStepsImpl, migrateActiveArchivedTasksToArchiveDbImpl, migrateLegacyArchiveEntriesToArchiveDbImpl, nextWorkflowDefinitionIdImpl, occupantsByColumnForWorkflowImpl, parseWorkflowLayoutImpl, pruneAgentLogFilesImpl, purgeTaskWorkflowSelectionRowsImpl, readAllWorkflowDefinitionsImpl, readRawProjectSettingsImpl, recordPluginGateVerdictImpl, recordVerificationCachePassImpl, removeMaterializedSelectionImpl, resolvePluginWorkflowStepImpl, resolveTaskWorkflowIrSyncImpl, revokeCliAutonomyImpl, selectTaskWorkflowAndReconcileImpl, writeTaskWorkflowSelectionImpl, getTaskWorkflowSelectionAsyncImpl, } from "./task-store/remaining-ops-8.js";
import { approveCliAutonomyImpl, approveWorkflowCliCommandImpl, cleanupOrphanedMaterializedStepsImpl, consumePluginGateVerdictsImpl, getAgentLogsByTimeRangeImpl, getDatabaseHealthImpl, getDistributedTaskIdAllocatorImpl, getExperimentSessionStoreImpl, getInReviewDurationEventsImpl, getMissionStoreImpl, getPluginStoreImpl, getSecretsStoreImpl, getSettingsSyncImpl, getTaskMergedTaskIdsImpl, getTaskWorkflowSelectionImpl, getImportTranslationImpl, recordImportTranslationImpl, pruneImportTranslationsImpl, type ImportTranslationCacheKey, type ImportTranslationCacheEntry, getVerificationCacheHitImpl, getWorkflowDefinitionImpl, healthCheckImpl, importLegacyAgentLogsOnceImpl, insertWorkflowDefinitionSyncImpl, isCliAutonomyApprovedImpl, isPluginInstalledImpl, isWorkflowCliCommandApprovedImpl, listWorkflowDefinitionsImpl, materializeExplicitWorkflowStepsImpl, materializeWorkflowStepsImpl, migrateActiveArchivedTasksToArchiveDbImpl, migrateLegacyArchiveEntriesToArchiveDbImpl, nextWorkflowDefinitionIdImpl, occupantsByColumnForWorkflowImpl, parseWorkflowLayoutImpl, pruneAgentLogFilesImpl, purgeTaskWorkflowSelectionRowsImpl, readAllWorkflowDefinitionsImpl, readRawProjectSettingsImpl, recordPluginGateVerdictImpl, recordVerificationCachePassImpl, removeMaterializedSelectionImpl, resolvePluginWorkflowStepImpl, resolveTaskWorkflowIrSyncImpl, revokeCliAutonomyImpl, selectTaskWorkflowAndReconcileImpl, writeTaskWorkflowSelectionImpl, getTaskWorkflowSelectionAsyncImpl, } from "./task-store/remaining-ops-8.js";
import { getTaskCommitAssociationsByLineageIdImpl, replaceLegacyTaskCommitAssociationsImpl } from "./task-store/task-commit-associations.js";
import { addTaskCommentImpl, applyBuiltInPromptOverridesSyncImpl, areAllDependenciesDoneImpl, artifactStoredNameImpl, assertWorkflowIrTraitsValidImpl, clearActivityLogImpl, clearTaskWorkflowSelectionImpl, deleteTaskByIdImpl, getDefaultWorkflowIdImpl, getInsightStoreImpl, getMergeQueuedTaskIdsImpl, getMergeRequestRecordImpl, getMergeRequestRecordAsyncImpl, getResearchStoreImpl, getTaskIdFromDirImpl, getTodoStoreImpl, getWorkflowWorkItemByIdentityImpl, hasActiveTaskImpl, invalidateConfigCacheAfterMigrationImpl, isTaskIdConflictErrorImpl, listLegacyAutoMergeStampCandidatesImpl, readTaskRowFromDbImpl, recordBranchGroupMemberLandedImpl, refreshDatabaseHealthImpl, resolveEffectiveWorkflowIdSyncImpl, resolveTaskCustomFieldDefsSyncImpl, resolveWorkflowBypassGuardsImpl, serializeConfigForDiskImpl, setPluginWorkflowStepTemplatesImpl, shouldSkipWorkflowMovePoliciesImpl, suppressWatcherImpl, upsertTaskWithFtsRecoveryImpl } from "./task-store/remaining-ops-10.js";
import { getTaskSelectClauseImpl2, createTaskPersistSerializationContextImpl, getTaskPersistValuesImpl, getTaskPatchDescriptorsImpl, normalizeTaskFromDiskImpl, writeTaskJsonFileImpl, rowToPrEntityImpl, generatePrEntityIdImpl, readTaskForMoveImpl, rowToMergeQueueEntryImpl, rowToMergeRequestRecordImpl, rowToCompletionHandoffMarkerImpl, rowToWorkflowWorkItemImpl, rowToRunAuditEventImpl } from "./task-store/remaining-ops-3.js";
@@ -2613,6 +2613,38 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
return recordVerificationCachePassImpl(this, treeSha, testCommand, buildCommand, taskId);
}
// ── Import Translation Cache ──────────────────────────────────────────────
/*
FNXC:GitHubImportTranslate 2026-07-15-09:30:
Durable translation cache for the Import Tasks panel. The preview and the import path both read through here, so an imported task carries the same translated prose the operator approved in the preview.
*/
/** Cached translation for an import item, or null on miss/edited-since. */
getImportTranslation(
key: ImportTranslationCacheKey,
): Promise<ImportTranslationCacheEntry | null> {
return getImportTranslationImpl(this, key);
}
/** Upsert a translation for an import item. */
recordImportTranslation(
key: ImportTranslationCacheKey,
value: { translatedTitle: string; translatedBody: string; detectedLocale?: string | null },
recordedAt: string = new Date().toISOString(),
): Promise<void> {
return recordImportTranslationImpl(this, key, value, recordedAt);
}
/** Drop cached translations for issues observed closed. */
pruneImportTranslations(
provider: string,
repoKey: string,
closedIssueNumbers: number[],
): Promise<number> {
return pruneImportTranslationsImpl(this, provider, repoKey, closedIssueNumbers);
}
// ── Shared mesh state export/apply helpers ───────────────────────────────
async upsertTaskCommitAssociation( input: Omit<TaskCommitAssociation, "id" | "createdAt" | "updatedAt"> & { id?: string }, ): Promise<TaskCommitAssociation> {

View File

@@ -1012,3 +1012,139 @@ export function recordVerificationCachePassImpl(store: TaskStore,
)
.run(treeSha, normalizedTest, normalizedBuild, recordedAt, taskId);
}
/*
FNXC:GitHubImportTranslate 2026-07-15-09:30:
Import auto-translation persists translations so an issue is translated at most once per target locale — reopening the Import Tasks panel or reloading the dashboard must never re-bill the AI helper.
These are PostgreSQL-only async ops. Unlike the legacy sync `verification_cache` helpers above (which still use SQLite `db.prepare`), they go through `asyncLayer` and always carry an explicit `project_id` predicate: every project shares one flat `project` schema, so an unscoped read would serve another project's translations.
*/
export interface ImportTranslationCacheEntry {
translatedTitle: string;
translatedBody: string;
detectedLocale: string | null;
recordedAt: string;
}
export interface ImportTranslationCacheKey {
provider: string;
repoKey: string;
issueNumber: number;
targetLocale: string;
/** Hash of the ORIGINAL title+body; a mismatch means the issue was edited. */
sourceHash: string;
}
function importTranslationScope(store: TaskStore) {
const projectId = store.asyncLayer?.projectId;
return projectId
? eq(schema.project.importTranslationCache.projectId, projectId)
: undefined;
}
/**
* Read a cached translation. Returns null on miss, and also on a `sourceHash`
* mismatch — an edited issue must re-translate rather than serve stale prose.
*/
export async function getImportTranslationImpl(
store: TaskStore,
key: ImportTranslationCacheKey,
): Promise<ImportTranslationCacheEntry | null> {
if (!store.asyncLayer) return null;
const table = schema.project.importTranslationCache;
const rows = await store.asyncLayer.db
.select({
translatedTitle: table.translatedTitle,
translatedBody: table.translatedBody,
detectedLocale: table.detectedLocale,
recordedAt: table.recordedAt,
sourceHash: table.sourceHash,
})
.from(table)
.where(
and(
importTranslationScope(store),
eq(table.provider, key.provider),
eq(table.repoKey, key.repoKey),
eq(table.issueNumber, key.issueNumber),
eq(table.targetLocale, key.targetLocale),
),
)
.limit(1);
const row = rows[0];
if (!row) return null;
// Stale-content guard: the issue body changed since we translated it.
if (row.sourceHash !== key.sourceHash) return null;
return {
translatedTitle: row.translatedTitle,
translatedBody: row.translatedBody,
detectedLocale: row.detectedLocale ?? null,
recordedAt: row.recordedAt,
};
}
/**
* Upsert a translation. Re-translating the same issue (after an edit) replaces
* the row rather than accumulating one row per revision.
*/
export async function recordImportTranslationImpl(
store: TaskStore,
key: ImportTranslationCacheKey,
value: { translatedTitle: string; translatedBody: string; detectedLocale?: string | null },
recordedAt: string,
): Promise<void> {
if (!store.asyncLayer) return;
const table = schema.project.importTranslationCache;
const projectId = store.asyncLayer.projectId;
await store.asyncLayer.db
.insert(table)
.values({
...(projectId ? { projectId } : {}),
provider: key.provider,
repoKey: key.repoKey,
issueNumber: key.issueNumber,
targetLocale: key.targetLocale,
sourceHash: key.sourceHash,
translatedTitle: value.translatedTitle,
translatedBody: value.translatedBody,
detectedLocale: value.detectedLocale ?? null,
recordedAt,
})
.onConflictDoUpdate({
target: [table.projectId, table.provider, table.repoKey, table.issueNumber, table.targetLocale],
set: {
sourceHash: key.sourceHash,
translatedTitle: value.translatedTitle,
translatedBody: value.translatedBody,
detectedLocale: value.detectedLocale ?? null,
recordedAt,
},
});
}
/**
* Drop cached translations for issues that are no longer open. This is the
* requirement's expiry rule — a translation persists "until the issue is
* closed". No-ops on an empty list so a fully-open page costs no query.
*/
export async function pruneImportTranslationsImpl(
store: TaskStore,
provider: string,
repoKey: string,
closedIssueNumbers: number[],
): Promise<number> {
if (!store.asyncLayer || closedIssueNumbers.length === 0) return 0;
const table = schema.project.importTranslationCache;
await store.asyncLayer.db
.delete(table)
.where(
and(
importTranslationScope(store),
eq(table.provider, provider),
eq(table.repoKey, repoKey),
inArray(table.issueNumber, closedIssueNumbers),
),
);
return closedIssueNumbers.length;
}

View File

@@ -2786,6 +2786,19 @@ export interface GlobalSettings {
/** Global baseline AI model ID for merger agent sessions.
* Must be set together with `mergerGlobalProvider`. */
mergerGlobalModelId?: string;
/*
FNXC:GitHubImportTranslate 2026-07-15-09:30:
Global baseline translate lane. Import auto-translation runs one short readonly call per issue, so operators typically pin a cheap/fast model here rather than inheriting the executor/planner model.
*/
/** Global baseline AI model provider for import auto-translation.
* Must be set together with `importTranslateGlobalModelId`. Falls back to the
* summarization lane, then `defaultProvider`/`defaultModelId`. */
importTranslateGlobalProvider?: string;
/** Global baseline AI model ID for import auto-translation.
* Must be set together with `importTranslateGlobalProvider`. */
importTranslateGlobalModelId?: string;
/** Optional global translate-lane thinking override. Inherits `defaultThinkingLevel` when unset. */
importTranslateGlobalThinkingLevel?: ThinkingLevel;
/** Optional global execution-lane thinking override. Inherits `defaultThinkingLevel` when unset. */
executionGlobalThinkingLevel?: ThinkingLevel;
/** Optional global planning-lane thinking override. Inherits `defaultThinkingLevel` when unset. */
@@ -3998,6 +4011,32 @@ export interface ProjectSettings {
mergerModelId?: string;
/** Optional project merger-lane thinking override. Inherits through global merger thinking then default thinking when unset. */
mergerThinkingLevel?: ThinkingLevel;
/*
FNXC:GitHubImportTranslate 2026-07-15-09:30:
Import Tasks auto-translation is a dedicated one-off AI helper lane, kept separate from the summarization lane so operators can pin a cheap/fast translation model without dragging title summarization onto it.
Both provider and model id must be set together; partial pairs are ignored and fall through to global translate lane, then summarization, then project/global default.
*/
/** Project AI model provider for GitHub/GitLab import auto-translation.
* Must be set together with `importTranslateModelId`. Falls back to
* `importTranslateGlobalProvider`/`importTranslateGlobalModelId`, then the
* summarization lane, then project/global default. */
importTranslateProvider?: string;
/** Project AI model ID for import auto-translation.
* Must be set together with `importTranslateProvider`. */
importTranslateModelId?: string;
/** Optional project translate-lane thinking override. Inherits through global translate thinking then default thinking when unset. */
importTranslateThinkingLevel?: ThinkingLevel;
/*
FNXC:GitHubImportTranslate 2026-07-15-09:30:
Auto-translation is OFF by default. This reverses the original opt-in-only stance (PR #2128) at operator request: import panels routinely list issues in languages the operator cannot read, so translation may now run automatically — but only when explicitly enabled, so import provenance stays faithful for operators who never opt in.
*/
/** When true, the import panel automatically translates foreign-language issue
* title+body into `importTranslateTargetLocale` and shows the translation by
* default. Default: false (opt-in). */
githubImportAutoTranslate?: boolean;
/** Target language for import auto-translation. When unset, follows the
* operator's active dashboard locale. */
importTranslateTargetLocale?: Locale;
/** Fallback model provider for title summarization. When unset, falls back to
* planning fallback, then global fallback. Must be set together with
* `titleSummarizerFallbackModelId`. */

View File

@@ -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.",

View File

@@ -29,7 +29,10 @@ import {
import { Loader2, RefreshCw, ArrowLeft, GitPullRequest, CircleDot, ChevronUp, ChevronDown, Bot, User } from "lucide-react";
import { GithubIcon } from "./GithubIcon";
import { MailboxMessageContent } from "./MailboxMessageContent";
import { useGitHubImportTranslation } from "./GitHubImportTranslateControls";
import {
useGitHubImportTranslation,
useGitHubImportAutoTranslate,
} from "./GitHubImportTranslateControls";
import type { TFunction } from "i18next";
import { useModalResizePersist } from "../hooks/useModalResizePersist";
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
@@ -327,6 +330,38 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
const dashboardLocale: Locale = isLocale(i18n.resolvedLanguage ?? i18n.language)
? (i18n.resolvedLanguage ?? i18n.language) as Locale
: DEFAULT_LOCALE;
/*
FNXC:GitHubImportTranslate 2026-07-15-09:30:
Auto-translate is off by default, so the panel reads the project setting before translating anything. `importTranslateTargetLocale` overrides the dashboard locale when the operator wants issues in a language other than the one the UI is rendered in; unset means "follow the dashboard language".
The server re-checks the same setting, so a stale value here can never cause an unwanted model call — this fetch drives the UI only.
*/
const [autoTranslateEnabled, setAutoTranslateEnabled] = useState(false);
const [translateLocaleSetting, setTranslateLocaleSetting] = useState<Locale | null>(null);
useEffect(() => {
if (!isOpen) return;
let cancelled = false;
fetchSettings(projectId)
.then((settings) => {
if (cancelled) return;
setAutoTranslateEnabled(settings.githubImportAutoTranslate === true);
setTranslateLocaleSetting(
isLocale(settings.importTranslateTargetLocale)
? settings.importTranslateTargetLocale
: null,
);
})
.catch(() => {
// Settings unavailable: stay on the safe default (no auto-translation).
if (!cancelled) setAutoTranslateEnabled(false);
});
return () => {
cancelled = true;
};
}, [isOpen, projectId]);
const translateTargetLocale: Locale = translateLocaleSetting ?? dashboardLocale;
const [owner, setOwner] = useState("");
const [repo, setRepo] = useState("");
const [labels, setLabels] = useState("");
@@ -1068,7 +1103,17 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
setError(null);
try {
const task = await apiImportGitHubIssue(owner.trim(), repo.trim(), selectedIssueNumber, projectId);
/*
FNXC:GitHubImportTranslate 2026-07-15-14:10:
Forward the panel's ACTIVE target locale so the imported task carries the translation shown in the preview. The server also falls back to the global `language` setting; this covers the case it cannot know — a browser-detected locale while global `language` is unset (PR #2141 review, P1).
*/
const task = await apiImportGitHubIssue(
owner.trim(),
repo.trim(),
selectedIssueNumber,
projectId,
translateTargetLocale,
);
onImport(task);
returnToIssueListAfterSuccess();
} catch (err) {
@@ -1257,12 +1302,33 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
return { key: null as string | null, title: "", body: "" };
}, [provider, selectedGitlabItem, selectedGitlabKey, activeTab, selectedIssue, selectedPull]);
/*
FNXC:GitHubImportTranslate 2026-07-15-09:30:
Requirement (2026-07-15): when auto-translate is on, translate foreign-language issues BEFORE showing them — so the list titles, not just the preview, read in the operator's language.
The 50-most-recent-open cap and the setting itself are enforced server-side; this only supplies the visible issue set and consumes the result.
*/
const autoTranslate = useGitHubImportAutoTranslate({
enabled: autoTranslateEnabled && provider === "github" && activeTab === "issues",
owner: owner.trim(),
repo: repo.trim(),
items: issues,
targetLocale: translateTargetLocale,
projectId,
});
const selectedAutoTranslation =
provider === "github" && activeTab === "issues" && selectedIssue
? autoTranslate.translations.get(selectedIssue.number) ?? null
: null;
const importTranslation = useGitHubImportTranslation({
selectionKey: translateSelection.key,
title: translateSelection.title,
body: translateSelection.body,
dashboardLocale,
dashboardLocale: translateTargetLocale,
projectId,
autoTranslation: selectedAutoTranslation,
autoTranslateEnabled,
});
if (!isOpen) return null;
@@ -1557,7 +1623,17 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
<div className="issue-main">
<div className="issue-heading-row">
<span className="issue-number">#{issue.number}</span>
<span className="issue-title">{issue.title}</span>
{/*
FNXC:GitHubImportTranslate 2026-07-15-09:30:
The LIST title shows the translation when auto-translate produced one — the requirement is that foreign issues are translated "before showing to the user", and the list is the first thing shown. `title` keeps the original so the untranslated text stays recoverable on hover.
*/}
<span
className="issue-title"
title={autoTranslate.translations.has(issue.number) ? issue.title : undefined}
data-translated={autoTranslate.translations.has(issue.number) ? "true" : undefined}
>
{autoTranslate.translations.get(issue.number)?.title ?? issue.title}
</span>
</div>
{issue.labels.length > 0 && (
<span className="issue-labels">

View File

@@ -1,15 +1,24 @@
/*
FNXC:GitHubImportTranslate 2026-07-15-09:30:
Auto-translation supersedes the original opt-in-only stance below, at operator request. Import panels routinely list issues in languages the operator cannot read, so translation MAY now run automatically — but only when `githubImportAutoTranslate` is switched on (default off), which preserves the faithful-provenance default for anyone who never opts in.
When auto-translate is on: the 50 most recent OPEN foreign-language issues are translated eagerly on list load and shown translated BY DEFAULT, with a toggle back to the original. Translations persist server-side until the issue closes, so re-opening the panel neither waits nor re-bills.
When it is off, behavior is unchanged from 2026-07-14: a manual per-selection offer.
FNXC:GitHubImportTranslate 2026-07-14-12:00:
Import Tasks preview shows translation controls only when selected issue/PR prose is not the dashboard language.
Operators can translate title+body into the active UI locale, toggle original vs translated, or dismiss the offer for the current selection.
Translation is opt-in (never automatic) so import provenance stays faithful until the operator asks.
Translation is opt-in (never automatic) so import provenance stays faithful until the operator asks. [Superseded 2026-07-15 for the auto-translate path; still the behavior when the setting is off.]
*/
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Languages, Loader2 } from "lucide-react";
import type { Locale } from "@fusion/core";
import { translateImportContent, getTranslateErrorMessage } from "../api";
import {
translateImportContent,
getTranslateErrorMessage,
autoTranslateImportIssues,
} from "../api";
import {
contentNeedsTranslation,
localeDisplayName,
@@ -21,6 +30,133 @@ export type ImportTranslateFields = {
body: string;
};
/*
FNXC:GitHubImportTranslate 2026-07-15-09:30:
List-level auto-translation. Requirement (2026-07-15): translate the 50 most recent OPEN issues eagerly so the LIST — not just the preview — reads in the operator's language.
Sorting/capping happens here rather than server-side so the cap applies to what the operator can actually see; the server re-applies its own cap as the authority.
Closed issues are excluded outright: their translations are neither created nor kept.
*/
export const AUTO_TRANSLATE_MAX_ISSUES = 50;
export interface AutoTranslateListItem {
number: number;
title: string;
body: string | null;
state?: "open" | "closed";
}
export interface UseGitHubImportAutoTranslateArgs {
enabled: boolean;
owner: string;
repo: string;
items: AutoTranslateListItem[];
targetLocale: Locale;
projectId?: string;
}
export interface GitHubImportAutoTranslateState {
/** number -> translated fields, for issues the server translated. */
translations: Map<number, ImportTranslateFields>;
loading: boolean;
/** True when more foreign issues existed than the per-load cap. */
capped: boolean;
error: string | null;
}
/**
* Eagerly translate the visible open issues when auto-translate is enabled.
* One request per (repo, locale, issue-set); the server serves repeats from its
* durable cache, so re-opening the panel neither waits nor re-bills.
*/
export function useGitHubImportAutoTranslate({
enabled,
owner,
repo,
items,
targetLocale,
projectId,
}: UseGitHubImportAutoTranslateArgs): GitHubImportAutoTranslateState {
const [translations, setTranslations] = useState<Map<number, ImportTranslateFields>>(
() => new Map(),
);
const [loading, setLoading] = useState(false);
const [capped, setCapped] = useState(false);
const [error, setError] = useState<string | null>(null);
// Only the 50 most recent OPEN issues are eligible. GitHub returns issues
// newest-first, so list order is already "most recent".
const eligible = useMemo(
() => items.filter((item) => item.state !== "closed").slice(0, AUTO_TRANSLATE_MAX_ISSUES),
[items],
);
/* Re-run only when the actual issue set changes — not on every list re-render,
which would re-request on unrelated state churn. */
const requestKey = useMemo(
() =>
enabled && owner && repo && eligible.length > 0
? `${owner}/${repo}|${targetLocale}|${eligible.map((i) => i.number).join(",")}`
: null,
[enabled, owner, repo, targetLocale, eligible],
);
useEffect(() => {
if (!requestKey) {
setTranslations(new Map());
setCapped(false);
setError(null);
return;
}
let cancelled = false;
setLoading(true);
setError(null);
autoTranslateImportIssues(
owner,
repo,
eligible.map((item) => ({
number: item.number,
title: item.title ?? "",
body: item.body ?? null,
state: item.state === "closed" ? "closed" : "open",
})),
targetLocale,
projectId,
)
.then((response) => {
if (cancelled) return;
const next = new Map<number, ImportTranslateFields>();
for (const [key, value] of Object.entries(response.translations ?? {})) {
const number = Number(key);
if (Number.isInteger(number)) {
next.set(number, { title: value.title, body: value.body });
}
}
setTranslations(next);
setCapped(Boolean(response.capped));
})
.catch((err) => {
if (cancelled) return;
// Fail soft: the list still renders in the original language.
setError(getTranslateErrorMessage(err));
setTranslations(new Map());
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
// `requestKey` encodes every input that should retrigger the fetch (repo,
// locale, issue set); depending on `eligible` directly would refetch on any
// list re-render.
}, [requestKey, owner, repo, eligible, targetLocale, projectId]);
return { translations, loading, capped, error };
}
export type ImportTranslateView = {
/** Fields currently shown in the preview (original or translated). */
display: ImportTranslateFields;
@@ -39,6 +175,14 @@ export interface UseGitHubImportTranslationArgs {
body: string;
dashboardLocale: Locale;
projectId?: string;
/*
FNXC:GitHubImportTranslate 2026-07-15-09:30:
When auto-translate is on, the selected item's translation is already fetched at list level. Passing it in means the preview shows the translation BY DEFAULT with no second request and no per-selection wait, while the toggle still reveals the untranslated original.
*/
/** Pre-fetched translation for the current selection (auto-translate mode). */
autoTranslation?: ImportTranslateFields | null;
/** Whether auto-translate is enabled for this project. */
autoTranslateEnabled?: boolean;
}
/**
@@ -51,6 +195,8 @@ export function useGitHubImportTranslation({
body,
dashboardLocale,
projectId,
autoTranslation = null,
autoTranslateEnabled = false,
}: UseGitHubImportTranslationArgs): ImportTranslateView {
const { t } = useTranslation("app");
const original = useMemo<ImportTranslateFields>(
@@ -74,20 +220,34 @@ export function useGitHubImportTranslation({
const [translating, setTranslating] = useState(false);
const [error, setError] = useState<string | null>(null);
// Reset view mode when selection changes; keep cache and dismissals.
/*
FNXC:GitHubImportTranslate 2026-07-15-09:30:
Selection change resets to the DEFAULT view for the current mode: translated when auto-translate supplied a translation for this item, original otherwise.
Requirement: "it should show translated version by default if it's turned on" — so the reset target is mode-dependent, not a hardcoded `false`.
*/
const hasAutoTranslation = Boolean(autoTranslateEnabled && autoTranslation);
useEffect(() => {
setShowingTranslation(false);
setShowingTranslation(hasAutoTranslation);
setError(null);
setTranslating(false);
}, [selectionKey]);
}, [selectionKey, hasAutoTranslation]);
const cached = selectionKey ? cache.get(selectionKey) : undefined;
// An auto-translation for the current selection takes precedence over any
// manually fetched one, so both modes read from a single source.
const cached = autoTranslateEnabled && autoTranslation
? autoTranslation
: selectionKey
? cache.get(selectionKey)
: undefined;
const dismissed = selectionKey ? dismissedKeys.has(selectionKey) : true;
/* With a translation already in hand the row is a toggle, not an offer, so it
shows regardless of the local detector's verdict — the server already
decided this item was foreign. */
const showControls = Boolean(
selectionKey &&
needs.needed &&
!dismissed &&
(hasAutoTranslation || needs.needed) &&
(original.title.trim() || original.body.trim()),
);

View File

@@ -2759,6 +2759,22 @@ export function SettingsModal({
helperText: "AI model used for auto-generating task titles and merge commit summaries.",
fallbackOrder: "Project override → Global summarization lane → Project planning lane → Project default lane → Global default lane → Automatic resolution",
},
/*
FNXC:GitHubImportTranslate 2026-07-15-09:30:
Import auto-translation gets its OWN lane instead of riding the summarization lane. Translating an imported GitHub/GitLab issue is one short, readonly, per-issue call with no repo context, so operators must be able to pin a cheap/fast model to it without dragging the summarization lane (which titles tasks and writes merge commit messages) onto that same cheap model. It still falls back THROUGH summarization, so operators who do not care get sensible behavior with zero configuration.
*/
{
laneId: "import-translate",
label: "Import Auto-Translation Model",
globalProviderKey: "importTranslateGlobalProvider",
globalModelKey: "importTranslateGlobalModelId",
globalThinkingKey: "importTranslateGlobalThinkingLevel",
projectProviderKey: "importTranslateProvider",
projectModelKey: "importTranslateModelId",
projectThinkingKey: "importTranslateThinkingLevel",
helperText: "AI model used to translate foreign-language GitHub/GitLab issue titles and bodies in the Import Tasks panel.",
fallbackOrder: "Project override → Global import-translate lane → Summarization lane → Project default → Global default",
},
];
/**

View File

@@ -46,12 +46,25 @@ import type { GlobalSettings, McpServersSettings, Settings } from "@fusion/core"
* Merger project lane (provider/model/thinking) is project-scoped like
* title summarizer — not workflow-moved — so it participates in the same
* changed-only/null-as-delete project-branch write path.
*
* FNXC:GitHubImportTranslate 2026-07-15-09:30:
* The import auto-translation settings are PROJECT-scoped: which language a repo's
* issues get translated into, and whether to translate at all, is a per-project
* decision, so they must route to the project patch and inherit when untouched.
* The lane's provider/model/thinking trio travels with the two non-model keys
* (`githubImportAutoTranslate`, `importTranslateTargetLocale`) so that clearing the
* toggle or the target locale serializes as null-as-delete (restoring inheritance)
* instead of being dropped as an unchanged inherited value. The companion
* `importTranslateGlobal*` keys are deliberately NOT listed here — they are global
* scope and are gated by GLOBAL_SECTION_KEYS below.
*/
export const MODEL_LANE_KEYS = [
"defaultProviderOverride", "defaultModelIdOverride",
"titleSummarizerProvider", "titleSummarizerModelId",
"titleSummarizerFallbackProvider", "titleSummarizerFallbackModelId", "titleSummarizerFallbackThinkingLevel",
"mergerProvider", "mergerModelId", "mergerThinkingLevel",
"githubImportAutoTranslate", "importTranslateTargetLocale",
"importTranslateProvider", "importTranslateModelId", "importTranslateThinkingLevel",
] as const;
const MODEL_LANE_KEY_SET = new Set<string>(MODEL_LANE_KEYS);
@@ -157,6 +170,15 @@ export const GLOBAL_SECTION_KEYS: Record<string, ReadonlySet<string>> = {
"mergerGlobalProvider",
"mergerGlobalModelId",
"mergerGlobalThinkingLevel",
/*
FNXC:GitHubImportTranslate 2026-07-15-09:30:
The import-translate GLOBAL lane keys must be section-allowlisted in both Models
sections (mirroring merger), otherwise the section gate in the global branch of
splitSettingsSave silently drops an operator's global lane edit on Save.
*/
"importTranslateGlobalProvider",
"importTranslateGlobalModelId",
"importTranslateGlobalThinkingLevel",
]),
"project-models": new Set([
"defaultProvider",
@@ -184,6 +206,9 @@ export const GLOBAL_SECTION_KEYS: Record<string, ReadonlySet<string>> = {
"mergerGlobalProvider",
"mergerGlobalModelId",
"mergerGlobalThinkingLevel",
"importTranslateGlobalProvider",
"importTranslateGlobalModelId",
"importTranslateGlobalThinkingLevel",
]),
"node-sync": new Set([
"settingsSyncEnabled",

View File

@@ -1,5 +1,13 @@
import { useEffect, useMemo, useState, type ReactNode } from "react";
import type { WorkflowDefinition } from "@fusion/core";
import { isLocale, SUPPORTED_LOCALES, type WorkflowDefinition } from "@fusion/core";
import { SettingsToggleRow } from "../SettingsToggleRow";
import { SettingsSelectRow } from "../SettingsSelectRow";
/*
FNXC:GitHubImportTranslate 2026-07-15-09:30:
Locale labels come from core's shared `localeDisplayName` (endonyms), NOT from the LanguageSelector component: importing a component module for a constant drags its i18n/react-i18next initialization into every consumer of this section, which breaks tests that mock react-i18next narrowly.
The core helper is the same list the translate banner labels source languages with, so the two surfaces cannot drift.
*/
import { localeDisplayName } from "@fusion/core/detect-content-language";
import { ProjectDefaultWorkflowField } from "../../WorkflowSelector";
import { WorkflowIcon } from "../../WorkflowIcon";
import { TrackingRepoSelect, type TrackingRepoOption } from "../../TrackingRepoSelect";
@@ -358,6 +366,45 @@ export function GeneralSection({ scopeBanner, form, setForm, projectId, addToast
<input id="githubLinkImportedIssuesToTracking" type="checkbox" checked={form.githubLinkImportedIssuesToTracking === true} onChange={(e) => setForm((f) => ({ ...f, githubLinkImportedIssuesToTracking: e.target.checked }))}/>{t("settings.general.alwaysLinkImportedGitHubIssuesToTracking", " Always link imported GitHub issues to GitHub tracking ")}</label>
<small>{t("settings.general.whenEnabledImportedGitHubIssuesUseTheirSource", "When enabled, GitHub issue imports become tracked tasks that adopt the source issue. This does not turn GitHub tracking on for ordinary new tasks. Default: disabled.")}</small>
</div>
{/*
FNXC:GitHubImportTranslate 2026-07-15-09:30:
Both controls live beside the other import-scoped GitHub settings because they
only ever affect the Import Tasks panel, never ordinary task creation.
Auto-translate is OFF by default: translation is a per-issue AI call, so operators
on all-English repos must never pay for it without asking. When ON, the panel
translates foreign-language issue titles/bodies into the target language and shows
the translation by default (the original stays one toggle away).
Target language is deliberately clearable — the empty option means "follow the
dashboard language", so an operator who switches the dashboard to Korean gets Korean
translations without touching this setting twice.
*/}
<SettingsToggleRow
descriptor={{
key: "githubImportAutoTranslate",
label: t("settings.general.autoTranslateImportedIssues", "Auto-translate imported issues"),
help: t("settings.general.autoTranslateImportedIssuesHelp", "When enabled, the Import Tasks panel automatically translates foreign-language issue titles and bodies into the target language below and shows the translation by default. You can always switch back to the original text, and imported tasks carry the translated text. Default: disabled."),
scope: "project",
}}
value={form.githubImportAutoTranslate === true}
onChange={(v) => setForm((f) => ({ ...f, githubImportAutoTranslate: v ?? undefined }))}
/>
<SettingsSelectRow
descriptor={{
key: "importTranslateTargetLocale",
label: t("settings.general.translationTargetLanguage", "Translation target language"),
help: t("settings.general.translationTargetLanguageHelp", "Language imported issues are translated into when auto-translation is enabled. No default — unset inherits the dashboard language."),
scope: "project",
options: [
{ value: "", label: t("settings.general.followDashboardLanguage", "Follow dashboard language") },
...SUPPORTED_LOCALES.map((locale) => ({ value: locale, label: localeDisplayName(locale) })),
],
}}
value={form.importTranslateTargetLocale ?? ""}
onChange={(v) => setForm((f) => ({
...f,
importTranslateTargetLocale: v && isLocale(v) ? v : undefined,
}))}
/>
<div className="form-group">
<label htmlFor="projectGithubTrackingDefaultRepoGeneral">{t("settings.general.projectDefaultTrackingRepo", "Project default tracking repo")}</label>
<TrackingRepoSelect id="projectGithubTrackingDefaultRepoGeneral" ariaLabel="Project default tracking repo" value={form.githubTrackingDefaultRepo ?? ""} options={projectTrackingRepoOptions} loading={projectTrackingRepoLoading} error={projectTrackingRepoError ?? undefined} placeholder={t("settings.general.ownerRepo", "owner/repo")} onChange={(nextValue) => setForm((f) => ({ ...f, githubTrackingDefaultRepo: nextValue || undefined }))}/>

View File

@@ -275,7 +275,8 @@ export function ProjectModelsSection({ scopeBanner, form, setForm, models, proje
// here. Execution/planning/validator workflow-specific lanes still redirect to
// workflow settings below.
// FNXC:Settings-MergerModel 2026-07-13-07:52: Merger is project-scoped (like summarization), not workflow-moved.
const projectModelLanes = modelLanes.filter((lane) => ["default", "merger", "summarization"].includes(lane.laneId));
// FNXC:GitHubImportTranslate 2026-07-15-09:30: The import-translate lane is project-scoped (like merger/summarization), so its project override must be editable here — otherwise the lane's projectProviderKey/projectModelKey would be unreachable and only the global lane could ever be set.
const projectModelLanes = modelLanes.filter((lane) => ["default", "merger", "summarization", "import-translate"].includes(lane.laneId));
const getProjectLaneLabel = (lane: ModelLane) => {
if (lane.laneId === "default") {
return "Project Default Model";
@@ -286,6 +287,9 @@ export function ProjectModelsSection({ scopeBanner, form, setForm, models, proje
if (lane.laneId === "summarization") {
return "Project Summarization Model";
}
if (lane.laneId === "import-translate") {
return "Project Import Auto-Translation Model";
}
return lane.label;
};
const getProjectLaneHelperText = (lane: ModelLane) => {
@@ -298,6 +302,9 @@ export function ProjectModelsSection({ scopeBanner, form, setForm, models, proje
if (lane.laneId === "summarization") {
return "Model used for title auto-summarization, merge commit summaries, GitHub tracking issue titles, and PR title/body generation.";
}
if (lane.laneId === "import-translate") {
return "Model used to translate foreign-language GitHub/GitLab issue titles and bodies in the Import Tasks panel. One short readonly call per issue — a cheap, fast model is usually the right pick.";
}
return lane.helperText;
};
const titleSummarizerFallbackValue = form.titleSummarizerFallbackProvider && form.titleSummarizerFallbackModelId

View File

@@ -235,6 +235,10 @@ const SETTING_DESCRIPTION_KEYS: Record<string, string> = {
ephemeralAgentsCanCreateTasks: "general.allowEphemeralAgentsToCreateTasksHint",
ephemeralAgentsEnabled: "general.whenEnabledDefaultFusionSpawnsShortLived",
githubLinkImportedIssuesToTracking: "general.whenEnabledImportedGitHubIssuesUseTheirSource",
// FNXC:GitHubImportTranslate 2026-07-15-09:30: surfaced as plain rows in
// GeneralSection beside the other import-scoped GitHub settings.
githubImportAutoTranslate: "general.autoTranslateImportedIssuesHelp",
importTranslateTargetLocale: "general.translationTargetLanguageHelp",
githubTrackingDedupEnabled: "general.whenEnabledFusionChecksOpenAndClosedIssues",
githubTrackingEnabledByDefault: "general.offDefault",
sessionAdvisorEnabledByDefault: "general.offDefault",
@@ -327,6 +331,14 @@ const NOT_SURFACED_ALLOWLIST: Record<string, string> = {
gitlabCommentOnDone: "not yet exposed as a distinct Settings field",
gitlabCommentTemplate: "not yet exposed as a distinct Settings field",
gitlabCloseSourceIssueOnDone: "not yet exposed as a distinct Settings field",
// FNXC:GitHubImportTranslate 2026-07-15-09:30: the import-translate lane is a
// model-lane picker (Settings -> Project/Global Models), not a description field.
importTranslateProvider: "configured via the model-lane picker, not a plain description field",
importTranslateModelId: "configured via the model-lane picker, not a plain description field",
importTranslateThinkingLevel: "configured via the model-lane picker, not a plain description field",
importTranslateGlobalProvider: "configured via the model-lane picker, not a plain description field",
importTranslateGlobalModelId: "configured via the model-lane picker, not a plain description field",
importTranslateGlobalThinkingLevel: "configured via the model-lane picker, not a plain description field",
titleSummarizerProvider: "configured via the model-lane picker, not a plain description field",
titleSummarizerModelId: "configured via the model-lane picker, not a plain description field",
titleSummarizerFallbackProvider: "configured via the model-lane picker, not a plain description field",

View File

@@ -1,233 +1,14 @@
/*
FNXC:GitHubImportTranslate 2026-07-14-12:00:
The GitHub (and GitLab) import preview must offer translation only when selected issue/PR content is in a different language than the active dashboard locale.
Client-side detection is heuristic (Unicode script counts + Latin stopword scoring) so the banner can appear without an AI round-trip; uncertain or same-family content stays silent rather than spamming a false-positive translate CTA.
FNXC:GitHubImportTranslate 2026-07-15-09:30:
Detection moved to `@fusion/core` (detect-content-language.ts) so the dashboard and the server-side auto-translate run share ONE heuristic and can never disagree about whether an issue needs translating.
This module stays as a re-export: existing component imports and the util's test suite keep their import path, and the move stays a pure relocation rather than a call-site churn.
Imported via the `@fusion/core/detect-content-language` SUBPATH, not the package root: the browser bundle aliases `@fusion/core` to the leaf `types.ts`, so a root import would not resolve at build time even though it typechecks.
*/
import type { Locale } from "@fusion/core";
import { SUPPORTED_LOCALES } from "@fusion/core";
/** Minimum alphabetic characters before we attempt language detection. */
export const MIN_DETECTABLE_CHARS = 24;
/**
* Script/language families used for mismatch decisions.
* zh-CN and zh-TW share `cjk` so Chinese content does not prompt translation when the UI is either Chinese locale.
* Latin locales (en/fr/es) share `latin` at the script layer and are disambiguated via stopword scores.
*/
export type LanguageFamily = "latin" | "cjk" | "hangul" | "other";
export type DetectedContentLanguage = {
/** Best-effort BCP-47-ish code among supported locales, or `unknown` when confidence is too low. */
locale: Locale | "unknown";
family: LanguageFamily;
/** Relative confidence of the best guess. */
confidence: "high" | "medium" | "low";
};
const LATIN_STOPWORDS: Record<"en" | "fr" | "es", readonly string[]> = {
en: [
"the", "and", "for", "that", "with", "this", "from", "have", "will", "are",
"not", "but", "you", "all", "can", "has", "was", "were", "been", "their",
"which", "when", "what", "into", "about", "would", "there", "should",
],
fr: [
"les", "des", "une", "est", "dans", "pour", "que", "qui", "sur", "avec",
"pas", "plus", "par", "sont", "cette", "aussi", "comme", "mais", "nous",
"vous", "être", "fait", "tout", "leur", "entre", "sans", "après",
],
es: [
"los", "las", "del", "una", "que", "por", "con", "para", "como", "más",
"este", "esta", "está", "son", "pero", "sus", "sobre", "entre", "cuando",
"también", "después", "desde", "hasta", "sin", "todos", "puede",
],
};
function countMatches(text: string, re: RegExp): number {
const matches = text.match(re);
return matches?.length ?? 0;
}
function familyForLocale(locale: Locale): LanguageFamily {
if (locale === "ko") return "hangul";
if (locale === "zh-CN" || locale === "zh-TW") return "cjk";
return "latin";
}
function isSupportedLocale(value: string): value is Locale {
return (SUPPORTED_LOCALES as readonly string[]).includes(value);
}
/**
* Score Latin text against en/fr/es stopword lists.
* Returns the best locale and a confidence derived from score separation.
*/
function scoreLatinLocale(text: string): { locale: Locale; confidence: DetectedContentLanguage["confidence"] } {
const tokens = text
.toLowerCase()
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.split(/[^a-zàâäéèêëïîôùûüçñ]+/i)
.filter((t) => t.length >= 2);
if (tokens.length < 6) {
return { locale: "en", confidence: "low" };
}
const scores: Record<"en" | "fr" | "es", number> = { en: 0, fr: 0, es: 0 };
for (const token of tokens) {
for (const locale of ["en", "fr", "es"] as const) {
if (LATIN_STOPWORDS[locale].includes(token)) {
scores[locale] += 1;
}
}
}
const ranked = (Object.entries(scores) as Array<["en" | "fr" | "es", number]>).sort(
(a, b) => b[1] - a[1],
);
const [best, second] = ranked;
const bestScore = best[1];
const secondScore = second[1];
if (bestScore === 0) {
return { locale: "en", confidence: "low" };
}
const ratio = secondScore === 0 ? Infinity : bestScore / secondScore;
const confidence: DetectedContentLanguage["confidence"] =
bestScore >= 4 && ratio >= 1.6 ? "high" : bestScore >= 2 && ratio >= 1.25 ? "medium" : "low";
return { locale: best[0], confidence };
}
/**
* Detect the likely language of free-form issue/PR content for import-preview translation gating.
* Intentionally conservative: short, code-heavy, or ambiguous samples return `unknown` / low confidence.
*/
export function detectContentLanguage(text: string): DetectedContentLanguage {
const sample = (text ?? "").trim();
if (!sample) {
return { locale: "unknown", family: "other", confidence: "low" };
}
// Strip fenced code, URLs, and GitHub usernames so detection focuses on prose.
const cleaned = sample
.replace(/```[\s\S]*?```/g, " ")
.replace(/`[^`]+`/g, " ")
.replace(/https?:\/\/\S+/gi, " ")
.replace(/@[\w-]+/g, " ")
.replace(/#\d+/g, " ");
const hangul = countMatches(cleaned, /[\uAC00-\uD7AF]/g);
const hiraganaKatakana = countMatches(cleaned, /[\u3040-\u30FF]/g);
const cjk = countMatches(cleaned, /[\u4E00-\u9FFF]/g);
const latin = countMatches(cleaned, /[A-Za-zÀ-ÖØ-öø-ÿ]/g);
const letters = hangul + hiraganaKatakana + cjk + latin;
if (letters < MIN_DETECTABLE_CHARS) {
return { locale: "unknown", family: "other", confidence: "low" };
}
const hangulShare = hangul / letters;
const cjkShare = cjk / letters;
const latinShare = latin / letters;
if (hangulShare >= 0.35) {
return {
locale: "ko",
family: "hangul",
confidence: hangulShare >= 0.55 ? "high" : "medium",
};
}
// Japanese (hiragana/katakana present) is not a dashboard locale — treat as non-matching CJK family.
if (hiraganaKatakana >= 8 || (hiraganaKatakana >= 3 && cjkShare >= 0.2)) {
return { locale: "unknown", family: "cjk", confidence: "high" };
}
if (cjkShare >= 0.35) {
// Cannot reliably split zh-CN vs zh-TW without a dictionary; either Chinese UI locale
// should suppress the translate CTA for CJK prose.
return {
locale: "zh-CN",
family: "cjk",
confidence: cjkShare >= 0.55 ? "high" : "medium",
};
}
if (latinShare >= 0.55) {
const latinGuess = scoreLatinLocale(cleaned);
return {
locale: latinGuess.locale,
family: "latin",
confidence: latinGuess.confidence,
};
}
return { locale: "unknown", family: "other", confidence: "low" };
}
/**
* Whether import-preview content should offer translation into `dashboardLocale`.
* Requires medium+ confidence and a family/locale mismatch so we do not nag same-language content.
*/
export function contentNeedsTranslation(
text: string,
dashboardLocale: Locale,
): { needed: boolean; detected: DetectedContentLanguage } {
const detected = detectContentLanguage(text);
if (detected.confidence === "low" || detected.locale === "unknown") {
// Still offer when family is clearly foreign (e.g. Japanese kana) even if locale is unknown.
if (detected.confidence === "high" && detected.family !== familyForLocale(dashboardLocale) && detected.family !== "other") {
return { needed: true, detected };
}
return { needed: false, detected };
}
if (detected.locale === dashboardLocale) {
return { needed: false, detected };
}
// Chinese UI locales treat Simplified/Traditional detection as same family.
if (
familyForLocale(dashboardLocale) === "cjk" &&
detected.family === "cjk" &&
isSupportedLocale(detected.locale) &&
familyForLocale(detected.locale) === "cjk"
) {
return { needed: false, detected };
}
// Latin locales that share the same stopword winner as dashboard.
if (detected.locale === dashboardLocale) {
return { needed: false, detected };
}
// Require medium+ confidence for same-script (latin) mismatches to limit false positives.
if (detected.family === familyForLocale(dashboardLocale) && detected.confidence !== "high") {
return { needed: false, detected };
}
return { needed: true, detected };
}
/** Human-readable endonym for a detected/source locale chip in the translate banner. */
export function localeDisplayName(locale: Locale | "unknown"): string {
switch (locale) {
case "en":
return "English";
case "zh-CN":
return "简体中文";
case "zh-TW":
return "繁體中文";
case "fr":
return "Français";
case "es":
return "Español";
case "ko":
return "한국어";
default:
return locale;
}
}
export {
MIN_DETECTABLE_CHARS,
detectContentLanguage,
contentNeedsTranslation,
localeDisplayName,
} from "@fusion/core/detect-content-language";
export type { LanguageFamily, DetectedContentLanguage } from "@fusion/core/detect-content-language";

View File

@@ -0,0 +1,309 @@
/*
FNXC:GitHubImportTranslate 2026-07-15-09:30:
Covers the invariants of import auto-translation across every surface that can spend money or leak stale prose:
- OFF-by-default and closed-issue rules mean NO model call (billing invariant).
- Same-language content is skipped before the model (the detect-first requirement).
- A cache hit spends nothing; an edited issue misses the cache instead of serving stale prose.
- Translations persist until the issue closes, then are pruned.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
const translateTextMock = vi.fn();
vi.mock("../ai-translate.js", async () => {
const actual = await vi.importActual<typeof import("../ai-translate.js")>("../ai-translate.js");
return { ...actual, translateText: translateTextMock };
});
const {
translateImportItems,
getCachedImportTranslation,
resolveTargetLocale,
isTranslatable,
hashSourceContent,
partitionImportItemsByCache,
selectEligibleItems,
} = await import("../import-translate-service.js");
/** Minimal in-memory stand-in for the durable cache. */
function makeStore(settings: Record<string, unknown> = {}): any {
const rows = new Map<string, { sourceHash: string; translatedTitle: string; translatedBody: string; detectedLocale: string | null; recordedAt: string }>();
const key = (k: { provider: string; repoKey: string; issueNumber: number; targetLocale: string }) =>
`${k.provider}|${k.repoKey}|${k.issueNumber}|${k.targetLocale}`;
return {
rows,
getSettings: vi.fn().mockResolvedValue({ githubImportAutoTranslate: true, ...settings }),
getRootDir: () => "/tmp/root",
getImportTranslation: vi.fn(async (k) => {
const row = rows.get(key(k));
if (!row || row.sourceHash !== k.sourceHash) return null;
return row;
}),
recordImportTranslation: vi.fn(async (k, v, recordedAt = "now") => {
rows.set(key(k), { sourceHash: k.sourceHash, translatedTitle: v.translatedTitle, translatedBody: v.translatedBody, detectedLocale: v.detectedLocale ?? null, recordedAt });
}),
pruneImportTranslations: vi.fn(async (provider: string, repoKey: string, numbers: number[]) => {
for (const n of numbers) rows.delete(`${provider}|${repoKey}|${n}|es`);
return numbers.length;
}),
};
}
const ctx = (store: any) => ({ store, rootDir: "/tmp/root", provider: "github" as const, repoKey: "o/r", targetLocale: "es" as const });
/*
FNXC:GitHubImportTranslate 2026-07-15-09:30:
Test prose is deliberately LONG. The shared detector rates short Latin-script text as only `medium` confidence and `contentNeedsTranslation` requires `high` for a same-script (latin-vs-latin) mismatch, so a one-line Spanish fixture is correctly reported as "no translation needed".
That conservatism is intended — it is what stops an English issue being shipped to the model — so the fixtures here reflect real issue bodies rather than weakening the threshold to make tests pass.
*/
const SPANISH_BODY =
"El servidor devuelve un error cuando el usuario intenta guardar los cambios en la configuracion. No se puede completar la operacion porque el sistema no responde. Por favor revise los registros del servidor para mas informacion sobre este problema.";
const ENGLISH_BODY =
"The server returns an error when the user tries to save the changes to the configuration. The operation cannot be completed because the system does not respond. Please review the server logs for more information about this problem.";
beforeEach(() => {
translateTextMock.mockReset();
translateTextMock.mockResolvedValue({ title: "TRANSLATED", body: "TRANSLATED BODY" });
});
describe("resolveTargetLocale", () => {
it("prefers the explicit project setting over the dashboard locale", () => {
expect(resolveTargetLocale("fr", "en")).toBe("fr");
});
it("falls back to the dashboard locale when the setting is unset", () => {
expect(resolveTargetLocale(undefined, "ko")).toBe("ko");
});
it("returns null when none is a supported locale", () => {
expect(resolveTargetLocale("klingon", undefined, undefined)).toBeNull();
});
/*
FNXC:GitHubImportTranslate 2026-07-15-14:10:
Regression: PR #2141 review (P1). The DEFAULT config leaves the project setting unset, and the
import route re-fetches server-side, so without the global `language` tier a default-configured
import resolved NO locale and silently imported the ORIGINAL prose.
*/
it("falls back to the global dashboard language when the project setting is unset", () => {
expect(resolveTargetLocale(undefined, undefined, "fr")).toBe("fr");
});
it("prefers the global language over a caller-supplied locale", () => {
expect(resolveTargetLocale(undefined, "en", "ko")).toBe("ko");
});
it("still honours a caller-supplied locale when global language is unset (browser-detected)", () => {
expect(resolveTargetLocale(undefined, "es", undefined)).toBe("es");
});
it("lets an explicit project setting win over both", () => {
expect(resolveTargetLocale("fr", "en", "ko")).toBe("fr");
});
});
describe("isTranslatable", () => {
it("skips closed issues", () => {
expect(isTranslatable({ number: 1, title: "Error del servidor", body: SPANISH_BODY, state: "closed" }, "en")).toBe(false);
});
it("skips empty content", () => {
expect(isTranslatable({ number: 1, title: "", body: "", state: "open" }, "en")).toBe(false);
});
it("skips content already in the target language", () => {
expect(isTranslatable({ number: 1, title: "Server error", body: ENGLISH_BODY, state: "open" }, "en")).toBe(false);
});
it("accepts foreign-language open content", () => {
expect(isTranslatable({ number: 1, title: "Error del servidor", body: SPANISH_BODY, state: "open" }, "en")).toBe(true);
});
});
describe("hashSourceContent", () => {
it("changes when the body is edited, so an edited issue cannot hit a stale cache", () => {
expect(hashSourceContent("t", "a")).not.toBe(hashSourceContent("t", "b"));
});
it("is stable for identical content", () => {
expect(hashSourceContent("t", "a")).toBe(hashSourceContent("t", "a"));
});
});
describe("translateImportItems", () => {
const foreign = { number: 7, title: "Server error", body: ENGLISH_BODY, state: "open" as const };
it("does not call the model for content already in the target language", async () => {
const store = makeStore();
// Target 'en', content is English -> nothing to do.
const out = await translateImportItems({ ...ctx(store), targetLocale: "en" }, [foreign]);
expect(translateTextMock).not.toHaveBeenCalled();
expect(out.size).toBe(0);
});
it("translates foreign open issues and persists the result", async () => {
const store = makeStore();
const item = { number: 7, title: "Error del servidor", body: SPANISH_BODY, state: "open" as const };
const out = await translateImportItems({ ...ctx(store), targetLocale: "en" }, [item]);
expect(translateTextMock).toHaveBeenCalledTimes(1);
expect(out.get(7)?.title).toBe("TRANSLATED");
expect(out.get(7)?.cached).toBe(false);
expect(store.recordImportTranslation).toHaveBeenCalledTimes(1);
});
it("serves a second run from the cache without calling the model again", async () => {
const store = makeStore();
const item = { number: 7, title: "Error del servidor", body: SPANISH_BODY, state: "open" as const };
const first = { ...ctx(store), targetLocale: "en" as const };
await translateImportItems(first, [item]);
translateTextMock.mockClear();
const out = await translateImportItems(first, [item]);
expect(translateTextMock).not.toHaveBeenCalled();
expect(out.get(7)?.cached).toBe(true);
expect(out.get(7)?.title).toBe("TRANSLATED");
});
it("re-translates when the issue body was edited (cache miss on new hash)", async () => {
const store = makeStore();
const c = { ...ctx(store), targetLocale: "en" as const };
await translateImportItems(c, [{ number: 7, title: "Error del servidor", body: SPANISH_BODY, state: "open" }]);
translateTextMock.mockClear();
await translateImportItems(c, [
{ number: 7, title: "Error del servidor", body: `${SPANISH_BODY} Ahora con mas detalles del fallo.`, state: "open" },
]);
expect(translateTextMock).toHaveBeenCalledTimes(1);
});
it("never translates closed issues and prunes their cached translations", async () => {
const store = makeStore();
const c = { ...ctx(store), targetLocale: "en" as const };
await translateImportItems(c, [
{ number: 9, title: "Error del servidor", body: SPANISH_BODY, state: "closed" },
]);
expect(translateTextMock).not.toHaveBeenCalled();
expect(store.pruneImportTranslations).toHaveBeenCalledWith("github", "o/r", [9]);
});
it("caps eager translation at 50 issues per load", async () => {
const store = makeStore();
const items = Array.from({ length: 60 }, (_, i) => ({
number: i + 1,
title: `Error del servidor numero ${i + 1}`,
body: SPANISH_BODY,
state: "open" as const,
}));
await translateImportItems({ ...ctx(store), targetLocale: "en" }, items);
expect(translateTextMock).toHaveBeenCalledTimes(50);
});
it("fails soft per item: one failure keeps the rest of the page translated", async () => {
const store = makeStore();
translateTextMock.mockRejectedValueOnce(new Error("model exploded"));
const items = [
{ number: 1, title: "Error del servidor uno", body: SPANISH_BODY, state: "open" as const },
{ number: 2, title: "Error del servidor dos", body: SPANISH_BODY, state: "open" as const },
];
const out = await translateImportItems({ ...ctx(store), targetLocale: "en" }, items);
// The failed item is simply absent (caller renders the original prose).
expect(out.size).toBe(1);
});
});
/*
FNXC:GitHubImportTranslate 2026-07-15-14:10:
Regression: PR #2141 review (P1). The route reserved rate-limit capacity per FOREIGN issue before the
cache was consulted, so reopening a panel of cached issues burned budget while calling the model zero
times. The budget is charged from `partition.uncached`, so these assert the partition — the thing the
route actually charges from — not an incidental count.
*/
describe("partitionImportItemsByCache (what the rate-limit budget is charged from)", () => {
const item = { number: 7, title: "Error del servidor", body: SPANISH_BODY, state: "open" as const };
it("reports an uncached item as billable exactly once", async () => {
const store = makeStore();
const c = { ...ctx(store), targetLocale: "en" as const };
const { cached, uncached } = await partitionImportItemsByCache(c, [item]);
expect(uncached).toHaveLength(1);
expect(cached.size).toBe(0);
});
it("charges NOTHING once the item is cached, however many times the panel reloads", async () => {
const store = makeStore();
const c = { ...ctx(store), targetLocale: "en" as const };
await translateImportItems(c, [item]);
for (let reload = 0; reload < 3; reload++) {
const { cached, uncached } = await partitionImportItemsByCache(c, [item]);
expect(uncached).toHaveLength(0); // zero cost charged
expect(cached.get(7)?.title).toBe("TRANSLATED");
}
});
it("bills only the uncached remainder of a mixed page", async () => {
const store = makeStore();
const c = { ...ctx(store), targetLocale: "en" as const };
const first = { number: 1, title: "Error del servidor uno", body: SPANISH_BODY, state: "open" as const };
const second = { number: 2, title: "Error del servidor dos", body: SPANISH_BODY, state: "open" as const };
await translateImportItems(c, [first]);
const { cached, uncached } = await partitionImportItemsByCache(c, [first, second]);
expect(uncached.map((i) => i.number)).toEqual([2]);
expect([...cached.keys()]).toEqual([1]);
});
it("reusing a partition does not re-bill the cached half", async () => {
const store = makeStore();
const c = { ...ctx(store), targetLocale: "en" as const };
const first = { number: 1, title: "Error del servidor uno", body: SPANISH_BODY, state: "open" as const };
const second = { number: 2, title: "Error del servidor dos", body: SPANISH_BODY, state: "open" as const };
await translateImportItems(c, [first]);
translateTextMock.mockClear();
const partition = await partitionImportItemsByCache(c, selectEligibleItems([first, second], "en"));
const out = await translateImportItems(c, [first, second], partition);
expect(translateTextMock).toHaveBeenCalledTimes(1); // only the uncached one
expect(out.get(1)?.cached).toBe(true);
expect(out.get(2)?.cached).toBe(false);
});
});
describe("getCachedImportTranslation (the import path)", () => {
it("returns null on a miss so import carries the original prose", async () => {
const store = makeStore();
const hit = await getCachedImportTranslation(
{ store, provider: "github" as const, repoKey: "o/r", targetLocale: "en" as const },
{ number: 3, title: "Error del servidor", body: SPANISH_BODY, state: "open" },
);
expect(hit).toBeNull();
});
it("returns the cached translation so the imported task carries the previewed text", async () => {
const store = makeStore();
const item = { number: 3, title: "Error del servidor", body: SPANISH_BODY, state: "open" as const };
await translateImportItems({ ...ctx(store), targetLocale: "en" }, [item]);
const hit = await getCachedImportTranslation(
{ store, provider: "github" as const, repoKey: "o/r", targetLocale: "en" as const },
item,
);
expect(hit).toEqual({ title: "TRANSLATED", body: "TRANSLATED BODY" });
});
it("returns null for a closed issue even if a row still exists", async () => {
const store = makeStore();
const item = { number: 3, title: "Error del servidor", body: SPANISH_BODY, state: "open" as const };
await translateImportItems({ ...ctx(store), targetLocale: "en" }, [item]);
const hit = await getCachedImportTranslation(
{ store, provider: "github" as const, repoKey: "o/r", targetLocale: "en" as const },
{ ...item, state: "closed" },
);
expect(hit).toBeNull();
});
});

View File

@@ -17,6 +17,7 @@ import { createFnAgent as engineCreateFnAgent, resolveMcpServersForStore } from
import {
checkRateLimit,
getRateLimitResetTime,
RATE_LIMIT_WINDOW_MS,
AiServiceError,
ValidationError,
} from "./ai-refine.js";
@@ -31,6 +32,59 @@ function ensureEngineReady(): Promise<void> {
/** Re-export shared AI helper rate-limit so routes share the refine/translate budget. */
export { checkRateLimit, getRateLimitResetTime, AiServiceError, ValidationError };
/*
FNXC:GitHubImportTranslate 2026-07-15-09:30:
Translation needs its OWN request budget, separate from the 10/hour refine/draft budget it originally shared.
Auto-translate fans out up to one call per listed issue (bounded at IMPORT_TRANSLATE_MAX_ISSUES), so on the shared budget a single panel open would both fail partway through AND starve refine/goal-draft for the rest of the hour.
The budget stays bounded (not removed) because each request still spends real model tokens; it is sized to allow a couple of full panel loads per hour, with the durable cache absorbing repeat views.
*/
/** Max issues auto-translated per panel load. Beyond this, remaining issues
* translate on selection instead. Operator-visible cap — surfaced in the UI. */
export const IMPORT_TRANSLATE_MAX_ISSUES = 50;
/** Max translate requests per IP per hour (own budget; see FNXC above). */
export const MAX_TRANSLATE_REQUESTS_PER_HOUR = 150;
interface TranslateRateLimitEntry {
count: number;
firstRequestAt: number;
}
const translateRateLimits = new Map<string, TranslateRateLimitEntry>();
/**
* Reserve `cost` translate requests for an IP against the translate-only budget.
* Returns true when the whole cost fits. Callers reserve the batch size up
* front so a partially-translated page never silently drops issues.
*/
export function checkTranslateRateLimit(ip: string, cost = 1): boolean {
const now = Date.now();
const entry = translateRateLimits.get(ip);
if (!entry || now - entry.firstRequestAt > RATE_LIMIT_WINDOW_MS) {
if (cost > MAX_TRANSLATE_REQUESTS_PER_HOUR) return false;
translateRateLimits.set(ip, { count: cost, firstRequestAt: now });
return true;
}
if (entry.count + cost > MAX_TRANSLATE_REQUESTS_PER_HOUR) return false;
entry.count += cost;
return true;
}
/** Reset time for the translate budget, or null when the IP has no entry. */
export function getTranslateRateLimitResetTime(ip: string): Date | null {
const entry = translateRateLimits.get(ip);
if (!entry) return null;
return new Date(entry.firstRequestAt + RATE_LIMIT_WINDOW_MS);
}
/** Test seam: clear translate budget state. */
export function resetTranslateRateLimits(): void {
translateRateLimits.clear();
}
/** Maximum combined characters accepted for translation (title + body). */
export const MAX_TRANSLATE_TEXT_LENGTH = 12000;
@@ -252,6 +306,8 @@ export async function translateText(
rootDir: string,
_promptOverrides?: PromptOverrideMap,
store?: TaskStore,
provider?: string,
modelId?: string,
): Promise<TranslateFields> {
await ensureEngineReady();
@@ -264,12 +320,30 @@ export async function translateText(
* FNXC:McpConfig 2026-07-14-12:00:
* Import-preview translation is a readonly dashboard helper. Resolve MCP from the request-scoped store like refine/goal-draft; never log secrets.
*/
const agentResult = await createFnAgent({
/*
FNXC:GitHubImportTranslate 2026-07-15-09:30:
Translation resolves its own model lane (see `resolveImportTranslateSettingsModel`) so operators can pin a cheap/fast model for what is one short readonly call per issue.
Provider and model are applied only as a COMPLETE pair — a partial pair falls through to automatic resolution rather than half-pinning a model, matching the both-or-neither rule every other lane enforces.
*/
const agentOptions: {
cwd: string;
systemPrompt: string;
tools: "readonly";
mcpServers: typeof mcpServers;
defaultProvider?: string;
defaultModelId?: string;
} = {
cwd: rootDir,
systemPrompt: TRANSLATE_SYSTEM_PROMPT,
tools: "readonly",
mcpServers,
});
};
if (provider && modelId) {
agentOptions.defaultProvider = provider;
agentOptions.defaultModelId = modelId;
}
const agentResult = await createFnAgent(agentOptions);
if (!agentResult?.session) {
throw new AiServiceError("Failed to initialize AI agent");

Binary file not shown.

View File

@@ -34,6 +34,7 @@ import {
resolvePluginEntryPath,
resolveExecutionSettingsModel,
resolveTitleSummarizerSettingsModel,
resolveImportTranslateSettingsModel,
writeAgentMemoryFile,
validateMcpServerDefinitionDetailed,
} from "@fusion/core";
@@ -2037,11 +2038,18 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
throw err;
}
/*
FNXC:GitHubImportTranslate 2026-07-15-09:30:
Manual (operator-clicked) translation resolves the same translate lane as auto-translation, so the model shown in Settings is the model that actually runs on both paths.
*/
const resolvedTranslateModel = resolveImportTranslateSettingsModel(settings);
const translated = await translateText(
validated,
rootDir,
settings.promptOverrides,
scopedStore,
resolvedTranslateModel.provider,
resolvedTranslateModel.modelId,
);
res.json({ fields: translated });
} catch (err: unknown) {

View File

@@ -2116,6 +2116,48 @@ function isIssueAlreadyImported(
&& sourceIssue.issueNumber === issueNumber);
}
/*
FNXC:GitHubImportTranslate 2026-07-15-09:30:
Shared by BOTH import surfaces (single import and batch import) so a batch-imported task carries the same translation a singly-imported one does — the requirement is about imported issues, not about which button was pressed.
Returns null (import the original) whenever auto-translate is off, no target locale resolves, the issue is closed, or nothing is cached for the issue's CURRENT content. Never calls the model: import must not block on, or fail because of, a translation.
*/
async function resolveImportedIssueTranslation(
req: Request,
store: TaskStore,
owner: string,
repo: string,
issue: { number: number; title: string; body: string | null; state: "open" | "closed" },
): Promise<{ title: string; body: string } | null> {
try {
const settings = await store.getSettings();
if (settings.githubImportAutoTranslate !== true) return null;
const { getCachedImportTranslation, resolveTargetLocale } = await import(
"../import-translate-service.js"
);
/*
FNXC:GitHubImportTranslate 2026-07-15-14:10:
The DEFAULT config leaves `importTranslateTargetLocale` unset ("follow the dashboard language"), so resolving from the project setting plus a client-sent locale alone made a default-configured import silently create the task from the ORIGINAL prose even though the panel showed a translation (PR #2141 review, P1).
Resolution therefore falls through to the global `language` setting server-side, which also fixes direct API callers and stale clients; the request locale stays as the last tier because `language` is itself unset when a surface browser-detects its locale.
*/
const targetLocale = resolveTargetLocale(
settings.importTranslateTargetLocale,
// The panel forwards its active locale; a direct API caller may not.
(req.body as { targetLocale?: unknown } | undefined)?.targetLocale,
settings.language,
);
if (!targetLocale) return null;
return await getCachedImportTranslation(
{ store, provider: "github", repoKey: `${owner}/${repo}`, targetLocale },
issue,
);
} catch {
// Translation lookup must never break an import.
return null;
}
}
async function resolveImportedIssueGithubTracking(store: TaskStore): Promise<{ enabled: true } | undefined> {
const projectSettings = await store.getSettings();
if (projectSettings.githubLinkImportedIssuesToTracking === true) {
@@ -4064,9 +4106,15 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
}
}
// Create the task
const title = issue.title.slice(0, 200);
const body = issue.body?.trim() || "(no description)";
/*
FNXC:GitHubImportTranslate 2026-07-15-09:30:
An imported issue carries the TRANSLATED prose when a translation exists, so the task an operator creates reads the same as the preview they approved.
Cache-read only: a miss imports the original rather than blocking the import on a fresh model call, because import must stay fast and must never fail because translation failed.
The `Source: <url>` suffix is appended AFTER translation so the URL is never rewritten by the model.
*/
const translatedIssue = await resolveImportedIssueTranslation(req, scopedStore, owner, repo, issue);
const title = (translatedIssue?.title || issue.title).slice(0, 200);
const body = (translatedIssue?.body ?? issue.body)?.trim() || "(no description)";
const description = `${body}\n\nSource: ${sourceUrl}`;
const importedIssueGithubTracking = await resolveImportedIssueGithubTracking(scopedStore);
@@ -4102,6 +4150,109 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
}
});
/*
FNXC:GitHubImportTranslate 2026-07-15-09:30:
Auto-translate endpoint for the Import Tasks list. The panel calls this once per load when `githubImportAutoTranslate` is on; it returns translated title+body for the foreign-language OPEN issues so the list reads in the operator's language rather than one issue at a time.
Results are cached durably server-side, so a second load of the same repo costs no model calls, and the import path reads the same cache — which is what makes the imported task carry the translation the operator previewed.
Requirement (2026-07-15): translate the 50 most recent OPEN issues; closed issues are never translated and their cached rows are pruned on sight.
*/
/**
* POST /api/github/issues/auto-translate
* Body: { owner, repo, items: [{number,title,body,state}], targetLocale? }
* Returns: { translations: Record<number, {title,body}>, enabled, targetLocale, capped }
*/
router.post("/github/issues/auto-translate", async (req, res) => {
try {
const { owner, repo, items, targetLocale: requestedLocale } = req.body ?? {};
if (!owner || typeof owner !== "string") throw badRequest("owner is required");
if (!repo || typeof repo !== "string") throw badRequest("repo is required");
if (!Array.isArray(items)) throw badRequest("items must be an array");
const { store: scopedStore } = await getProjectContext(req);
const settings = await scopedStore.getSettings();
const {
translateImportItems,
resolveTargetLocale,
selectEligibleItems,
partitionImportItemsByCache,
isTranslatable,
} = await import("../import-translate-service.js");
const {
checkTranslateRateLimit,
getTranslateRateLimitResetTime,
} = await import("../ai-translate.js");
/*
FNXC:GitHubImportTranslate 2026-07-15-09:30:
The setting is enforced server-side, not only by hiding UI: an off setting must mean "no model calls and no billing", even if a stale client or a direct API caller asks for translation.
*/
if (settings.githubImportAutoTranslate !== true) {
res.json({ translations: {}, enabled: false, targetLocale: null, capped: false });
return;
}
const targetLocale = resolveTargetLocale(
settings.importTranslateTargetLocale,
requestedLocale,
settings.language,
);
if (!targetLocale) {
res.json({ translations: {}, enabled: true, targetLocale: null, capped: false });
return;
}
const normalized = items
.filter((item: unknown): item is Record<string, unknown> => Boolean(item) && typeof item === "object")
.map((item) => ({
number: Number(item.number),
title: typeof item.title === "string" ? item.title : "",
body: typeof item.body === "string" ? item.body : null,
state: item.state === "closed" ? ("closed" as const) : ("open" as const),
}))
.filter((item) => Number.isInteger(item.number) && item.number > 0);
const ctx = {
store: scopedStore,
rootDir: scopedStore.getRootDir(),
provider: "github" as const,
repoKey: `${owner}/${repo}`,
targetLocale,
};
/*
FNXC:GitHubImportTranslate 2026-07-15-14:10:
Charge the budget for MODEL CALLS ONLY — partition against the durable cache BEFORE reserving.
Reserving per foreign issue meant reopening a panel of 50 cached issues burned 50 slots while calling the model zero times, rate-limiting the panel for the rest of the hour despite costing nothing (PR #2141 review, P1).
The `capped` flag likewise reflects eligible issues (what the operator sees capped), while `cost` reflects only the uncached ones.
*/
const allEligible = normalized.filter((item) => isTranslatable(item, targetLocale));
const eligible = selectEligibleItems(normalized, targetLocale);
const capped = allEligible.length > eligible.length;
const partition = await partitionImportItemsByCache(ctx, eligible);
const cost = partition.uncached.length;
const ip = req.ip || req.socket.remoteAddress || "unknown";
if (cost > 0 && !checkTranslateRateLimit(ip, cost)) {
const resetTime = getTranslateRateLimitResetTime(ip);
throw rateLimited(
`Translation rate limit exceeded. Reset at ${resetTime?.toISOString() || "unknown"}`,
);
}
const translated = await translateImportItems(ctx, normalized, partition);
const translations: Record<number, { title: string; body: string }> = {};
for (const [number, value] of translated) {
translations[number] = { title: value.title, body: value.body };
}
res.json({ translations, enabled: true, targetLocale, capped });
} catch (err: unknown) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err);
}
});
/**
* POST /api/github/issues/batch-import
* Import multiple GitHub issues as fn tasks with throttling.
@@ -4164,11 +4315,16 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
const url = `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${issueNumber}`;
// Use throttled fetch to avoid rate limits
/*
FNXC:GitHubImportTranslate 2026-07-15-09:30:
`state` is surfaced on the batch fetch so batch import applies the same closed-issue rule as single import: a closed issue never serves a cached translation.
*/
const fetchResult = await githubClient.fetchThrottled<{
number: number;
title: string;
body: string | null;
html_url: string;
state?: "open" | "closed";
pull_request?: unknown;
}>(url, {}, { delayMs: delayMs ?? 1000, maxRetries: 3 });
@@ -4207,9 +4363,18 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
continue;
}
// Create the task
const title = issue.title.slice(0, 200);
const body = issue.body?.trim() || "(no description)";
/*
FNXC:GitHubImportTranslate 2026-07-15-09:30:
Batch import carries translations exactly like single import (shared helper) — the requirement is about imported issues, not about which import button was used.
*/
const batchTranslation = await resolveImportedIssueTranslation(req, scopedStore, owner, repo, {
number: issue.number,
title: issue.title,
body: issue.body,
state: issue.state === "closed" ? "closed" : "open",
});
const title = (batchTranslation?.title || issue.title).slice(0, 200);
const body = (batchTranslation?.body ?? issue.body)?.trim() || "(no description)";
const description = `${body}\n\nSource: ${sourceUrl}`;
try {

View File

@@ -123,6 +123,13 @@ export default defineConfig({
},
resolve: {
alias: {
/*
FNXC:GitHubImportTranslate 2026-07-15-09:30:
The browser bundle aliases `@fusion/core` to the leaf `types.ts` to keep Node-only deps out of the client, so anything the app imports from core must resolve to a browser-safe module.
Language detection is pure string logic shared with the server; alias its subpath explicitly rather than widening the `@fusion/core` alias, which would drag the full index (and its Node deps) into the bundle.
This alias MUST precede the `@fusion/core` entry — Vite matches aliases in order, so the broader key would otherwise swallow the subpath.
*/
"@fusion/core/detect-content-language": resolve(__dirname, "../core/src/detect-content-language.ts"),
"@fusion/core": resolve(__dirname, "../core/src/types.ts"),
"@fusion/dashboard/app/components/TaskCard": resolve(__dirname, "app/components/TaskCard.tsx"),
// FNXC:PluginBuild 2026-06-22-03:50: Bundled plugin source can import the dashboard's shared ViewHeader through the package export; Vite needs the same source alias during dashboard builds so plugin UI normalization does not fail only in CI merge builds.

View File

@@ -467,6 +467,11 @@ export default defineConfig({
plugins: [react()],
resolve: {
alias: {
/*
FNXC:GitHubImportTranslate 2026-07-15-09:30:
Must precede the `@fusion/core` alias: Vite string aliases match by PREFIX, so the broader key would rewrite this subpath to `index.ts/detect-content-language` and fail to resolve.
*/
"@fusion/core/detect-content-language": resolve(__dirname, "../core/src/detect-content-language.ts"),
"@fusion/core": resolve(__dirname, "../core/src/index.ts"),
"@fusion/engine": resolve(__dirname, "../engine/src/index.ts"),
"@fusion/plugin-sdk": resolve(__dirname, "../plugin-sdk/src/index.ts"),

View File

@@ -5918,6 +5918,11 @@
"quickChatLauncherHint": "Choose whether Quick Chat opens from the draggable floating button, a footer button beside Terminal, or stays hidden. Default: off (hidden).",
"showTaskChatsInCommonFeedHint": "When enabled, populated task-detail Chat conversations appear in the common Direct feed. Empty task chats stay hidden. Default: disabled.",
"whenEnabledImportedGitHubIssuesUseTheirSource": "When enabled, GitHub issue imports become tracked tasks that adopt the source issue. This does not turn GitHub tracking on for ordinary new tasks. Default: disabled.",
"autoTranslateImportedIssues": "Auto-translate imported issues",
"autoTranslateImportedIssuesHelp": "When enabled, the Import Tasks panel automatically translates foreign-language issue titles and bodies into the target language below and shows the translation by default. You can always switch back to the original text, and imported tasks carry the translated text. Default: disabled.",
"translationTargetLanguage": "Translation target language",
"translationTargetLanguageHelp": "Language imported issues are translated into when auto-translation is enabled. No default — unset inherits the dashboard language.",
"followDashboardLanguage": "Follow dashboard language",
"gitLabEnabledHint": "Configure GitLab.com or self-managed GitLab URLs. Blank values inherit global fallbacks and then GitLab.com. No default — unset (unset behaves as enabled until explicitly disabled).",
"allowEphemeralAgentsToCreateTasksHint": "When enabled (default), ephemeral task-worker agents can open follow-up tasks via fn_task_create. When disabled, only humans and permanent agents can create tasks; ephemeral callers are rejected.",
"quickChatCloseOnOutsideClickHint": "When enabled, clicking outside the Quick Chat window closes it. Disable to keep it open until you close it explicitly. Default: enabled.",