feat(FN-4735): complete Step 1 — backend recent issues endpoint

Fusion-Task-Id: FN-4735
Fusion-Task-Lineage: e171d3ca-d415-4f4d-8ec0-d94ca97f1aab
This commit is contained in:
Fusion (runfusion.ai)
2026-05-16 08:49:32 -07:00
committed by gsxdsm
parent 51c60fe1b5
commit ad1caa0445
4 changed files with 210 additions and 11 deletions

View File

@@ -867,6 +867,8 @@ describe("GitHubClient", () => {
body: "Body 1",
url: "https://github.com/owner/repo/issues/1",
labels: [{ name: "bug" }],
state: "OPEN" as const,
updatedAt: "2026-05-16T08:00:00Z",
},
{
number: 2,
@@ -874,6 +876,8 @@ describe("GitHubClient", () => {
body: "Body 2",
url: "https://github.com/owner/repo/issues/2",
labels: [{ name: "feature" }],
state: "CLOSED" as const,
updatedAt: "2026-05-15T08:00:00Z",
},
];
@@ -887,10 +891,12 @@ describe("GitHubClient", () => {
"--repo", "owner/repo",
"--state", "open",
"--limit", "30",
"--json", "number,title,body,url,labels",
"--json", "number,title,body,url,labels,state,updatedAt",
]);
expect(result).toHaveLength(2);
expect(result[0].number).toBe(1);
expect(result[0].state).toBe("open");
expect(result[0].updatedAt).toBe("2026-05-16T08:00:00Z");
});
it("respects limit parameter", async () => {
@@ -912,6 +918,16 @@ describe("GitHubClient", () => {
expect(result[0].number).toBe(1);
});
it("supports explicit all-state issue listings", async () => {
mockRunGhJsonAsync.mockResolvedValue(mockIssues);
await client.listIssues("owner", "repo", { state: "all" });
expect(mockRunGhJsonAsync).toHaveBeenCalledWith(
expect.arrayContaining(["--state", "all"]),
);
});
it("falls back to REST API when gh CLI fails and token is available", async () => {
mockRunGhJsonAsync.mockRejectedValue(new Error("gh failed"));
@@ -926,6 +942,18 @@ describe("GitHubClient", () => {
body: "API body",
html_url: "https://github.com/owner/repo/issues/1",
labels: [{ name: "api" }],
state: "open",
updated_at: "2026-05-16T10:00:00Z",
},
{
number: 3,
title: "API Pull Request",
body: "PR body",
html_url: "https://github.com/owner/repo/issues/3",
labels: [{ name: "api" }],
state: "open",
updated_at: "2026-05-16T10:00:00Z",
pull_request: {},
},
]),
});
@@ -935,6 +963,8 @@ describe("GitHubClient", () => {
expect(mockFetch).toHaveBeenCalled();
expect(result).toHaveLength(1);
expect(result[0].state).toBe("open");
expect(result[0].updatedAt).toBe("2026-05-16T10:00:00Z");
vi.restoreAllMocks();
});

View File

@@ -361,6 +361,77 @@ beforeEach(async () => {
});
describe("GET /github/issues/recent", () => {
let store: TaskStore;
let listIssuesSpy: ReturnType<typeof vi.fn>;
beforeEach(() => {
store = createMockStore();
mockIsGhAuthenticated.mockReturnValue(true);
listIssuesSpy = vi.fn();
vi.spyOn(GitHubClient.prototype, "listIssues").mockImplementation(listIssuesSpy);
});
afterEach(() => {
vi.restoreAllMocks();
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
return app;
}
it("returns filtered recent issues and caches responses", async () => {
const gitRepo = getSharedGitTestRepo();
execFileSync("git", ["-C", gitRepo.repoDir, "remote", "set-url", "origin", "https://github.com/owner/repo.git"], { stdio: "pipe" });
store.getRootDir = vi.fn().mockReturnValue(gitRepo.repoDir);
listIssuesSpy.mockResolvedValue([
{ number: 42, title: "Feature polish", body: null, html_url: "https://github.com/owner/repo/issues/42", labels: [], state: "open", updatedAt: "2026-05-16T00:00:00Z" },
{ number: 12, title: "Fix tests", body: null, html_url: "https://github.com/owner/repo/issues/12", labels: [], state: "closed", updatedAt: "2026-05-15T00:00:00Z" },
{ number: 99, title: "Draft PR", body: null, html_url: "https://github.com/owner/repo/pull/99", labels: [], state: "open", updatedAt: "2026-05-14T00:00:00Z" },
]);
const first = await REQUEST(buildApp(), "GET", "/api/github/issues/recent?q=feat&limit=20");
const second = await REQUEST(buildApp(), "GET", "/api/github/issues/recent?q=42&limit=20");
expect(first.status).toBe(200);
expect(first.body).toHaveLength(1);
expect(first.body[0]).toMatchObject({ number: 42, repository: "owner/repo", state: "open" });
expect(second.status).toBe(200);
expect(second.body).toHaveLength(1);
expect(second.body[0].number).toBe(42);
const all = await REQUEST(buildApp(), "GET", "/api/github/issues/recent");
expect(all.body.some((item: { number: number }) => item.number === 99)).toBe(false);
expect(listIssuesSpy).toHaveBeenCalledTimes(1);
});
it("returns empty list when no remote exists", async () => {
const res = await REQUEST(buildApp(), "GET", "/api/github/issues/recent");
expect(res.status).toBe(200);
expect(res.body).toEqual([]);
expect(listIssuesSpy).not.toHaveBeenCalled();
});
it("returns empty list when not authenticated", async () => {
const gitRepo = getSharedGitTestRepo();
execFileSync("git", ["-C", gitRepo.repoDir, "remote", "set-url", "origin", "https://github.com/owner/repo.git"], { stdio: "pipe" });
store.getRootDir = vi.fn().mockReturnValue(gitRepo.repoDir);
mockIsGhAuthenticated.mockReturnValueOnce(false);
const res = await REQUEST(buildApp(), "GET", "/api/github/issues/recent");
expect(res.status).toBe(200);
expect(res.body).toEqual([]);
expect(listIssuesSpy).not.toHaveBeenCalled();
});
});
describe("POST /github/issues/fetch", () => {
let store: TaskStore;
let listIssuesSpy: ReturnType<typeof vi.fn>;

View File

@@ -2062,19 +2062,21 @@ export class GitHubClient {
}
/**
* List open issues from a repository.
* List issues from a repository.
* Uses gh CLI if available, otherwise falls back to REST API.
*/
async listIssues(
owner: string,
repo: string,
options?: { limit?: number; labels?: string[] }
options?: { limit?: number; labels?: string[]; state?: "open" | "all" }
): Promise<Array<{
number: number;
title: string;
body: string | null;
html_url: string;
labels: Array<{ name: string }>;
state?: "open" | "closed";
updatedAt?: string;
}>> {
if (this.hasGhAuth()) {
try {
@@ -2096,16 +2098,19 @@ export class GitHubClient {
private async listIssuesWithGh(
owner: string,
repo: string,
options?: { limit?: number; labels?: string[] }
options?: { limit?: number; labels?: string[]; state?: "open" | "all" }
): Promise<Array<{
number: number;
title: string;
body: string | null;
html_url: string;
labels: Array<{ name: string }>;
state?: "open" | "closed";
updatedAt?: string;
}>> {
const limit = options?.limit ?? 30;
const state = options?.state ?? "open";
// gh issue list doesn't support label filtering directly, so we fetch and filter client-side
const issues = await runGhJsonAsync<Array<{
number: number;
@@ -2113,12 +2118,14 @@ export class GitHubClient {
body: string;
url: string;
labels: Array<{ name: string }>;
state: "OPEN" | "CLOSED";
updatedAt: string;
}>>([
"issue", "list",
"--repo", `${owner}/${repo}`,
"--state", "open",
"--state", state,
"--limit", String(Math.min(limit, 100)),
"--json", "number,title,body,url,labels",
"--json", "number,title,body,url,labels,state,updatedAt",
]);
let result = issues.map((issue) => ({
@@ -2127,6 +2134,8 @@ export class GitHubClient {
body: issue.body,
html_url: issue.url,
labels: issue.labels,
state: this.mapGhIssueState(issue.state),
updatedAt: issue.updatedAt,
}));
// Filter by labels if specified (client-side filtering)
@@ -2144,18 +2153,21 @@ export class GitHubClient {
private async listIssuesWithApi(
owner: string,
repo: string,
options?: { limit?: number; labels?: string[] }
options?: { limit?: number; labels?: string[]; state?: "open" | "all" }
): Promise<Array<{
number: number;
title: string;
body: string | null;
html_url: string;
labels: Array<{ name: string }>;
state?: "open" | "closed";
updatedAt?: string;
}>> {
const limit = options?.limit ?? 30;
const state = options?.state ?? "open";
const params = new URLSearchParams();
params.append("state", "open");
params.append("state", state);
params.append("per_page", String(Math.min(limit, 100)));
if (options?.labels && options.labels.length > 0) {
params.append("labels", options.labels.join(","));
@@ -2179,11 +2191,24 @@ export class GitHubClient {
body: string | null;
html_url: string;
labels: Array<{ name: string }>;
state: string;
updated_at: string;
pull_request?: unknown;
}>;
// Filter out pull requests (they have a pull_request property)
return data.filter((issue) => !issue.pull_request).slice(0, limit);
return data
.filter((issue) => !issue.pull_request)
.map((issue) => ({
number: issue.number,
title: issue.title,
body: issue.body,
html_url: issue.html_url,
labels: issue.labels,
state: this.mapIssueState(issue.state),
updatedAt: issue.updated_at,
}))
.slice(0, limit);
}
/**

View File

@@ -117,6 +117,19 @@ export async function getGitHubRemotes(cwd?: string): Promise<GitRemote[]> {
}
}
const RECENT_ISSUES_CACHE_TTL_MS = 60_000;
// Intentionally module-scoped and TTL-only. We do not proactively invalidate on remote
// changes because the 60s window is short and keeps per-keystroke chat lookups cheap.
const recentIssuesCache = new Map<string, { fetchedAt: number; items: Array<{
number: number;
title: string;
state: "open" | "closed";
htmlUrl: string;
repository: string;
updatedAt?: string;
}> }>();
export async function isGitRepo(cwd?: string): Promise<boolean> {
try {
await runGitCommand(["rev-parse", "--git-dir"], cwd, 5000);
@@ -2042,6 +2055,66 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
// ── GitHub Import Routes ──────────────────────────────────────────
/**
* GET /api/github/issues/recent
* Returns recent issues for the first GitHub remote (prefer origin when present).
*/
router.get("/github/issues/recent", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const rootDir = scopedStore.getRootDir();
const remotes = await getGitHubRemotes(rootDir);
const remote = remotes.find((item) => item.name === "origin") ?? remotes[0];
if (!remote || !isGhAuthenticated()) {
res.json([]);
return;
}
const rawLimit = Number.parseInt(String(req.query.limit ?? "20"), 10);
const limit = Number.isFinite(rawLimit) ? Math.max(1, Math.min(rawLimit, 100)) : 20;
const q = typeof req.query.q === "string" ? req.query.q.trim().toLowerCase() : "";
const cacheKey = `${remote.owner}/${remote.repo}`;
const now = Date.now();
const cached = recentIssuesCache.get(cacheKey);
let items = cached?.items;
if (!cached || now - cached.fetchedAt > RECENT_ISSUES_CACHE_TTL_MS) {
const client = new GitHubClient(githubToken);
try {
const issues = await client.listIssues(remote.owner, remote.repo, { limit: 100, state: "all" });
items = issues
.filter((issue) => issue.html_url.includes("/issues/"))
.map((issue) => ({
number: issue.number,
title: issue.title,
state: issue.state ?? "open",
htmlUrl: issue.html_url,
repository: cacheKey,
updatedAt: issue.updatedAt,
}));
recentIssuesCache.set(cacheKey, { fetchedAt: now, items });
} catch {
res.json([]);
return;
}
}
const filtered = (items ?? []).filter((issue) => {
if (!q) return true;
return String(issue.number).startsWith(q) || issue.title.toLowerCase().includes(q);
});
res.json(filtered.slice(0, limit));
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err);
}
});
/**
* POST /api/github/issues/fetch
* Fetch open issues from a GitHub repository.