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:
committed by
gsxdsm
parent
51c60fe1b5
commit
ad1caa0445
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user