Skip to content

Commit 3e7e1c4

Browse files
committed
Merge branch 'claude/add-mcp-issue-creation-9GoGi' into 'main'
feat(mcp): add create_issue tool for issue creation via MCP See merge request postgres-ai/postgres_ai!127
2 parents 4364c56 + 148a3c5 commit 3e7e1c4

7 files changed

Lines changed: 2479 additions & 81 deletions

File tree

cli/bin/postgres-ai.ts

Lines changed: 192 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import * as os from "os";
1010
import * as crypto from "node:crypto";
1111
import { Client } from "pg";
1212
import { startMcpServer } from "../lib/mcp-server";
13-
import { fetchIssues, fetchIssueComments, createIssueComment, fetchIssue } from "../lib/issues";
13+
import { fetchIssues, fetchIssueComments, createIssueComment, fetchIssue, createIssue, updateIssue, updateIssueComment } from "../lib/issues";
1414
import { resolveBaseUrls } from "../lib/util";
1515
import { applyInitPlan, buildInitPlan, connectWithSslFallback, DEFAULT_MONITORING_USER, redactPasswordsInSql, resolveAdminConnection, resolveMonitoringPassword, verifyInitSetup } from "../lib/init";
1616
import * as pkce from "../lib/pkce";
@@ -42,7 +42,7 @@ async function execPromise(command: string): Promise<{ stdout: string; stderr: s
4242
childProcess.exec(command, (error, stdout, stderr) => {
4343
if (error) {
4444
const err = error as Error & { code: number };
45-
err.code = error.code ?? 1;
45+
err.code = typeof error.code === "number" ? error.code : 1;
4646
reject(err);
4747
} else {
4848
resolve({ stdout, stderr });
@@ -56,7 +56,7 @@ async function execFilePromise(file: string, args: string[]): Promise<{ stdout:
5656
childProcess.execFile(file, args, (error, stdout, stderr) => {
5757
if (error) {
5858
const err = error as Error & { code: number };
59-
err.code = error.code ?? 1;
59+
err.code = typeof error.code === "number" ? error.code : 1;
6060
reject(err);
6161
} else {
6262
resolve({ stdout, stderr });
@@ -2508,7 +2508,7 @@ issues
25082508
});
25092509

25102510
issues
2511-
.command("post_comment <issueId> <content>")
2511+
.command("post-comment <issueId> <content>")
25122512
.description("post a new comment to an issue")
25132513
.option("--parent <uuid>", "parent comment id")
25142514
.option("--debug", "enable debug output")
@@ -2553,6 +2553,194 @@ issues
25532553
}
25542554
});
25552555

2556+
issues
2557+
.command("create <title>")
2558+
.description("create a new issue")
2559+
.option("--org-id <id>", "organization id (defaults to config orgId)", (v) => parseInt(v, 10))
2560+
.option("--project-id <id>", "project id", (v) => parseInt(v, 10))
2561+
.option("--description <text>", "issue description (supports \\\\n)")
2562+
.option(
2563+
"--label <label>",
2564+
"issue label (repeatable)",
2565+
(value: string, previous: string[]) => {
2566+
previous.push(value);
2567+
return previous;
2568+
},
2569+
[] as string[]
2570+
)
2571+
.option("--debug", "enable debug output")
2572+
.option("--json", "output raw JSON")
2573+
.action(async (rawTitle: string, opts: { orgId?: number; projectId?: number; description?: string; label?: string[]; debug?: boolean; json?: boolean }) => {
2574+
try {
2575+
const rootOpts = program.opts<CliOptions>();
2576+
const cfg = config.readConfig();
2577+
const { apiKey } = getConfig(rootOpts);
2578+
if (!apiKey) {
2579+
console.error("API key is required. Run 'pgai auth' first or set --api-key.");
2580+
process.exitCode = 1;
2581+
return;
2582+
}
2583+
2584+
const title = interpretEscapes(String(rawTitle || "").trim());
2585+
if (!title) {
2586+
console.error("title is required");
2587+
process.exitCode = 1;
2588+
return;
2589+
}
2590+
2591+
const orgId = typeof opts.orgId === "number" && !Number.isNaN(opts.orgId) ? opts.orgId : cfg.orgId;
2592+
if (typeof orgId !== "number") {
2593+
console.error("org_id is required. Either pass --org-id or run 'pgai auth' to store it in config.");
2594+
process.exitCode = 1;
2595+
return;
2596+
}
2597+
2598+
const description = opts.description !== undefined ? interpretEscapes(String(opts.description)) : undefined;
2599+
const labels = Array.isArray(opts.label) && opts.label.length > 0 ? opts.label.map(String) : undefined;
2600+
const projectId = typeof opts.projectId === "number" && !Number.isNaN(opts.projectId) ? opts.projectId : undefined;
2601+
2602+
const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
2603+
const result = await createIssue({
2604+
apiKey,
2605+
apiBaseUrl,
2606+
title,
2607+
orgId,
2608+
description,
2609+
projectId,
2610+
labels,
2611+
debug: !!opts.debug,
2612+
});
2613+
printResult(result, opts.json);
2614+
} catch (err) {
2615+
const message = err instanceof Error ? err.message : String(err);
2616+
console.error(message);
2617+
process.exitCode = 1;
2618+
}
2619+
});
2620+
2621+
issues
2622+
.command("update <issueId>")
2623+
.description("update an existing issue (title/description/status/labels)")
2624+
.option("--title <text>", "new title (supports \\\\n)")
2625+
.option("--description <text>", "new description (supports \\\\n)")
2626+
.option("--status <value>", "status: open|closed|0|1")
2627+
.option(
2628+
"--label <label>",
2629+
"set labels (repeatable). If provided, replaces existing labels.",
2630+
(value: string, previous: string[]) => {
2631+
previous.push(value);
2632+
return previous;
2633+
},
2634+
[] as string[]
2635+
)
2636+
.option("--clear-labels", "set labels to an empty list")
2637+
.option("--debug", "enable debug output")
2638+
.option("--json", "output raw JSON")
2639+
.action(async (issueId: string, opts: { title?: string; description?: string; status?: string; label?: string[]; clearLabels?: boolean; debug?: boolean; json?: boolean }) => {
2640+
try {
2641+
const rootOpts = program.opts<CliOptions>();
2642+
const cfg = config.readConfig();
2643+
const { apiKey } = getConfig(rootOpts);
2644+
if (!apiKey) {
2645+
console.error("API key is required. Run 'pgai auth' first or set --api-key.");
2646+
process.exitCode = 1;
2647+
return;
2648+
}
2649+
2650+
const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
2651+
2652+
const title = opts.title !== undefined ? interpretEscapes(String(opts.title)) : undefined;
2653+
const description = opts.description !== undefined ? interpretEscapes(String(opts.description)) : undefined;
2654+
2655+
let status: number | undefined = undefined;
2656+
if (opts.status !== undefined) {
2657+
const raw = String(opts.status).trim().toLowerCase();
2658+
if (raw === "open") status = 0;
2659+
else if (raw === "closed") status = 1;
2660+
else {
2661+
const n = Number(raw);
2662+
if (!Number.isFinite(n)) {
2663+
console.error("status must be open|closed|0|1");
2664+
process.exitCode = 1;
2665+
return;
2666+
}
2667+
status = n;
2668+
}
2669+
if (status !== 0 && status !== 1) {
2670+
console.error("status must be 0 (open) or 1 (closed)");
2671+
process.exitCode = 1;
2672+
return;
2673+
}
2674+
}
2675+
2676+
let labels: string[] | undefined = undefined;
2677+
if (opts.clearLabels) {
2678+
labels = [];
2679+
} else if (Array.isArray(opts.label) && opts.label.length > 0) {
2680+
labels = opts.label.map(String);
2681+
}
2682+
2683+
const result = await updateIssue({
2684+
apiKey,
2685+
apiBaseUrl,
2686+
issueId,
2687+
title,
2688+
description,
2689+
status,
2690+
labels,
2691+
debug: !!opts.debug,
2692+
});
2693+
printResult(result, opts.json);
2694+
} catch (err) {
2695+
const message = err instanceof Error ? err.message : String(err);
2696+
console.error(message);
2697+
process.exitCode = 1;
2698+
}
2699+
});
2700+
2701+
issues
2702+
.command("update-comment <commentId> <content>")
2703+
.description("update an existing issue comment")
2704+
.option("--debug", "enable debug output")
2705+
.option("--json", "output raw JSON")
2706+
.action(async (commentId: string, content: string, opts: { debug?: boolean; json?: boolean }) => {
2707+
try {
2708+
if (opts.debug) {
2709+
// eslint-disable-next-line no-console
2710+
console.log(`Debug: Original content: ${JSON.stringify(content)}`);
2711+
}
2712+
content = interpretEscapes(content);
2713+
if (opts.debug) {
2714+
// eslint-disable-next-line no-console
2715+
console.log(`Debug: Interpreted content: ${JSON.stringify(content)}`);
2716+
}
2717+
2718+
const rootOpts = program.opts<CliOptions>();
2719+
const cfg = config.readConfig();
2720+
const { apiKey } = getConfig(rootOpts);
2721+
if (!apiKey) {
2722+
console.error("API key is required. Run 'pgai auth' first or set --api-key.");
2723+
process.exitCode = 1;
2724+
return;
2725+
}
2726+
2727+
const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
2728+
2729+
const result = await updateIssueComment({
2730+
apiKey,
2731+
apiBaseUrl,
2732+
commentId,
2733+
content,
2734+
debug: !!opts.debug,
2735+
});
2736+
printResult(result, opts.json);
2737+
} catch (err) {
2738+
const message = err instanceof Error ? err.message : String(err);
2739+
console.error(message);
2740+
process.exitCode = 1;
2741+
}
2742+
});
2743+
25562744
// MCP server
25572745
const mcp = program.command("mcp").description("MCP server integration");
25582746

cli/lib/config.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,10 +56,10 @@ export function readConfig(): Config {
5656
try {
5757
const content = fs.readFileSync(userConfigPath, "utf8");
5858
const parsed = JSON.parse(content);
59-
config.apiKey = parsed.apiKey || null;
60-
config.baseUrl = parsed.baseUrl || null;
61-
config.orgId = parsed.orgId || null;
62-
config.defaultProject = parsed.defaultProject || null;
59+
config.apiKey = parsed.apiKey ?? null;
60+
config.baseUrl = parsed.baseUrl ?? null;
61+
config.orgId = parsed.orgId ?? null;
62+
config.defaultProject = parsed.defaultProject ?? null;
6363
return config;
6464
} catch (err) {
6565
const message = err instanceof Error ? err.message : String(err);

0 commit comments

Comments
 (0)