FN-8249: persist GitHub translations and show status

Persist legacy GitHub translation cache entries and expose background translation progress to import operators.

- Backfill historic unscoped translation cache partitions during schema migration.
- Display accessible translating and failure status in the GitHub issues import list.
- Add migration, service, and UI coverage plus operator documentation.

Files changed:
 .../fn-8249-github-import-translation-status.md    |  7 ++
 docs/dashboard-guide.md                            |  2 +-
 .../postgres/import-translation-cache.pg.test.ts   | 13 +++-
 .../src/__tests__/postgres/schema-applier.test.ts  | 48 ++++++++++++
 ...translation_cache_legacy_partition_backfill.sql | 31 ++++++++
 packages/core/src/postgres/schema-applier.ts       | 29 ++++++-
 .../dashboard/app/components/GitHubImportModal.css | 42 ++++++++++
 .../dashboard/app/components/GitHubImportModal.tsx | 29 +++++++
 .../__tests__/GitHubImportModal.test.tsx           | 90 ++++++++++++++++++++++
 .../src/__tests__/import-translate-service.test.ts | 62 +++++++++++++++
 10 files changed, 346 insertions(+), 7 deletions(-)

Fusion-Task-Id: FN-8249

Fusion-Task-Lineage: 79c26d85-50f1-4d01-9341-a17db5e57f8f

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-17 16:46:42 -07:00
parent b0654031bc
commit d8735b3dbe
10 changed files with 346 additions and 7 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: The Import from GitHub screen now shows a status indicator while issues are being translated.
category: feature
dev: GitHubImportModal renders the existing auto-translate loading and error state as a polite status indicator; migration 0019 backfills historic blank cache partitions so reopened stores serve durable translations.

View File

@@ -330,7 +330,7 @@ Use Import Tasks on desktop/tablet:
3. Stay on **Issues** or switch to **Pull Requests**, then optionally enter issue label filters before loading results. 3. Stay on **Issues** or switch to **Pull Requests**, then optionally enter issue label filters before loading results.
Expected outcome: the list pane shows matching open issues or pull requests and marks entries that already exist on the board. Use **Hide imported** beside the imported count to remove those unavailable rows from the current Issues, Pull Requests, or GitLab list; turning it off restores the greyed **Imported** rows. After a successful GitHub or GitLab import, the source row is marked **Imported** and made unavailable immediately, without waiting for the board list to refresh. Expected outcome: the list pane shows matching open issues or pull requests and marks entries that already exist on the board. Use **Hide imported** beside the imported count to remove those unavailable rows from the current Issues, Pull Requests, or GitLab list; turning it off restores the greyed **Imported** rows. After a successful GitHub or GitLab import, the source row is marked **Imported** and made unavailable immediately, without waiting for the board list to refresh.
4. Select an issue or pull request row. 4. Select an issue or pull request row.
Expected outcome: the full-width candidate list stays visible while its title, source link, body, labels or PR metadata, and import controls open in a draggable and resizable detail window. On mobile, that detail is a full-screen sheet. When selected title/body content is in another language, the detail offers **Translate**, **Show original** / **Show translation**, and **Dismiss**; translation is display-only. With GitHub import auto-translate enabled, every reachable page of open GitHub issues is translated as you page through the fetched list (up to the 300-issue fetch cap per one-hour translate budget); repeat views use the translation cache. Pull request and GitLab lists retain the per-selection translation flow. A pull request preview also shows its checks; use **Refresh checks** to fetch current GitHub check status and comments without reopening the detail. Each failed check has a **Create fix task** action that creates a new task prefilled with the repository, PR, branches, check status, and check-details link. Expected outcome: the full-width candidate list stays visible while its title, source link, body, labels or PR metadata, and import controls open in a draggable and resizable detail window. On mobile, that detail is a full-screen sheet. When selected title/body content is in another language, the detail offers **Translate**, **Show original** / **Show translation**, and **Dismiss**; translation is display-only. With GitHub import auto-translate enabled, every reachable page of open GitHub issues is translated as you page through the fetched list (up to the 300-issue fetch cap per one-hour translate budget); repeat views use the translation cache. While a page is translating, the issues list shows a **Translating…** status indicator and surfaces any translation failure without blocking import or browsing. Pull request and GitLab lists retain the per-selection translation flow. A pull request preview also shows its checks; use **Refresh checks** to fetch current GitHub check status and comments without reopening the detail. Each failed check has a **Create fix task** action that creates a new task prefilled with the repository, PR, branches, check status, and check-details link.
<!-- <!--
FNXC:GitHubImportDocs 2026-07-17-12:00: FNXC:GitHubImportDocs 2026-07-17-12:00:
Import Tasks documentation distinguishes Add comment (an upstream GitHub mutation) from Import as task Import Tasks documentation distinguishes Add comment (an upstream GitHub mutation) from Import as task

