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:
7
.changeset/fn-8249-github-import-translation-status.md
Normal file
7
.changeset/fn-8249-github-import-translation-status.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) = '';
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user