feat(FN-1236): migrate GitHub PR workflows to gh authentication
- Require gh-authenticated paths for task pr-create and dashboard PR routes - Switch dashboard command and extension GitHub tools to use the gh auth client instead of token-based auth - Update CLI and dashboard tests to cover gh-only createPr and import behavior - Refresh README guidance and add a changeset removing the GitHub token requirement for PR flows
This commit is contained in:
@@ -58,7 +58,7 @@ When execution finishes and the reviewer signs off, the task moves to "in review
|
||||
|
||||
`autoMerge` still controls whether Fusion performs completion automatically at all. If `autoMerge` is disabled, tasks stay in **In Review** until you finish the merge yourself.
|
||||
|
||||
For PR-first mode, authenticate GitHub with `gh auth login` or `GITHUB_TOKEN`, and make sure the task branch already exists on GitHub as `kb/<task-id-lower>`. Fusion does **not** push branches for you before PR creation.
|
||||
For PR-first mode, authenticate GitHub with `gh auth login`, and make sure the task branch already exists on GitHub as `kb/<task-id-lower>`. Fusion does **not** push branches for you before PR creation.
|
||||
|
||||
Worktrees can be cleaned up after merge or reused by the next task to keep build caches warm.
|
||||
|
||||
|
||||
@@ -3,8 +3,16 @@ import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
|
||||
vi.mock("@fusion/core/gh-cli", () => ({
|
||||
isGhAvailable: vi.fn(() => true),
|
||||
isGhAuthenticated: vi.fn(() => true),
|
||||
runGhJsonAsync: vi.fn(),
|
||||
getGhErrorMessage: vi.fn((error: unknown) => (error instanceof Error ? error.message : String(error))),
|
||||
}));
|
||||
|
||||
import kbExtension from "../extension.js";
|
||||
import { TaskStore } from "@fusion/core";
|
||||
import { isGhAvailable, isGhAuthenticated, runGhJsonAsync } from "@fusion/core/gh-cli";
|
||||
|
||||
// ── Mock ExtensionAPI that captures registrations ──────────────────
|
||||
|
||||
@@ -62,6 +70,10 @@ describe("fn pi extension", () => {
|
||||
let api: ReturnType<typeof createMockAPI>;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.mocked(isGhAvailable).mockReturnValue(true);
|
||||
vi.mocked(isGhAuthenticated).mockReturnValue(true);
|
||||
vi.mocked(runGhJsonAsync).mockReset();
|
||||
|
||||
tmpDir = await mkdtemp(join(tmpdir(), "kb-ext-test-"));
|
||||
api = createMockAPI();
|
||||
kbExtension(api);
|
||||
@@ -961,4 +973,76 @@ describe("fn pi extension", () => {
|
||||
expect(linkedTask.sliceId).toBe(slice.details.sliceId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GitHub import tools", () => {
|
||||
it("fn_task_import_github requires gh auth", async () => {
|
||||
const tool = api.tools.get("fn_task_import_github")!;
|
||||
vi.mocked(isGhAvailable).mockReturnValue(false);
|
||||
|
||||
await expect(
|
||||
tool.execute("gh-1", { ownerRepo: "acme/demo" }, undefined, undefined, makeCtx(tmpDir)),
|
||||
).rejects.toThrow("GitHub CLI (gh) is not available or not authenticated. Run 'gh auth login'.");
|
||||
});
|
||||
|
||||
it("fn_task_import_github imports issues via gh api", async () => {
|
||||
const tool = api.tools.get("fn_task_import_github")!;
|
||||
vi.mocked(runGhJsonAsync).mockResolvedValueOnce([
|
||||
{
|
||||
number: 1,
|
||||
title: "Issue one",
|
||||
body: "First issue body",
|
||||
html_url: "https://github.com/acme/demo/issues/1",
|
||||
},
|
||||
{
|
||||
number: 2,
|
||||
title: "Issue two",
|
||||
body: "Second issue body",
|
||||
html_url: "https://github.com/acme/demo/issues/2",
|
||||
},
|
||||
] as never);
|
||||
|
||||
const result = await tool.execute(
|
||||
"gh-2",
|
||||
{ ownerRepo: "acme/demo", limit: 5 },
|
||||
undefined,
|
||||
undefined,
|
||||
makeCtx(tmpDir),
|
||||
);
|
||||
|
||||
expect(result.content[0].text).toContain("Imported 2 tasks from acme/demo");
|
||||
expect(result.details.createdTasks).toHaveLength(2);
|
||||
expect(vi.mocked(runGhJsonAsync)).toHaveBeenCalledWith([
|
||||
"api",
|
||||
"repos/acme/demo/issues?state=open&per_page=5",
|
||||
]);
|
||||
});
|
||||
|
||||
it("fn_task_browse_github_issues lists issues via gh api", async () => {
|
||||
const tool = api.tools.get("fn_task_browse_github_issues")!;
|
||||
vi.mocked(runGhJsonAsync).mockResolvedValueOnce([
|
||||
{
|
||||
number: 10,
|
||||
title: "Investigate latency",
|
||||
body: null,
|
||||
html_url: "https://github.com/acme/demo/issues/10",
|
||||
labels: [{ name: "perf" }],
|
||||
},
|
||||
] as never);
|
||||
|
||||
const result = await tool.execute(
|
||||
"gh-3",
|
||||
{ owner: "acme", repo: "demo", limit: 10 },
|
||||
undefined,
|
||||
undefined,
|
||||
makeCtx(tmpDir),
|
||||
);
|
||||
|
||||
expect(result.content[0].text).toContain("Found 1 open issues in acme/demo");
|
||||
expect(result.details.issues[0]).toMatchObject({ number: 10, labels: ["perf"] });
|
||||
expect(vi.mocked(runGhJsonAsync)).toHaveBeenCalledWith([
|
||||
"api",
|
||||
"repos/acme/demo/issues?state=open&per_page=10",
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -395,7 +395,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
// pause is deduplicated across concurrent agents.
|
||||
//
|
||||
const usageLimitPauser = new UsageLimitPauser(store);
|
||||
const githubClient = new GitHubClient(process.env.GITHUB_TOKEN);
|
||||
const githubClient = new GitHubClient();
|
||||
|
||||
// AI-powered merge handler (used by the web UI for manual merges).
|
||||
// Wrapped with the shared semaphore so merges count toward the global
|
||||
|
||||
@@ -65,6 +65,8 @@ vi.mock("@fusion/core/gh-cli", () => ({
|
||||
isGhAvailable: vi.fn(),
|
||||
isGhAuthenticated: vi.fn(),
|
||||
getCurrentRepo: vi.fn(),
|
||||
runGhJsonAsync: vi.fn(),
|
||||
getGhErrorMessage: vi.fn((error: unknown) => (error instanceof Error ? error.message : String(error))),
|
||||
}));
|
||||
|
||||
// Mock project-context
|
||||
@@ -79,7 +81,12 @@ import { createInterface } from "node:readline/promises";
|
||||
import { TaskStore } from "@fusion/core";
|
||||
import { watchFile, unwatchFile, statSync, existsSync, readFileSync } from "node:fs";
|
||||
import { runTaskShow, runTaskCreate, runTaskList, runTaskDuplicate, runTaskRefine, runTaskDelete, runTaskRetry, runTaskLogs, runTaskComment, runTaskComments, runTaskPrCreate, runTaskPlan, runTaskMove, runTaskAttach, runTaskPause, runTaskUnpause, runTaskArchive, runTaskUnarchive, runTaskSteer, runTaskImportFromGitHub, runTaskImportGitHubInteractive, runTaskUpdate, runTaskLog, runTaskMerge, type LogsOptions } from "./task.js";
|
||||
import { isGhAvailable, isGhAuthenticated, getCurrentRepo } from "@fusion/core/gh-cli";
|
||||
import {
|
||||
getCurrentRepo,
|
||||
isGhAuthenticated,
|
||||
isGhAvailable,
|
||||
runGhJsonAsync,
|
||||
} from "@fusion/core/gh-cli";
|
||||
import { GitHubClient } from "@fusion/dashboard";
|
||||
import { createSession, submitResponse } from "@fusion/dashboard/planning";
|
||||
import { resolveProject } from "../project-context.js";
|
||||
@@ -606,6 +613,8 @@ describe("project-aware task command behavior", () => {
|
||||
it("routes GitHub import commands through the resolved project store", async () => {
|
||||
const listTasks = vi.fn().mockResolvedValue([]);
|
||||
const createTask = vi.fn().mockResolvedValue(makeTask({ id: "FN-200" }));
|
||||
vi.mocked(isGhAvailable).mockReturnValue(true);
|
||||
vi.mocked(isGhAuthenticated).mockReturnValue(true);
|
||||
|
||||
vi.mocked(resolveProject).mockResolvedValue({
|
||||
projectId: "proj_test",
|
||||
@@ -615,22 +624,21 @@ describe("project-aware task command behavior", () => {
|
||||
store: { listTasks, createTask } as unknown as TaskStore,
|
||||
});
|
||||
|
||||
const fetchSpy = vi.spyOn(global, "fetch" as any).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ([{ number: 1, title: "Issue 1", body: "Body", html_url: "https://github.com/acme/demo/issues/1", labels: [] }]),
|
||||
} as Response);
|
||||
vi.mocked(runGhJsonAsync).mockResolvedValueOnce([
|
||||
{ number: 1, title: "Issue 1", body: "Body", html_url: "https://github.com/acme/demo/issues/1", labels: [] },
|
||||
] as never);
|
||||
|
||||
await runTaskImportFromGitHub("acme/demo", { limit: 1 }, "demo-project");
|
||||
expect(resolveProject).toHaveBeenCalledWith("demo-project");
|
||||
expect(listTasks).toHaveBeenCalled();
|
||||
|
||||
fetchSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("routes interactive GitHub import through the resolved project store", async () => {
|
||||
const listTasks = vi.fn().mockResolvedValue([]);
|
||||
const createTask = vi.fn().mockResolvedValue(makeTask({ id: "FN-201" }));
|
||||
const mockQuestion = vi.fn().mockResolvedValue("all");
|
||||
vi.mocked(isGhAvailable).mockReturnValue(true);
|
||||
vi.mocked(isGhAuthenticated).mockReturnValue(true);
|
||||
|
||||
vi.mocked(createInterface).mockReturnValue({
|
||||
question: mockQuestion,
|
||||
@@ -645,18 +653,15 @@ describe("project-aware task command behavior", () => {
|
||||
store: { listTasks, createTask } as unknown as TaskStore,
|
||||
});
|
||||
|
||||
const fetchSpy = vi.spyOn(global, "fetch" as any).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ([{ number: 2, title: "Issue 2", body: "Body", html_url: "https://github.com/acme/demo/issues/2", labels: [] }]),
|
||||
} as Response);
|
||||
vi.mocked(runGhJsonAsync).mockResolvedValueOnce([
|
||||
{ number: 2, title: "Issue 2", body: "Body", html_url: "https://github.com/acme/demo/issues/2", labels: [] },
|
||||
] as never);
|
||||
|
||||
await runTaskImportGitHubInteractive("acme/demo", { limit: 1 }, "demo-project");
|
||||
|
||||
expect(resolveProject).toHaveBeenCalledWith("demo-project");
|
||||
expect(listTasks).toHaveBeenCalled();
|
||||
expect(createTask).toHaveBeenCalled();
|
||||
|
||||
fetchSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("surfaces project resolution failures from shared context when project flag is explicit", async () => {
|
||||
@@ -850,14 +855,13 @@ describe("runTaskImportGitHubInteractive", () => {
|
||||
let errorSpy: ReturnType<typeof vi.spyOn>;
|
||||
let mockCreateTask: ReturnType<typeof vi.fn>;
|
||||
let mockListTasks: ReturnType<typeof vi.fn>;
|
||||
let fetchSpy: ReturnType<typeof vi.fn>;
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
fetchSpy = vi.fn();
|
||||
globalThis.fetch = fetchSpy as any;
|
||||
vi.mocked(isGhAvailable).mockReturnValue(true);
|
||||
vi.mocked(isGhAuthenticated).mockReturnValue(true);
|
||||
vi.mocked(runGhJsonAsync).mockReset();
|
||||
|
||||
mockCreateTask = vi.fn().mockImplementation((input: { description: string; title?: string }) => ({
|
||||
id: `KB-${String(mockCreateTask.mock.calls.length).padStart(3, "0")}`,
|
||||
@@ -882,7 +886,6 @@ describe("runTaskImportGitHubInteractive", () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
@@ -897,15 +900,11 @@ describe("runTaskImportGitHubInteractive", () => {
|
||||
});
|
||||
|
||||
it("imports selected issues via interactive mode", async () => {
|
||||
fetchSpy.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve([
|
||||
mockIssue(1, "First Issue", "Description 1"),
|
||||
mockIssue(2, "Second Issue", "Description 2"),
|
||||
mockIssue(3, "Third Issue", "Description 3"),
|
||||
]),
|
||||
} as Response);
|
||||
vi.mocked(runGhJsonAsync).mockResolvedValueOnce([
|
||||
mockIssue(1, "First Issue", "Description 1"),
|
||||
mockIssue(2, "Second Issue", "Description 2"),
|
||||
mockIssue(3, "Third Issue", "Description 3"),
|
||||
] as never);
|
||||
|
||||
// Mock readline to select issues 1 and 3
|
||||
const mockReadline = {
|
||||
@@ -932,14 +931,10 @@ describe("runTaskImportGitHubInteractive", () => {
|
||||
});
|
||||
|
||||
it('imports all issues when "all" is selected', async () => {
|
||||
fetchSpy.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve([
|
||||
mockIssue(1, "First Issue", "Description 1"),
|
||||
mockIssue(2, "Second Issue", "Description 2"),
|
||||
]),
|
||||
} as Response);
|
||||
vi.mocked(runGhJsonAsync).mockResolvedValueOnce([
|
||||
mockIssue(1, "First Issue", "Description 1"),
|
||||
mockIssue(2, "Second Issue", "Description 2"),
|
||||
] as never);
|
||||
|
||||
// Mock readline to select "all"
|
||||
const mockReadline = {
|
||||
@@ -963,14 +958,10 @@ describe("runTaskImportGitHubInteractive", () => {
|
||||
},
|
||||
]);
|
||||
|
||||
fetchSpy.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve([
|
||||
mockIssue(1, "First Issue", "Description 1"),
|
||||
mockIssue(2, "Second Issue", "Description 2"),
|
||||
]),
|
||||
} as Response);
|
||||
vi.mocked(runGhJsonAsync).mockResolvedValueOnce([
|
||||
mockIssue(1, "First Issue", "Description 1"),
|
||||
mockIssue(2, "Second Issue", "Description 2"),
|
||||
] as never);
|
||||
|
||||
const mockReadline = {
|
||||
question: vi.fn().mockResolvedValueOnce("all"),
|
||||
@@ -995,11 +986,7 @@ describe("runTaskImportGitHubInteractive", () => {
|
||||
});
|
||||
|
||||
it("handles empty issues list", async () => {
|
||||
fetchSpy.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve([]),
|
||||
} as Response);
|
||||
vi.mocked(runGhJsonAsync).mockResolvedValueOnce([] as never);
|
||||
|
||||
await runTaskImportGitHubInteractive("owner/repo");
|
||||
|
||||
@@ -1021,11 +1008,7 @@ describe("runTaskImportGitHubInteractive", () => {
|
||||
});
|
||||
|
||||
it("handles API errors gracefully", async () => {
|
||||
fetchSpy.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 404,
|
||||
statusText: "Not Found",
|
||||
} as Response);
|
||||
vi.mocked(runGhJsonAsync).mockRejectedValueOnce(new Error("Repository not found"));
|
||||
|
||||
const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => {
|
||||
throw new Error("process.exit");
|
||||
@@ -1037,13 +1020,9 @@ describe("runTaskImportGitHubInteractive", () => {
|
||||
});
|
||||
|
||||
it("re-prompts on invalid input", async () => {
|
||||
fetchSpy.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve([
|
||||
mockIssue(1, "First Issue", "Description 1"),
|
||||
]),
|
||||
} as Response);
|
||||
vi.mocked(runGhJsonAsync).mockResolvedValueOnce([
|
||||
mockIssue(1, "First Issue", "Description 1"),
|
||||
] as never);
|
||||
|
||||
// First invalid input, then valid
|
||||
const mockReadline = {
|
||||
@@ -1061,13 +1040,9 @@ describe("runTaskImportGitHubInteractive", () => {
|
||||
});
|
||||
|
||||
it("re-prompts on out of range selection", async () => {
|
||||
fetchSpy.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve([
|
||||
mockIssue(1, "First Issue", "Description 1"),
|
||||
]),
|
||||
} as Response);
|
||||
vi.mocked(runGhJsonAsync).mockResolvedValueOnce([
|
||||
mockIssue(1, "First Issue", "Description 1"),
|
||||
] as never);
|
||||
|
||||
// First out of range, then valid
|
||||
const mockReadline = {
|
||||
@@ -1089,22 +1064,6 @@ describe("runTaskImportGitHubInteractive", () => {
|
||||
import { fetchGitHubIssues, runTaskImportFromGitHub, type GitHubIssue } from "./task.js";
|
||||
|
||||
describe("fetchGitHubIssues", () => {
|
||||
let fetchSpy: ReturnType<typeof vi.fn>;
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalEnv = process.env.GITHUB_TOKEN;
|
||||
|
||||
beforeEach(() => {
|
||||
fetchSpy = vi.fn();
|
||||
globalThis.fetch = fetchSpy as any;
|
||||
delete process.env.GITHUB_TOKEN;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
process.env.GITHUB_TOKEN = originalEnv;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const mockIssue: GitHubIssue = {
|
||||
number: 1,
|
||||
title: "Test Issue",
|
||||
@@ -1115,36 +1074,28 @@ describe("fetchGitHubIssues", () => {
|
||||
updated_at: "2024-01-02T00:00:00Z",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(isGhAvailable).mockReturnValue(true);
|
||||
vi.mocked(isGhAuthenticated).mockReturnValue(true);
|
||||
vi.mocked(runGhJsonAsync).mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("fetches issues successfully", async () => {
|
||||
fetchSpy.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve([mockIssue]),
|
||||
} as Response);
|
||||
vi.mocked(runGhJsonAsync).mockResolvedValueOnce([mockIssue] as never);
|
||||
|
||||
const issues = await fetchGitHubIssues("owner", "repo");
|
||||
|
||||
expect(issues).toHaveLength(1);
|
||||
expect(issues[0].number).toBe(1);
|
||||
expect(issues[0].title).toBe("Test Issue");
|
||||
expect(fetchSpy).toHaveBeenCalledOnce();
|
||||
const url = fetchSpy.mock.calls[0][0] as string;
|
||||
expect(url).toContain("https://api.github.com/repos/owner/repo/issues");
|
||||
expect(url).toContain("state=open");
|
||||
});
|
||||
|
||||
it("includes Authorization header when GITHUB_TOKEN is set", async () => {
|
||||
process.env.GITHUB_TOKEN = "test-token";
|
||||
fetchSpy.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve([mockIssue]),
|
||||
} as Response);
|
||||
|
||||
await fetchGitHubIssues("owner", "repo");
|
||||
|
||||
const headers = fetchSpy.mock.calls[0][1]?.headers as Record<string, string>;
|
||||
expect(headers.Authorization).toBe("Bearer test-token");
|
||||
expect(runGhJsonAsync).toHaveBeenCalledWith([
|
||||
"api",
|
||||
"repos/owner/repo/issues?state=open&per_page=30",
|
||||
]);
|
||||
});
|
||||
|
||||
it("respects limit option", async () => {
|
||||
@@ -1152,39 +1103,31 @@ describe("fetchGitHubIssues", () => {
|
||||
...mockIssue,
|
||||
number: i + 1,
|
||||
}));
|
||||
fetchSpy.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve(manyIssues),
|
||||
} as Response);
|
||||
vi.mocked(runGhJsonAsync).mockResolvedValueOnce(manyIssues as never);
|
||||
|
||||
const issues = await fetchGitHubIssues("owner", "repo", { limit: 10 });
|
||||
|
||||
expect(issues).toHaveLength(10);
|
||||
const url = fetchSpy.mock.calls[0][0] as string;
|
||||
expect(url).toContain("per_page=10");
|
||||
expect(runGhJsonAsync).toHaveBeenCalledWith([
|
||||
"api",
|
||||
"repos/owner/repo/issues?state=open&per_page=10",
|
||||
]);
|
||||
});
|
||||
|
||||
it("respects labels option", async () => {
|
||||
fetchSpy.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve([mockIssue]),
|
||||
} as Response);
|
||||
vi.mocked(runGhJsonAsync).mockResolvedValueOnce([mockIssue] as never);
|
||||
|
||||
await fetchGitHubIssues("owner", "repo", { labels: ["bug", "enhancement"] });
|
||||
|
||||
const url = fetchSpy.mock.calls[0][0] as string;
|
||||
expect(url).toContain("labels=bug%2Cenhancement");
|
||||
expect(runGhJsonAsync).toHaveBeenCalledWith([
|
||||
"api",
|
||||
"repos/owner/repo/issues?state=open&per_page=30&labels=bug%2Cenhancement",
|
||||
]);
|
||||
});
|
||||
|
||||
it("filters out pull requests", async () => {
|
||||
const pr = { ...mockIssue, pull_request: {} };
|
||||
fetchSpy.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve([mockIssue, pr]),
|
||||
} as Response);
|
||||
const pr = { ...mockIssue, number: 2, pull_request: {} };
|
||||
vi.mocked(runGhJsonAsync).mockResolvedValueOnce([mockIssue, pr] as never);
|
||||
|
||||
const issues = await fetchGitHubIssues("owner", "repo");
|
||||
|
||||
@@ -1192,35 +1135,29 @@ describe("fetchGitHubIssues", () => {
|
||||
expect(issues[0].number).toBe(1);
|
||||
});
|
||||
|
||||
it("throws error for 404", async () => {
|
||||
fetchSpy.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 404,
|
||||
statusText: "Not Found",
|
||||
} as Response);
|
||||
it("throws error when gh CLI is unavailable", async () => {
|
||||
vi.mocked(isGhAvailable).mockReturnValue(false);
|
||||
|
||||
await expect(fetchGitHubIssues("owner", "repo")).rejects.toThrow(
|
||||
"GitHub CLI (gh) is not available or not authenticated. Run 'gh auth login'.",
|
||||
);
|
||||
expect(runGhJsonAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("throws error when gh CLI is not authenticated", async () => {
|
||||
vi.mocked(isGhAuthenticated).mockReturnValue(false);
|
||||
|
||||
await expect(fetchGitHubIssues("owner", "repo")).rejects.toThrow(
|
||||
"GitHub CLI (gh) is not available or not authenticated. Run 'gh auth login'.",
|
||||
);
|
||||
expect(runGhJsonAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("surfaces gh api errors", async () => {
|
||||
vi.mocked(runGhJsonAsync).mockRejectedValueOnce(new Error("Repository not found"));
|
||||
|
||||
await expect(fetchGitHubIssues("owner", "repo")).rejects.toThrow("Repository not found");
|
||||
});
|
||||
|
||||
it("throws error for 401/403", async () => {
|
||||
fetchSpy.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 403,
|
||||
statusText: "Forbidden",
|
||||
} as Response);
|
||||
|
||||
await expect(fetchGitHubIssues("owner", "repo")).rejects.toThrow("Authentication failed");
|
||||
});
|
||||
|
||||
it("throws generic error for other status codes", async () => {
|
||||
fetchSpy.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 500,
|
||||
statusText: "Server Error",
|
||||
} as Response);
|
||||
|
||||
await expect(fetchGitHubIssues("owner", "repo")).rejects.toThrow("GitHub API error: 500");
|
||||
});
|
||||
});
|
||||
|
||||
describe("runTaskImportFromGitHub", () => {
|
||||
@@ -1228,14 +1165,13 @@ describe("runTaskImportFromGitHub", () => {
|
||||
let errorSpy: ReturnType<typeof vi.spyOn>;
|
||||
let mockCreateTask: ReturnType<typeof vi.fn>;
|
||||
let mockListTasks: ReturnType<typeof vi.fn>;
|
||||
let fetchSpy: ReturnType<typeof vi.fn>;
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
fetchSpy = vi.fn();
|
||||
globalThis.fetch = fetchSpy as any;
|
||||
vi.mocked(isGhAvailable).mockReturnValue(true);
|
||||
vi.mocked(isGhAuthenticated).mockReturnValue(true);
|
||||
vi.mocked(runGhJsonAsync).mockReset();
|
||||
|
||||
mockCreateTask = vi.fn().mockImplementation((input: { description: string; title?: string }) => ({
|
||||
id: `KB-${String(mockCreateTask.mock.calls.length).padStart(3, "0")}`,
|
||||
@@ -1260,7 +1196,6 @@ describe("runTaskImportFromGitHub", () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
@@ -1275,14 +1210,10 @@ describe("runTaskImportFromGitHub", () => {
|
||||
});
|
||||
|
||||
it("imports issues and creates tasks", async () => {
|
||||
fetchSpy.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve([
|
||||
mockIssue(1, "First Issue", "Description 1"),
|
||||
mockIssue(2, "Second Issue", "Description 2"),
|
||||
]),
|
||||
} as Response);
|
||||
vi.mocked(runGhJsonAsync).mockResolvedValueOnce([
|
||||
mockIssue(1, "First Issue", "Description 1"),
|
||||
mockIssue(2, "Second Issue", "Description 2"),
|
||||
] as never);
|
||||
|
||||
await runTaskImportFromGitHub("owner/repo");
|
||||
|
||||
@@ -1310,14 +1241,10 @@ describe("runTaskImportFromGitHub", () => {
|
||||
},
|
||||
]);
|
||||
|
||||
fetchSpy.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve([
|
||||
mockIssue(1, "First Issue", "Description 1"),
|
||||
mockIssue(2, "Second Issue", "Description 2"),
|
||||
]),
|
||||
} as Response);
|
||||
vi.mocked(runGhJsonAsync).mockResolvedValueOnce([
|
||||
mockIssue(1, "First Issue", "Description 1"),
|
||||
mockIssue(2, "Second Issue", "Description 2"),
|
||||
] as never);
|
||||
|
||||
await runTaskImportFromGitHub("owner/repo");
|
||||
|
||||
@@ -1329,11 +1256,7 @@ describe("runTaskImportFromGitHub", () => {
|
||||
});
|
||||
|
||||
it("handles empty issues list", async () => {
|
||||
fetchSpy.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve([]),
|
||||
} as Response);
|
||||
vi.mocked(runGhJsonAsync).mockResolvedValueOnce([] as never);
|
||||
|
||||
await runTaskImportFromGitHub("owner/repo");
|
||||
|
||||
@@ -1355,11 +1278,7 @@ describe("runTaskImportFromGitHub", () => {
|
||||
});
|
||||
|
||||
it("handles API errors gracefully", async () => {
|
||||
fetchSpy.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 404,
|
||||
statusText: "Not Found",
|
||||
} as Response);
|
||||
vi.mocked(runGhJsonAsync).mockRejectedValueOnce(new Error("Repository not found"));
|
||||
|
||||
const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => {
|
||||
throw new Error("process.exit");
|
||||
@@ -1370,11 +1289,7 @@ describe("runTaskImportFromGitHub", () => {
|
||||
});
|
||||
|
||||
it("uses (no description) for empty body", async () => {
|
||||
fetchSpy.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve([mockIssue(1, "No Body Issue", null)]),
|
||||
} as Response);
|
||||
vi.mocked(runGhJsonAsync).mockResolvedValueOnce([mockIssue(1, "No Body Issue", null)] as never);
|
||||
|
||||
await runTaskImportFromGitHub("owner/repo");
|
||||
|
||||
@@ -1388,11 +1303,7 @@ describe("runTaskImportFromGitHub", () => {
|
||||
|
||||
it("truncates long titles to 200 chars", async () => {
|
||||
const longTitle = "A".repeat(250);
|
||||
fetchSpy.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve([mockIssue(1, longTitle, "Body")]),
|
||||
} as Response);
|
||||
vi.mocked(runGhJsonAsync).mockResolvedValueOnce([mockIssue(1, longTitle, "Body")] as never);
|
||||
|
||||
await runTaskImportFromGitHub("owner/repo");
|
||||
|
||||
@@ -2414,35 +2325,43 @@ describe("runTaskPrCreate", () => {
|
||||
it("exits with error when no GitHub auth available", async () => {
|
||||
const task = makeInReviewTask();
|
||||
mockGetTask.mockResolvedValueOnce(task);
|
||||
|
||||
|
||||
vi.mocked(isGhAvailable).mockReturnValue(false);
|
||||
vi.mocked(isGhAuthenticated).mockReturnValue(false);
|
||||
|
||||
|
||||
const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => {
|
||||
throw new Error("process.exit");
|
||||
}) as (code?: number) => never);
|
||||
|
||||
await expect(runTaskPrCreate("FN-001", {})).rejects.toThrow("process.exit");
|
||||
|
||||
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Not authenticated with GitHub"));
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
"Error: GitHub CLI (gh) is not available or not authenticated. Run 'gh auth login'.",
|
||||
);
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
exitSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("uses GITHUB_TOKEN when gh CLI not available", async () => {
|
||||
it("exits with error when gh CLI is unavailable even if GITHUB_TOKEN is set", async () => {
|
||||
const task = makeInReviewTask();
|
||||
mockGetTask.mockResolvedValueOnce(task);
|
||||
|
||||
|
||||
vi.mocked(isGhAvailable).mockReturnValue(false);
|
||||
vi.mocked(isGhAuthenticated).mockReturnValue(false);
|
||||
process.env.GITHUB_TOKEN = "test-token";
|
||||
|
||||
mockCreatePr.mockResolvedValueOnce(makePrInfo());
|
||||
|
||||
await runTaskPrCreate("FN-001", {});
|
||||
const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => {
|
||||
throw new Error("process.exit");
|
||||
}) as (code?: number) => never);
|
||||
|
||||
expect(GitHubClient).toHaveBeenCalledWith("test-token");
|
||||
expect(mockUpdatePrInfo).toHaveBeenCalled();
|
||||
await expect(runTaskPrCreate("FN-001", {})).rejects.toThrow("process.exit");
|
||||
|
||||
expect(GitHubClient).not.toHaveBeenCalled();
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
"Error: GitHub CLI (gh) is not available or not authenticated. Run 'gh auth login'.",
|
||||
);
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
exitSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("exits with error when no repository detected", async () => {
|
||||
|
||||
@@ -6,7 +6,13 @@ import { createSession, submitResponse, RateLimitError, SessionNotFoundError, In
|
||||
import { watchFile, unwatchFile, statSync, existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { GitHubClient } from "@fusion/dashboard";
|
||||
import { isGhAvailable, isGhAuthenticated, getCurrentRepo } from "@fusion/core/gh-cli";
|
||||
import {
|
||||
getGhErrorMessage,
|
||||
getCurrentRepo,
|
||||
isGhAuthenticated,
|
||||
isGhAvailable,
|
||||
runGhJsonAsync,
|
||||
} from "@fusion/core/gh-cli";
|
||||
import { resolveProject, type ProjectContext } from "../project-context.js";
|
||||
|
||||
const STEP_STATUSES: StepStatus[] = ["pending", "in-progress", "done", "skipped"];
|
||||
@@ -843,7 +849,10 @@ export async function fetchGitHubIssues(
|
||||
options: FetchGitHubIssuesOptions = {}
|
||||
): Promise<GitHubIssue[]> {
|
||||
const { limit = 30, labels, since } = options;
|
||||
const token = process.env.GITHUB_TOKEN;
|
||||
|
||||
if (!isGhAvailable() || !isGhAuthenticated()) {
|
||||
throw new Error("GitHub CLI (gh) is not available or not authenticated. Run 'gh auth login'.");
|
||||
}
|
||||
|
||||
// Build query parameters - only open issues, no PRs
|
||||
const params = new URLSearchParams();
|
||||
@@ -856,46 +865,13 @@ export async function fetchGitHubIssues(
|
||||
params.append("since", since);
|
||||
}
|
||||
|
||||
const url = `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues?${params}`;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
Accept: "application/vnd.github+json",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
"User-Agent": "fn/1.0",
|
||||
};
|
||||
|
||||
if (token) {
|
||||
headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 30000);
|
||||
const path = `repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues?${params.toString()}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
throw new Error(`Repository not found or not accessible: ${owner}/${repo}`);
|
||||
}
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
throw new Error(
|
||||
`Authentication failed or rate limited. ${
|
||||
token ? "Check your GITHUB_TOKEN." : "Set GITHUB_TOKEN env var."
|
||||
}`
|
||||
);
|
||||
}
|
||||
throw new Error(`GitHub API error: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
// Filter out pull requests (they have a pull_request property)
|
||||
const issues = (await response.json()) as GitHubIssue[];
|
||||
return issues.filter((issue) => !("pull_request" in issue && issue.pull_request)).slice(0, limit);
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
const issues = await runGhJsonAsync<Array<GitHubIssue & { pull_request?: unknown }>>(["api", path]);
|
||||
return issues.filter((issue) => !issue.pull_request).slice(0, limit);
|
||||
} catch (error) {
|
||||
throw new Error(getGhErrorMessage(error));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1140,10 +1116,8 @@ export async function runTaskPrCreate(id: string, options: PrCreateOptions = {},
|
||||
}
|
||||
|
||||
// Validate GitHub auth
|
||||
const hasGhAuth = isGhAvailable() && isGhAuthenticated();
|
||||
const hasToken = !!process.env.GITHUB_TOKEN;
|
||||
if (!hasGhAuth && !hasToken) {
|
||||
console.error("Error: Not authenticated with GitHub. Run 'gh auth login' or set GITHUB_TOKEN.");
|
||||
if (!isGhAvailable() || !isGhAuthenticated()) {
|
||||
console.error("Error: GitHub CLI (gh) is not available or not authenticated. Run 'gh auth login'.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -1166,8 +1140,7 @@ export async function runTaskPrCreate(id: string, options: PrCreateOptions = {},
|
||||
}
|
||||
|
||||
// Create PR via GitHubClient
|
||||
const githubToken = process.env.GITHUB_TOKEN;
|
||||
const client = new GitHubClient(githubToken);
|
||||
const client = new GitHubClient();
|
||||
|
||||
try {
|
||||
const prInfo = await client.createPr({
|
||||
|
||||
@@ -8,6 +8,12 @@ import {
|
||||
type Column,
|
||||
type Task,
|
||||
} from "@fusion/core";
|
||||
import {
|
||||
getGhErrorMessage,
|
||||
isGhAuthenticated,
|
||||
isGhAvailable,
|
||||
runGhJsonAsync,
|
||||
} from "@fusion/core/gh-cli";
|
||||
import { resolve, basename, extname } from "node:path";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
@@ -51,6 +57,57 @@ function formatTaskLine(t: Task): string {
|
||||
return `${t.id} ${label}${deps}${paused}`;
|
||||
}
|
||||
|
||||
interface GitHubIssueApiResult {
|
||||
number: number;
|
||||
title: string;
|
||||
body: string | null;
|
||||
html_url: string;
|
||||
labels?: Array<{ name: string }>;
|
||||
pull_request?: unknown;
|
||||
}
|
||||
|
||||
function ensureGhCliAuth(): void {
|
||||
if (!isGhAvailable() || !isGhAuthenticated()) {
|
||||
throw new Error("GitHub CLI (gh) is not available or not authenticated. Run 'gh auth login'.");
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchGitHubIssuesViaGh(
|
||||
owner: string,
|
||||
repo: string,
|
||||
options: { limit?: number; labels?: string[] } = {},
|
||||
): Promise<GitHubIssueApiResult[]> {
|
||||
ensureGhCliAuth();
|
||||
|
||||
const queryParams = new URLSearchParams();
|
||||
queryParams.append("state", "open");
|
||||
queryParams.append("per_page", String(Math.min(options.limit ?? 30, 100)));
|
||||
if (options.labels && options.labels.length > 0) {
|
||||
queryParams.append("labels", options.labels.join(","));
|
||||
}
|
||||
|
||||
const path = `repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues?${queryParams.toString()}`;
|
||||
|
||||
try {
|
||||
const issues = await runGhJsonAsync<GitHubIssueApiResult[]>(["api", path]);
|
||||
return issues.filter((issue) => !issue.pull_request);
|
||||
} catch (error) {
|
||||
throw new Error(getGhErrorMessage(error));
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchGitHubIssueViaGh(owner: string, repo: string, issueNumber: number): Promise<GitHubIssueApiResult> {
|
||||
ensureGhCliAuth();
|
||||
|
||||
const path = `repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${issueNumber}`;
|
||||
|
||||
try {
|
||||
return await runGhJsonAsync<GitHubIssueApiResult>(["api", path]);
|
||||
} catch (error) {
|
||||
throw new Error(getGhErrorMessage(error));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Extension entry point ──────────────────────────────────────────
|
||||
|
||||
export default function kbExtension(pi: ExtensionAPI) {
|
||||
@@ -668,8 +725,7 @@ export default function kbExtension(pi: ExtensionAPI) {
|
||||
promptSnippet: "Import GitHub issues as Fusion tasks",
|
||||
promptGuidelines: [
|
||||
"Use for syncing GitHub issue backlog to Fusion board",
|
||||
"Uses gh CLI authentication when available (run 'gh auth login')",
|
||||
"Falls back to GITHUB_TOKEN env var for private repositories without gh CLI",
|
||||
"Uses gh CLI authentication (run 'gh auth login')",
|
||||
"Use --limit to control how many issues to import (default: 30)",
|
||||
"Use --labels to filter by specific labels",
|
||||
],
|
||||
@@ -693,51 +749,53 @@ export default function kbExtension(pi: ExtensionAPI) {
|
||||
}),
|
||||
|
||||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||
// Import the function dynamically to avoid circular dependencies
|
||||
const { runTaskImportFromGitHub } = await import("./commands/task.js");
|
||||
|
||||
const [owner, repo] = params.ownerRepo.split("/");
|
||||
const limit = params.limit ?? 30;
|
||||
const labels = params.labels;
|
||||
|
||||
// Capture console output
|
||||
const originalLog = console.log;
|
||||
const originalError = console.error;
|
||||
const logs: string[] = [];
|
||||
const issues = await fetchGitHubIssuesViaGh(owner, repo, { limit, labels });
|
||||
|
||||
console.log = (...args: unknown[]) => {
|
||||
const line = args.map(String).join(" ");
|
||||
logs.push(line);
|
||||
originalLog.apply(console, args);
|
||||
};
|
||||
console.error = (...args: unknown[]) => {
|
||||
const line = args.map(String).join(" ");
|
||||
logs.push(line);
|
||||
originalError.apply(console, args);
|
||||
};
|
||||
|
||||
try {
|
||||
await runTaskImportFromGitHub(params.ownerRepo, { limit, labels });
|
||||
} finally {
|
||||
console.log = originalLog;
|
||||
console.error = originalError;
|
||||
if (issues.length === 0) {
|
||||
return {
|
||||
content: [{ type: "text", text: `No open issues found in ${owner}/${repo}.` }],
|
||||
details: { createdTasks: [], summary: `Imported 0 tasks from ${owner}/${repo}` },
|
||||
};
|
||||
}
|
||||
|
||||
// Parse created task IDs from logs
|
||||
const store = await getStore(ctx.cwd);
|
||||
const existingTasks = await store.listTasks();
|
||||
const createdTasks: Array<{ id: string; title: string }> = [];
|
||||
for (const line of logs) {
|
||||
const match = line.match(/Created (KB-\d+):\s*(.+)$/);
|
||||
if (match) {
|
||||
createdTasks.push({ id: match[1], title: match[2].trim() });
|
||||
|
||||
for (const issue of issues) {
|
||||
const sourceUrl = issue.html_url;
|
||||
const alreadyImported = existingTasks.some((task) => task.description.includes(sourceUrl));
|
||||
if (alreadyImported) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const title = issue.title.slice(0, 200);
|
||||
const body = issue.body?.trim() || "(no description)";
|
||||
const description = `${body}\n\nSource: ${sourceUrl}`;
|
||||
|
||||
const task = await store.createTask({
|
||||
title: title || undefined,
|
||||
description,
|
||||
column: "triage",
|
||||
dependencies: [],
|
||||
});
|
||||
|
||||
await store.logEntry(task.id, "Imported from GitHub", sourceUrl);
|
||||
createdTasks.push({ id: task.id, title: task.title || issue.title });
|
||||
|
||||
existingTasks.push({ ...task, description });
|
||||
}
|
||||
|
||||
const summary = logs.find((l) => l.includes("✓ Imported")) || "Import complete";
|
||||
|
||||
const summary = `✓ Imported ${createdTasks.length} tasks from ${owner}/${repo}`;
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `${summary}\n\nCreated tasks:\n${createdTasks.map((t) => ` ${t.id}: ${t.title}`).join("\n") || " None"}`,
|
||||
text: `${summary}\n\nCreated tasks:\n${createdTasks.map((task) => ` ${task.id}: ${task.title}`).join("\n") || " None"}`,
|
||||
},
|
||||
],
|
||||
details: { createdTasks, summary },
|
||||
@@ -757,8 +815,7 @@ export default function kbExtension(pi: ExtensionAPI) {
|
||||
promptSnippet: "Import a specific GitHub issue as a Fusion task",
|
||||
promptGuidelines: [
|
||||
"Use for importing a single known issue by its number",
|
||||
"Uses gh CLI authentication when available (run 'gh auth login')",
|
||||
"Falls back to GITHUB_TOKEN env var for private repositories without gh CLI",
|
||||
"Uses gh CLI authentication (run 'gh auth login')",
|
||||
"Skips import if the issue is already imported (checks for existing Source URL)",
|
||||
],
|
||||
parameters: Type.Object({
|
||||
@@ -776,52 +833,10 @@ export default function kbExtension(pi: ExtensionAPI) {
|
||||
|
||||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||
const { owner, repo, issueNumber } = params;
|
||||
const token = process.env.GITHUB_TOKEN;
|
||||
const issue = await fetchGitHubIssueViaGh(owner, repo, issueNumber);
|
||||
|
||||
// Build URL for single issue
|
||||
const url = `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${issueNumber}`;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
Accept: "application/vnd.github+json",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
"User-Agent": "fusion-cli/1.0",
|
||||
};
|
||||
|
||||
if (token) {
|
||||
headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 30000);
|
||||
|
||||
let issue: { number: number; title: string; body: string | null; html_url: string };
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
throw new Error(`Issue #${issueNumber} not found in ${owner}/${repo}`);
|
||||
}
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
throw new Error(
|
||||
`Authentication failed. ${token ? "Check your GITHUB_TOKEN." : "Set GITHUB_TOKEN env var."}`
|
||||
);
|
||||
}
|
||||
throw new Error(`GitHub API error: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
issue = await response.json() as typeof issue;
|
||||
|
||||
// Check if it's a pull request
|
||||
if ("pull_request" in issue && issue.pull_request) {
|
||||
throw new Error(`#${issueNumber} is a pull request, not an issue`);
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
if (issue.pull_request) {
|
||||
throw new Error(`#${issueNumber} is a pull request, not an issue`);
|
||||
}
|
||||
|
||||
// Check if already imported
|
||||
@@ -855,6 +870,8 @@ export default function kbExtension(pi: ExtensionAPI) {
|
||||
dependencies: [],
|
||||
});
|
||||
|
||||
await store.logEntry(task.id, "Imported from GitHub", sourceUrl);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
@@ -883,7 +900,7 @@ export default function kbExtension(pi: ExtensionAPI) {
|
||||
"Returns a list you can reference when importing specific issues",
|
||||
"Use --limit to control how many issues to show (default: 30)",
|
||||
"Use --labels to filter by specific labels",
|
||||
"Requires GITHUB_TOKEN env var for private repositories",
|
||||
"Uses gh CLI authentication (run 'gh auth login')",
|
||||
],
|
||||
parameters: Type.Object({
|
||||
owner: Type.String({
|
||||
@@ -908,57 +925,7 @@ export default function kbExtension(pi: ExtensionAPI) {
|
||||
|
||||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||
const { owner, repo, limit = 30, labels } = params;
|
||||
const token = process.env.GITHUB_TOKEN;
|
||||
|
||||
// Build query parameters
|
||||
const queryParams = new URLSearchParams();
|
||||
queryParams.append("state", "open");
|
||||
queryParams.append("per_page", String(Math.min(limit, 100)));
|
||||
if (labels && labels.length > 0) {
|
||||
queryParams.append("labels", labels.join(","));
|
||||
}
|
||||
|
||||
const url = `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues?${queryParams}`;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
Accept: "application/vnd.github+json",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
"User-Agent": "fusion-cli/1.0",
|
||||
};
|
||||
|
||||
if (token) {
|
||||
headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 30000);
|
||||
|
||||
let issues: Array<{ number: number; title: string; html_url: string; labels: Array<{ name: string }> }>;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
throw new Error(`Repository not found: ${owner}/${repo}`);
|
||||
}
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
throw new Error(
|
||||
`Authentication failed. ${token ? "Check your GITHUB_TOKEN." : "Set GITHUB_TOKEN env var."}`
|
||||
);
|
||||
}
|
||||
throw new Error(`GitHub API error: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const allIssues = await response.json() as typeof issues;
|
||||
// Filter out pull requests
|
||||
issues = allIssues.filter((i) => !("pull_request" in i && i.pull_request)).slice(0, limit);
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
const issues = await fetchGitHubIssuesViaGh(owner, repo, { limit, labels });
|
||||
|
||||
if (issues.length === 0) {
|
||||
return {
|
||||
@@ -984,7 +951,8 @@ export default function kbExtension(pi: ExtensionAPI) {
|
||||
|
||||
for (const issue of issues) {
|
||||
const isImported = importedUrls.has(issue.html_url);
|
||||
const labelStr = issue.labels.length > 0 ? ` [${issue.labels.map((l) => l.name).join(", ")}]` : "";
|
||||
const issueLabels = issue.labels ?? [];
|
||||
const labelStr = issueLabels.length > 0 ? ` [${issueLabels.map((label) => label.name).join(", ")}]` : "";
|
||||
const importedStr = isImported ? " ✓ Imported" : "";
|
||||
lines.push(` #${issue.number}: ${issue.title.slice(0, 80)}${issue.title.length > 80 ? "…" : ""}${labelStr}${importedStr}`);
|
||||
lines.push(` ${issue.html_url}`);
|
||||
@@ -996,12 +964,12 @@ export default function kbExtension(pi: ExtensionAPI) {
|
||||
content: [{ type: "text", text: lines.join("\n") }],
|
||||
details: {
|
||||
count: issues.length,
|
||||
issues: issues.map((i) => ({
|
||||
number: i.number,
|
||||
title: i.title,
|
||||
url: i.html_url,
|
||||
labels: i.labels.map((l) => l.name),
|
||||
imported: importedUrls.has(i.html_url),
|
||||
issues: issues.map((issue) => ({
|
||||
number: issue.number,
|
||||
title: issue.title,
|
||||
url: issue.html_url,
|
||||
labels: (issue.labels ?? []).map((label) => label.name),
|
||||
imported: importedUrls.has(issue.html_url),
|
||||
})),
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user