View File

@@ -46,14 +46,21 @@ async function reopenStore(harness: PgTestHarness, projectId?: string): Promise<
} }
pgDescribe("import translation cache persistence (PostgreSQL)", () => { pgDescribe("import translation cache persistence (PostgreSQL)", () => {
it("records then reads a translation from a fresh store against the same database", async () => { /*
FNXC:GitHubImportTranslate 2026-07-17-23:00:
Both GitHub and GitLab use this durable cache contract. Reopen a real
PostgreSQL-backed store rather than a mock so either provider cannot silently
re-bill translation after the daemon restarts.
*/
it.each(["github", "gitlab"] as const)("records then reads a %s translation from a fresh store against the same database", async (provider) => {
const harness = await createTaskStoreForTest({ prefix: "fusion_translation_cache" }); const harness = await createTaskStoreForTest({ prefix: "fusion_translation_cache" });
let reopened: { store: TaskStore; layer: AsyncDataLayer } | null = null; let reopened: { store: TaskStore; layer: AsyncDataLayer } | null = null;
const providerKey = { ...key, provider };
try { try {
await harness.store.recordImportTranslation(key, value, "2026-07-16T00:00:00.000Z"); await harness.store.recordImportTranslation(providerKey, value, "2026-07-16T00:00:00.000Z");
reopened = await reopenStore(harness); reopened = await reopenStore(harness);
await expect(reopened.store.getImportTranslation(key)).resolves.toEqual({ await expect(reopened.store.getImportTranslation(providerKey)).resolves.toEqual({
...value, ...value,
recordedAt: "2026-07-16T00:00:00.000Z", recordedAt: "2026-07-16T00:00:00.000Z",
}); });

View File

@@ -44,6 +44,7 @@ import {
MISSION_FIX_IDEMPOTENCY_VERSION, MISSION_FIX_IDEMPOTENCY_VERSION,
IMPORT_TRANSLATION_CACHE_VERSION, IMPORT_TRANSLATION_CACHE_VERSION,
IMPORT_TRANSLATION_CACHE_SCOPE_FIX_VERSION, IMPORT_TRANSLATION_CACHE_SCOPE_FIX_VERSION,
IMPORT_TRANSLATION_CACHE_LEGACY_PARTITION_BACKFILL_VERSION,
OWNER_PROJECT_ID_SPLIT_VERSION, OWNER_PROJECT_ID_SPLIT_VERSION,
/* /*
FNXC:PostgresSchema 2026-07-16-08:00: FNXC:PostgresSchema 2026-07-16-08:00:
@@ -145,6 +146,12 @@ describe("schema-applier: immutable migration identities", () => {
expect(Number(SCHEMA_BASELINE_VERSION)).toBeGreaterThanOrEqual(Number(IMPORT_TRANSLATION_CACHE_SCOPE_FIX_VERSION)); expect(Number(SCHEMA_BASELINE_VERSION)).toBeGreaterThanOrEqual(Number(IMPORT_TRANSLATION_CACHE_SCOPE_FIX_VERSION));
}); });
it("keeps the import translation legacy-partition backfill assigned to version 0019", () => {
expect(IMPORT_TRANSLATION_CACHE_LEGACY_PARTITION_BACKFILL_VERSION).toBe("0019");
expect(Number(SCHEMA_BASELINE_VERSION))
.toBeGreaterThanOrEqual(Number(IMPORT_TRANSLATION_CACHE_LEGACY_PARTITION_BACKFILL_VERSION));
});
it("keeps the per-task merger model lane assigned to version 0017", () => { it("keeps the per-task merger model lane assigned to version 0017", () => {
expect(TASK_MERGER_MODEL_LANE_VERSION).toBe("0017"); expect(TASK_MERGER_MODEL_LANE_VERSION).toBe("0017");
expect(Number(SCHEMA_BASELINE_VERSION)).toBeGreaterThanOrEqual(Number(TASK_MERGER_MODEL_LANE_VERSION)); expect(Number(SCHEMA_BASELINE_VERSION)).toBeGreaterThanOrEqual(Number(TASK_MERGER_MODEL_LANE_VERSION));
@@ -1135,6 +1142,7 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => {
IMPORT_TRANSLATION_CACHE_SCOPE_FIX_VERSION, IMPORT_TRANSLATION_CACHE_SCOPE_FIX_VERSION,
TASK_MERGER_MODEL_LANE_VERSION, TASK_MERGER_MODEL_LANE_VERSION,
BULK_COMPLETION_REFUSAL_AT_VERSION, BULK_COMPLETION_REFUSAL_AT_VERSION,
IMPORT_TRANSLATION_CACHE_LEGACY_PARTITION_BACKFILL_VERSION,
]); ]);
expect((await applySchemaBaseline(ctx.db, { pluginHooks: [] })).applied).toBe(false); expect((await applySchemaBaseline(ctx.db, { pluginHooks: [] })).applied).toBe(false);
}); });
@@ -1179,6 +1187,7 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => {
IMPORT_TRANSLATION_CACHE_SCOPE_FIX_VERSION, IMPORT_TRANSLATION_CACHE_SCOPE_FIX_VERSION,
TASK_MERGER_MODEL_LANE_VERSION, TASK_MERGER_MODEL_LANE_VERSION,
BULK_COMPLETION_REFUSAL_AT_VERSION, BULK_COMPLETION_REFUSAL_AT_VERSION,
IMPORT_TRANSLATION_CACHE_LEGACY_PARTITION_BACKFILL_VERSION,
]); ]);
}); });
@@ -1223,6 +1232,42 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => {
expect(await getAppliedMigrations(ctx.db)).toContain(IMPORT_TRANSLATION_CACHE_SCOPE_FIX_VERSION); expect(await getAppliedMigrations(ctx.db)).toContain(IMPORT_TRANSLATION_CACHE_SCOPE_FIX_VERSION);
}); });
/*
FNXC:GitHubImportTranslate 2026-07-17-23:48:
Existing deployments can contain a blank cache partition from pre-0016
writes. Recreate that durable row, then apply only the new forward migration
and prove the post-restart legacy scope can discover it.
*/
it("backfills historic blank import translation cache partitions", async () => {
ctx = await setupFreshDb();
await applySchemaBaseline(ctx.db, { pluginHooks: [] });
await ctx.db.execute(sql.raw(`
ALTER TABLE project.import_translation_cache DISABLE TRIGGER fusion_assign_project_id;
ALTER TABLE project.import_translation_cache DISABLE ROW LEVEL SECURITY;
INSERT INTO project.import_translation_cache (
project_id, provider, repo_key, issue_number, target_locale, source_hash,
translated_title, translated_body, detected_locale, recorded_at
) VALUES (
'', 'github', 'owner/repo', 42, 'en', 'legacy-source-hash',
'Translated title', 'Translated body', NULL, '2026-07-16T00:00:00.000Z'
);
ALTER TABLE project.import_translation_cache ENABLE ROW LEVEL SECURITY;
ALTER TABLE project.import_translation_cache ENABLE TRIGGER fusion_assign_project_id;
DELETE FROM public.fusion_schema_migrations WHERE version = '0019';
`));
expect(await getAppliedMigrations(ctx.db))
.not.toContain(IMPORT_TRANSLATION_CACHE_LEGACY_PARTITION_BACKFILL_VERSION);
expect((await applySchemaBaseline(ctx.db, { pluginHooks: [] })).applied).toBe(true);
await expect(ctx.db.execute(sql`
SELECT project_id FROM project.import_translation_cache
WHERE provider = 'github' AND repo_key = 'owner/repo' AND issue_number = 42
`)).resolves.toEqual([{ project_id: "__legacy_unscoped__" }]);
expect(await getAppliedMigrations(ctx.db))
.toContain(IMPORT_TRANSLATION_CACHE_LEGACY_PARTITION_BACKFILL_VERSION);
});
it("upgrades a 0001 database by backfilling analytics ownership", async () => { it("upgrades a 0001 database by backfilling analytics ownership", async () => {
ctx = await setupFreshDb(); ctx = await setupFreshDb();
await applySchemaBaseline(ctx.db, { pluginHooks: [] }); await applySchemaBaseline(ctx.db, { pluginHooks: [] });
@@ -1276,6 +1321,7 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => {
IMPORT_TRANSLATION_CACHE_SCOPE_FIX_VERSION, IMPORT_TRANSLATION_CACHE_SCOPE_FIX_VERSION,
TASK_MERGER_MODEL_LANE_VERSION, TASK_MERGER_MODEL_LANE_VERSION,
BULK_COMPLETION_REFUSAL_AT_VERSION, BULK_COMPLETION_REFUSAL_AT_VERSION,
IMPORT_TRANSLATION_CACHE_LEGACY_PARTITION_BACKFILL_VERSION,
]); ]);
}); });
@@ -1334,6 +1380,7 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => {
IMPORT_TRANSLATION_CACHE_SCOPE_FIX_VERSION, IMPORT_TRANSLATION_CACHE_SCOPE_FIX_VERSION,
TASK_MERGER_MODEL_LANE_VERSION, TASK_MERGER_MODEL_LANE_VERSION,
BULK_COMPLETION_REFUSAL_AT_VERSION, BULK_COMPLETION_REFUSAL_AT_VERSION,
IMPORT_TRANSLATION_CACHE_LEGACY_PARTITION_BACKFILL_VERSION,
]); ]);
}); });
@@ -1392,6 +1439,7 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => {
IMPORT_TRANSLATION_CACHE_SCOPE_FIX_VERSION, IMPORT_TRANSLATION_CACHE_SCOPE_FIX_VERSION,
TASK_MERGER_MODEL_LANE_VERSION, TASK_MERGER_MODEL_LANE_VERSION,
BULK_COMPLETION_REFUSAL_AT_VERSION, BULK_COMPLETION_REFUSAL_AT_VERSION,
IMPORT_TRANSLATION_CACHE_LEGACY_PARTITION_BACKFILL_VERSION,
]); ]);
}); });
}); });

