Skip to content

Commit eb35c9d

Browse files
committed
Add issues comments management cli commands
1 parent 54e07f1 commit eb35c9d

File tree

4 files changed

+267
-3
lines changed

4 files changed

+267
-3
lines changed

cli/README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,17 @@ Cursor configuration example (Settings → MCP):
118118
Tools exposed:
119119
- list_issues: returns the same JSON as `pgai issues list`.
120120

121+
### Issues management (`issues` group)
122+
123+
```bash
124+
pgai issues list # List issues
125+
pgai issues comments <issueId> # List comments for an issue
126+
pgai issues post_comment <issueId> <content> # Post a comment to an issue
127+
# Options:
128+
# --parent <uuid> Parent comment ID (for replies)
129+
# --debug Enable debug output
130+
```
131+
121132
#### Grafana management
122133
```bash
123134
postgres-ai mon generate-grafana-password # Generate new Grafana password

cli/bin/postgres-ai.ts

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import * as readline from "readline";
1313
import * as http from "https";
1414
import { URL } from "url";
1515
import { startMcpServer } from "../lib/mcp-server";
16-
import { fetchIssues } from "../lib/issues";
16+
import { fetchIssues, fetchIssueComments, createIssueComment } from "../lib/issues";
1717
import { resolveBaseUrls } from "../lib/util";
1818

1919
const execPromise = promisify(exec);
@@ -1201,6 +1201,76 @@ issues
12011201
}
12021202
});
12031203

1204+
issues
1205+
.command("comments <issueId>")
1206+
.description("list comments for an issue")
1207+
.option("--debug", "enable debug output")
1208+
.action(async (issueId: string, opts: { debug?: boolean }) => {
1209+
try {
1210+
const rootOpts = program.opts<CliOptions>();
1211+
const cfg = config.readConfig();
1212+
const { apiKey } = getConfig(rootOpts);
1213+
if (!apiKey) {
1214+
console.error("API key is required. Run 'pgai auth' first or set --api-key.");
1215+
process.exitCode = 1;
1216+
return;
1217+
}
1218+
1219+
const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
1220+
1221+
const result = await fetchIssueComments({ apiKey, apiBaseUrl, issueId, debug: !!opts.debug });
1222+
if (typeof result === "string") {
1223+
process.stdout.write(result);
1224+
if (!/\n$/.test(result)) console.log();
1225+
} else {
1226+
console.log(JSON.stringify(result, null, 2));
1227+
}
1228+
} catch (err) {
1229+
const message = err instanceof Error ? err.message : String(err);
1230+
console.error(message);
1231+
process.exitCode = 1;
1232+
}
1233+
});
1234+
1235+
issues
1236+
.command("post_comment <issueId> <content>")
1237+
.description("post a new comment to an issue")
1238+
.option("--parent <uuid>", "parent comment id")
1239+
.option("--debug", "enable debug output")
1240+
.action(async (issueId: string, content: string, opts: { parent?: string; debug?: boolean }) => {
1241+
try {
1242+
const rootOpts = program.opts<CliOptions>();
1243+
const cfg = config.readConfig();
1244+
const { apiKey } = getConfig(rootOpts);
1245+
if (!apiKey) {
1246+
console.error("API key is required. Run 'pgai auth' first or set --api-key.");
1247+
process.exitCode = 1;
1248+
return;
1249+
}
1250+
1251+
const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
1252+
1253+
const result = await createIssueComment({
1254+
apiKey,
1255+
apiBaseUrl,
1256+
issueId,
1257+
content,
1258+
parentCommentId: opts.parent,
1259+
debug: !!opts.debug,
1260+
});
1261+
if (typeof result === "string") {
1262+
process.stdout.write(result);
1263+
if (!/\n$/.test(result)) console.log();
1264+
} else {
1265+
console.log(JSON.stringify(result, null, 2));
1266+
}
1267+
} catch (err) {
1268+
const message = err instanceof Error ? err.message : String(err);
1269+
console.error(message);
1270+
process.exitCode = 1;
1271+
}
1272+
});
1273+
12041274
// MCP server
12051275
const mcp = program.command("mcp").description("MCP server integration");
12061276

cli/lib/issues.ts

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,3 +81,186 @@ export async function fetchIssues(params: FetchIssuesParams): Promise<unknown> {
8181
}
8282

8383

84+
export interface FetchIssueCommentsParams {
85+
apiKey: string;
86+
apiBaseUrl: string;
87+
issueId: string;
88+
debug?: boolean;
89+
}
90+
91+
export async function fetchIssueComments(params: FetchIssueCommentsParams): Promise<unknown> {
92+
const { apiKey, apiBaseUrl, issueId, debug } = params;
93+
if (!apiKey) {
94+
throw new Error("API key is required");
95+
}
96+
if (!issueId) {
97+
throw new Error("issueId is required");
98+
}
99+
100+
const base = normalizeBaseUrl(apiBaseUrl);
101+
const url = new URL(`${base}/issue_comments?issue_id=eq.${encodeURIComponent(issueId)}`);
102+
103+
const headers: Record<string, string> = {
104+
"access-token": apiKey,
105+
"Prefer": "return=representation",
106+
"Content-Type": "application/json",
107+
};
108+
109+
if (debug) {
110+
const debugHeaders: Record<string, string> = { ...headers, "access-token": maskSecret(apiKey) };
111+
// eslint-disable-next-line no-console
112+
console.log(`Debug: Resolved API base URL: ${base}`);
113+
// eslint-disable-next-line no-console
114+
console.log(`Debug: GET URL: ${url.toString()}`);
115+
// eslint-disable-next-line no-console
116+
console.log(`Debug: Auth scheme: access-token`);
117+
// eslint-disable-next-line no-console
118+
console.log(`Debug: Request headers: ${JSON.stringify(debugHeaders)}`);
119+
}
120+
121+
return new Promise((resolve, reject) => {
122+
const req = https.request(
123+
url,
124+
{
125+
method: "GET",
126+
headers,
127+
},
128+
(res) => {
129+
let data = "";
130+
res.on("data", (chunk) => (data += chunk));
131+
res.on("end", () => {
132+
if (debug) {
133+
// eslint-disable-next-line no-console
134+
console.log(`Debug: Response status: ${res.statusCode}`);
135+
// eslint-disable-next-line no-console
136+
console.log(`Debug: Response headers: ${JSON.stringify(res.headers)}`);
137+
}
138+
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
139+
try {
140+
const parsed = JSON.parse(data);
141+
resolve(parsed);
142+
} catch {
143+
resolve(data);
144+
}
145+
} else {
146+
let errMsg = `Failed to fetch issue comments: HTTP ${res.statusCode}`;
147+
if (data) {
148+
try {
149+
const errObj = JSON.parse(data);
150+
errMsg += `\n${JSON.stringify(errObj, null, 2)}`;
151+
} catch {
152+
errMsg += `\n${data}`;
153+
}
154+
}
155+
reject(new Error(errMsg));
156+
}
157+
});
158+
}
159+
);
160+
161+
req.on("error", (err: Error) => reject(err));
162+
req.end();
163+
});
164+
}
165+
166+
export interface CreateIssueCommentParams {
167+
apiKey: string;
168+
apiBaseUrl: string;
169+
issueId: string;
170+
content: string;
171+
parentCommentId?: string;
172+
debug?: boolean;
173+
}
174+
175+
export async function createIssueComment(params: CreateIssueCommentParams): Promise<unknown> {
176+
const { apiKey, apiBaseUrl, issueId, content, parentCommentId, debug } = params;
177+
if (!apiKey) {
178+
throw new Error("API key is required");
179+
}
180+
if (!issueId) {
181+
throw new Error("issueId is required");
182+
}
183+
if (!content) {
184+
throw new Error("content is required");
185+
}
186+
187+
const base = normalizeBaseUrl(apiBaseUrl);
188+
const url = new URL(`${base}/rpc/issue_comment_create`);
189+
190+
const bodyObj: Record<string, unknown> = {
191+
issue_id: issueId,
192+
content: content,
193+
};
194+
if (parentCommentId) {
195+
bodyObj.parent_comment_id = parentCommentId;
196+
}
197+
const body = JSON.stringify(bodyObj);
198+
199+
const headers: Record<string, string> = {
200+
"access-token": apiKey,
201+
"Prefer": "return=representation",
202+
"Content-Type": "application/json",
203+
"Content-Length": Buffer.byteLength(body).toString(),
204+
};
205+
206+
if (debug) {
207+
const debugHeaders: Record<string, string> = { ...headers, "access-token": maskSecret(apiKey) };
208+
// eslint-disable-next-line no-console
209+
console.log(`Debug: Resolved API base URL: ${base}`);
210+
// eslint-disable-next-line no-console
211+
console.log(`Debug: POST URL: ${url.toString()}`);
212+
// eslint-disable-next-line no-console
213+
console.log(`Debug: Auth scheme: access-token`);
214+
// eslint-disable-next-line no-console
215+
console.log(`Debug: Request headers: ${JSON.stringify(debugHeaders)}`);
216+
// eslint-disable-next-line no-console
217+
console.log(`Debug: Request body: ${body}`);
218+
}
219+
220+
return new Promise((resolve, reject) => {
221+
const req = https.request(
222+
url,
223+
{
224+
method: "POST",
225+
headers,
226+
},
227+
(res) => {
228+
let data = "";
229+
res.on("data", (chunk) => (data += chunk));
230+
res.on("end", () => {
231+
if (debug) {
232+
// eslint-disable-next-line no-console
233+
console.log(`Debug: Response status: ${res.statusCode}`);
234+
// eslint-disable-next-line no-console
235+
console.log(`Debug: Response headers: ${JSON.stringify(res.headers)}`);
236+
}
237+
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
238+
try {
239+
const parsed = JSON.parse(data);
240+
resolve(parsed);
241+
} catch {
242+
resolve(data);
243+
}
244+
} else {
245+
let errMsg = `Failed to create issue comment: HTTP ${res.statusCode}`;
246+
if (data) {
247+
try {
248+
const errObj = JSON.parse(data);
249+
errMsg += `\n${JSON.stringify(errObj, null, 2)}`;
250+
} catch {
251+
errMsg += `\n${data}`;
252+
}
253+
}
254+
reject(new Error(errMsg));
255+
}
256+
});
257+
}
258+
);
259+
260+
req.on("error", (err: Error) => reject(err));
261+
req.write(body);
262+
req.end();
263+
});
264+
}
265+
266+

cli/package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)