feat(FN-4748): complete Step 4 — add startup github tracking reconciliation
Fusion-Task-Id: FN-4748 Fusion-Task-Lineage: b540f985-c20f-438e-8290-a07531c6612c
This commit is contained in:
committed by
gsxdsm
parent
e9933cfa94
commit
2a802ab4c8
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it, vi, type Mock } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { TaskStore } from "@fusion/core";
|
||||
import { GitHubTrackingReconciler } from "../github-tracking-reconciler.js";
|
||||
import { GitHubTrackingReconciler, RECONCILE_CONCURRENCY_LIMIT } from "../github-tracking-reconciler.js";
|
||||
|
||||
const { mockGetIssue, mockSetIssueState } = vi.hoisted(() => ({
|
||||
mockGetIssue: vi.fn(),
|
||||
@@ -32,23 +32,82 @@ function createStore(tasks: Array<Record<string, unknown>>): TaskStore {
|
||||
}
|
||||
|
||||
describe("GitHubTrackingReconciler", () => {
|
||||
it("closes already-done tasks whose linked issue is still open", async () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
it("closes open issues for done tracked tasks", async () => {
|
||||
mockResolveGithubTrackingAuth.mockReturnValue({ ok: true, auth: { mode: "token", token: "ghp_test" } });
|
||||
mockGetIssue.mockResolvedValue({ state: "open" });
|
||||
const store = createStore([{ id: "FN-1", status: "done", githubTracking: { enabled: true, issue: { owner: "o", repo: "r", number: 1 } } }]);
|
||||
|
||||
const result = await new GitHubTrackingReconciler().reconcile(store);
|
||||
|
||||
expect(mockSetIssueState).toHaveBeenCalledWith("o", "r", 1, "closed", "completed");
|
||||
expect(result.closed).toBe(1);
|
||||
});
|
||||
|
||||
it("skips closed issues and invalid tracking tasks", async () => {
|
||||
mockResolveGithubTrackingAuth.mockReturnValue({ ok: true, auth: { mode: "token", token: "ghp_test" } });
|
||||
mockGetIssue.mockResolvedValue({ state: "closed" });
|
||||
const store = createStore([
|
||||
{
|
||||
id: "FN-1",
|
||||
status: "done",
|
||||
githubTracking: {
|
||||
enabled: true,
|
||||
issue: { owner: "owner", repo: "repo", number: 42 },
|
||||
},
|
||||
},
|
||||
{ id: "FN-1", status: "done", githubTracking: { enabled: true, issue: { owner: "o", repo: "r", number: 1 } } },
|
||||
{ id: "FN-2", status: "done", githubTracking: { enabled: false, issue: { owner: "o", repo: "r", number: 2 } } },
|
||||
{ id: "FN-3", status: "done", githubTracking: { enabled: true, issue: { owner: "o", repo: "", number: 3 } } },
|
||||
{ id: "FN-4", status: "todo", githubTracking: { enabled: true, issue: { owner: "o", repo: "r", number: 4 } } },
|
||||
]);
|
||||
|
||||
const reconciler = new GitHubTrackingReconciler();
|
||||
await reconciler.reconcile(store);
|
||||
const result = await new GitHubTrackingReconciler().reconcile(store);
|
||||
|
||||
expect(mockSetIssueState).toHaveBeenCalledWith("owner", "repo", 42, "closed", "completed");
|
||||
expect(result.closed).toBe(0);
|
||||
expect(result.skipped).toBe(3);
|
||||
expect(mockSetIssueState).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("logs and continues on per-issue errors", async () => {
|
||||
mockResolveGithubTrackingAuth.mockReturnValue({ ok: true, auth: { mode: "token", token: "ghp_test" } });
|
||||
mockGetIssue.mockRejectedValueOnce(new Error("boom"));
|
||||
mockGetIssue.mockResolvedValueOnce({ state: "open" });
|
||||
const store = createStore([
|
||||
{ id: "FN-1", status: "done", githubTracking: { enabled: true, issue: { owner: "o", repo: "r", number: 1 } } },
|
||||
{ id: "FN-2", status: "done", githubTracking: { enabled: true, issue: { owner: "o", repo: "r", number: 2 } } },
|
||||
]);
|
||||
|
||||
const result = await new GitHubTrackingReconciler().reconcile(store);
|
||||
|
||||
expect(result.errors).toBe(1);
|
||||
expect(result.closed).toBe(1);
|
||||
expect((store.logEntry as any)).toHaveBeenCalledWith("FN-1", "Failed to reconcile GitHub tracking issue", "boom");
|
||||
});
|
||||
|
||||
it("skips and logs when auth is unavailable", async () => {
|
||||
mockResolveGithubTrackingAuth.mockReturnValue({ ok: false, message: "no auth" });
|
||||
const store = createStore([{ id: "FN-1", status: "done", githubTracking: { enabled: true, issue: { owner: "o", repo: "r", number: 1 } } }]);
|
||||
|
||||
const result = await new GitHubTrackingReconciler().reconcile(store);
|
||||
|
||||
expect(result.skipped).toBe(1);
|
||||
expect((store.logEntry as any)).toHaveBeenCalledWith("FN-1", "Skipped GitHub tracking issue reconciliation", "no auth");
|
||||
});
|
||||
|
||||
it("respects concurrency cap", async () => {
|
||||
mockResolveGithubTrackingAuth.mockReturnValue({ ok: true, auth: { mode: "token", token: "ghp_test" } });
|
||||
let inFlight = 0;
|
||||
let maxInFlight = 0;
|
||||
mockGetIssue.mockImplementation(async () => {
|
||||
inFlight += 1;
|
||||
maxInFlight = Math.max(maxInFlight, inFlight);
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
inFlight -= 1;
|
||||
return { state: "closed" };
|
||||
});
|
||||
|
||||
const tasks = Array.from({ length: 10 }, (_, i) => ({
|
||||
id: `FN-${i + 1}`,
|
||||
status: "done",
|
||||
githubTracking: { enabled: true, issue: { owner: "o", repo: "r", number: i + 1 } },
|
||||
}));
|
||||
|
||||
await new GitHubTrackingReconciler().reconcile(createStore(tasks));
|
||||
expect(maxInFlight).toBeLessThanOrEqual(RECONCILE_CONCURRENCY_LIMIT);
|
||||
});
|
||||
});
|
||||
|
||||
76
packages/dashboard/src/github-tracking-reconciler.ts
Normal file
76
packages/dashboard/src/github-tracking-reconciler.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import type { GlobalSettings, ProjectSettings, Task, TaskStore } from "@fusion/core";
|
||||
import { resolveGithubTrackingAuth } from "./github-auth.js";
|
||||
import { GitHubClient } from "./github.js";
|
||||
|
||||
const RECONCILE_SCAN_LIMIT = 200;
|
||||
const RECONCILE_CONCURRENCY_LIMIT = 4;
|
||||
|
||||
export class GitHubTrackingReconciler {
|
||||
async reconcile(store: TaskStore): Promise<{ scanned: number; closed: number; skipped: number; errors: number }> {
|
||||
const tasks = (await store.listTasks({ slim: true, includeArchived: false }))
|
||||
.filter((task) => task.status === "done")
|
||||
.slice(0, RECONCILE_SCAN_LIMIT);
|
||||
|
||||
const projectSettings = await store.getSettings() as Pick<ProjectSettings, "githubAuthMode" | "githubAuthToken">;
|
||||
const globalSettings = (await store.getGlobalSettingsStore?.()?.getSettings?.() ?? {}) as Pick<GlobalSettings, never>;
|
||||
const resolution = resolveGithubTrackingAuth({ projectSettings, globalSettings });
|
||||
if (!resolution.ok) {
|
||||
for (const task of tasks) {
|
||||
await store.logEntry(task.id, "Skipped GitHub tracking issue reconciliation", resolution.message);
|
||||
}
|
||||
return { scanned: tasks.length, closed: 0, skipped: tasks.length, errors: 0 };
|
||||
}
|
||||
|
||||
const client = resolution.auth.mode === "token"
|
||||
? new GitHubClient({ token: resolution.auth.token, forceMode: "token" })
|
||||
: new GitHubClient({ forceMode: "gh-cli" });
|
||||
|
||||
let closed = 0;
|
||||
let skipped = 0;
|
||||
let errors = 0;
|
||||
|
||||
await runWithConcurrencyLimit(tasks, RECONCILE_CONCURRENCY_LIMIT, async (task) => {
|
||||
const issue = task.githubTracking?.issue;
|
||||
if (task.githubTracking?.enabled !== true || !issue?.owner || !issue.repo || !issue.number) {
|
||||
skipped += 1;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const linkedIssue = await client.getIssue(issue.owner, issue.repo, issue.number);
|
||||
if (!linkedIssue || linkedIssue.state === "closed") {
|
||||
skipped += 1;
|
||||
return;
|
||||
}
|
||||
|
||||
await client.setIssueState(issue.owner, issue.repo, issue.number, "closed", "completed");
|
||||
closed += 1;
|
||||
} catch (error) {
|
||||
errors += 1;
|
||||
await store.logEntry(
|
||||
task.id,
|
||||
"Failed to reconcile GitHub tracking issue",
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return { scanned: tasks.length, closed, skipped, errors };
|
||||
}
|
||||
}
|
||||
|
||||
async function runWithConcurrencyLimit<T>(items: T[], limit: number, worker: (item: T) => Promise<void>): Promise<void> {
|
||||
const queue = [...items];
|
||||
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
|
||||
while (queue.length > 0) {
|
||||
const item = queue.shift();
|
||||
if (item !== undefined) {
|
||||
await worker(item);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(workers);
|
||||
}
|
||||
|
||||
export { RECONCILE_CONCURRENCY_LIMIT, RECONCILE_SCAN_LIMIT };
|
||||
@@ -25,6 +25,7 @@ export { GitHubPollingService, type GitHubPollingServiceOptions, type TaskWatchI
|
||||
export { GitHubIssueCommentService, DEFAULT_COMMENT_TEMPLATE } from "./github-issue-comment.js";
|
||||
export { GitHubTrackingCommentService, formatTrackingComment } from "./github-tracking-comments.js";
|
||||
export { GitHubTrackingStateService, decideIssueAction } from "./github-tracking-state.js";
|
||||
export { GitHubTrackingReconciler, RECONCILE_CONCURRENCY_LIMIT, RECONCILE_SCAN_LIMIT } from "./github-tracking-reconciler.js";
|
||||
export { getCliPackageVersion, resolveCliPackageVersionInfo, type CliPackageVersionInfo } from "./cli-package-version.js";
|
||||
export {
|
||||
ApiError,
|
||||
|
||||
@@ -23,6 +23,7 @@ import { GitHubClient, parseBadgeUrl } from "../github.js";
|
||||
import { GitHubIssueCommentService } from "../github-issue-comment.js";
|
||||
import { GitHubTrackingCommentService } from "../github-tracking-comments.js";
|
||||
import { GitHubTrackingStateService } from "../github-tracking-state.js";
|
||||
import { GitHubTrackingReconciler } from "../github-tracking-reconciler.js";
|
||||
import { githubRateLimiter } from "../github-poll.js";
|
||||
import { listRegisteredProjectStores, onProjectStoreRegistered } from "../project-store-resolver.js";
|
||||
import {
|
||||
@@ -1126,6 +1127,8 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
|
||||
ctx.registerDispose(() => githubTrackingCommentService.stop());
|
||||
|
||||
const githubTrackingStateService = new GitHubTrackingStateService(store);
|
||||
const githubTrackingReconciler = new GitHubTrackingReconciler();
|
||||
const reconcileScheduledStores = new WeakSet<TaskStore>();
|
||||
githubTrackingStateService.start();
|
||||
|
||||
const attachedStateStores = new Set<TaskStore>();
|
||||
@@ -1135,6 +1138,13 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
|
||||
}
|
||||
attachedStateStores.add(projectStore);
|
||||
githubTrackingStateService.attach(projectStore);
|
||||
|
||||
if (!reconcileScheduledStores.has(projectStore)) {
|
||||
reconcileScheduledStores.add(projectStore);
|
||||
setImmediate(() => {
|
||||
void githubTrackingReconciler.reconcile(projectStore);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
attachStateStore(store);
|
||||
|
||||
Reference in New Issue
Block a user