View File

@@ -0,0 +1,31 @@
/*
FNXC:GitHubImportTranslate 2026-07-17-23:48:
Migration 0016 fixed the cache partition contract for new writes, but it did
not rewrite translations recorded before that fix with a blank project_id.
A restarted unbound store now reads __legacy_unscoped__, so backfill historic
blank rows into that same partition to serve the cache instead of re-billing
translation. When both partitions contain one cache key, retain the most
recently recorded value before normalizing the remaining legacy row.
*/
DELETE FROM project.import_translation_cache AS normalized
USING project.import_translation_cache AS legacy
WHERE normalized.project_id = '__legacy_unscoped__'
AND btrim(legacy.project_id) = ''
AND normalized.provider = legacy.provider
AND normalized.repo_key = legacy.repo_key
AND normalized.issue_number = legacy.issue_number
AND normalized.target_locale = legacy.target_locale
AND normalized.recorded_at < legacy.recorded_at;
DELETE FROM project.import_translation_cache AS legacy
USING project.import_translation_cache AS normalized
WHERE btrim(legacy.project_id) = ''
AND normalized.project_id = '__legacy_unscoped__'
AND normalized.provider = legacy.provider
AND normalized.repo_key = legacy.repo_key
AND normalized.issue_number = legacy.issue_number
AND normalized.target_locale = legacy.target_locale;
UPDATE project.import_translation_cache
SET project_id = '__legacy_unscoped__'
WHERE btrim(project_id) = '';

