diff --git a/haunted.yaml.example b/haunted.yaml.example index c8df084..cdee74a 100644 --- a/haunted.yaml.example +++ b/haunted.yaml.example @@ -47,6 +47,9 @@ pull_requests: # Project board settings project: enabled: true + number: 8 # GitHub Project number (from URL: /projects/8) + owner: "owner" # GitHub username or organization name + auto_add_issues: true # Automatically add new issues to the project columns: - name: "Backlog" status: "backlog" diff --git a/src/agents/orchestrator.test.ts b/src/agents/orchestrator.test.ts index 4dbfc74..bbdb26f 100644 --- a/src/agents/orchestrator.test.ts +++ b/src/agents/orchestrator.test.ts @@ -78,6 +78,9 @@ describe("Orchestrator", () => { }, project: { enabled: true, + number: undefined, + owner: undefined, + auto_add_issues: true, columns: [ { name: "Backlog", status: "backlog" }, { name: "In Progress", status: "in_progress" }, diff --git a/src/config/defaults.ts b/src/config/defaults.ts index 8019c3c..c3c8f47 100644 --- a/src/config/defaults.ts +++ b/src/config/defaults.ts @@ -38,6 +38,9 @@ export const DEFAULT_CONFIG: Omit = { }, project: { enabled: true, + number: undefined, + owner: undefined, + auto_add_issues: true, columns: [ { name: "Backlog", status: "backlog" }, { name: "In Progress", status: "in_progress" }, diff --git a/src/config/schema.ts b/src/config/schema.ts index 74937d1..ed68b09 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -161,10 +161,16 @@ const DEFAULT_COLUMNS = [ export const ProjectConfigSchema = z .object({ enabled: z.boolean().optional(), + number: z.number().optional().describe("GitHub Project number"), + owner: z.string().optional().describe("GitHub Project owner (user or organization)"), + auto_add_issues: z.boolean().optional().describe("Automatically add new issues to the project"), columns: z.array(ProjectColumnSchema).optional(), }) .transform((val) => ({ enabled: val.enabled ?? true, + number: val.number, + owner: val.owner, + auto_add_issues: val.auto_add_issues ?? true, columns: val.columns ?? DEFAULT_COLUMNS, })); @@ -285,6 +291,9 @@ export const ConfigSchema = z project: z .object({ enabled: z.boolean().optional(), + number: z.number().optional(), + owner: z.string().optional(), + auto_add_issues: z.boolean().optional(), columns: z.array(ProjectColumnSchema).optional(), }) .optional(), @@ -337,6 +346,9 @@ export const ConfigSchema = z }, project: { enabled: val.project?.enabled ?? true, + number: val.project?.number, + owner: val.project?.owner, + auto_add_issues: val.project?.auto_add_issues ?? true, columns: val.project?.columns ?? DEFAULT_COLUMNS, }, labels: { diff --git a/src/events/handlers/issue.test.ts b/src/events/handlers/issue.test.ts new file mode 100644 index 0000000..1d56aae --- /dev/null +++ b/src/events/handlers/issue.test.ts @@ -0,0 +1,369 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { createIssueHandlers } from "./issue.ts"; +import type { Config } from "@/config/schema.ts"; +import type { Orchestrator } from "@/agents/orchestrator.ts"; +import type { GitHubEvent } from "@/events/types.ts"; +import * as projects from "@/github/projects.ts"; + +// Mock the projects module +vi.mock("@/github/projects.ts", () => ({ + addIssueToProject: vi.fn(), +})); + +// Mock logger to avoid noise in tests +vi.mock("@/utils/logger.ts", () => ({ + createLogger: () => ({ + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }), +})); + +describe("issue handlers", () => { + const mockAddIssueToProject = vi.mocked(projects.addIssueToProject); + + const mockOrchestrator = { + processIssue: vi.fn(), + cancelIssueProcessing: vi.fn(), + } as unknown as Orchestrator; + + const createMockConfig = (projectConfig: Partial = {}): Config => ({ + version: "1.0", + scope: { type: "repo", target: "owner/repo" }, + github: { + webhook: { enabled: true, port: 3000, secret: undefined }, + polling: { enabled: true, interval: 60 }, + }, + agents: { + house_master: { enabled: true, auto_assign: true, auto_review: true }, + claude_code: { enabled: true, branch_prefix: "haunted/", auto_test: true }, + }, + pull_requests: { + auto_merge: { enabled: false, require_approval: true, require_ci_pass: true }, + rules: [], + }, + project: { + enabled: true, + number: 8, + owner: "Pr0gCat", + auto_add_issues: true, + columns: [ + { name: "Backlog", status: "backlog" as const }, + { name: "In Progress", status: "in_progress" as const }, + { name: "Review", status: "review" as const }, + { name: "Done", status: "done" as const }, + ], + ...projectConfig, + }, + labels: { + human_only: "human-only", + skip: "haunted-skip", + auto_merge: "auto-merge", + needs_review: "needs-review", + issue_types: {}, + complexity: {}, + priority: {}, + auto_label: true, + }, + }); + + const createMockEvent = (overrides: Partial<{ + issueNumber: number; + issueTitle: string; + labels: string[]; + htmlUrl: string; + }> = {}): GitHubEvent => ({ + type: "issues", + action: "opened", + payload: { + action: "opened", + issue: { + number: overrides.issueNumber ?? 10, + title: overrides.issueTitle ?? "Test Issue", + body: "Test body", + state: "open", + user: { login: "testuser" }, + labels: (overrides.labels ?? []).map((name) => ({ name })), + assignees: [], + html_url: overrides.htmlUrl ?? "https://github.com/owner/repo/issues/10", + }, + repository: { + full_name: "owner/repo", + default_branch: "main", + }, + sender: { login: "testuser" }, + }, + deliveryId: "test-delivery-id", + receivedAt: new Date(), + }); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("handleIssueOpened", () => { + describe("auto-add to project", () => { + it("should add issue to project when configured", async () => { + const config = createMockConfig(); + const handlers = createIssueHandlers(config, mockOrchestrator); + const event = createMockEvent(); + + mockAddIssueToProject.mockResolvedValueOnce("PVTI_123"); + + await handlers.handleIssueOpened(event); + + // Wait for the non-blocking project add to complete + await vi.waitFor(() => { + expect(mockAddIssueToProject).toHaveBeenCalledWith( + "Pr0gCat", + 8, + "https://github.com/owner/repo/issues/10" + ); + }); + }); + + it("should not add issue to project when project is disabled", async () => { + const config = createMockConfig({ enabled: false }); + const handlers = createIssueHandlers(config, mockOrchestrator); + const event = createMockEvent(); + + await handlers.handleIssueOpened(event); + + // Allow any pending promises to resolve + await vi.waitFor(() => { + expect(mockAddIssueToProject).not.toHaveBeenCalled(); + }); + }); + + it("should not add issue to project when project number is not configured", async () => { + const config = createMockConfig({ number: undefined }); + const handlers = createIssueHandlers(config, mockOrchestrator); + const event = createMockEvent(); + + await handlers.handleIssueOpened(event); + + await vi.waitFor(() => { + expect(mockAddIssueToProject).not.toHaveBeenCalled(); + }); + }); + + it("should not add issue to project when project owner is not configured", async () => { + const config = createMockConfig({ owner: undefined }); + const handlers = createIssueHandlers(config, mockOrchestrator); + const event = createMockEvent(); + + await handlers.handleIssueOpened(event); + + await vi.waitFor(() => { + expect(mockAddIssueToProject).not.toHaveBeenCalled(); + }); + }); + + it("should not add issue to project when auto_add_issues is disabled", async () => { + const config = createMockConfig({ auto_add_issues: false }); + const handlers = createIssueHandlers(config, mockOrchestrator); + const event = createMockEvent(); + + await handlers.handleIssueOpened(event); + + await vi.waitFor(() => { + expect(mockAddIssueToProject).not.toHaveBeenCalled(); + }); + }); + + it("should continue processing even if adding to project fails", async () => { + const config = createMockConfig(); + const handlers = createIssueHandlers(config, mockOrchestrator); + const event = createMockEvent(); + + mockAddIssueToProject.mockRejectedValueOnce(new Error("API error")); + + await handlers.handleIssueOpened(event); + + // Wait for the non-blocking project add to fail silently + await vi.waitFor(() => { + expect(mockAddIssueToProject).toHaveBeenCalled(); + }); + + // Should still process the issue normally + expect(mockOrchestrator.processIssue).toHaveBeenCalled(); + }); + }); + + describe("label checks", () => { + it("should skip processing when human-only label is present", async () => { + const config = createMockConfig(); + const handlers = createIssueHandlers(config, mockOrchestrator); + const event = createMockEvent({ labels: ["human-only"] }); + + await handlers.handleIssueOpened(event); + + expect(mockOrchestrator.processIssue).not.toHaveBeenCalled(); + }); + + it("should skip processing when skip label is present", async () => { + const config = createMockConfig(); + const handlers = createIssueHandlers(config, mockOrchestrator); + const event = createMockEvent({ labels: ["haunted-skip"] }); + + await handlers.handleIssueOpened(event); + + expect(mockOrchestrator.processIssue).not.toHaveBeenCalled(); + }); + + it("should still add to project even with skip labels", async () => { + const config = createMockConfig(); + const handlers = createIssueHandlers(config, mockOrchestrator); + const event = createMockEvent({ labels: ["human-only"] }); + + mockAddIssueToProject.mockResolvedValueOnce("PVTI_123"); + + await handlers.handleIssueOpened(event); + + // Wait for the non-blocking project add to complete + await vi.waitFor(() => { + expect(mockAddIssueToProject).toHaveBeenCalled(); + }); + + // But orchestrator processing should be skipped + expect(mockOrchestrator.processIssue).not.toHaveBeenCalled(); + }); + }); + + describe("orchestrator processing", () => { + it("should call orchestrator.processIssue with correct data", async () => { + const config = createMockConfig(); + const handlers = createIssueHandlers(config, mockOrchestrator); + const event = createMockEvent({ + issueNumber: 42, + issueTitle: "Fix the bug", + labels: ["bug", "urgent"], + }); + + await handlers.handleIssueOpened(event); + + expect(mockOrchestrator.processIssue).toHaveBeenCalledWith({ + repo: "owner/repo", + number: 42, + title: "Fix the bug", + body: "Test body", + labels: ["bug", "urgent"], + author: "testuser", + }); + }); + }); + }); + + describe("handleIssueClosed", () => { + it("should cancel issue processing", async () => { + const config = createMockConfig(); + const handlers = createIssueHandlers(config, mockOrchestrator); + + const event: GitHubEvent = { + type: "issues", + action: "closed", + payload: { + action: "closed", + issue: { + number: 10, + title: "Test Issue", + body: "Test body", + state: "closed", + user: { login: "testuser" }, + labels: [], + assignees: [], + html_url: "https://github.com/owner/repo/issues/10", + }, + repository: { + full_name: "owner/repo", + default_branch: "main", + }, + sender: { login: "testuser" }, + }, + deliveryId: "test-delivery-id", + receivedAt: new Date(), + }; + + await handlers.handleIssueClosed(event); + + expect(mockOrchestrator.cancelIssueProcessing).toHaveBeenCalledWith( + "owner/repo", + 10 + ); + }); + }); + + describe("handleIssueLabeled", () => { + it("should cancel processing when human-only label is added", async () => { + const config = createMockConfig(); + const handlers = createIssueHandlers(config, mockOrchestrator); + + const event: GitHubEvent = { + type: "issues", + action: "labeled", + payload: { + action: "labeled", + issue: { + number: 10, + title: "Test Issue", + body: "Test body", + state: "open", + user: { login: "testuser" }, + labels: [{ name: "human-only" }], + assignees: [], + html_url: "https://github.com/owner/repo/issues/10", + }, + repository: { + full_name: "owner/repo", + default_branch: "main", + }, + sender: { login: "testuser" }, + }, + deliveryId: "test-delivery-id", + receivedAt: new Date(), + }; + + await handlers.handleIssueLabeled(event); + + expect(mockOrchestrator.cancelIssueProcessing).toHaveBeenCalledWith( + "owner/repo", + 10 + ); + }); + + it("should not cancel processing for other labels", async () => { + const config = createMockConfig(); + const handlers = createIssueHandlers(config, mockOrchestrator); + + const event: GitHubEvent = { + type: "issues", + action: "labeled", + payload: { + action: "labeled", + issue: { + number: 10, + title: "Test Issue", + body: "Test body", + state: "open", + user: { login: "testuser" }, + labels: [{ name: "bug" }], + assignees: [], + html_url: "https://github.com/owner/repo/issues/10", + }, + repository: { + full_name: "owner/repo", + default_branch: "main", + }, + sender: { login: "testuser" }, + }, + deliveryId: "test-delivery-id", + receivedAt: new Date(), + }; + + await handlers.handleIssueLabeled(event); + + expect(mockOrchestrator.cancelIssueProcessing).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/events/handlers/issue.ts b/src/events/handlers/issue.ts index bb19d7e..02df09c 100644 --- a/src/events/handlers/issue.ts +++ b/src/events/handlers/issue.ts @@ -2,10 +2,39 @@ import { createLogger } from "@/utils/logger.ts"; import type { GitHubEvent, IssueEventPayload } from "@/events/types.ts"; import type { Config } from "@/config/schema.ts"; import type { Orchestrator } from "@/agents/orchestrator.ts"; +import { addIssueToProject } from "@/github/projects.ts"; const logger = createLogger("issue-handler"); export function createIssueHandlers(config: Config, orchestrator: Orchestrator) { + /** + * Automatically add an issue to the configured GitHub Project board. + * This runs independently of other issue processing. + */ + async function addIssueToProjectBoard(issueUrl: string, issueNumber: number): Promise { + const { project } = config; + + // Skip if project integration is disabled or not configured + if (!project.enabled || !project.number || !project.owner || !project.auto_add_issues) { + logger.debug({ issueNumber }, "Project auto-add disabled or not configured"); + return; + } + + try { + const itemId = await addIssueToProject(project.owner, project.number, issueUrl); + logger.info( + { issueNumber, projectNumber: project.number, projectOwner: project.owner, itemId }, + "Issue automatically added to project board" + ); + } catch (error) { + // Log error but don't fail the whole issue handling + logger.warn( + { issueNumber, projectNumber: project.number, error }, + "Failed to add issue to project board" + ); + } + } + async function handleIssueOpened(event: GitHubEvent): Promise { const payload = event.payload as unknown as IssueEventPayload; const { issue, repository } = payload; @@ -13,6 +42,11 @@ export function createIssueHandlers(config: Config, orchestrator: Orchestrator) logger.info({ repo, number: issue.number, title: issue.title }, "New issue opened"); + // Automatically add issue to project board (non-blocking) + addIssueToProjectBoard(issue.html_url, issue.number).catch((error) => { + logger.error({ error, issueNumber: issue.number }, "Unexpected error adding issue to project"); + }); + const labels = issue.labels.map((l) => l.name); if (labels.includes(config.labels.human_only)) { diff --git a/src/events/webhook-server.test.ts b/src/events/webhook-server.test.ts index ca24650..b28b64b 100644 --- a/src/events/webhook-server.test.ts +++ b/src/events/webhook-server.test.ts @@ -40,7 +40,7 @@ describe("webhook-server", () => { auto_merge: { enabled: false, require_approval: true, require_ci_pass: true }, rules: [], }, - project: { enabled: false, columns: [] }, + project: { enabled: false, number: undefined, owner: undefined, auto_add_issues: false, columns: [] }, labels: { human_only: "human-only", skip: "haunted-skip", diff --git a/src/github/projects.test.ts b/src/github/projects.test.ts new file mode 100644 index 0000000..e56222c --- /dev/null +++ b/src/github/projects.test.ts @@ -0,0 +1,444 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { + listProjects, + getProject, + getProjectItems, + addIssueToProject, + getProjectFields, + moveItemToColumn, +} from "./projects.ts"; +import * as ghCli from "./cli.ts"; + +// Mock the gh CLI module +vi.mock("./cli.ts", () => ({ + gh: vi.fn(), +})); + +// Mock logger to avoid noise in tests +vi.mock("@/utils/logger.ts", () => ({ + createLogger: () => ({ + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }), +})); + +describe("github/projects", () => { + const mockGh = vi.mocked(ghCli.gh); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("listProjects", () => { + it("should list user projects", async () => { + const mockProjects = { + data: { + user: { + projectsV2: { + nodes: [ + { + id: "PVT_1", + title: "Haunted Project", + number: 8, + url: "https://github.com/users/Pr0gCat/projects/8", + }, + ], + }, + }, + }, + }; + + mockGh.mockResolvedValueOnce({ + stdout: JSON.stringify(mockProjects), + stderr: "", + exitCode: 0, + }); + + const result = await listProjects("Pr0gCat"); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + id: "PVT_1", + title: "Haunted Project", + number: 8, + url: "https://github.com/users/Pr0gCat/projects/8", + }); + }); + + it("should fall back to organization projects if user query fails", async () => { + const mockOrgProjects = { + data: { + organization: { + projectsV2: { + nodes: [ + { + id: "PVT_ORG_1", + title: "Org Project", + number: 1, + url: "https://github.com/orgs/myorg/projects/1", + }, + ], + }, + }, + }, + }; + + mockGh + .mockResolvedValueOnce({ + stdout: "", + stderr: "User not found", + exitCode: 1, + }) + .mockResolvedValueOnce({ + stdout: JSON.stringify(mockOrgProjects), + stderr: "", + exitCode: 0, + }); + + const result = await listProjects("myorg"); + + expect(mockGh).toHaveBeenCalledTimes(2); + expect(result).toHaveLength(1); + expect(result[0]?.title).toBe("Org Project"); + }); + + it("should throw if both user and org queries fail", async () => { + mockGh + .mockResolvedValueOnce({ + stdout: "", + stderr: "User not found", + exitCode: 1, + }) + .mockResolvedValueOnce({ + stdout: "", + stderr: "Organization not found", + exitCode: 1, + }); + + await expect(listProjects("nonexistent")).rejects.toThrow( + "Failed to list projects" + ); + }); + }); + + describe("getProject", () => { + it("should return project by number", async () => { + const mockProjects = { + data: { + user: { + projectsV2: { + nodes: [ + { id: "PVT_1", title: "Project 1", number: 1, url: "url1" }, + { id: "PVT_2", title: "Project 2", number: 8, url: "url2" }, + ], + }, + }, + }, + }; + + mockGh.mockResolvedValueOnce({ + stdout: JSON.stringify(mockProjects), + stderr: "", + exitCode: 0, + }); + + const result = await getProject("Pr0gCat", 8); + + expect(result).toEqual({ + id: "PVT_2", + title: "Project 2", + number: 8, + url: "url2", + }); + }); + + it("should return null if project not found", async () => { + const mockProjects = { + data: { + user: { + projectsV2: { + nodes: [ + { id: "PVT_1", title: "Project 1", number: 1, url: "url1" }, + ], + }, + }, + }, + }; + + mockGh.mockResolvedValueOnce({ + stdout: JSON.stringify(mockProjects), + stderr: "", + exitCode: 0, + }); + + const result = await getProject("Pr0gCat", 999); + expect(result).toBeNull(); + }); + }); + + describe("getProjectItems", () => { + it("should list project items", async () => { + const mockItems = { + items: [ + { + id: "ITEM_1", + title: "Issue Title", + status: "In Progress", + type: "ISSUE", + content: { number: 10, repository: "owner/repo" }, + }, + ], + }; + + mockGh.mockResolvedValueOnce({ + stdout: JSON.stringify(mockItems), + stderr: "", + exitCode: 0, + }); + + const result = await getProjectItems("Pr0gCat", 8); + + expect(mockGh).toHaveBeenCalledWith([ + "project", + "item-list", + "8", + "--owner", + "Pr0gCat", + "--format", + "json", + ]); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + id: "ITEM_1", + title: "Issue Title", + status: "In Progress", + type: "ISSUE", + content: { number: 10, repository: "owner/repo" }, + }); + }); + + it("should throw on error", async () => { + mockGh.mockResolvedValueOnce({ + stdout: "", + stderr: "Project not found", + exitCode: 1, + }); + + await expect(getProjectItems("owner", 999)).rejects.toThrow( + "Failed to get project items" + ); + }); + }); + + describe("addIssueToProject", () => { + it("should add issue to project and return item id", async () => { + mockGh.mockResolvedValueOnce({ + stdout: JSON.stringify({ id: "PVTI_123" }), + stderr: "", + exitCode: 0, + }); + + const result = await addIssueToProject( + "Pr0gCat", + 8, + "https://github.com/Pr0gCat/haunted/issues/10" + ); + + expect(mockGh).toHaveBeenCalledWith([ + "project", + "item-add", + "8", + "--owner", + "Pr0gCat", + "--url", + "https://github.com/Pr0gCat/haunted/issues/10", + "--format", + "json", + ]); + + expect(result).toBe("PVTI_123"); + }); + + it("should throw on error", async () => { + mockGh.mockResolvedValueOnce({ + stdout: "", + stderr: "Cannot add item to project", + exitCode: 1, + }); + + await expect( + addIssueToProject("owner", 8, "https://github.com/owner/repo/issues/1") + ).rejects.toThrow("Failed to add issue to project"); + }); + }); + + describe("getProjectFields", () => { + it("should list project fields", async () => { + const mockFields = { + fields: [ + { id: "FIELD_1", name: "Title" }, + { + id: "FIELD_2", + name: "Status", + options: [ + { id: "OPT_1", name: "Backlog" }, + { id: "OPT_2", name: "In Progress" }, + { id: "OPT_3", name: "Done" }, + ], + }, + ], + }; + + mockGh.mockResolvedValueOnce({ + stdout: JSON.stringify(mockFields), + stderr: "", + exitCode: 0, + }); + + const result = await getProjectFields("Pr0gCat", 8); + + expect(mockGh).toHaveBeenCalledWith([ + "project", + "field-list", + "8", + "--owner", + "Pr0gCat", + "--format", + "json", + ]); + + expect(result).toHaveLength(2); + expect(result[1]?.options).toHaveLength(3); + }); + + it("should throw on error", async () => { + mockGh.mockResolvedValueOnce({ + stdout: "", + stderr: "Project not found", + exitCode: 1, + }); + + await expect(getProjectFields("owner", 999)).rejects.toThrow( + "Failed to get project fields" + ); + }); + }); + + describe("moveItemToColumn", () => { + it("should move item to specified column", async () => { + // First call: get project fields + const mockFields = { + fields: [ + { + id: "FIELD_STATUS", + name: "Status", + options: [ + { id: "OPT_BACKLOG", name: "Backlog" }, + { id: "OPT_IN_PROGRESS", name: "In Progress" }, + { id: "OPT_DONE", name: "Done" }, + ], + }, + ], + }; + + mockGh + .mockResolvedValueOnce({ + stdout: JSON.stringify(mockFields), + stderr: "", + exitCode: 0, + }) + .mockResolvedValueOnce({ + stdout: "", + stderr: "", + exitCode: 0, + }); + + await moveItemToColumn("Pr0gCat", 8, "ITEM_1", "In Progress"); + + expect(mockGh).toHaveBeenCalledTimes(2); + expect(mockGh).toHaveBeenLastCalledWith([ + "project", + "item-edit", + "--id", + "ITEM_1", + "--project-id", + "8", + "--field-id", + "FIELD_STATUS", + "--single-select-option-id", + "OPT_IN_PROGRESS", + ]); + }); + + it("should match column name case-insensitively", async () => { + const mockFields = { + fields: [ + { + id: "FIELD_STATUS", + name: "Status", + options: [{ id: "OPT_DONE", name: "Done" }], + }, + ], + }; + + mockGh + .mockResolvedValueOnce({ + stdout: JSON.stringify(mockFields), + stderr: "", + exitCode: 0, + }) + .mockResolvedValueOnce({ + stdout: "", + stderr: "", + exitCode: 0, + }); + + await moveItemToColumn("owner", 8, "ITEM_1", "done"); // lowercase + + expect(mockGh).toHaveBeenLastCalledWith( + expect.arrayContaining(["--single-select-option-id", "OPT_DONE"]) + ); + }); + + it("should throw if Status field not found", async () => { + const mockFields = { + fields: [{ id: "FIELD_1", name: "Title" }], + }; + + mockGh.mockResolvedValueOnce({ + stdout: JSON.stringify(mockFields), + stderr: "", + exitCode: 0, + }); + + await expect( + moveItemToColumn("owner", 8, "ITEM_1", "Done") + ).rejects.toThrow("Project does not have a Status field"); + }); + + it("should throw if column not found", async () => { + const mockFields = { + fields: [ + { + id: "FIELD_STATUS", + name: "Status", + options: [{ id: "OPT_DONE", name: "Done" }], + }, + ], + }; + + mockGh.mockResolvedValueOnce({ + stdout: JSON.stringify(mockFields), + stderr: "", + exitCode: 0, + }); + + await expect( + moveItemToColumn("owner", 8, "ITEM_1", "NonExistent") + ).rejects.toThrow('Column "NonExistent" not found in project'); + }); + }); +}); diff --git a/src/github/projects.ts b/src/github/projects.ts index eeee12d..0290dc1 100644 --- a/src/github/projects.ts +++ b/src/github/projects.ts @@ -26,8 +26,10 @@ export async function listProjects(owner: string): Promise { "api", "graphql", "-f", - `query=query { - user(login: "${owner}") { + `owner=${owner}`, + "-f", + `query=query($owner: String!) { + user(login: $owner) { projectsV2(first: 20) { nodes { id @@ -45,8 +47,10 @@ export async function listProjects(owner: string): Promise { "api", "graphql", "-f", - `query=query { - organization(login: "${owner}") { + `owner=${owner}`, + "-f", + `query=query($owner: String!) { + organization(login: $owner) { projectsV2(first: 20) { nodes { id