FN-8230: translate GitHub import issues across pages
GitHub import auto-translation now covers every reachable issue page within the fetch cap. - Translate and retain per-page results while invalidating changed or reloaded issues - Raise the translation budget for a 300-issue traversal and cover pagination regressions - Document the GitHub-only pagination behavior and add a patch changeset Files changed: .changeset/fn-8230-import-translate-pagination.md | 7 ++ docs/dashboard-guide.md | 2 +- docs/settings-reference.md | 4 +- .../dashboard/app/components/GitHubImportModal.tsx | 33 ++++++--- .../components/GitHubImportTranslateControls.tsx | 80 ++++++++++++--------- .../__tests__/GitHubImportAutoTranslate.test.tsx | 66 ++++++++++++----- .../__tests__/GitHubImportModal.test.tsx | 32 +++++++++ .../dashboard/src/__tests__/ai-translate.test.ts | 20 ++++++ packages/dashboard/src/ai-translate.ts | 6 +- packages/dashboard/src/import-translate-service.ts | Bin 11109 -> 11099 bytes 10 files changed, 186 insertions(+), 64 deletions(-) Fusion-Task-Id: FN-8230 Fusion-Task-Lineage: 2c3ce6f6-a8f3-46a9-b93a-0192f0cdaf54 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-8230-import-translate-pagination.md
Normal file
7
.changeset/fn-8230-import-translate-pagination.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: GitHub-import auto-translate now translates issues on every page, not just the first 50.
|
||||
category: fix
|
||||
dev: `useGitHubImportAutoTranslate` is now page-scoped, accumulates translations across page navigation, and invalidates per-issue on content change; the per-IP translate budget is raised to fit one full 300-issue fetch-cap traversal per hour.
|
||||
@@ -319,7 +319,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. 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. 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
|
||||
|
||||
@@ -651,7 +651,7 @@ 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). Translations persist across app restarts until the upstream issue changes or closes, avoiding repeat model calls. Off by default so all-English projects never pay for a per-issue AI call. Dashboard location: **Settings → Project → General → GitHub Tracking**. |
|
||||
| `githubImportAutoTranslate` | `boolean` | `false` | Project-scoped, import-only option. When enabled, the Import Tasks panel automatically translates foreign-language **GitHub open issue** titles and bodies into `importTranslateTargetLocale` and shows the translation by default (the original text stays one toggle away). Translation follows each reachable page of the fetched list (up to the 300-issue fetch cap in one hour); cached repeat views do not spend model calls. Pull requests and GitLab retain per-selection translation. Translations persist across app restarts until the upstream issue changes or closes. 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. |
|
||||
@@ -1126,7 +1126,7 @@ If the configured title summarizer provider/model is stale and no longer exists
|
||||
|
||||
### 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.
|
||||
Dedicated model lane used by `githubImportAutoTranslate` to translate foreign-language open GitHub issue titles and bodies across the reachable Import Tasks pages. 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`
|
||||
|
||||
@@ -495,6 +495,13 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
|
||||
const [selectedIssueNumber, setSelectedIssueNumber] = useState<number | null>(null);
|
||||
// FNXC:GitHubImport 2026-07-16-16:20: 0-based client-side page index for the issues list (reset on every reload/filter change).
|
||||
const [issuePage, setIssuePage] = useState(0);
|
||||
/*
|
||||
FNXC:GitHubImportTranslate 2026-07-17-12:50:
|
||||
FN-8230 distinguishes a successful upstream issues reload from client-side paging. The translation
|
||||
hook clears its accumulated page cache only when this monotonic generation advances, preventing
|
||||
stale prose after reload without re-billing prior pages while the operator navigates them.
|
||||
*/
|
||||
const [issuesReloadGeneration, setIssuesReloadGeneration] = useState(0);
|
||||
|
||||
// Pulls state
|
||||
const [pulls, setPulls] = useState<GitHubPull[]>([]);
|
||||
@@ -823,6 +830,7 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
|
||||
*/
|
||||
const fetchedIssues = await apiFetchGitHubIssues(owner.trim(), repo.trim(), ISSUES_FETCH_CAP, labelArray.length > 0 ? labelArray : undefined);
|
||||
setIssues(fetchedIssues);
|
||||
setIssuesReloadGeneration((generation) => generation + 1);
|
||||
if (fetchedIssues.length === 0) {
|
||||
setIsIssuesEmptyState(true);
|
||||
}
|
||||
@@ -1411,15 +1419,24 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
|
||||
}, [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.
|
||||
FNXC:GitHubImportTranslate 2026-07-17-12:50:
|
||||
Supply the exact paged render window so page 2+ translates on arrival. This calculation must precede
|
||||
the hook (rather than use the whole fetched list) while retaining the shared page values used below.
|
||||
*/
|
||||
const translationVisibleIssues = hideImported ? issues.filter((issue) => !isUrlImported(issue.html_url)) : issues;
|
||||
const translationIssuePageCount = Math.max(1, Math.ceil(translationVisibleIssues.length / ISSUES_PAGE_SIZE));
|
||||
const translationClampedIssuePage = Math.min(issuePage, translationIssuePageCount - 1);
|
||||
const translationPagedIssues = translationVisibleIssues.slice(
|
||||
translationClampedIssuePage * ISSUES_PAGE_SIZE,
|
||||
(translationClampedIssuePage + 1) * ISSUES_PAGE_SIZE,
|
||||
);
|
||||
|
||||
const autoTranslate = useGitHubImportAutoTranslate({
|
||||
enabled: autoTranslateEnabled && provider === "github" && activeTab === "issues",
|
||||
owner: owner.trim(),
|
||||
repo: repo.trim(),
|
||||
items: issues,
|
||||
items: translationPagedIssues,
|
||||
reloadGeneration: issuesReloadGeneration,
|
||||
targetLocale: translateTargetLocale,
|
||||
projectId,
|
||||
});
|
||||
@@ -1460,7 +1477,7 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
|
||||
Hide imported removes imported rows from every provider's render set, while toggle-off preserves the original arrays and
|
||||
imported counts always use the full fetched sets. Use `isUrlImported` so optimistic imports also disappear immediately.
|
||||
*/
|
||||
const visibleIssues = hideImported ? issues.filter((issue) => !isUrlImported(issue.html_url)) : issues;
|
||||
const visibleIssues = translationVisibleIssues;
|
||||
/*
|
||||
FNXC:GitHubImport 2026-07-16-16:20:
|
||||
Client-side page window over the (already label/hide-imported filtered) visible issues. issuePage is
|
||||
@@ -1468,9 +1485,9 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
|
||||
an out-of-range page. isIssuesTruncated flags that the repo hit ISSUES_FETCH_CAP so the operator knows
|
||||
the list is bounded, not complete.
|
||||
*/
|
||||
const issuePageCount = Math.max(1, Math.ceil(visibleIssues.length / ISSUES_PAGE_SIZE));
|
||||
const clampedIssuePage = Math.min(issuePage, issuePageCount - 1);
|
||||
const pagedIssues = visibleIssues.slice(clampedIssuePage * ISSUES_PAGE_SIZE, (clampedIssuePage + 1) * ISSUES_PAGE_SIZE);
|
||||
const issuePageCount = translationIssuePageCount;
|
||||
const clampedIssuePage = translationClampedIssuePage;
|
||||
const pagedIssues = translationPagedIssues;
|
||||
const isIssuesTruncated = issues.length >= ISSUES_FETCH_CAP;
|
||||
const visiblePulls = hideImported ? pulls.filter((pull) => !isUrlImported(pull.html_url)) : pulls;
|
||||
const visibleGitlabItems = hideImported ? gitlabItems.filter((item) => !isUrlImported(item.webUrl)) : gitlabItems;
|
||||
|
||||
@@ -83,17 +83,18 @@ export interface UseGitHubImportAutoTranslateArgs {
|
||||
enabled: boolean;
|
||||
owner: string;
|
||||
repo: string;
|
||||
/** The caller's current client-side page window, never the entire fetched set. */
|
||||
items: AutoTranslateListItem[];
|
||||
/** Monotonic identity bumped only after a successful issues reload. */
|
||||
reloadGeneration: number;
|
||||
targetLocale: Locale;
|
||||
projectId?: string;
|
||||
}
|
||||
|
||||
export interface GitHubImportAutoTranslateState {
|
||||
/** number -> translated fields, for issues the server translated. */
|
||||
/** number -> translated fields, accumulated while paging within one reload. */
|
||||
translations: Map<number, ImportTranslateFields>;
|
||||
loading: boolean;
|
||||
/** True when more foreign issues existed than the per-load cap. */
|
||||
capped: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
@@ -107,6 +108,7 @@ export function useGitHubImportAutoTranslate({
|
||||
owner,
|
||||
repo,
|
||||
items,
|
||||
reloadGeneration,
|
||||
targetLocale,
|
||||
projectId,
|
||||
}: UseGitHubImportAutoTranslateArgs): GitHubImportAutoTranslateState {
|
||||
@@ -121,21 +123,20 @@ export function useGitHubImportAutoTranslate({
|
||||
`items` is a fresh ARRAY IDENTITY on most renders, so neither it nor anything derived from it may sit in the effect's dependency list: the effect calls setState, setState re-renders, the re-render mints a new array, and the effect fires again — an infinite render loop (caught as an OOM under renderHook).
|
||||
Everything the effect depends on is therefore reduced to STRING keys (stable by value), and the live issue data is read from a ref at run time instead of being a dependency.
|
||||
*/
|
||||
// Only the 50 most recent OPEN issues are eligible. GitHub returns issues
|
||||
// newest-first, so list order is already "most recent".
|
||||
/*
|
||||
FNXC:GitHubImportTranslate 2026-07-17-12:50:
|
||||
FN-8230 passes the already-paged window from GitHubImportModal rather than a page index here.
|
||||
That keeps this hook independent of the modal's hide-imported filtering while ensuring every
|
||||
reachable page is eligible; the retained 50-item slice remains a defensive per-request bound.
|
||||
*/
|
||||
const eligible = useMemo(
|
||||
() => items.filter((item) => item.state !== "closed").slice(0, AUTO_TRANSLATE_MAX_ISSUES),
|
||||
[items],
|
||||
);
|
||||
|
||||
/** True when more open issues exist than a single load will translate. */
|
||||
const openCount = useMemo(
|
||||
() => items.filter((item) => item.state !== "closed").length,
|
||||
[items],
|
||||
);
|
||||
|
||||
const eligibleRef = useRef(eligible);
|
||||
eligibleRef.current = eligible;
|
||||
const translationSignaturesRef = useRef<Map<number, string>>(new Map());
|
||||
|
||||
/*
|
||||
FNXC:GitHubImportTranslate 2026-07-15-18:40:
|
||||
@@ -147,39 +148,53 @@ export function useGitHubImportAutoTranslate({
|
||||
[eligible],
|
||||
);
|
||||
|
||||
/* Stable-by-value key: re-runs only when the actual issue set / content / repo
|
||||
/ locale changes, not on unrelated list re-renders. */
|
||||
/* Stable-by-value key: re-runs only when the actual visible page content changes,
|
||||
not on unrelated list re-renders. */
|
||||
const requestKey = useMemo(
|
||||
() =>
|
||||
enabled && owner && repo && eligible.length > 0
|
||||
? `${owner}/${repo}|${targetLocale}|${eligible.map((i) => i.number).join(",")}|${contentSignature}`
|
||||
? `${owner}/${repo}|${targetLocale}|${reloadGeneration}|${eligible.map((i) => i.number).join(",")}|${contentSignature}`
|
||||
: null,
|
||||
[enabled, owner, repo, targetLocale, eligible, contentSignature],
|
||||
[enabled, owner, repo, targetLocale, reloadGeneration, eligible, contentSignature],
|
||||
);
|
||||
const resetKey = `${enabled ? "on" : "off"}|${owner}/${repo}|${targetLocale}|${reloadGeneration}`;
|
||||
|
||||
/*
|
||||
FNXC:GitHubImportTranslate 2026-07-15-20:15:
|
||||
`capped` is DERIVED, not state (PR #2147 review). Holding it in state meant setting it from the translation effect, which forced `capExceeded` into that effect's dependency list — so a 51st open issue appearing (while the eligible first 50 were unchanged) re-ran the whole effect, cleared the translations, and re-requested all 50 purely to update a badge.
|
||||
Deriving it removes the dependency, and with it the entire class of "cap indicator restarts translation" bug: there is nothing to keep in sync.
|
||||
FNXC:GitHubImportTranslate 2026-07-17-12:50:
|
||||
Page navigation must accumulate translations, whereas a successful reload, source change, or locale
|
||||
change must discard them. Keep this reset separate from the page request effect so an unchanged
|
||||
translated issue is never re-billed, but edited prose (new per-item signature) is re-requested.
|
||||
*/
|
||||
const capExceeded = openCount > AUTO_TRANSLATE_MAX_ISSUES;
|
||||
const capped = requestKey !== null && capExceeded;
|
||||
useEffect(() => {
|
||||
translationSignaturesRef.current.clear();
|
||||
setTranslations((prev) => (prev.size > 0 ? new Map() : prev));
|
||||
setError((prev) => (prev ? null : prev));
|
||||
setLoading((prev) => (prev ? false : prev));
|
||||
}, [resetKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!requestKey) {
|
||||
// Identity-preserving resets: returning the previous value when there is
|
||||
// nothing to clear avoids a state change (and therefore a re-render loop).
|
||||
setTranslations((prev) => (prev.size > 0 ? new Map() : prev));
|
||||
setError((prev) => (prev ? null : prev));
|
||||
setLoading((prev) => (prev ? false : prev));
|
||||
return;
|
||||
}
|
||||
if (!requestKey) return;
|
||||
|
||||
const pending = eligibleRef.current;
|
||||
const pageItems = eligibleRef.current;
|
||||
const pending = pageItems.filter((item) => {
|
||||
const signature = hashImportItemsForKey([item]);
|
||||
return translationSignaturesRef.current.get(item.number) !== signature;
|
||||
});
|
||||
const staleNumbers = pageItems
|
||||
.filter((item) => translationSignaturesRef.current.has(item.number) && translationSignaturesRef.current.get(item.number) !== hashImportItemsForKey([item]))
|
||||
.map((item) => item.number);
|
||||
if (staleNumbers.length > 0) {
|
||||
for (const number of staleNumbers) translationSignaturesRef.current.delete(number);
|
||||
setTranslations((prev) => {
|
||||
const next = new Map(prev);
|
||||
for (const number of staleNumbers) next.delete(number);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
if (pending.length === 0) return;
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError((prev) => (prev ? null : prev));
|
||||
setTranslations((prev) => (prev.size > 0 ? new Map() : prev));
|
||||
|
||||
/*
|
||||
FNXC:GitHubImportTranslate 2026-07-15-17:05:
|
||||
@@ -224,6 +239,8 @@ export function useGitHubImportAutoTranslate({
|
||||
const number = Number(key);
|
||||
if (Number.isInteger(number)) {
|
||||
next.set(number, { title: value.title, body: value.body });
|
||||
const item = chunk.find((candidate) => candidate.number === number);
|
||||
if (item) translationSignaturesRef.current.set(number, hashImportItemsForKey([item]));
|
||||
}
|
||||
}
|
||||
return next;
|
||||
@@ -247,10 +264,9 @@ export function useGitHubImportAutoTranslate({
|
||||
// Deps are STRING/scalar only. `requestKey` already encodes repo+locale+issue
|
||||
// set; adding `items`/`eligible` (array identities) would re-fire the effect on
|
||||
// every render and loop. Live data comes from `eligibleRef`.
|
||||
// NOTE: `capExceeded` is deliberately absent — see the derived-`capped` note above.
|
||||
}, [requestKey, owner, repo, targetLocale, projectId]);
|
||||
|
||||
return { translations, loading, capped, error };
|
||||
return { translations, loading, error };
|
||||
}
|
||||
|
||||
export type ImportTranslateView = {
|
||||
|
||||
@@ -65,7 +65,7 @@ afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const base = { enabled: true, owner: "o", repo: "r", targetLocale: "en" as const };
|
||||
const base = { enabled: true, owner: "o", repo: "r", reloadGeneration: 0, targetLocale: "en" as const };
|
||||
|
||||
describe("useGitHubImportAutoTranslate — background streaming", () => {
|
||||
it("returns immediately with no translations (the list never waits on it)", () => {
|
||||
@@ -196,30 +196,61 @@ describe("useGitHubImportAutoTranslate — background streaming", () => {
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:GitHubImportTranslate 2026-07-15-20:15:
|
||||
Regression: PR #2147 review. `capped` used to be state written from the translation effect, which
|
||||
forced `capExceeded` into that effect's deps — so a 51st open issue appearing (eligible first 50
|
||||
unchanged) cleared the translations and re-requested all 50 just to update a badge.
|
||||
FNXC:GitHubImportTranslate 2026-07-17-12:50:
|
||||
FN-8230 regression invariant: every reachable 30-item page translates, prior pages remain
|
||||
accumulated, and revisiting unchanged prose spends no additional requests.
|
||||
*/
|
||||
it("does not restart translation when the open count crosses the cap", async () => {
|
||||
it("translates page 2 issue #55 while retaining page 1 without re-billing it", async () => {
|
||||
const allItems = makeItems(60);
|
||||
autoTranslateImportIssues.mockImplementation((_o, _r, chunk) => Promise.resolve(reply(chunk)));
|
||||
const fifty = makeItems(50);
|
||||
const { rerender, result } = renderHook(
|
||||
({ items }) => useGitHubImportAutoTranslate({ ...base, items }),
|
||||
{ initialProps: { items: fifty } },
|
||||
{ initialProps: { items: allItems.slice(0, 30) } },
|
||||
);
|
||||
await waitFor(() => expect(result.current.translations.size).toBe(30));
|
||||
|
||||
rerender({ items: allItems.slice(30, 60) });
|
||||
await waitFor(() => expect(result.current.translations.size).toBe(60));
|
||||
expect(result.current.translations.get(1)?.title).toBe("T1");
|
||||
expect(result.current.translations.get(55)?.title).toBe("T55");
|
||||
|
||||
const callsAfterTwoPages = autoTranslateImportIssues.mock.calls.length;
|
||||
rerender({ items: allItems.slice(0, 30) });
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
const callsAfterFirstRun = autoTranslateImportIssues.mock.calls.length;
|
||||
expect(result.current.capped).toBe(false);
|
||||
expect(result.current.translations.size).toBe(50);
|
||||
expect(autoTranslateImportIssues).toHaveBeenCalledTimes(callsAfterTwoPages);
|
||||
});
|
||||
|
||||
// A 51st open issue appears: the eligible first 50 are unchanged, so this must
|
||||
// flip `capped` WITHOUT clearing translations or re-billing the page.
|
||||
rerender({ items: [...fifty, { number: 51, title: "t51", body: "b", state: "open" as const }] });
|
||||
await waitFor(() => expect(result.current.capped).toBe(true));
|
||||
it("re-requests changed prose and replaces its accumulated translation", async () => {
|
||||
autoTranslateImportIssues
|
||||
.mockImplementationOnce((_o, _r, chunk) => Promise.resolve(reply(chunk)))
|
||||
.mockImplementationOnce((_o, _r, chunk) => Promise.resolve({ ...reply(chunk), translations: { 1: { title: "UPDATED", body: "UPDATED" } } }));
|
||||
const original = [{ number: 1, title: "t1", body: "original", state: "open" as const }];
|
||||
const { rerender, result } = renderHook(
|
||||
({ items }) => useGitHubImportAutoTranslate({ ...base, items }),
|
||||
{ initialProps: { items: original } },
|
||||
);
|
||||
await waitFor(() => expect(result.current.translations.get(1)?.title).toBe("T1"));
|
||||
|
||||
expect(autoTranslateImportIssues.mock.calls.length).toBe(callsAfterFirstRun);
|
||||
expect(result.current.translations.size).toBe(50);
|
||||
rerender({ items: [{ ...original[0], body: "edited" }] });
|
||||
await waitFor(() => expect(result.current.translations.get(1)?.title).toBe("UPDATED"));
|
||||
expect(autoTranslateImportIssues).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("clears accumulated prior pages when the reload generation changes", async () => {
|
||||
const allItems = makeItems(60);
|
||||
autoTranslateImportIssues.mockImplementation((_o, _r, chunk) => Promise.resolve(reply(chunk)));
|
||||
const { rerender, result } = renderHook(
|
||||
({ items, reloadGeneration }) => useGitHubImportAutoTranslate({ ...base, items, reloadGeneration }),
|
||||
{ initialProps: { items: allItems.slice(0, 30), reloadGeneration: 0 } },
|
||||
);
|
||||
await waitFor(() => expect(result.current.translations.size).toBe(30));
|
||||
rerender({ items: allItems.slice(30, 60), reloadGeneration: 0 });
|
||||
await waitFor(() => expect(result.current.translations.size).toBe(60));
|
||||
|
||||
rerender({ items: allItems.slice(30, 60), reloadGeneration: 1 });
|
||||
await waitFor(() => expect(result.current.translations.size).toBe(30));
|
||||
expect(result.current.translations.has(1)).toBe(false);
|
||||
expect(result.current.translations.get(55)?.title).toBe("T55");
|
||||
});
|
||||
|
||||
it("never sends closed issues and caps the page at the 50 most recent open", async () => {
|
||||
@@ -232,7 +263,6 @@ describe("useGitHubImportAutoTranslate — background streaming", () => {
|
||||
const sent = autoTranslateImportIssues.mock.calls.flatMap((c) => c[2] as { number: number }[]);
|
||||
expect(sent).toHaveLength(50);
|
||||
expect(sent.some((i) => i.number === 999)).toBe(false);
|
||||
expect(result.current.capped).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
fetchGitRemotes,
|
||||
createTask,
|
||||
translateImportContent,
|
||||
autoTranslateImportIssues,
|
||||
} from "../../api";
|
||||
import type { Task } from "@fusion/core";
|
||||
import type { GitRemote } from "../../api";
|
||||
@@ -54,6 +55,7 @@ vi.mock("../../api", async (importOriginal) => {
|
||||
fetchGitRemotes: vi.fn(),
|
||||
createTask: vi.fn(),
|
||||
translateImportContent: vi.fn(),
|
||||
autoTranslateImportIssues: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -204,8 +206,15 @@ describe("GitHubImportModal", () => {
|
||||
vi.mocked(apiImportGitLabMergeRequest).mockReset();
|
||||
vi.mocked(fetchSettings).mockReset();
|
||||
vi.mocked(createTask).mockReset();
|
||||
vi.mocked(autoTranslateImportIssues).mockReset();
|
||||
vi.mocked(createTask).mockResolvedValue(mockTask);
|
||||
vi.mocked(fetchSettings).mockResolvedValue({ gitlabEnabled: true } as never);
|
||||
vi.mocked(autoTranslateImportIssues).mockImplementation(async (_owner, _repo, items) => ({
|
||||
enabled: true,
|
||||
targetLocale: "en",
|
||||
capped: false,
|
||||
translations: Object.fromEntries(items.map((item) => [item.number, { title: `Translated ${item.number}`, body: item.body ?? "" }])),
|
||||
}));
|
||||
// Set default mock for apiFetchGitHubIssues to return empty array (prevents undefined issues state)
|
||||
vi.mocked(apiFetchGitHubIssues).mockResolvedValue([]);
|
||||
vi.mocked(apiFetchGitHubPulls).mockResolvedValue([]);
|
||||
@@ -1259,6 +1268,29 @@ describe("GitHubImportModal", () => {
|
||||
expect(screen.getByText(/Page 2 of 3/)).toBeTruthy();
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:GitHubImportTranslate 2026-07-17-12:50:
|
||||
FN-8230 verifies the render surface, not only the hook: page 2 row #55 must receive the
|
||||
accumulated page-scoped translation and expose its data-translated contract.
|
||||
*/
|
||||
it("renders translated titles on page 2 beyond the former 50-item cap", async () => {
|
||||
const manyIssues = Array.from({ length: 60 }, (_, i) => ({
|
||||
number: i + 1, title: `Foreign ${i + 1}`, body: `Foreign body ${i + 1}`,
|
||||
html_url: `https://github.com/owner/repo/issues/${i + 1}`, labels: [],
|
||||
}));
|
||||
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
|
||||
vi.mocked(fetchSettings).mockResolvedValue({ gitlabEnabled: true, githubImportAutoTranslate: true } as never);
|
||||
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce(manyIssues);
|
||||
|
||||
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
|
||||
await screen.findByText("Translated 1");
|
||||
fireEvent.click(screen.getByRole("button", { name: /Next/i }));
|
||||
|
||||
await waitFor(() => expect(screen.getByText("Translated 55")).toBeTruthy());
|
||||
const row = screen.getByText("Translated 55").closest(".issue-item") as HTMLElement;
|
||||
expect(row.querySelector('[data-translated="true"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows no page controls when the issue list fits on one page", async () => {
|
||||
const issues = [
|
||||
{ number: 1, title: "Only Issue", body: "Body", html_url: "https://github.com/owner/repo/issues/1", labels: [] },
|
||||
|
||||
@@ -6,6 +6,9 @@ import {
|
||||
MAX_TRANSLATE_TEXT_LENGTH,
|
||||
MIN_TRANSLATE_TEXT_LENGTH,
|
||||
checkRateLimit,
|
||||
checkTranslateRateLimit,
|
||||
resetTranslateRateLimits,
|
||||
MAX_TRANSLATE_REQUESTS_PER_HOUR,
|
||||
ValidationError,
|
||||
AiServiceError,
|
||||
} from "../ai-translate.js";
|
||||
@@ -36,6 +39,7 @@ describe("ai-translate module", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
__resetRefineState();
|
||||
resetTranslateRateLimits();
|
||||
vi.clearAllMocks();
|
||||
mockCreateFnAgent.mockResolvedValue(null);
|
||||
mockResolveMcpServersForStore.mockResolvedValue({ servers: [], errors: [] });
|
||||
@@ -132,6 +136,22 @@ describe("ai-translate module", () => {
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:GitHubImportTranslate 2026-07-17-12:50:
|
||||
FN-8230 reserves cost only for uncached model calls, but the ceiling must allow every one of
|
||||
the 300 issues a user can traverse from the import fetch cap, while still rejecting overflow.
|
||||
*/
|
||||
describe("translate-only rate budget", () => {
|
||||
it("allows one complete 300-item traversal and rejects the next uncached call", () => {
|
||||
const ip = "10.0.0.2";
|
||||
for (let page = 0; page < 10; page++) {
|
||||
expect(checkTranslateRateLimit(ip, 30)).toBe(true);
|
||||
}
|
||||
expect(MAX_TRANSLATE_REQUESTS_PER_HOUR).toBe(300);
|
||||
expect(checkTranslateRateLimit(ip, 1)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("translateText", () => {
|
||||
it("returns translated fields from the AI agent", async () => {
|
||||
mockAgentWithAssistantText(JSON.stringify({ title: "Hello", body: "This is a test" }));
|
||||
|
||||
@@ -35,8 +35,8 @@ 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.
|
||||
Auto-translate now fans out one client-side page at a time (at most 30 items), so the translate-only budget must fit one complete 300-item fetched-list traversal without starving refine/goal-draft.
|
||||
The budget stays bounded (not removed) because each uncached request still spends real model tokens; durable cache hits make repeat page views free.
|
||||
*/
|
||||
|
||||
/** Max issues auto-translated per panel load. Beyond this, remaining issues
|
||||
@@ -44,7 +44,7 @@ The budget stays bounded (not removed) because each request still spends real mo
|
||||
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;
|
||||
export const MAX_TRANSLATE_REQUESTS_PER_HOUR = 300;
|
||||
|
||||
interface TranslateRateLimitEntry {
|
||||
count: number;
|
||||
|
||||
Binary file not shown.
Reference in New Issue
Block a user