@zereight/mcp-gitlab 2.1.58 → 2.1.60
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/README.ko.md +13 -1
- package/README.md +163 -126
- package/README.zh-CN.md +13 -1
- package/build/index.js +303 -13
- package/build/schemas.js +157 -3
- package/build/scripts/check-skill-sync.js +5 -0
- package/build/scripts/generate-tool-docs.js +5 -0
- package/build/test/test-deployment-tools.js +84 -1
- package/build/test/test-orbit.js +155 -0
- package/build/test/test-permission-mode.js +1 -0
- package/build/test/test-toolset-filtering.js +3 -1
- package/build/tools/registry.js +136 -2
- package/build/tools/tool-descriptions.js +6 -0
- package/build/utils/redact-sensitive.js +11 -4
- package/package.json +1 -1
package/build/schemas.js
CHANGED
|
@@ -119,6 +119,8 @@ export const GitLabPipelineJobSchema = z.object({
|
|
|
119
119
|
web_url: z.string().optional(),
|
|
120
120
|
allow_failure: z.coerce.boolean().optional(),
|
|
121
121
|
retried: z.coerce.boolean().optional(),
|
|
122
|
+
when: z.string().optional(),
|
|
123
|
+
needs: z.array(z.unknown()).optional(),
|
|
122
124
|
tag_list: z.array(z.string()).optional(),
|
|
123
125
|
runner: z
|
|
124
126
|
.object({
|
|
@@ -257,6 +259,38 @@ export const GetPipelineSchema = z.object({
|
|
|
257
259
|
project_id: z.coerce.string().describe("Project ID or URL-encoded path"),
|
|
258
260
|
pipeline_id: z.coerce.string().describe("The ID of the pipeline"),
|
|
259
261
|
});
|
|
262
|
+
export const GetPipelineVariablesSchema = GetPipelineSchema.merge(PaginationOptionsSchema);
|
|
263
|
+
export const UpdatePipelineMetadataSchema = GetPipelineSchema.extend({
|
|
264
|
+
name: z.string().min(1).describe("New pipeline name"),
|
|
265
|
+
});
|
|
266
|
+
export const DeletePipelineSchema = GetPipelineSchema;
|
|
267
|
+
export const PipelineReportSchema = GetPipelineSchema.merge(PaginationOptionsSchema);
|
|
268
|
+
export const PlayPipelineJobsSchema = z.object({
|
|
269
|
+
project_id: z.coerce.string().describe("Project ID or URL-encoded path"),
|
|
270
|
+
job_ids: z
|
|
271
|
+
.array(z.coerce.string())
|
|
272
|
+
.min(1)
|
|
273
|
+
.refine((jobIds) => new Set(jobIds).size === jobIds.length, "job_ids must not contain duplicates")
|
|
274
|
+
.describe("Job IDs to play, in dependency order"),
|
|
275
|
+
job_variables_attributes: z
|
|
276
|
+
.array(z.object({ key: z.string(), value: z.string() }))
|
|
277
|
+
.optional()
|
|
278
|
+
.describe("Custom job variables to use when running each job"),
|
|
279
|
+
timeout_seconds: z.coerce
|
|
280
|
+
.number()
|
|
281
|
+
.int()
|
|
282
|
+
.min(1)
|
|
283
|
+
.max(600)
|
|
284
|
+
.optional()
|
|
285
|
+
.describe("Maximum seconds to wait for each job to reach a terminal status (applied per job; total duration scales with the batch size)"),
|
|
286
|
+
poll_interval_seconds: z.coerce
|
|
287
|
+
.number()
|
|
288
|
+
.int()
|
|
289
|
+
.min(1)
|
|
290
|
+
.max(30)
|
|
291
|
+
.optional()
|
|
292
|
+
.describe("Seconds between status polls while waiting for each job"),
|
|
293
|
+
});
|
|
260
294
|
export const GitLabMergeRequestPipelineSchema = z.object({
|
|
261
295
|
id: z.coerce.string(),
|
|
262
296
|
sha: z.string(),
|
|
@@ -380,6 +414,28 @@ export const GetCiCatalogResourceSchema = z.union([
|
|
|
380
414
|
message: "Provide exactly one of id or full_path",
|
|
381
415
|
});
|
|
382
416
|
// Deployment related schemas
|
|
417
|
+
const GitLabDeploymentApprovalUserSchema = z.object({
|
|
418
|
+
id: z.coerce.string().optional(),
|
|
419
|
+
username: z.string().optional(),
|
|
420
|
+
name: z.string().optional(),
|
|
421
|
+
state: z.string().optional(),
|
|
422
|
+
avatar_url: z.string().nullable().optional(),
|
|
423
|
+
web_url: z.string().optional(),
|
|
424
|
+
});
|
|
425
|
+
const GitLabDeploymentApprovalSchema = z.object({
|
|
426
|
+
user: GitLabDeploymentApprovalUserSchema.optional(),
|
|
427
|
+
status: z.string().optional(),
|
|
428
|
+
created_at: z.string().optional(),
|
|
429
|
+
comment: z.string().nullable().optional(),
|
|
430
|
+
});
|
|
431
|
+
const GitLabDeploymentApprovalRuleSchema = z.object({
|
|
432
|
+
user_id: z.coerce.string().nullable().optional(),
|
|
433
|
+
group_id: z.coerce.string().nullable().optional(),
|
|
434
|
+
access_level: z.coerce.number().nullable().optional(),
|
|
435
|
+
access_level_description: z.string().nullable().optional(),
|
|
436
|
+
required_approvals: z.coerce.number().optional(),
|
|
437
|
+
deployment_approvals: z.array(GitLabDeploymentApprovalSchema).optional(),
|
|
438
|
+
});
|
|
383
439
|
export const GitLabDeploymentSchema = z.object({
|
|
384
440
|
id: z.coerce.string(),
|
|
385
441
|
iid: z.coerce.string().optional(),
|
|
@@ -427,6 +483,11 @@ export const GitLabDeploymentSchema = z.object({
|
|
|
427
483
|
})
|
|
428
484
|
.optional(),
|
|
429
485
|
web_url: z.string().optional(),
|
|
486
|
+
pending_approval_count: z.coerce.number().optional(),
|
|
487
|
+
approvals: z.array(GitLabDeploymentApprovalSchema).optional(),
|
|
488
|
+
approval_summary: z
|
|
489
|
+
.object({ rules: z.array(GitLabDeploymentApprovalRuleSchema).optional() })
|
|
490
|
+
.optional(),
|
|
430
491
|
});
|
|
431
492
|
export const ListDeploymentsSchema = z
|
|
432
493
|
.object({
|
|
@@ -457,6 +518,19 @@ export const GetDeploymentSchema = z.object({
|
|
|
457
518
|
project_id: z.coerce.string().describe("Project ID or URL-encoded path"),
|
|
458
519
|
deployment_id: z.coerce.string().describe("The ID of the deployment"),
|
|
459
520
|
});
|
|
521
|
+
export const CreateDeploymentSchema = z.object({
|
|
522
|
+
project_id: z.coerce.string(), environment: z.string(), sha: z.string(), ref: z.string(),
|
|
523
|
+
tag: z.boolean(), status: z.enum(["running", "success", "failed", "canceled"]),
|
|
524
|
+
});
|
|
525
|
+
export const UpdateDeploymentSchema = GetDeploymentSchema.extend({
|
|
526
|
+
status: z.enum(["running", "success", "failed", "canceled"]),
|
|
527
|
+
});
|
|
528
|
+
export const DeploymentApprovalSchema = GetDeploymentSchema.extend({
|
|
529
|
+
status: z.enum(["approved", "rejected"]),
|
|
530
|
+
comment: z.string().optional(),
|
|
531
|
+
represented_as: z.string().optional(),
|
|
532
|
+
});
|
|
533
|
+
export const ListDeploymentMergeRequestsSchema = GetDeploymentSchema.merge(PaginationOptionsSchema);
|
|
460
534
|
// Environment related schemas
|
|
461
535
|
const GitLabEnvironmentLastDeploymentSchema = z.object({
|
|
462
536
|
id: z.coerce.string().optional(),
|
|
@@ -496,6 +570,31 @@ export const GetEnvironmentSchema = z.object({
|
|
|
496
570
|
project_id: z.coerce.string().describe("Project ID or URL-encoded path"),
|
|
497
571
|
environment_id: z.coerce.string().describe("The ID of the environment"),
|
|
498
572
|
});
|
|
573
|
+
export const UpdateEnvironmentSchema = GetEnvironmentSchema.extend({
|
|
574
|
+
external_url: z.string().url().nullable().optional(),
|
|
575
|
+
tier: z.enum(["production", "staging", "testing", "development", "other"]).optional(),
|
|
576
|
+
auto_stop_setting: z.enum(["always", "with_action"]).optional(),
|
|
577
|
+
});
|
|
578
|
+
export const StopEnvironmentSchema = GetEnvironmentSchema.extend({ force: coerceBooleanString.optional() });
|
|
579
|
+
export const StopStaleEnvironmentsSchema = z.object({ project_id: z.coerce.string(), before: z.string() });
|
|
580
|
+
export const DeleteReviewAppEnvironmentsSchema = z.object({
|
|
581
|
+
project_id: z.coerce.string(), before: z.string().optional(), limit: z.coerce.number().optional(),
|
|
582
|
+
dry_run: coerceBooleanString
|
|
583
|
+
.optional()
|
|
584
|
+
.describe("Preview the cleanup when true (default); set to false to schedule deletion one week later"),
|
|
585
|
+
});
|
|
586
|
+
export const PipelineTriggerIdSchema = z.object({ project_id: z.coerce.string(), trigger_id: z.coerce.string() });
|
|
587
|
+
export const ListPipelineTriggersSchema = z.object({ project_id: z.coerce.string() });
|
|
588
|
+
export const CreatePipelineTriggerSchema = z.object({ project_id: z.coerce.string(), description: z.string() });
|
|
589
|
+
export const UpdatePipelineTriggerSchema = PipelineTriggerIdSchema.extend({ description: z.string() });
|
|
590
|
+
export const TriggerPipelineSchema = z.object({
|
|
591
|
+
project_id: z.coerce.string(), token: z.string(), ref: z.string(),
|
|
592
|
+
variables: z.record(z.string()).optional(),
|
|
593
|
+
inputs: z
|
|
594
|
+
.record(z.unknown())
|
|
595
|
+
.optional()
|
|
596
|
+
.describe("Structured pipeline inputs; supported from GitLab 17.10 behind a feature flag and generally available from GitLab 18.1. Omit on older GitLab versions."),
|
|
597
|
+
});
|
|
499
598
|
// Schema for creating a new pipeline
|
|
500
599
|
export const CreatePipelineSchema = z.object({
|
|
501
600
|
project_id: z.coerce.string().describe("Project ID or URL-encoded path"),
|
|
@@ -771,15 +870,51 @@ export const PlayPipelineJobSchema = z.object({
|
|
|
771
870
|
}))
|
|
772
871
|
.optional()
|
|
773
872
|
.describe("Custom job variables to use when running the job"),
|
|
873
|
+
job_inputs: z.record(z.unknown()).optional().describe("Typed job input values"),
|
|
774
874
|
});
|
|
775
875
|
// Schema for retrying a job
|
|
776
|
-
export const RetryPipelineJobSchema = PipelineJobControlSchema
|
|
876
|
+
export const RetryPipelineJobSchema = PipelineJobControlSchema.extend({
|
|
877
|
+
job_inputs: z.record(z.unknown()).optional().describe("Typed job input values"),
|
|
878
|
+
});
|
|
777
879
|
// Schema for canceling a job
|
|
778
880
|
export const CancelPipelineJobSchema = z.object({
|
|
779
881
|
project_id: z.coerce.string().describe("Project ID or URL-encoded path"),
|
|
780
882
|
job_id: z.coerce.string().describe("The ID of the job"),
|
|
781
883
|
force: z.coerce.boolean().optional().describe("Force cancellation of the job"),
|
|
782
884
|
});
|
|
885
|
+
export const ErasePipelineJobSchema = PipelineJobControlSchema;
|
|
886
|
+
export const WaitForPipelineSchema = GetPipelineSchema.extend({
|
|
887
|
+
timeout_seconds: z.coerce
|
|
888
|
+
.number()
|
|
889
|
+
.int()
|
|
890
|
+
.min(1)
|
|
891
|
+
.max(600)
|
|
892
|
+
.optional()
|
|
893
|
+
.describe("Maximum seconds to wait for this pipeline to reach a terminal status"),
|
|
894
|
+
poll_interval_seconds: z.coerce
|
|
895
|
+
.number()
|
|
896
|
+
.int()
|
|
897
|
+
.min(1)
|
|
898
|
+
.max(30)
|
|
899
|
+
.optional()
|
|
900
|
+
.describe("Seconds between status polls while waiting for this pipeline"),
|
|
901
|
+
});
|
|
902
|
+
export const WaitForPipelineJobSchema = PipelineJobControlSchema.extend({
|
|
903
|
+
timeout_seconds: z.coerce
|
|
904
|
+
.number()
|
|
905
|
+
.int()
|
|
906
|
+
.min(1)
|
|
907
|
+
.max(600)
|
|
908
|
+
.optional()
|
|
909
|
+
.describe("Maximum seconds to wait for this job to reach a terminal status"),
|
|
910
|
+
poll_interval_seconds: z.coerce
|
|
911
|
+
.number()
|
|
912
|
+
.int()
|
|
913
|
+
.min(1)
|
|
914
|
+
.max(30)
|
|
915
|
+
.optional()
|
|
916
|
+
.describe("Seconds between status polls while waiting for this job"),
|
|
917
|
+
});
|
|
783
918
|
// User schemas
|
|
784
919
|
export const GitLabUserSchema = z.object({
|
|
785
920
|
username: z.string().optional(), // Changed from login to match GitLab API
|
|
@@ -2360,8 +2495,7 @@ export const ListGroupMergeRequestsSchema = ListMergeRequestsSchema.omit({
|
|
|
2360
2495
|
project_id: true,
|
|
2361
2496
|
}).extend({
|
|
2362
2497
|
group_id: z.coerce.string().describe("Group ID or URL-encoded path"),
|
|
2363
|
-
non_archived:
|
|
2364
|
-
.boolean()
|
|
2498
|
+
non_archived: coerceBooleanString
|
|
2365
2499
|
.optional()
|
|
2366
2500
|
.describe("Return merge requests from non-archived projects only. Defaults to true."),
|
|
2367
2501
|
source_project_id: z.coerce
|
|
@@ -4500,3 +4634,23 @@ export const ConfirmVulnerabilitySchema = z.object({
|
|
|
4500
4634
|
.describe("The ID of the vulnerability to confirm (numeric or GraphQL global ID)"),
|
|
4501
4635
|
comment: z.string().optional().describe("Optional comment explaining the confirmation"),
|
|
4502
4636
|
});
|
|
4637
|
+
// --- GitLab Orbit (knowledge graph) ---
|
|
4638
|
+
// Query the Orbit property graph: groups, projects, MRs, pipelines, issues,
|
|
4639
|
+
// vulnerabilities, and source code indexed as one queryable graph.
|
|
4640
|
+
// Requires Premium/Ultimate; Beta; query calls consume GitLab credits.
|
|
4641
|
+
export const OrbitQuerySchema = z.object({
|
|
4642
|
+
query: z
|
|
4643
|
+
.record(z.string(), z.unknown())
|
|
4644
|
+
.describe("GitLab Orbit query DSL object (see the Orbit query-language reference for node/edge types and operators)"),
|
|
4645
|
+
format: z
|
|
4646
|
+
.enum(["raw", "llm"])
|
|
4647
|
+
.optional()
|
|
4648
|
+
.default("llm")
|
|
4649
|
+
.describe("Response format: 'raw' for structured JSON, 'llm' for compact agent-optimized text (default)"),
|
|
4650
|
+
});
|
|
4651
|
+
// Schema for fetching the current Orbit graph schema (node/edge types)
|
|
4652
|
+
export const OrbitSchemaSchema = z.object({});
|
|
4653
|
+
// Schema for checking Orbit indexing status
|
|
4654
|
+
export const OrbitStatusSchema = z.object({});
|
|
4655
|
+
// Schema for listing the MCP tool definitions Orbit exposes
|
|
4656
|
+
export const OrbitToolsSchema = z.object({});
|
|
@@ -119,6 +119,10 @@ const GROUP_META = {
|
|
|
119
119
|
title: "Vulnerabilities",
|
|
120
120
|
blurb: "AI-assisted vulnerability triage — list findings, inspect details, dismiss with reason, or confirm for remediation. Backed by the GitLab GraphQL API; requires GitLab Ultimate.",
|
|
121
121
|
},
|
|
122
|
+
orbit: {
|
|
123
|
+
title: "GitLab Orbit",
|
|
124
|
+
blurb: "Query the Orbit SDLC knowledge graph (Beta; Premium/Ultimate). Graph queries consume GitLab credits; schema, status, and tool listing are free.",
|
|
125
|
+
},
|
|
122
126
|
};
|
|
123
127
|
const GROUP_ORDER = [
|
|
124
128
|
"projects",
|
|
@@ -141,6 +145,7 @@ const GROUP_ORDER = [
|
|
|
141
145
|
"search",
|
|
142
146
|
"dependency_proxy",
|
|
143
147
|
"vulnerabilities",
|
|
148
|
+
"orbit",
|
|
144
149
|
];
|
|
145
150
|
// Authoritative classification — uses the `readOnlyTools` set from
|
|
146
151
|
// tools/registry.ts. That set is what the server itself consults to decide
|
|
@@ -62,7 +62,7 @@ async function callTool(toolName, args, env) {
|
|
|
62
62
|
env: {
|
|
63
63
|
...process.env,
|
|
64
64
|
...env,
|
|
65
|
-
GITLAB_READ_ONLY_MODE: "true",
|
|
65
|
+
GITLAB_READ_ONLY_MODE: env.GITLAB_READ_ONLY_MODE ?? "true",
|
|
66
66
|
USE_PIPELINE: "true",
|
|
67
67
|
},
|
|
68
68
|
});
|
|
@@ -238,8 +238,62 @@ describe("deployment and environment tools", () => {
|
|
|
238
238
|
stage: "deploy",
|
|
239
239
|
pipeline: { id: "190349", status: "success", ref: "master", sha: "abc123" },
|
|
240
240
|
},
|
|
241
|
+
pending_approval_count: 1,
|
|
242
|
+
approvals: [
|
|
243
|
+
{
|
|
244
|
+
user: {
|
|
245
|
+
id: "100",
|
|
246
|
+
username: "security-user-1",
|
|
247
|
+
name: "Security User",
|
|
248
|
+
state: "active",
|
|
249
|
+
web_url: "https://gitlab.example.com/security-user-1",
|
|
250
|
+
},
|
|
251
|
+
status: "approved",
|
|
252
|
+
created_at: "2026-02-20T16:33:00.000Z",
|
|
253
|
+
comment: "Looks good to me",
|
|
254
|
+
},
|
|
255
|
+
],
|
|
256
|
+
approval_summary: {
|
|
257
|
+
rules: [
|
|
258
|
+
{
|
|
259
|
+
group_id: "134",
|
|
260
|
+
access_level: null,
|
|
261
|
+
access_level_description: "security-group",
|
|
262
|
+
required_approvals: 2,
|
|
263
|
+
deployment_approvals: [
|
|
264
|
+
{
|
|
265
|
+
user: {
|
|
266
|
+
id: "100",
|
|
267
|
+
username: "security-user-1",
|
|
268
|
+
name: "Security User",
|
|
269
|
+
state: "active",
|
|
270
|
+
web_url: "https://gitlab.example.com/security-user-1",
|
|
271
|
+
},
|
|
272
|
+
status: "approved",
|
|
273
|
+
created_at: "2026-02-20T16:33:00.000Z",
|
|
274
|
+
comment: "Looks good to me",
|
|
275
|
+
},
|
|
276
|
+
],
|
|
277
|
+
},
|
|
278
|
+
],
|
|
279
|
+
},
|
|
241
280
|
});
|
|
242
281
|
});
|
|
282
|
+
mockGitLab.addMockHandler("post", `/projects/${TEST_PROJECT_ID}/deployments`, (_req, res) => {
|
|
283
|
+
res.status(201).json({ id: "created-deployment", status: "created" });
|
|
284
|
+
});
|
|
285
|
+
mockGitLab.addMockHandler("put", `/projects/${TEST_PROJECT_ID}/deployments/${TEST_DEPLOYMENT_ID}`, (_req, res) => {
|
|
286
|
+
res.json({ id: TEST_DEPLOYMENT_ID, status: "success" });
|
|
287
|
+
});
|
|
288
|
+
mockGitLab.addMockHandler("delete", `/projects/${TEST_PROJECT_ID}/deployments/${TEST_DEPLOYMENT_ID}`, (_req, res) => {
|
|
289
|
+
res.status(204).send();
|
|
290
|
+
});
|
|
291
|
+
mockGitLab.addMockHandler("get", `/projects/${TEST_PROJECT_ID}/deployments/${TEST_DEPLOYMENT_ID}/merge_requests`, (_req, res) => {
|
|
292
|
+
res.json([{ iid: TEST_MERGE_REQUEST_IID, title: "Shipped change" }]);
|
|
293
|
+
});
|
|
294
|
+
mockGitLab.addMockHandler("post", `/projects/${TEST_PROJECT_ID}/deployments/${TEST_DEPLOYMENT_ID}/approval`, (_req, res) => {
|
|
295
|
+
res.json({ id: TEST_DEPLOYMENT_ID, status: "approved" });
|
|
296
|
+
});
|
|
243
297
|
mockGitLab.addMockHandler("get", `/projects/${TEST_PROJECT_ID}/environments`, (req, res) => {
|
|
244
298
|
res.json([
|
|
245
299
|
{
|
|
@@ -298,6 +352,35 @@ describe("deployment and environment tools", () => {
|
|
|
298
352
|
assert.strictEqual(result.id, TEST_DEPLOYMENT_ID);
|
|
299
353
|
assert.strictEqual(result.ref, "master");
|
|
300
354
|
assert.strictEqual(result.status, "success");
|
|
355
|
+
assert.strictEqual(result.pending_approval_count, 1);
|
|
356
|
+
assert.strictEqual(result.approvals[0].status, "approved");
|
|
357
|
+
assert.strictEqual(result.approvals[0].user.username, "security-user-1");
|
|
358
|
+
assert.strictEqual(result.approval_summary.rules[0].group_id, "134");
|
|
359
|
+
assert.strictEqual(result.approval_summary.rules[0].deployment_approvals[0].comment, "Looks good to me");
|
|
360
|
+
});
|
|
361
|
+
test("supports deployment lifecycle and approval operations", async () => {
|
|
362
|
+
const env = {
|
|
363
|
+
GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
|
|
364
|
+
GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN,
|
|
365
|
+
GITLAB_READ_ONLY_MODE: "false",
|
|
366
|
+
};
|
|
367
|
+
const created = await callTool("create_deployment", {
|
|
368
|
+
project_id: TEST_PROJECT_ID,
|
|
369
|
+
environment: "stage",
|
|
370
|
+
sha: "abc123",
|
|
371
|
+
ref: "master",
|
|
372
|
+
tag: false,
|
|
373
|
+
status: "running",
|
|
374
|
+
}, env);
|
|
375
|
+
assert.strictEqual(created.id, "created-deployment");
|
|
376
|
+
const updated = await callTool("update_deployment", { project_id: TEST_PROJECT_ID, deployment_id: TEST_DEPLOYMENT_ID, status: "success" }, env);
|
|
377
|
+
assert.strictEqual(updated.status, "success");
|
|
378
|
+
const mergeRequests = await callTool("list_deployment_merge_requests", { project_id: TEST_PROJECT_ID, deployment_id: TEST_DEPLOYMENT_ID }, env);
|
|
379
|
+
assert.strictEqual(mergeRequests[0].iid, TEST_MERGE_REQUEST_IID);
|
|
380
|
+
const approval = await callTool("approve_deployment", { project_id: TEST_PROJECT_ID, deployment_id: TEST_DEPLOYMENT_ID, status: "approved" }, env);
|
|
381
|
+
assert.strictEqual(approval.status, "approved");
|
|
382
|
+
const deleted = await callTool("delete_deployment", { project_id: TEST_PROJECT_ID, deployment_id: TEST_DEPLOYMENT_ID }, env);
|
|
383
|
+
assert.deepStrictEqual(deleted, { success: true });
|
|
301
384
|
});
|
|
302
385
|
test("list_environments returns environment list", async () => {
|
|
303
386
|
const result = await callTool("list_environments", { project_id: TEST_PROJECT_ID }, {
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { describe, test, before, after } from "node:test";
|
|
2
|
+
import assert from "node:assert";
|
|
3
|
+
import { spawn } from "child_process";
|
|
4
|
+
import { MockGitLabServer, findMockServerPort } from "./utils/mock-gitlab-server.js";
|
|
5
|
+
const MOCK_TOKEN = "glpat-mock-token-12345";
|
|
6
|
+
const mockSchema = {
|
|
7
|
+
nodes: [{ name: "Project", properties: ["id", "name"] }],
|
|
8
|
+
edges: [{ name: "HAS_MERGE_REQUEST", from: "Project", to: "MergeRequest" }],
|
|
9
|
+
};
|
|
10
|
+
const mockStatus = {
|
|
11
|
+
status: "ready",
|
|
12
|
+
last_indexed_at: "2026-09-05T10:00:00Z",
|
|
13
|
+
indexed_groups: 3,
|
|
14
|
+
};
|
|
15
|
+
const mockTools = {
|
|
16
|
+
tools: [{ name: "orbit_query", description: "Execute a graph query" }],
|
|
17
|
+
};
|
|
18
|
+
const mockQueryRaw = {
|
|
19
|
+
results: [{ project: "group/a", failures: 7 }],
|
|
20
|
+
};
|
|
21
|
+
const mockQueryLlm = "Top failing project: group/a (7 failures).";
|
|
22
|
+
function baseEnv(mockGitLabUrl) {
|
|
23
|
+
return {
|
|
24
|
+
GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
|
|
25
|
+
GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN,
|
|
26
|
+
GITLAB_TOOLSETS: "orbit",
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
async function callToolAsync(env, name, args) {
|
|
30
|
+
return new Promise((resolve, reject) => {
|
|
31
|
+
const proc = spawn("node", ["build/index.js"], {
|
|
32
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
33
|
+
env: { ...process.env, ...env },
|
|
34
|
+
});
|
|
35
|
+
let output = "";
|
|
36
|
+
let errorOutput = "";
|
|
37
|
+
proc.stdout?.on("data", (chunk) => (output += chunk));
|
|
38
|
+
proc.stderr?.on("data", (chunk) => (errorOutput += chunk));
|
|
39
|
+
proc.on("close", code => {
|
|
40
|
+
if (code !== 0) {
|
|
41
|
+
return reject(new Error(`Process exited with code ${code}: ${errorOutput}`));
|
|
42
|
+
}
|
|
43
|
+
const line = output.split("\n").find(entry => entry.startsWith("{"));
|
|
44
|
+
if (!line) {
|
|
45
|
+
return reject(new Error("No JSON output found"));
|
|
46
|
+
}
|
|
47
|
+
try {
|
|
48
|
+
const response = JSON.parse(line);
|
|
49
|
+
if (response.error) {
|
|
50
|
+
reject(new Error(JSON.stringify(response.error)));
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const content = response.result?.content?.[0]?.text;
|
|
54
|
+
if (content === undefined) {
|
|
55
|
+
reject(new Error("No tool result content"));
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
resolve(content);
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
reject(error);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
proc.stdin?.end(JSON.stringify({
|
|
65
|
+
jsonrpc: "2.0",
|
|
66
|
+
id: 1,
|
|
67
|
+
method: "tools/call",
|
|
68
|
+
params: { name, arguments: args },
|
|
69
|
+
}) + "\n");
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
describe("orbit tools", () => {
|
|
73
|
+
let mockGitLab;
|
|
74
|
+
let mockGitLabUrl;
|
|
75
|
+
let lastQueryBody = null;
|
|
76
|
+
before(async () => {
|
|
77
|
+
const mockPort = await findMockServerPort();
|
|
78
|
+
mockGitLab = new MockGitLabServer({
|
|
79
|
+
port: mockPort,
|
|
80
|
+
validTokens: [MOCK_TOKEN],
|
|
81
|
+
});
|
|
82
|
+
mockGitLab.addMockHandler("get", "/orbit/schema", (_req, res) => {
|
|
83
|
+
res.json(mockSchema);
|
|
84
|
+
});
|
|
85
|
+
mockGitLab.addMockHandler("get", "/orbit/status", (_req, res) => {
|
|
86
|
+
res.json(mockStatus);
|
|
87
|
+
});
|
|
88
|
+
mockGitLab.addMockHandler("get", "/orbit/tools", (_req, res) => {
|
|
89
|
+
res.json(mockTools);
|
|
90
|
+
});
|
|
91
|
+
mockGitLab.addMockHandler("post", "/orbit/query", (req, res) => {
|
|
92
|
+
lastQueryBody = req.body;
|
|
93
|
+
if (req.body?.format === "raw") {
|
|
94
|
+
res.json(mockQueryRaw);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
res.type("text/plain").send(mockQueryLlm);
|
|
98
|
+
});
|
|
99
|
+
await mockGitLab.start();
|
|
100
|
+
mockGitLabUrl = mockGitLab.getUrl();
|
|
101
|
+
});
|
|
102
|
+
after(async () => {
|
|
103
|
+
await mockGitLab.stop();
|
|
104
|
+
});
|
|
105
|
+
test("orbit_get_schema returns the graph schema", async () => {
|
|
106
|
+
const text = await callToolAsync(baseEnv(mockGitLabUrl), "orbit_get_schema", {});
|
|
107
|
+
assert.deepEqual(JSON.parse(text), mockSchema);
|
|
108
|
+
});
|
|
109
|
+
test("orbit_get_status returns indexing status", async () => {
|
|
110
|
+
const text = await callToolAsync(baseEnv(mockGitLabUrl), "orbit_get_status", {});
|
|
111
|
+
assert.deepEqual(JSON.parse(text), mockStatus);
|
|
112
|
+
});
|
|
113
|
+
test("orbit_list_tools returns tool definitions", async () => {
|
|
114
|
+
const text = await callToolAsync(baseEnv(mockGitLabUrl), "orbit_list_tools", {});
|
|
115
|
+
assert.deepEqual(JSON.parse(text), mockTools);
|
|
116
|
+
});
|
|
117
|
+
test("orbit_query with format=raw returns structured JSON", async () => {
|
|
118
|
+
const query = { node: "Project", limit: 5 };
|
|
119
|
+
const text = await callToolAsync(baseEnv(mockGitLabUrl), "orbit_query", {
|
|
120
|
+
query,
|
|
121
|
+
format: "raw",
|
|
122
|
+
});
|
|
123
|
+
assert.deepEqual(JSON.parse(text), mockQueryRaw);
|
|
124
|
+
assert.deepEqual(lastQueryBody, { query, format: "raw" });
|
|
125
|
+
});
|
|
126
|
+
test("orbit_query defaults to llm format and passes text through", async () => {
|
|
127
|
+
const query = { node: "Project", limit: 5 };
|
|
128
|
+
const text = await callToolAsync(baseEnv(mockGitLabUrl), "orbit_query", { query });
|
|
129
|
+
assert.equal(text, mockQueryLlm);
|
|
130
|
+
assert.deepEqual(lastQueryBody, { query, format: "llm" });
|
|
131
|
+
});
|
|
132
|
+
for (const toolName of [
|
|
133
|
+
"orbit_query",
|
|
134
|
+
"orbit_get_schema",
|
|
135
|
+
"orbit_get_status",
|
|
136
|
+
"orbit_list_tools",
|
|
137
|
+
]) {
|
|
138
|
+
test(`${toolName} is refused under strict project scope`, async () => {
|
|
139
|
+
const env = {
|
|
140
|
+
...baseEnv(mockGitLabUrl),
|
|
141
|
+
ENABLE_STRICT_PROJECT_SCOPE: "true",
|
|
142
|
+
GITLAB_ALLOWED_PROJECT_IDS: "1,2",
|
|
143
|
+
};
|
|
144
|
+
const args = toolName === "orbit_query" ? { query: {} } : {};
|
|
145
|
+
let message = "";
|
|
146
|
+
try {
|
|
147
|
+
message = await callToolAsync(env, toolName, args);
|
|
148
|
+
}
|
|
149
|
+
catch (error) {
|
|
150
|
+
message = error.message;
|
|
151
|
+
}
|
|
152
|
+
assert.match(message, /not allowed while a project allowlist is in effect/);
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
});
|
|
@@ -67,6 +67,7 @@ const MODIFY_SAMPLE_TOOLS = [
|
|
|
67
67
|
"create_branch",
|
|
68
68
|
"push_files",
|
|
69
69
|
"merge_merge_request",
|
|
70
|
+
"stop_stale_environments",
|
|
70
71
|
];
|
|
71
72
|
const READ_SAMPLE_TOOLS = ["list_issues", "get_project", "list_merge_requests"];
|
|
72
73
|
describe("Permission Mode", { concurrency: 1 }, () => {
|
|
@@ -23,7 +23,7 @@ const TOOLSET_TOOL_COUNTS = {
|
|
|
23
23
|
projects: 11,
|
|
24
24
|
labels: 5,
|
|
25
25
|
ci: 4,
|
|
26
|
-
pipelines:
|
|
26
|
+
pipelines: 56,
|
|
27
27
|
milestones: 17,
|
|
28
28
|
wiki: 10,
|
|
29
29
|
releases: 7,
|
|
@@ -36,6 +36,7 @@ const TOOLSET_TOOL_COUNTS = {
|
|
|
36
36
|
variables: 10,
|
|
37
37
|
dependency_proxy: 4,
|
|
38
38
|
vulnerabilities: 4,
|
|
39
|
+
orbit: 4,
|
|
39
40
|
};
|
|
40
41
|
const LEGACY_PIPELINE_CI_TOOL_COUNT = 2;
|
|
41
42
|
const LEGACY_PIPELINE_TOOL_COUNT = TOOLSET_TOOL_COUNTS.pipelines + LEGACY_PIPELINE_CI_TOOL_COUNT;
|
|
@@ -62,6 +63,7 @@ const NON_DEFAULT_TOOLSETS = [
|
|
|
62
63
|
"variables",
|
|
63
64
|
"dependency_proxy",
|
|
64
65
|
"vulnerabilities",
|
|
66
|
+
"orbit",
|
|
65
67
|
];
|
|
66
68
|
// discover_tools meta-tool is always force-injected (Step 5.5)
|
|
67
69
|
const DISCOVER_TOOLS_COUNT = 1;
|