@f5xc-salesdemos/xcsh 18.35.2 → 18.36.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +7 -7
- package/src/config/settings-schema.ts +11 -0
- package/src/internal-urls/build-info.generated.ts +8 -8
- package/src/prompts/system/system-prompt.md +11 -2
- package/src/prompts/tools/glab-issue-list.md +7 -0
- package/src/prompts/tools/glab-issue-view.md +7 -0
- package/src/prompts/tools/glab-search.md +7 -0
- package/src/prompts/tools/glab-setup.md +6 -0
- package/src/prompts/tools/xcsh-api.md +2 -0
- package/src/tools/bash.ts +17 -1
- package/src/tools/glab/config.ts +37 -0
- package/src/tools/glab/exec.ts +66 -0
- package/src/tools/glab/formatters.ts +73 -0
- package/src/tools/glab/graphql.ts +59 -0
- package/src/tools/glab/types.ts +86 -0
- package/src/tools/glab.ts +409 -0
- package/src/tools/index.ts +7 -0
- package/src/tools/xcsh-api.ts +26 -18
- package/src/utils/git.ts +10 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@f5xc-salesdemos/xcsh",
|
|
4
|
-
"version": "18.
|
|
4
|
+
"version": "18.36.0",
|
|
5
5
|
"description": "Coding agent CLI with read, bash, edit, write tools and session management",
|
|
6
6
|
"homepage": "https://github.com/f5xc-salesdemos/xcsh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -48,12 +48,12 @@
|
|
|
48
48
|
"dependencies": {
|
|
49
49
|
"@agentclientprotocol/sdk": "0.16.1",
|
|
50
50
|
"@mozilla/readability": "^0.6",
|
|
51
|
-
"@f5xc-salesdemos/xcsh-stats": "18.
|
|
52
|
-
"@f5xc-salesdemos/pi-agent-core": "18.
|
|
53
|
-
"@f5xc-salesdemos/pi-ai": "18.
|
|
54
|
-
"@f5xc-salesdemos/pi-natives": "18.
|
|
55
|
-
"@f5xc-salesdemos/pi-tui": "18.
|
|
56
|
-
"@f5xc-salesdemos/pi-utils": "18.
|
|
51
|
+
"@f5xc-salesdemos/xcsh-stats": "18.36.0",
|
|
52
|
+
"@f5xc-salesdemos/pi-agent-core": "18.36.0",
|
|
53
|
+
"@f5xc-salesdemos/pi-ai": "18.36.0",
|
|
54
|
+
"@f5xc-salesdemos/pi-natives": "18.36.0",
|
|
55
|
+
"@f5xc-salesdemos/pi-tui": "18.36.0",
|
|
56
|
+
"@f5xc-salesdemos/pi-utils": "18.36.0",
|
|
57
57
|
"@sinclair/typebox": "^0.34",
|
|
58
58
|
"@xterm/headless": "^6.0",
|
|
59
59
|
"ajv": "^8.18",
|
|
@@ -1328,6 +1328,17 @@ export const SETTINGS_SCHEMA = {
|
|
|
1328
1328
|
},
|
|
1329
1329
|
},
|
|
1330
1330
|
|
|
1331
|
+
"gitlab.enabled": {
|
|
1332
|
+
type: "boolean",
|
|
1333
|
+
default: true,
|
|
1334
|
+
ui: {
|
|
1335
|
+
tab: "tools",
|
|
1336
|
+
label: "GitLab CLI",
|
|
1337
|
+
description:
|
|
1338
|
+
"Enable read-only glab_* tools for GitLab issue tracking, search, and work item access via glab CLI",
|
|
1339
|
+
},
|
|
1340
|
+
},
|
|
1341
|
+
|
|
1331
1342
|
"web_search.enabled": {
|
|
1332
1343
|
type: "boolean",
|
|
1333
1344
|
default: true,
|
|
@@ -17,17 +17,17 @@ export interface BuildInfo {
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
export const BUILD_INFO: BuildInfo = {
|
|
20
|
-
"version": "18.
|
|
21
|
-
"commit": "
|
|
22
|
-
"shortCommit": "
|
|
20
|
+
"version": "18.36.0",
|
|
21
|
+
"commit": "e184ba33c5bf2f52613a85f40b7ebd0d5043deb5",
|
|
22
|
+
"shortCommit": "e184ba3",
|
|
23
23
|
"branch": "main",
|
|
24
|
-
"tag": "v18.
|
|
25
|
-
"commitDate": "2026-05-
|
|
26
|
-
"buildDate": "2026-05-
|
|
24
|
+
"tag": "v18.36.0",
|
|
25
|
+
"commitDate": "2026-05-04T03:30:53Z",
|
|
26
|
+
"buildDate": "2026-05-04T03:50:55.993Z",
|
|
27
27
|
"dirty": false,
|
|
28
28
|
"prNumber": "",
|
|
29
29
|
"repoUrl": "https://github.com/f5xc-salesdemos/xcsh",
|
|
30
30
|
"repoSlug": "f5xc-salesdemos/xcsh",
|
|
31
|
-
"commitUrl": "https://github.com/f5xc-salesdemos/xcsh/commit/
|
|
32
|
-
"releaseUrl": "https://github.com/f5xc-salesdemos/xcsh/releases/tag/v18.
|
|
31
|
+
"commitUrl": "https://github.com/f5xc-salesdemos/xcsh/commit/e184ba33c5bf2f52613a85f40b7ebd0d5043deb5",
|
|
32
|
+
"releaseUrl": "https://github.com/f5xc-salesdemos/xcsh/releases/tag/v18.36.0"
|
|
33
33
|
};
|
|
@@ -196,13 +196,22 @@ Most tools resolve custom protocol URLs to internal resources (not web URLs):
|
|
|
196
196
|
|
|
197
197
|
When the user needs to **make an API call** (create, read, update, delete):
|
|
198
198
|
|
|
199
|
-
1. `xcsh://api-catalog/?resource={resource_name}` → get endpoint path, method,
|
|
200
|
-
payload JSON,
|
|
199
|
+
1. `xcsh://api-catalog/?resource={resource_name}&compact=true` → get endpoint path, method,
|
|
200
|
+
minimum payload JSON, OneOf recommendations, and response summary
|
|
201
201
|
2. Call `xcsh_api` tool with `method`, `path`, `params` (all `{placeholder}` substitutions), and `payload`
|
|
202
202
|
|
|
203
|
+
When the resource type and required parameters are clear, your **first output
|
|
204
|
+
MUST be the catalog tool call** — do not preface with explanation or deliberation.
|
|
205
|
+
If required parameters (e.g., namespace) are ambiguous, ask first.
|
|
206
|
+
|
|
203
207
|
The `xcsh_api` tool handles authentication, URL construction, and HTTP execution.
|
|
204
208
|
Never construct curl commands for F5 XC API calls — use `xcsh_api` instead.
|
|
205
209
|
|
|
210
|
+
After `xcsh_api` returns a 200 or 201 response, report the result immediately.
|
|
211
|
+
Do not issue a follow-up GET to verify — the response body is the verification.
|
|
212
|
+
Only issue a GET if the user explicitly asks to read current state, or if the
|
|
213
|
+
initial call returned a non-2xx status.
|
|
214
|
+
|
|
206
215
|
If the resource name is unknown, search first:
|
|
207
216
|
`xcsh://api-catalog/?search={term}` → find the matching category, then read it.
|
|
208
217
|
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
List GitLab issues with structured filters via glab CLI. Returns a markdown summary table.
|
|
2
|
+
|
|
3
|
+
<instruction>
|
|
4
|
+
Use for "show open bugs", "list issues assigned to X", "show high priority issues".
|
|
5
|
+
Supports: state (opened/closed/all), labels, assignee, search text, milestone, sort field, order direction, limit.
|
|
6
|
+
Defaults to the configured project. Pass project explicitly to override.
|
|
7
|
+
</instruction>
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
View a single GitLab issue with full details, description, and comments via glab CLI.
|
|
2
|
+
|
|
3
|
+
<instruction>
|
|
4
|
+
Use when the user asks to "show issue #N" or "view details of issue X".
|
|
5
|
+
Returns structured markdown with title, state, author, labels, assignee, milestone, description, and comments thread.
|
|
6
|
+
System notes (automated messages) are filtered out.
|
|
7
|
+
</instruction>
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
Full-text search across GitLab issue titles, descriptions, labels, and comments via glab CLI.
|
|
2
|
+
|
|
3
|
+
<instruction>
|
|
4
|
+
Use for "find issues about Tempus", "search for login timeout bugs", "bugs mentioning Safari".
|
|
5
|
+
Three-tier search: REST API (fast, titles+descriptions), GraphQL (includes comments), client-side dedup.
|
|
6
|
+
Supports state filtering and label filtering alongside text search.
|
|
7
|
+
</instruction>
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
GitLab onboarding wizard via glab CLI. Check installation, authenticate, discover projects, and persist configuration.
|
|
2
|
+
|
|
3
|
+
<instruction>
|
|
4
|
+
Actions: "check" (verify glab installed), "status" (auth + config), "login" (browser OAuth), "select_project" (list available projects), "save_project" (persist selected project path).
|
|
5
|
+
Run this sequence when the user first asks about GitLab issues and no project is configured.
|
|
6
|
+
</instruction>
|
|
@@ -7,3 +7,5 @@ Pass all path `{placeholder}` values via `params`, e.g. `{ namespace: "default",
|
|
|
7
7
|
Body is sent for all methods except GET when `payload` is provided — including DELETE operations that require a body.
|
|
8
8
|
|
|
9
9
|
Use this tool after reading the API catalog to get the endpoint path and payload structure.
|
|
10
|
+
|
|
11
|
+
API calls to the same F5 XC tenant reuse a single TLS connection — sequential calls are faster than parallel calls. Do not issue multiple xcsh_api calls in the same turn; issue them one at a time.
|
package/src/tools/bash.ts
CHANGED
|
@@ -772,10 +772,26 @@ export const bashToolRenderer = {
|
|
|
772
772
|
const verbose = getBashVerboseSetting();
|
|
773
773
|
const hasAsyncDetails = details?.async != null;
|
|
774
774
|
const forceExpand = isError || hasAsyncDetails || hasSixelOutput;
|
|
775
|
-
if (!verbose && !expanded && !forceExpand
|
|
775
|
+
if (!verbose && !expanded && !forceExpand) {
|
|
776
776
|
const rawCmd = args?.command;
|
|
777
777
|
const summaryText =
|
|
778
778
|
args?.description ?? (rawCmd && rawCmd.length > 60 ? `${rawCmd.slice(0, 60)}…` : rawCmd) ?? "…";
|
|
779
|
+
|
|
780
|
+
if (options.isPartial) {
|
|
781
|
+
const lineCount = rawOutputLines.filter(l => l.trim().length > 0).length;
|
|
782
|
+
const line = renderStatusLine(
|
|
783
|
+
{
|
|
784
|
+
icon: "running",
|
|
785
|
+
spinnerFrame: options.spinnerFrame,
|
|
786
|
+
title: "Bash",
|
|
787
|
+
description: summaryText,
|
|
788
|
+
meta: lineCount > 0 ? [`${lineCount} lines`] : undefined,
|
|
789
|
+
},
|
|
790
|
+
uiTheme,
|
|
791
|
+
);
|
|
792
|
+
return [truncateToWidth(line, width)];
|
|
793
|
+
}
|
|
794
|
+
|
|
779
795
|
const line = renderStatusLine(
|
|
780
796
|
{
|
|
781
797
|
title: "Bash",
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import * as fs from "node:fs/promises";
|
|
2
|
+
import * as os from "node:os";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import type { GlabConfig } from "./types";
|
|
5
|
+
|
|
6
|
+
const CONFIG_FILENAME = "glab-config.json";
|
|
7
|
+
const XCSH_DIR = ".xcsh";
|
|
8
|
+
|
|
9
|
+
export async function loadConfig(cwd: string): Promise<GlabConfig | null> {
|
|
10
|
+
const projectConfig = path.join(cwd, XCSH_DIR, CONFIG_FILENAME);
|
|
11
|
+
try {
|
|
12
|
+
const raw = await fs.readFile(projectConfig, "utf8");
|
|
13
|
+
return JSON.parse(raw) as GlabConfig;
|
|
14
|
+
} catch {
|
|
15
|
+
// try user-level fallback
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const userConfig = path.join(os.homedir(), ".xcsh", "agent", CONFIG_FILENAME);
|
|
19
|
+
try {
|
|
20
|
+
const raw = await fs.readFile(userConfig, "utf8");
|
|
21
|
+
return JSON.parse(raw) as GlabConfig;
|
|
22
|
+
} catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function saveConfig(cwd: string, config: GlabConfig): Promise<void> {
|
|
28
|
+
const dir = path.join(cwd, XCSH_DIR);
|
|
29
|
+
await fs.mkdir(dir, { recursive: true });
|
|
30
|
+
await fs.writeFile(path.join(dir, CONFIG_FILENAME), JSON.stringify(config, null, 2), "utf8");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function resolveProject(paramProject: string | undefined, cwd: string): Promise<string | null> {
|
|
34
|
+
if (paramProject) return paramProject;
|
|
35
|
+
const config = await loadConfig(cwd);
|
|
36
|
+
return config?.project ?? null;
|
|
37
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
export interface GlabExecResult {
|
|
2
|
+
stdout: string;
|
|
3
|
+
stderr: string;
|
|
4
|
+
code: number;
|
|
5
|
+
killed: boolean;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface GlabExecApi {
|
|
9
|
+
cwd: string;
|
|
10
|
+
exec(command: string, args: string[], options?: { signal?: AbortSignal; cwd?: string }): Promise<GlabExecResult>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class GlabAuthError extends Error {
|
|
14
|
+
constructor(message: string) {
|
|
15
|
+
super(`GitLab auth error: ${message}. Run glab_setup with action "login".`);
|
|
16
|
+
this.name = "GlabAuthError";
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class GlabNotFoundError extends Error {
|
|
21
|
+
constructor(message: string) {
|
|
22
|
+
super(`GitLab resource not found (404/403): ${message}`);
|
|
23
|
+
this.name = "GlabNotFoundError";
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class GlabExecError extends Error {
|
|
28
|
+
constructor(
|
|
29
|
+
message: string,
|
|
30
|
+
public readonly code: number,
|
|
31
|
+
) {
|
|
32
|
+
super(`glab command failed (exit ${code}): ${message}`);
|
|
33
|
+
this.name = "GlabExecError";
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function checkInstalled(pi: GlabExecApi): Promise<boolean> {
|
|
38
|
+
const result = await pi.exec("which", ["glab"], { cwd: pi.cwd });
|
|
39
|
+
return result.code === 0;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function checkAuth(pi: GlabExecApi): Promise<boolean> {
|
|
43
|
+
const result = await pi.exec("glab", ["auth", "status"], { cwd: pi.cwd });
|
|
44
|
+
return result.code === 0;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function execGlab(pi: GlabExecApi, args: string[], signal?: AbortSignal): Promise<GlabExecResult> {
|
|
48
|
+
const result = await pi.exec("glab", args, { signal, cwd: pi.cwd });
|
|
49
|
+
if (result.killed) throw new Error("Command was cancelled");
|
|
50
|
+
if (result.code !== 0) {
|
|
51
|
+
const stderr = result.stderr.toLowerCase();
|
|
52
|
+
if (stderr.includes("auth") || stderr.includes("not logged in") || stderr.includes("token")) {
|
|
53
|
+
throw new GlabAuthError(result.stderr);
|
|
54
|
+
}
|
|
55
|
+
if (stderr.includes("404") || stderr.includes("403") || stderr.includes("not found")) {
|
|
56
|
+
throw new GlabNotFoundError(result.stderr);
|
|
57
|
+
}
|
|
58
|
+
throw new GlabExecError(result.stderr, result.code);
|
|
59
|
+
}
|
|
60
|
+
return result;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function execGlabJson<T = unknown>(pi: GlabExecApi, args: string[], signal?: AbortSignal): Promise<T> {
|
|
64
|
+
const result = await execGlab(pi, args, signal);
|
|
65
|
+
return JSON.parse(result.stdout) as T;
|
|
66
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { GlabIssue } from "./types";
|
|
2
|
+
|
|
3
|
+
function formatDate(iso: string): string {
|
|
4
|
+
return iso.slice(0, 10);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function truncateLabels(labels: string[], maxLen = 30): string {
|
|
8
|
+
if (!labels.length) return "(none)";
|
|
9
|
+
const joined = labels.join(", ");
|
|
10
|
+
if (joined.length <= maxLen) return joined;
|
|
11
|
+
const truncated = labels.slice(0, 3).join(", ");
|
|
12
|
+
const remaining = labels.length - 3;
|
|
13
|
+
return remaining > 0 ? `${truncated}, +${remaining}` : truncated;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function formatIssueTable(issues: GlabIssue[]): string {
|
|
17
|
+
if (issues.length === 0) {
|
|
18
|
+
return "No issues found matching the criteria.";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const header = "| IID | Title | State | Labels | Assignee | Updated |";
|
|
22
|
+
const divider = "|-----|-------|-------|--------|----------|---------|";
|
|
23
|
+
|
|
24
|
+
const rows = issues.map(issue => {
|
|
25
|
+
const title = issue.title.length > 50 ? `${issue.title.slice(0, 47)}...` : issue.title;
|
|
26
|
+
const labels = truncateLabels(issue.labels);
|
|
27
|
+
const assignee = issue.assignees.length > 0 ? `@${issue.assignees[0].username}` : "unassigned";
|
|
28
|
+
const updated = formatDate(issue.updated_at);
|
|
29
|
+
return `| ${issue.iid} | ${title} | ${issue.state} | ${labels} | ${assignee} | ${updated} |`;
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
return [header, divider, ...rows].join("\n");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function formatIssueDetail(issue: GlabIssue): string {
|
|
36
|
+
const lines: string[] = [];
|
|
37
|
+
|
|
38
|
+
lines.push(`# Issue #${issue.iid}: ${issue.title}`);
|
|
39
|
+
lines.push("");
|
|
40
|
+
lines.push(
|
|
41
|
+
`State: ${issue.state} | Author: @${issue.author.username} | Created: ${formatDate(issue.created_at)} | Updated: ${formatDate(issue.updated_at)}`,
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
if (issue.labels.length > 0) {
|
|
45
|
+
lines.push(`Labels: ${issue.labels.join(", ")}`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const assigneeStr =
|
|
49
|
+
issue.assignees.length > 0 ? issue.assignees.map(a => `@${a.username}`).join(", ") : "unassigned";
|
|
50
|
+
lines.push(`Assignee: ${assigneeStr}${issue.milestone ? ` | Milestone: ${issue.milestone.title}` : ""}`);
|
|
51
|
+
lines.push("");
|
|
52
|
+
lines.push("---");
|
|
53
|
+
lines.push("");
|
|
54
|
+
lines.push(issue.description || "(no description)");
|
|
55
|
+
|
|
56
|
+
const humanNotes = (issue.notes ?? []).filter(n => !n.system);
|
|
57
|
+
lines.push("");
|
|
58
|
+
lines.push("---");
|
|
59
|
+
lines.push("");
|
|
60
|
+
|
|
61
|
+
if (humanNotes.length === 0) {
|
|
62
|
+
lines.push("No comments.");
|
|
63
|
+
} else {
|
|
64
|
+
lines.push(`## Comments (${humanNotes.length})`);
|
|
65
|
+
for (const note of humanNotes) {
|
|
66
|
+
lines.push("");
|
|
67
|
+
lines.push(`**@${note.author.username}** (${formatDate(note.created_at)}):`);
|
|
68
|
+
lines.push("> " + note.body.split("\n").join("\n> "));
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return lines.join("\n");
|
|
73
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { GlabExecApi } from "./exec";
|
|
2
|
+
import { execGlab } from "./exec";
|
|
3
|
+
import type { GraphQLIssueNode, GraphQLSearchResponse } from "./types";
|
|
4
|
+
|
|
5
|
+
export function buildSearchQuery(includeState: boolean): string {
|
|
6
|
+
const stateVar = includeState ? ", $state: IssuableState" : "";
|
|
7
|
+
const stateArg = includeState ? ", state: $state" : "";
|
|
8
|
+
return `
|
|
9
|
+
query SearchIssues($projectPath: ID!, $search: String!, $first: Int!${stateVar}) {
|
|
10
|
+
project(fullPath: $projectPath) {
|
|
11
|
+
issues(search: $search, first: $first${stateArg}) {
|
|
12
|
+
nodes {
|
|
13
|
+
iid
|
|
14
|
+
title
|
|
15
|
+
state
|
|
16
|
+
labels { nodes { title } }
|
|
17
|
+
assignees { nodes { username } }
|
|
18
|
+
updatedAt
|
|
19
|
+
notes(first: 20) {
|
|
20
|
+
nodes {
|
|
21
|
+
body
|
|
22
|
+
author { username }
|
|
23
|
+
createdAt
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}`.trim();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function executeGraphQL(
|
|
33
|
+
pi: GlabExecApi,
|
|
34
|
+
projectPath: string,
|
|
35
|
+
searchText: string,
|
|
36
|
+
limit: number,
|
|
37
|
+
signal?: AbortSignal,
|
|
38
|
+
state?: string,
|
|
39
|
+
): Promise<GraphQLIssueNode[]> {
|
|
40
|
+
const includeState = !!state && state !== "all";
|
|
41
|
+
const query = buildSearchQuery(includeState);
|
|
42
|
+
const vars: Record<string, unknown> = { projectPath, search: searchText, first: limit };
|
|
43
|
+
if (includeState) vars.state = state === "closed" ? "closed" : "opened";
|
|
44
|
+
const variables = JSON.stringify(vars);
|
|
45
|
+
|
|
46
|
+
const result = await execGlab(
|
|
47
|
+
pi,
|
|
48
|
+
["api", "graphql", "-f", `query=${query}`, "-f", `variables=${variables}`],
|
|
49
|
+
signal,
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
const response = JSON.parse(result.stdout) as GraphQLSearchResponse;
|
|
53
|
+
|
|
54
|
+
if (response.errors && response.errors.length > 0) {
|
|
55
|
+
throw new Error(response.errors.map(e => e.message).join("; "));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return response.data?.project?.issues?.nodes ?? [];
|
|
59
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// .xcsh/tools/glab/lib/types.ts
|
|
2
|
+
|
|
3
|
+
export interface GlabLabel {
|
|
4
|
+
name: string;
|
|
5
|
+
color: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface GlabUser {
|
|
9
|
+
username: string;
|
|
10
|
+
name: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface GlabMilestone {
|
|
14
|
+
title: string;
|
|
15
|
+
iid: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface GlabNote {
|
|
19
|
+
id: number;
|
|
20
|
+
body: string;
|
|
21
|
+
author: GlabUser;
|
|
22
|
+
created_at: string;
|
|
23
|
+
updated_at: string;
|
|
24
|
+
system: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface GlabIssue {
|
|
28
|
+
id: number;
|
|
29
|
+
iid: number;
|
|
30
|
+
title: string;
|
|
31
|
+
description: string;
|
|
32
|
+
state: "opened" | "closed";
|
|
33
|
+
labels: string[];
|
|
34
|
+
assignees: GlabUser[];
|
|
35
|
+
author: GlabUser;
|
|
36
|
+
milestone: GlabMilestone | null;
|
|
37
|
+
created_at: string;
|
|
38
|
+
updated_at: string;
|
|
39
|
+
web_url: string;
|
|
40
|
+
references: { full: string };
|
|
41
|
+
issue_type: string;
|
|
42
|
+
notes?: GlabNote[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface GlabProject {
|
|
46
|
+
id: number;
|
|
47
|
+
name: string;
|
|
48
|
+
name_with_namespace: string;
|
|
49
|
+
path_with_namespace: string;
|
|
50
|
+
web_url: string;
|
|
51
|
+
description: string | null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface GlabConfig {
|
|
55
|
+
project: string;
|
|
56
|
+
hostname: string;
|
|
57
|
+
defaultState: "opened" | "closed" | "all";
|
|
58
|
+
perPage: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface GraphQLIssueNode {
|
|
62
|
+
iid: string;
|
|
63
|
+
title: string;
|
|
64
|
+
state: string;
|
|
65
|
+
labels: { nodes: Array<{ title: string }> };
|
|
66
|
+
assignees: { nodes: Array<{ username: string }> };
|
|
67
|
+
updatedAt: string;
|
|
68
|
+
notes: {
|
|
69
|
+
nodes: Array<{
|
|
70
|
+
body: string;
|
|
71
|
+
author: { username: string };
|
|
72
|
+
createdAt: string;
|
|
73
|
+
}>;
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface GraphQLSearchResponse {
|
|
78
|
+
data?: {
|
|
79
|
+
project?: {
|
|
80
|
+
issues?: {
|
|
81
|
+
nodes: GraphQLIssueNode[];
|
|
82
|
+
};
|
|
83
|
+
};
|
|
84
|
+
};
|
|
85
|
+
errors?: Array<{ message: string }>;
|
|
86
|
+
}
|
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AgentTool,
|
|
3
|
+
AgentToolContext,
|
|
4
|
+
AgentToolResult,
|
|
5
|
+
AgentToolUpdateCallback,
|
|
6
|
+
} from "@f5xc-salesdemos/pi-agent-core";
|
|
7
|
+
import { prompt } from "@f5xc-salesdemos/pi-utils";
|
|
8
|
+
import { type Static, Type } from "@sinclair/typebox";
|
|
9
|
+
import glabIssueListDescription from "../prompts/tools/glab-issue-list.md" with { type: "text" };
|
|
10
|
+
import glabIssueViewDescription from "../prompts/tools/glab-issue-view.md" with { type: "text" };
|
|
11
|
+
import glabSearchDescription from "../prompts/tools/glab-search.md" with { type: "text" };
|
|
12
|
+
import glabSetupDescription from "../prompts/tools/glab-setup.md" with { type: "text" };
|
|
13
|
+
import * as git from "../utils/git";
|
|
14
|
+
import type { ToolSession } from ".";
|
|
15
|
+
import { loadConfig, resolveProject, saveConfig } from "./glab/config";
|
|
16
|
+
import type { GlabExecApi } from "./glab/exec";
|
|
17
|
+
import { checkAuth, checkInstalled, execGlabJson, GlabAuthError } from "./glab/exec";
|
|
18
|
+
import { formatIssueDetail, formatIssueTable } from "./glab/formatters";
|
|
19
|
+
import { executeGraphQL } from "./glab/graphql";
|
|
20
|
+
import type { GlabIssue, GlabProject, GraphQLIssueNode } from "./glab/types";
|
|
21
|
+
|
|
22
|
+
function makeExecApi(cwd: string): GlabExecApi {
|
|
23
|
+
return {
|
|
24
|
+
cwd,
|
|
25
|
+
async exec(command: string, args: string[], options?: { signal?: AbortSignal; cwd?: string }) {
|
|
26
|
+
const child = Bun.spawn([command, ...args], {
|
|
27
|
+
cwd: options?.cwd ?? cwd,
|
|
28
|
+
stdin: "ignore",
|
|
29
|
+
stdout: "pipe",
|
|
30
|
+
stderr: "pipe",
|
|
31
|
+
signal: options?.signal,
|
|
32
|
+
});
|
|
33
|
+
if (!child.stdout || !child.stderr) {
|
|
34
|
+
return { stdout: "", stderr: "Failed to capture output", code: 1, killed: false };
|
|
35
|
+
}
|
|
36
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
37
|
+
new Response(child.stdout).text(),
|
|
38
|
+
new Response(child.stderr).text(),
|
|
39
|
+
child.exited,
|
|
40
|
+
]);
|
|
41
|
+
return {
|
|
42
|
+
stdout: stdout.trim(),
|
|
43
|
+
stderr: stderr.trim(),
|
|
44
|
+
code: exitCode ?? 0,
|
|
45
|
+
killed: child.killed,
|
|
46
|
+
};
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ─── Schemas ─────────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
const glabSetupSchema = Type.Object({
|
|
54
|
+
action: Type.Union(
|
|
55
|
+
[
|
|
56
|
+
Type.Literal("check"),
|
|
57
|
+
Type.Literal("login"),
|
|
58
|
+
Type.Literal("select_project"),
|
|
59
|
+
Type.Literal("save_project"),
|
|
60
|
+
Type.Literal("status"),
|
|
61
|
+
],
|
|
62
|
+
{ description: "Onboarding action to perform" },
|
|
63
|
+
),
|
|
64
|
+
project: Type.Optional(Type.String({ description: "Project path to persist (used with save_project)" })),
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const glabIssueListSchema = Type.Object({
|
|
68
|
+
project: Type.Optional(
|
|
69
|
+
Type.String({ description: "GitLab project path (e.g. group/repo). Defaults to configured project." }),
|
|
70
|
+
),
|
|
71
|
+
state: Type.Optional(
|
|
72
|
+
Type.Union([Type.Literal("opened"), Type.Literal("closed"), Type.Literal("all")], {
|
|
73
|
+
description: "Filter by issue state",
|
|
74
|
+
}),
|
|
75
|
+
),
|
|
76
|
+
labels: Type.Optional(Type.Array(Type.String(), { description: "Filter by labels" })),
|
|
77
|
+
assignee: Type.Optional(Type.String({ description: "Filter by assignee username" })),
|
|
78
|
+
search: Type.Optional(Type.String({ description: "Search text in title and description" })),
|
|
79
|
+
milestone: Type.Optional(Type.String()),
|
|
80
|
+
sort: Type.Optional(
|
|
81
|
+
Type.Union(
|
|
82
|
+
[Type.Literal("created_at"), Type.Literal("updated_at"), Type.Literal("priority"), Type.Literal("due_date")],
|
|
83
|
+
{ description: "Sort field" },
|
|
84
|
+
),
|
|
85
|
+
),
|
|
86
|
+
order: Type.Optional(Type.Union([Type.Literal("asc"), Type.Literal("desc")], { description: "Sort direction" })),
|
|
87
|
+
limit: Type.Optional(Type.Number({ default: 30, maximum: 100, description: "Max results" })),
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const glabIssueViewSchema = Type.Object({
|
|
91
|
+
issue: Type.Union([Type.Number(), Type.String()], { description: "Issue IID number or full URL" }),
|
|
92
|
+
project: Type.Optional(Type.String()),
|
|
93
|
+
comments: Type.Optional(Type.Boolean({ default: true })),
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const glabSearchSchema = Type.Object({
|
|
97
|
+
query: Type.String({ description: "Search text to find across issue titles, descriptions, labels, and comments" }),
|
|
98
|
+
project: Type.Optional(Type.String()),
|
|
99
|
+
state: Type.Optional(
|
|
100
|
+
Type.Union([Type.Literal("opened"), Type.Literal("closed"), Type.Literal("all")], {
|
|
101
|
+
description: "Filter by issue state",
|
|
102
|
+
}),
|
|
103
|
+
),
|
|
104
|
+
labels: Type.Optional(Type.Array(Type.String())),
|
|
105
|
+
limit: Type.Optional(Type.Number({ default: 20, maximum: 100 })),
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
type GlabSetupInput = Static<typeof glabSetupSchema>;
|
|
109
|
+
type GlabIssueListInput = Static<typeof glabIssueListSchema>;
|
|
110
|
+
type GlabIssueViewInput = Static<typeof glabIssueViewSchema>;
|
|
111
|
+
type GlabSearchInput = Static<typeof glabSearchSchema>;
|
|
112
|
+
|
|
113
|
+
interface GlabToolDetails {
|
|
114
|
+
items?: GlabIssue[];
|
|
115
|
+
issue?: GlabIssue;
|
|
116
|
+
projects?: GlabProject[];
|
|
117
|
+
total?: number;
|
|
118
|
+
project?: string;
|
|
119
|
+
query?: string;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function textResult(text: string, details?: GlabToolDetails): AgentToolResult<GlabToolDetails> {
|
|
123
|
+
return { content: [{ type: "text", text }], details };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ─── GlabSetupTool ───────────────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
export class GlabSetupTool implements AgentTool<typeof glabSetupSchema, GlabToolDetails> {
|
|
129
|
+
readonly name = "glab_setup";
|
|
130
|
+
readonly label = "GitLab Setup";
|
|
131
|
+
readonly description = prompt.render(glabSetupDescription);
|
|
132
|
+
readonly parameters = glabSetupSchema;
|
|
133
|
+
|
|
134
|
+
constructor(private readonly session: ToolSession) {}
|
|
135
|
+
|
|
136
|
+
static createIf(session: ToolSession): GlabSetupTool | null {
|
|
137
|
+
if (!git.gitlab.available()) return null;
|
|
138
|
+
return new GlabSetupTool(session);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async execute(
|
|
142
|
+
_toolCallId: string,
|
|
143
|
+
params: GlabSetupInput,
|
|
144
|
+
signal?: AbortSignal,
|
|
145
|
+
_onUpdate?: AgentToolUpdateCallback<GlabToolDetails>,
|
|
146
|
+
_context?: AgentToolContext,
|
|
147
|
+
): Promise<AgentToolResult<GlabToolDetails>> {
|
|
148
|
+
const api = makeExecApi(this.session.cwd);
|
|
149
|
+
|
|
150
|
+
switch (params.action) {
|
|
151
|
+
case "check": {
|
|
152
|
+
const installed = await checkInstalled(api);
|
|
153
|
+
if (!installed) {
|
|
154
|
+
return textResult(
|
|
155
|
+
'glab is not installed.\n\nInstall it with:\n- **macOS**: `brew install glab`\n- **Linux**: Download from https://gitlab.com/gitlab-org/cli/-/releases\n- **Windows**: `winget install gitlab.glab`\n\nAfter installing, call glab_setup with action "login" to authenticate.',
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
const ver = await api.exec("glab", ["--version"], { signal });
|
|
159
|
+
return textResult(`glab is installed: ${ver.stdout.trim()}`);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
case "status": {
|
|
163
|
+
const authResult = await api.exec("glab", ["auth", "status"], { signal });
|
|
164
|
+
const config = await loadConfig(this.session.cwd);
|
|
165
|
+
const projectInfo = config?.project
|
|
166
|
+
? `\nConfigured project: ${config.project}`
|
|
167
|
+
: "\nNo project configured. Run select_project to choose one.";
|
|
168
|
+
const authStatus = authResult.code === 0 ? authResult.stdout : `Not authenticated: ${authResult.stderr}`;
|
|
169
|
+
return textResult(authStatus + projectInfo);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
case "login":
|
|
173
|
+
return textResult(
|
|
174
|
+
"Starting GitLab authentication...\n\nRunning: `glab auth login --hostname gitlab.com --git-protocol https --web`\n\nYour browser will open for you to authorize access. Return here after authorizing.",
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
case "select_project": {
|
|
178
|
+
const authenticated = await checkAuth(api);
|
|
179
|
+
if (!authenticated) {
|
|
180
|
+
return textResult('Not authenticated. Run glab_setup with action "login" first.');
|
|
181
|
+
}
|
|
182
|
+
const projects = await execGlabJson<GlabProject[]>(
|
|
183
|
+
api,
|
|
184
|
+
["repo", "list", "--member", "--output", "json", "--per-page", "50"],
|
|
185
|
+
signal,
|
|
186
|
+
);
|
|
187
|
+
if (!projects.length) {
|
|
188
|
+
return textResult("No projects found for your account.");
|
|
189
|
+
}
|
|
190
|
+
const list = projects
|
|
191
|
+
.map((p, i) => `${i + 1}. **${p.name_with_namespace}** — \`${p.path_with_namespace}\``)
|
|
192
|
+
.join("\n");
|
|
193
|
+
return textResult(
|
|
194
|
+
`Found ${projects.length} projects:\n\n${list}\n\nWhich project do you want to use for GitLab issue tracking? Reply with the number or full path.`,
|
|
195
|
+
{ projects },
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
case "save_project": {
|
|
200
|
+
if (!params.project) {
|
|
201
|
+
return textResult("Error: project parameter is required for save_project action.");
|
|
202
|
+
}
|
|
203
|
+
const existing = (await loadConfig(this.session.cwd)) ?? {
|
|
204
|
+
project: "",
|
|
205
|
+
hostname: "gitlab.com",
|
|
206
|
+
defaultState: "opened" as const,
|
|
207
|
+
perPage: 30,
|
|
208
|
+
};
|
|
209
|
+
await saveConfig(this.session.cwd, { ...existing, project: params.project });
|
|
210
|
+
return textResult(`Configuration saved. Default project set to: **${params.project}**`);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
default:
|
|
214
|
+
return textResult(`Unknown action: ${params.action}`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ─── GlabIssueListTool ──────────────────────────────────────────────────
|
|
220
|
+
|
|
221
|
+
export class GlabIssueListTool implements AgentTool<typeof glabIssueListSchema, GlabToolDetails> {
|
|
222
|
+
readonly name = "glab_issue_list";
|
|
223
|
+
readonly label = "GitLab Issues";
|
|
224
|
+
readonly description = prompt.render(glabIssueListDescription);
|
|
225
|
+
readonly parameters = glabIssueListSchema;
|
|
226
|
+
|
|
227
|
+
constructor(private readonly session: ToolSession) {}
|
|
228
|
+
|
|
229
|
+
static createIf(session: ToolSession): GlabIssueListTool | null {
|
|
230
|
+
if (!git.gitlab.available()) return null;
|
|
231
|
+
return new GlabIssueListTool(session);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async execute(
|
|
235
|
+
_toolCallId: string,
|
|
236
|
+
params: GlabIssueListInput,
|
|
237
|
+
signal?: AbortSignal,
|
|
238
|
+
_onUpdate?: AgentToolUpdateCallback<GlabToolDetails>,
|
|
239
|
+
_context?: AgentToolContext,
|
|
240
|
+
): Promise<AgentToolResult<GlabToolDetails>> {
|
|
241
|
+
const api = makeExecApi(this.session.cwd);
|
|
242
|
+
const project = await resolveProject(params.project, this.session.cwd);
|
|
243
|
+
if (!project) {
|
|
244
|
+
return textResult("No GitLab project configured. Run glab_setup to set one up.");
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const args = ["issue", "list", "--output", "json", "--repo", project];
|
|
248
|
+
if (params.state === "opened") args.push("--opened");
|
|
249
|
+
else if (params.state === "closed") args.push("--closed");
|
|
250
|
+
else if (params.state === "all") args.push("--all");
|
|
251
|
+
if (params.labels?.length) args.push("--label", params.labels.join(","));
|
|
252
|
+
if (params.assignee) args.push("--assignee", params.assignee);
|
|
253
|
+
if (params.search) args.push("--search", params.search);
|
|
254
|
+
if (params.milestone) args.push("--milestone", params.milestone);
|
|
255
|
+
if (params.sort) args.push("--order", params.sort);
|
|
256
|
+
if (params.order) args.push("--sort", params.order);
|
|
257
|
+
args.push("--per-page", String(Math.min(params.limit ?? 30, 100)));
|
|
258
|
+
|
|
259
|
+
try {
|
|
260
|
+
const issues = await execGlabJson<GlabIssue[]>(api, args, signal);
|
|
261
|
+
return textResult(formatIssueTable(issues), { items: issues, total: issues.length, project });
|
|
262
|
+
} catch (err) {
|
|
263
|
+
if (err instanceof GlabAuthError) return textResult((err as Error).message);
|
|
264
|
+
throw err;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ─── GlabIssueViewTool ──────────────────────────────────────────────────
|
|
270
|
+
|
|
271
|
+
export class GlabIssueViewTool implements AgentTool<typeof glabIssueViewSchema, GlabToolDetails> {
|
|
272
|
+
readonly name = "glab_issue_view";
|
|
273
|
+
readonly label = "GitLab Issue";
|
|
274
|
+
readonly description = prompt.render(glabIssueViewDescription);
|
|
275
|
+
readonly parameters = glabIssueViewSchema;
|
|
276
|
+
|
|
277
|
+
constructor(private readonly session: ToolSession) {}
|
|
278
|
+
|
|
279
|
+
static createIf(session: ToolSession): GlabIssueViewTool | null {
|
|
280
|
+
if (!git.gitlab.available()) return null;
|
|
281
|
+
return new GlabIssueViewTool(session);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async execute(
|
|
285
|
+
_toolCallId: string,
|
|
286
|
+
params: GlabIssueViewInput,
|
|
287
|
+
signal?: AbortSignal,
|
|
288
|
+
_onUpdate?: AgentToolUpdateCallback<GlabToolDetails>,
|
|
289
|
+
_context?: AgentToolContext,
|
|
290
|
+
): Promise<AgentToolResult<GlabToolDetails>> {
|
|
291
|
+
const api = makeExecApi(this.session.cwd);
|
|
292
|
+
const project = await resolveProject(params.project, this.session.cwd);
|
|
293
|
+
if (!project) {
|
|
294
|
+
return textResult("No GitLab project configured. Run glab_setup to set one up.");
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const issueId = String(params.issue);
|
|
298
|
+
const args = ["issue", "view", issueId, "--output", "json", "--repo", project];
|
|
299
|
+
if (params.comments !== false) args.push("--comments");
|
|
300
|
+
|
|
301
|
+
try {
|
|
302
|
+
const issue = await execGlabJson<GlabIssue>(api, args, signal);
|
|
303
|
+
return textResult(formatIssueDetail(issue), { issue, project });
|
|
304
|
+
} catch (err) {
|
|
305
|
+
if (err instanceof GlabAuthError) return textResult((err as Error).message);
|
|
306
|
+
throw err;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// ─── GlabSearchTool ─────────────────────────────────────────────────────
|
|
312
|
+
|
|
313
|
+
export class GlabSearchTool implements AgentTool<typeof glabSearchSchema, GlabToolDetails> {
|
|
314
|
+
readonly name = "glab_search";
|
|
315
|
+
readonly label = "GitLab Search";
|
|
316
|
+
readonly description = prompt.render(glabSearchDescription);
|
|
317
|
+
readonly parameters = glabSearchSchema;
|
|
318
|
+
|
|
319
|
+
constructor(private readonly session: ToolSession) {}
|
|
320
|
+
|
|
321
|
+
static createIf(session: ToolSession): GlabSearchTool | null {
|
|
322
|
+
if (!git.gitlab.available()) return null;
|
|
323
|
+
return new GlabSearchTool(session);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async execute(
|
|
327
|
+
_toolCallId: string,
|
|
328
|
+
params: GlabSearchInput,
|
|
329
|
+
signal?: AbortSignal,
|
|
330
|
+
_onUpdate?: AgentToolUpdateCallback<GlabToolDetails>,
|
|
331
|
+
_context?: AgentToolContext,
|
|
332
|
+
): Promise<AgentToolResult<GlabToolDetails>> {
|
|
333
|
+
const api = makeExecApi(this.session.cwd);
|
|
334
|
+
const project = await resolveProject(params.project, this.session.cwd);
|
|
335
|
+
if (!project) {
|
|
336
|
+
return textResult("No GitLab project configured. Run glab_setup to set one up.");
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const limit = Math.min(params.limit ?? 20, 100);
|
|
340
|
+
let issues: GlabIssue[] = [];
|
|
341
|
+
|
|
342
|
+
const restArgs = [
|
|
343
|
+
"issue",
|
|
344
|
+
"list",
|
|
345
|
+
"--output",
|
|
346
|
+
"json",
|
|
347
|
+
"--repo",
|
|
348
|
+
project,
|
|
349
|
+
"--search",
|
|
350
|
+
params.query,
|
|
351
|
+
"--per-page",
|
|
352
|
+
String(limit),
|
|
353
|
+
];
|
|
354
|
+
if (params.state === "opened") restArgs.push("--opened");
|
|
355
|
+
else if (params.state === "closed") restArgs.push("--closed");
|
|
356
|
+
else if (params.state === "all") restArgs.push("--all");
|
|
357
|
+
if (params.labels?.length) restArgs.push("--label", params.labels.join(","));
|
|
358
|
+
|
|
359
|
+
try {
|
|
360
|
+
issues = await execGlabJson<GlabIssue[]>(api, restArgs, signal);
|
|
361
|
+
} catch (err) {
|
|
362
|
+
if (err instanceof GlabAuthError) return textResult((err as Error).message);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
let graphqlNodes: GraphQLIssueNode[] = [];
|
|
366
|
+
try {
|
|
367
|
+
graphqlNodes = await executeGraphQL(api, project, params.query, limit, signal, params.state);
|
|
368
|
+
} catch {
|
|
369
|
+
// GraphQL unavailable — use REST results only
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (graphqlNodes.length > 0) {
|
|
373
|
+
const seenIids = new Set(issues.map(i => i.iid));
|
|
374
|
+
for (const node of graphqlNodes) {
|
|
375
|
+
const iid = parseInt(node.iid, 10);
|
|
376
|
+
if (seenIids.has(iid)) continue;
|
|
377
|
+
seenIids.add(iid);
|
|
378
|
+
const lowerQuery = params.query.toLowerCase();
|
|
379
|
+
const inTitle = node.title.toLowerCase().includes(lowerQuery);
|
|
380
|
+
const inComments = node.notes.nodes.some(n => n.body.toLowerCase().includes(lowerQuery));
|
|
381
|
+
if (inTitle || inComments) {
|
|
382
|
+
issues.push({
|
|
383
|
+
id: iid,
|
|
384
|
+
iid,
|
|
385
|
+
title: node.title,
|
|
386
|
+
description: "",
|
|
387
|
+
state: node.state === "OPEN" ? "opened" : "closed",
|
|
388
|
+
labels: node.labels.nodes.map(l => l.title),
|
|
389
|
+
assignees: node.assignees.nodes.map(a => ({ username: a.username, name: a.username })),
|
|
390
|
+
author: { username: "", name: "" },
|
|
391
|
+
milestone: null,
|
|
392
|
+
created_at: node.updatedAt,
|
|
393
|
+
updated_at: node.updatedAt,
|
|
394
|
+
web_url: `https://gitlab.com/${project}/-/issues/${iid}`,
|
|
395
|
+
references: { full: `${project}#${iid}` },
|
|
396
|
+
issue_type: "issue",
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
return textResult(formatIssueTable(issues), {
|
|
403
|
+
items: issues,
|
|
404
|
+
total: issues.length,
|
|
405
|
+
project,
|
|
406
|
+
query: params.query,
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
}
|
package/src/tools/index.ts
CHANGED
|
@@ -40,6 +40,7 @@ import {
|
|
|
40
40
|
GhSearchIssuesTool,
|
|
41
41
|
GhSearchPrsTool,
|
|
42
42
|
} from "./gh";
|
|
43
|
+
import { GlabIssueListTool, GlabIssueViewTool, GlabSearchTool, GlabSetupTool } from "./glab";
|
|
43
44
|
import { GrepTool } from "./grep";
|
|
44
45
|
import { InspectImageTool } from "./inspect-image";
|
|
45
46
|
import { NotebookTool } from "./notebook";
|
|
@@ -80,6 +81,7 @@ export * from "./exit-plan-mode";
|
|
|
80
81
|
export * from "./find";
|
|
81
82
|
export * from "./gemini-image";
|
|
82
83
|
export * from "./gh";
|
|
84
|
+
export * from "./glab";
|
|
83
85
|
export * from "./grep";
|
|
84
86
|
export * from "./inspect-image";
|
|
85
87
|
export * from "./notebook";
|
|
@@ -229,6 +231,10 @@ export const BUILTIN_TOOLS: Record<string, ToolFactory> = {
|
|
|
229
231
|
gh_run_watch: GhRunWatchTool.createIf,
|
|
230
232
|
gh_search_issues: GhSearchIssuesTool.createIf,
|
|
231
233
|
gh_search_prs: GhSearchPrsTool.createIf,
|
|
234
|
+
glab_setup: GlabSetupTool.createIf,
|
|
235
|
+
glab_issue_list: GlabIssueListTool.createIf,
|
|
236
|
+
glab_issue_view: GlabIssueViewTool.createIf,
|
|
237
|
+
glab_search: GlabSearchTool.createIf,
|
|
232
238
|
find: s => new FindTool(s),
|
|
233
239
|
grep: s => new GrepTool(s),
|
|
234
240
|
lsp: LspTool.createIf,
|
|
@@ -398,6 +404,7 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
|
|
|
398
404
|
if (name === "find") return session.settings.get("find.enabled");
|
|
399
405
|
if (name === "grep") return session.settings.get("grep.enabled");
|
|
400
406
|
if (name.startsWith("gh_")) return session.settings.get("github.enabled");
|
|
407
|
+
if (name.startsWith("glab_")) return session.settings.get("gitlab.enabled");
|
|
401
408
|
if (name === "ast_grep") return session.settings.get("astGrep.enabled");
|
|
402
409
|
if (name === "ast_edit") return session.settings.get("astEdit.enabled");
|
|
403
410
|
if (name === "render_mermaid") return session.settings.get("renderMermaid.enabled");
|
package/src/tools/xcsh-api.ts
CHANGED
|
@@ -25,6 +25,7 @@ export interface XcshApiToolDetails {
|
|
|
25
25
|
status: number;
|
|
26
26
|
url: string;
|
|
27
27
|
method: string;
|
|
28
|
+
requestId: string;
|
|
28
29
|
}
|
|
29
30
|
|
|
30
31
|
type XcshApiResult = AgentToolResult<XcshApiToolDetails> & { isError?: boolean };
|
|
@@ -35,21 +36,31 @@ export class XcshApiTool implements AgentTool<typeof xcshApiSchema, XcshApiToolD
|
|
|
35
36
|
readonly description: string;
|
|
36
37
|
readonly parameters = xcshApiSchema;
|
|
37
38
|
|
|
39
|
+
#apiBase: string;
|
|
40
|
+
#apiToken: string;
|
|
41
|
+
|
|
38
42
|
constructor(_session: ToolSession) {
|
|
39
43
|
this.description = prompt.render(xcshApiDescription);
|
|
44
|
+
this.#apiBase = (process.env.F5XC_API_URL ?? "").replace(/\/+$/, "");
|
|
45
|
+
this.#apiToken = process.env.F5XC_API_TOKEN ?? "";
|
|
46
|
+
|
|
47
|
+
if (this.#apiBase && this.#apiToken) {
|
|
48
|
+
fetch(`${this.#apiBase}/api/web/namespaces`, {
|
|
49
|
+
method: "HEAD",
|
|
50
|
+
headers: { Authorization: `APIToken ${this.#apiToken}` },
|
|
51
|
+
}).catch(() => {});
|
|
52
|
+
}
|
|
40
53
|
}
|
|
41
54
|
|
|
42
55
|
async execute(_toolCallId: string, params: XcshApiParams): Promise<XcshApiResult> {
|
|
43
|
-
|
|
44
|
-
if (!apiUrl) {
|
|
56
|
+
if (!this.#apiBase) {
|
|
45
57
|
return {
|
|
46
58
|
content: [{ type: "text", text: "Error: F5XC_API_URL environment variable is not set." }],
|
|
47
59
|
isError: true,
|
|
48
60
|
};
|
|
49
61
|
}
|
|
50
62
|
|
|
51
|
-
|
|
52
|
-
if (!apiToken) {
|
|
63
|
+
if (!this.#apiToken) {
|
|
53
64
|
return {
|
|
54
65
|
content: [{ type: "text", text: "Error: F5XC_API_TOKEN environment variable is not set." }],
|
|
55
66
|
isError: true,
|
|
@@ -63,13 +74,19 @@ export class XcshApiTool implements AgentTool<typeof xcshApiSchema, XcshApiToolD
|
|
|
63
74
|
}
|
|
64
75
|
}
|
|
65
76
|
|
|
66
|
-
const url = `${
|
|
77
|
+
const url = `${this.#apiBase}${resolvedPath}`;
|
|
78
|
+
const requestId = crypto.randomUUID();
|
|
67
79
|
const headers: Record<string, string> = {
|
|
68
|
-
Authorization: `APIToken ${apiToken}`,
|
|
80
|
+
Authorization: `APIToken ${this.#apiToken}`,
|
|
69
81
|
Accept: "application/json",
|
|
82
|
+
"X-Request-ID": requestId,
|
|
70
83
|
};
|
|
71
84
|
|
|
72
|
-
const init: RequestInit = {
|
|
85
|
+
const init: RequestInit = {
|
|
86
|
+
method: params.method,
|
|
87
|
+
headers,
|
|
88
|
+
signal: AbortSignal.timeout(30_000),
|
|
89
|
+
};
|
|
73
90
|
|
|
74
91
|
if (params.payload && params.method !== "GET") {
|
|
75
92
|
headers["Content-Type"] = "application/json";
|
|
@@ -78,21 +95,12 @@ export class XcshApiTool implements AgentTool<typeof xcshApiSchema, XcshApiToolD
|
|
|
78
95
|
|
|
79
96
|
try {
|
|
80
97
|
const response = await fetch(url, init);
|
|
81
|
-
const
|
|
82
|
-
let bodyText: string;
|
|
83
|
-
|
|
84
|
-
if (contentType.includes("application/json")) {
|
|
85
|
-
const json = await response.json();
|
|
86
|
-
bodyText = JSON.stringify(json, null, 2);
|
|
87
|
-
} else {
|
|
88
|
-
bodyText = await response.text();
|
|
89
|
-
}
|
|
90
|
-
|
|
98
|
+
const bodyText = await response.text();
|
|
91
99
|
const statusLine = `${response.status} ${response.statusText}`;
|
|
92
100
|
|
|
93
101
|
return {
|
|
94
102
|
content: [{ type: "text", text: `${statusLine}\n\n${bodyText}` }],
|
|
95
|
-
details: { status: response.status, url, method: params.method },
|
|
103
|
+
details: { status: response.status, url, method: params.method, requestId },
|
|
96
104
|
...(response.status >= 400 ? { isError: true } : {}),
|
|
97
105
|
};
|
|
98
106
|
} catch (err) {
|
package/src/utils/git.ts
CHANGED
|
@@ -1421,3 +1421,13 @@ export const github = {
|
|
|
1421
1421
|
return result.stdout;
|
|
1422
1422
|
},
|
|
1423
1423
|
};
|
|
1424
|
+
|
|
1425
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
1426
|
+
// API: gitlab (GitLab CLI — glab)
|
|
1427
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
1428
|
+
|
|
1429
|
+
export const gitlab = {
|
|
1430
|
+
available(): boolean {
|
|
1431
|
+
return Boolean($which("glab"));
|
|
1432
|
+
},
|
|
1433
|
+
};
|