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.
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.
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:
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)", () => {
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" });
let reopened: { store: TaskStore; layer: AsyncDataLayer } | null = null;
const providerKey = { ...key, provider };
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);
await expect(reopened.store.getImportTranslation(key)).resolves.toEqual({
await expect(reopened.store.getImportTranslation(providerKey)).resolves.toEqual({
...value,
recordedAt: "2026-07-16T00:00:00.000Z",
});

View File

@@ -44,6 +44,7 @@ import {
MISSION_FIX_IDEMPOTENCY_VERSION,
IMPORT_TRANSLATION_CACHE_VERSION,
IMPORT_TRANSLATION_CACHE_SCOPE_FIX_VERSION,
IMPORT_TRANSLATION_CACHE_LEGACY_PARTITION_BACKFILL_VERSION,
OWNER_PROJECT_ID_SPLIT_VERSION,
/*
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));
});
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", () => {
expect(TASK_MERGER_MODEL_LANE_VERSION).toBe("0017");
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,
TASK_MERGER_MODEL_LANE_VERSION,
BULK_COMPLETION_REFUSAL_AT_VERSION,
IMPORT_TRANSLATION_CACHE_LEGACY_PARTITION_BACKFILL_VERSION,
]);
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,
TASK_MERGER_MODEL_LANE_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);
});
/*
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 () => {
ctx = await setupFreshDb();
await applySchemaBaseline(ctx.db, { pluginHooks: [] });
@@ -1276,6 +1321,7 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => {
IMPORT_TRANSLATION_CACHE_SCOPE_FIX_VERSION,
TASK_MERGER_MODEL_LANE_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,
TASK_MERGER_MODEL_LANE_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,
TASK_MERGER_MODEL_LANE_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. */
/*
FNXC:MultiProjectIsolation 2026-07-15-23:40:
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.
FNXC:GitHubImportTranslate 2026-07-17-23:48:
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 AUTOMATION_ISOLATION_SCHEMA_VERSION = "0001";
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";
/*
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:
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,
@@ -155,6 +163,11 @@ const IMPORT_TRANSLATION_CACHE_SCOPE_FIX_MIGRATION_PATH = join(
"migrations",
"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(
__dirname,
"migrations",
@@ -262,6 +275,7 @@ export async function applySchemaBaseline(
const missionFixIdempotencyAlreadyApplied = applied.includes(MISSION_FIX_IDEMPOTENCY_VERSION);
const importTranslationCacheAlreadyApplied = applied.includes(IMPORT_TRANSLATION_CACHE_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 chatSessionPinsAlreadyApplied = applied.includes(CHAT_SESSION_PINS_VERSION);
const executorToolFailureRetryAlreadyApplied = applied.includes(EXECUTOR_TOOL_FAILURE_RETRY_VERSION);
@@ -600,6 +614,15 @@ export async function applySchemaBaseline(
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 };
});
}

View File

@@ -508,6 +508,48 @@ Compact row under metadata so title/body stay readable; error uses the same toke
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 {
flex: 1;
min-height: 0;

View File

@@ -1762,6 +1762,35 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
)}
</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">
{!hasResultsContent && (
<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:
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 { 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();
@@ -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 });
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:
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)", () => {
it("returns null on a miss so import carries the original prose", async () => {
const store = makeStore();