View File

@@ -28,10 +28,10 @@ import { runPluginSchemaInitHooks, DEFAULT_PLUGIN_SCHEMA_INIT_HOOKS, type Plugin
/** The latest PostgreSQL schema version known to this applier. */ /** The latest PostgreSQL schema version known to this applier. */
/* /*
FNXC:MultiProjectIsolation 2026-07-15-23:40: FNXC:GitHubImportTranslate 2026-07-17-23:48:
Advances to 0012 after the owner_project_id domain/partition split and chat pin timestamp. Per-migration identities above stay fixed; only this latest-version marker moves. Advances to 0019 for the import-translation legacy-partition backfill. Per-migration identities above stay fixed; only this latest-version marker moves.
*/ */
export const SCHEMA_BASELINE_VERSION = "0018"; export const SCHEMA_BASELINE_VERSION = "0019";
const INITIAL_SCHEMA_VERSION = "0000"; const INITIAL_SCHEMA_VERSION = "0000";
const AUTOMATION_ISOLATION_SCHEMA_VERSION = "0001"; const AUTOMATION_ISOLATION_SCHEMA_VERSION = "0001";
const ANALYTICS_ISOLATION_SCHEMA_VERSION = "0002"; const ANALYTICS_ISOLATION_SCHEMA_VERSION = "0002";
@@ -63,6 +63,14 @@ export const IMPORT_TRANSLATION_CACHE_VERSION = "0010";
*/ */
export const IMPORT_TRANSLATION_CACHE_SCOPE_FIX_VERSION = "0016"; export const IMPORT_TRANSLATION_CACHE_SCOPE_FIX_VERSION = "0016";
/* /*
FNXC:GitHubImportTranslate 2026-07-17-23:48:
0016 aligned future cache writes with the normalized legacy partition, but a
pre-0016 cache row can still carry a historic blank project_id. Migration 0019
backfills that durable data before a restarted store scopes cache reads to
__legacy_unscoped__, preventing an avoidable re-translation.
*/
export const IMPORT_TRANSLATION_CACHE_LEGACY_PARTITION_BACKFILL_VERSION = "0019";
/*
FNXC:MultiProjectIsolation 2026-07-15-23:40: FNXC:MultiProjectIsolation 2026-07-15-23:40:
Version 0011 splits the domain "project" field from the RLS partition on the tables Version 0011 splits the domain "project" field from the RLS partition on the tables
that conflated them: `project_id` stays the trigger/GUC-owned isolation partition, that conflated them: `project_id` stays the trigger/GUC-owned isolation partition,
@@ -155,6 +163,11 @@ const IMPORT_TRANSLATION_CACHE_SCOPE_FIX_MIGRATION_PATH = join(
"migrations", "migrations",
"0016_import_translation_cache_scope_fix.sql", "0016_import_translation_cache_scope_fix.sql",
); );
const IMPORT_TRANSLATION_CACHE_LEGACY_PARTITION_BACKFILL_MIGRATION_PATH = join(
__dirname,
"migrations",
"0019_import_translation_cache_legacy_partition_backfill.sql",
);
const OWNER_PROJECT_ID_SPLIT_MIGRATION_PATH = join( const OWNER_PROJECT_ID_SPLIT_MIGRATION_PATH = join(
__dirname, __dirname,
"migrations", "migrations",
@@ -262,6 +275,7 @@ export async function applySchemaBaseline(
const missionFixIdempotencyAlreadyApplied = applied.includes(MISSION_FIX_IDEMPOTENCY_VERSION); const missionFixIdempotencyAlreadyApplied = applied.includes(MISSION_FIX_IDEMPOTENCY_VERSION);
const importTranslationCacheAlreadyApplied = applied.includes(IMPORT_TRANSLATION_CACHE_VERSION); const importTranslationCacheAlreadyApplied = applied.includes(IMPORT_TRANSLATION_CACHE_VERSION);
const importTranslationCacheScopeFixAlreadyApplied = applied.includes(IMPORT_TRANSLATION_CACHE_SCOPE_FIX_VERSION); const importTranslationCacheScopeFixAlreadyApplied = applied.includes(IMPORT_TRANSLATION_CACHE_SCOPE_FIX_VERSION);
const importTranslationCacheLegacyPartitionBackfillAlreadyApplied = applied.includes(IMPORT_TRANSLATION_CACHE_LEGACY_PARTITION_BACKFILL_VERSION);
const ownerProjectIdSplitAlreadyApplied = applied.includes(OWNER_PROJECT_ID_SPLIT_VERSION); const ownerProjectIdSplitAlreadyApplied = applied.includes(OWNER_PROJECT_ID_SPLIT_VERSION);
const chatSessionPinsAlreadyApplied = applied.includes(CHAT_SESSION_PINS_VERSION); const chatSessionPinsAlreadyApplied = applied.includes(CHAT_SESSION_PINS_VERSION);
const executorToolFailureRetryAlreadyApplied = applied.includes(EXECUTOR_TOOL_FAILURE_RETRY_VERSION); const executorToolFailureRetryAlreadyApplied = applied.includes(EXECUTOR_TOOL_FAILURE_RETRY_VERSION);
@@ -600,6 +614,15 @@ export async function applySchemaBaseline(
schemaChanged = true; schemaChanged = true;
} }
if (!importTranslationCacheLegacyPartitionBackfillAlreadyApplied) {
const migrationSql = await readFile(IMPORT_TRANSLATION_CACHE_LEGACY_PARTITION_BACKFILL_MIGRATION_PATH, "utf8");
await tx.execute(sql.raw(migrationSql));
await tx.execute(
sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${IMPORT_TRANSLATION_CACHE_LEGACY_PARTITION_BACKFILL_VERSION}) ON CONFLICT (version) DO NOTHING`,
);
schemaChanged = true;
}
return { applied: schemaChanged, pluginHooksRun: pluginHooks.length }; return { applied: schemaChanged, pluginHooksRun: pluginHooks.length };
}); });
} }

View File

@@ -508,6 +508,48 @@ Compact row under metadata so title/body stay readable; error uses the same toke
color: var(--color-error); color: var(--color-error);
} }
/*
FNXC:GitHubImportTranslate 2026-07-17-15:48:
The issues list must disclose background auto-translation without blocking browsing. Reuse the global
status-dot state colors and the existing spinner; the wrapper exists only for active or failed work so
idle and auto-translate-off views retain their original layout.
*/
.github-import-autotranslate-status {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-sm);
margin-bottom: var(--space-sm);
color: var(--text-muted);
font-size: var(--font-size-sm, 13px);
}
.github-import-autotranslate-status__working,
.github-import-autotranslate-status__error {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
min-width: 0;
}
.github-import-autotranslate-status__working {
color: var(--color-warning);
}
.github-import-autotranslate-status__error {
color: var(--color-error);
}
@media (max-width: 768px) {
.github-import-autotranslate-status {
align-items: flex-start;
}
.github-import-autotranslate-status__error {
flex-basis: 100%;
}
}
.github-import-pane-content { .github-import-pane-content {
flex: 1; flex: 1;
min-height: 0; min-height: 0;

View File

@@ -1762,6 +1762,35 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
)} )}
</div> </div>
{/*
FNXC:GitHubImportTranslate 2026-07-17-15:48:
Eager list translation is intentionally background and fail-soft, but operators need a visible,
polite announcement while chunks are in flight and when a chunk fails. Render no container while
idle so auto-translate-off and cache-served loads leave no empty status shell in the issues list.
*/}
{provider === "github" && activeTab === "issues" && (autoTranslate.loading || autoTranslate.error) && (
<div
className="github-import-autotranslate-status"
data-testid="github-import-autotranslate-status"
role="status"
aria-live="polite"
>
{autoTranslate.loading && (
<div className="github-import-autotranslate-status__working">
<span className="status-dot status-dot--connecting" aria-hidden="true" />
<Loader2 size={14} className="spin" aria-hidden="true" />
<span>{t("git.translateWorking", "Translating…")}</span>
</div>
)}
{autoTranslate.error && (
<div className="github-import-autotranslate-status__error">
<span className="status-dot status-dot--error" aria-hidden="true" />
<span>{autoTranslate.error}</span>
</div>
)}
</div>
)}
<div className="github-import-pane-content"> <div className="github-import-pane-content">
{!hasResultsContent && ( {!hasResultsContent && (
<div className="github-import-state github-import-state--idle" data-testid="github-import-results-idle"> <div className="github-import-state github-import-state--idle" data-testid="github-import-results-idle">

View File

@@ -1235,6 +1235,96 @@ describe("GitHubImportModal", () => {
}); });
}); });
/*
FNXC:GitHubImportTranslate 2026-07-17-15:48:
List-level translation remains non-blocking, but its asynchronous state must be visible while a page
is translating and must leave no aria-live shell after success, error, disabled auto-translate, or tab changes.
*/
it("announces auto-translation progress, completion, and fail-soft errors", async () => {
const issue = { number: 1, title: "Foreign issue", body: "Foreign body", html_url: "https://github.com/owner/repo/issues/1", labels: [] };
let resolveTranslation: ((value: { enabled: boolean; targetLocale: string; capped: boolean; translations: Record<string, { title: string; body: string }> }) => void) | undefined;
const pendingTranslation = new Promise<{ enabled: boolean; targetLocale: string; capped: boolean; translations: Record<string, { title: string; body: string }> }>((resolve) => {
resolveTranslation = resolve;
});
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
vi.mocked(fetchSettings).mockResolvedValue({ gitlabEnabled: true, githubImportAutoTranslate: true } as never);
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce([issue]);
vi.mocked(autoTranslateImportIssues).mockReturnValueOnce(pendingTranslation as never);
render(<GitHubImportModal isOpen onClose={onClose} onImport={onImport} tasks={[]} />);
const status = await screen.findByTestId("github-import-autotranslate-status");
expect(status).toHaveAttribute("role", "status");
expect(status).toHaveAttribute("aria-live", "polite");
expect(within(status).getByText("Translating…")).toBeTruthy();
resolveTranslation?.({
enabled: true,
targetLocale: "en",
capped: false,
translations: { 1: { title: "Translated issue", body: "Translated body" } },
});
await screen.findByText("Translated issue");
await waitFor(() => expect(screen.queryByTestId("github-import-autotranslate-status")).toBeNull());
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce([issue]);
vi.mocked(autoTranslateImportIssues).mockRejectedValueOnce(new Error("Translation unavailable"));
fireEvent.click(screen.getByRole("button", { name: /Load issues/i }));
const errorStatus = await screen.findByTestId("github-import-autotranslate-status");
expect(within(errorStatus).getByText("Failed to translate content. Please try again.")).toBeTruthy();
});
it("keeps translation status visible after earlier chunks have landed", async () => {
const issues = Array.from({ length: 9 }, (_, index) => ({
number: index + 1,
title: `Foreign issue ${index + 1}`,
body: `Foreign body ${index + 1}`,
html_url: `https://github.com/owner/repo/issues/${index + 1}`,
labels: [],
}));
let resolveFinalChunk: ((value: { enabled: boolean; targetLocale: string; capped: boolean; translations: Record<string, { title: string; body: string }> }) => void) | undefined;
const finalChunk = new Promise<{ enabled: boolean; targetLocale: string; capped: boolean; translations: Record<string, { title: string; body: string }> }>((resolve) => {
resolveFinalChunk = resolve;
});
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
vi.mocked(fetchSettings).mockResolvedValue({ gitlabEnabled: true, githubImportAutoTranslate: true } as never);
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce(issues);
vi.mocked(autoTranslateImportIssues)
.mockResolvedValueOnce({
enabled: true,
targetLocale: "en",
capped: false,
translations: Object.fromEntries(issues.slice(0, 8).map((issue) => [issue.number, { title: `Translated ${issue.number}`, body: issue.body }])),
})
.mockReturnValueOnce(finalChunk as never);
render(<GitHubImportModal isOpen onClose={onClose} onImport={onImport} tasks={[]} />);
await screen.findByText("Translated 1");
expect(screen.getByTestId("github-import-autotranslate-status")).toBeTruthy();
resolveFinalChunk?.({
enabled: true,
targetLocale: "en",
capped: false,
translations: { 9: { title: "Translated 9", body: "Translated body" } },
});
await screen.findByText("Translated 9");
await waitFor(() => expect(screen.queryByTestId("github-import-autotranslate-status")).toBeNull());
});
it("omits auto-translation status when disabled or outside the issues tab", async () => {
const issue = { number: 1, title: "Foreign issue", body: "Foreign body", html_url: "https://github.com/owner/repo/issues/1", labels: [] };
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce([issue]);
render(<GitHubImportModal isOpen onClose={onClose} onImport={onImport} tasks={[]} />);
await screen.findByText("Foreign issue");
expect(screen.queryByTestId("github-import-autotranslate-status")).toBeNull();
fireEvent.click(screen.getByRole("tab", { name: /Pull Requests/i }));
expect(screen.queryByTestId("github-import-autotranslate-status")).toBeNull();
});
/* /*
FNXC:GitHubImport 2026-07-16-16:20: FNXC:GitHubImport 2026-07-16-16:20:
Page controls for repos with >1 page (30/page). Asserts page 1 shows only the first 30, the pager reports Page controls for repos with >1 page (30/page). Asserts page 1 shows only the first 30, the pager reports

View File

@@ -8,6 +8,11 @@ Covers the invariants of import auto-translation across every surface that can s
*/ */
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
import { createConnectionSetFromUrl } from "../../../core/src/postgres/connection.js";
import { createAsyncDataLayer, type AsyncDataLayer } from "../../../core/src/postgres/data-layer.js";
import type { ResolvedBackend } from "../../../core/src/postgres/backend-resolver.js";
import { TaskStore } from "../../../core/src/store.js";
import { createTaskStoreForTest, pgDescribe, type PgTestHarness } from "../../../core/src/__test-utils__/pg-test-harness.js";
const translateTextMock = vi.fn(); const translateTextMock = vi.fn();
@@ -54,6 +59,23 @@ function makeStore(
const ctx = (store: any) => ({ store, rootDir: "/tmp/root", provider: "github" as const, repoKey: "o/r", targetLocale: "es" as const }); const ctx = (store: any) => ({ store, rootDir: "/tmp/root", provider: "github" as const, repoKey: "o/r", targetLocale: "es" as const });
async function reopenStore(harness: PgTestHarness): Promise<{ store: TaskStore; layer: AsyncDataLayer }> {
const backend: ResolvedBackend = {
mode: "external",
runtimeUrl: harness.testUrl,
migrationUrl: harness.testUrl,
migrationUrlOverridden: false,
};
const connections = await createConnectionSetFromUrl(backend, {
poolMax: 1,
connectTimeoutSeconds: 5,
});
const layer = createAsyncDataLayer(connections);
const store = new TaskStore(harness.rootDir, undefined, { asyncLayer: layer });
await store.init();
return { store, layer };
}
/* /*
FNXC:GitHubImportTranslate 2026-07-15-09:30: 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". 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".
@@ -301,6 +323,46 @@ describe("partitionImportItemsByCache (what the rate-limit budget is charged fro
}); });
}); });
/*
FNXC:GitHubImportTranslate 2026-07-17-23:30:
This exercises the production auto-translate service across a real TaskStore
restart, rather than only testing the cache primitive or an in-memory fake.
The reopened GitHub request must be cache-served and make zero model calls.
*/
pgDescribe("translateImportItems durable GitHub cache integration", () => {
it("serves a fresh-store GitHub auto-translate request from PostgreSQL without re-billing", async () => {
const harness = await createTaskStoreForTest({ prefix: "fusion_import_translate_service" });
let reopened: { store: TaskStore; layer: AsyncDataLayer } | null = null;
const item = { number: 91, title: "Error del servidor", body: SPANISH_BODY, state: "open" as const };
try {
const firstContext = {
store: harness.store,
rootDir: harness.rootDir,
provider: "github" as const,
repoKey: "owner/repo",
targetLocale: "en" as const,
};
await translateImportItems(firstContext, [item]);
expect(translateTextMock).toHaveBeenCalledTimes(1);
await harness.store.close();
reopened = await reopenStore(harness);
translateTextMock.mockClear();
const secondContext = { ...firstContext, store: reopened.store };
const partition = await partitionImportItemsByCache(secondContext, [item]);
const result = await translateImportItems(secondContext, [item], partition);
expect(partition.uncached).toHaveLength(0);
expect(translateTextMock).not.toHaveBeenCalled();
expect(result.get(item.number)).toMatchObject({ title: "TRANSLATED", body: "TRANSLATED BODY", cached: true });
} finally {
await reopened?.store.close();
await harness.teardown();
}
});
});
describe("getCachedImportTranslation (the import path)", () => { describe("getCachedImportTranslation (the import path)", () => {
it("returns null on a miss so import carries the original prose", async () => { it("returns null on a miss so import carries the original prose", async () => {
const store = makeStore(); const store = makeStore();