@gaodes/pi-gitlab 0.3.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/.primecodex.json +19 -0
- package/.upstream.json +49 -0
- package/CHANGELOG.md +47 -0
- package/LICENSE +21 -0
- package/README.md +132 -0
- package/package.json +79 -0
- package/skills/gitlab-assistant/SKILL.md +28 -0
- package/skills/gitlab-issue/SKILL.md +41 -0
- package/skills/gitlab-mr/SKILL.md +49 -0
- package/skills/gitlab-pipeline/SKILL.md +36 -0
- package/skills/gitlab-release/SKILL.md +45 -0
- package/skills/gitlab-workflow/SKILL.md +39 -0
- package/src/commands/gitlab-doctor.ts +228 -0
- package/src/config/guard.ts +96 -0
- package/src/config/loader.ts +170 -0
- package/src/config/types.ts +57 -0
- package/src/events/resourcesDiscover.ts +27 -0
- package/src/index.ts +87 -0
- package/src/lib/confirm.ts +47 -0
- package/src/lib/env.ts +48 -0
- package/src/lib/errors.ts +156 -0
- package/src/lib/gitRemoteParse.ts +41 -0
- package/src/lib/glab.ts +34 -0
- package/src/lib/pagination.ts +3 -0
- package/src/lib/projectCache.ts +39 -0
- package/src/lib/projectFallback.ts +19 -0
- package/src/lib/redact.ts +24 -0
- package/src/lib/resolveProjectId.ts +37 -0
- package/src/lib/schemas.ts +16 -0
- package/src/tools/IMPLEMENTATION-NOTE-1B.md +33 -0
- package/src/tools/gitlab_api.ts +146 -0
- package/src/tools/gitlab_force_push_safe.ts +246 -0
- package/src/tools/gitlab_issue_close.ts +64 -0
- package/src/tools/gitlab_issue_create.ts +71 -0
- package/src/tools/gitlab_issue_list.ts +110 -0
- package/src/tools/gitlab_job_logs.ts +89 -0
- package/src/tools/gitlab_mr_bulk_approve.ts +109 -0
- package/src/tools/gitlab_mr_create.ts +83 -0
- package/src/tools/gitlab_mr_list.ts +108 -0
- package/src/tools/gitlab_mr_merge.ts +86 -0
- package/src/tools/gitlab_mr_view.ts +99 -0
- package/src/tools/gitlab_pipeline_run.ts +68 -0
- package/src/tools/gitlab_pipeline_status.ts +122 -0
- package/src/tools/gitlab_project_resolve.ts +59 -0
- package/src/tools/gitlab_release_create.ts +92 -0
- package/src/tools/gitlab_release_list.ts +97 -0
- package/src/tools/gitlab_release_view.ts +78 -0
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ExtensionAPI,
|
|
3
|
+
ExtensionContext,
|
|
4
|
+
} from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { Type } from "typebox";
|
|
6
|
+
import { requireSetup, setupRequiredResult } from "../lib/errors.js";
|
|
7
|
+
import { glab } from "../lib/glab.js";
|
|
8
|
+
import { requireConfirm } from "../lib/confirm.js";
|
|
9
|
+
import { loadConfig } from "../config/loader.js";
|
|
10
|
+
import { resolveProject } from "../lib/projectFallback.js";
|
|
11
|
+
import { resolveProjectId } from "../lib/resolveProjectId.js";
|
|
12
|
+
import { spawn } from "node:child_process";
|
|
13
|
+
|
|
14
|
+
export function registerGitlabForcePushSafe(pi: ExtensionAPI) {
|
|
15
|
+
pi.registerTool({
|
|
16
|
+
name: "gitlab_force_push_safe",
|
|
17
|
+
label: "Force Push (Safe)",
|
|
18
|
+
description:
|
|
19
|
+
"Force-push a branch after safety checks: verifies protected branch status, pauses for protection removal if needed, then re-protects after push. Requires confirmation.",
|
|
20
|
+
parameters: Type.Object(
|
|
21
|
+
{
|
|
22
|
+
remote: Type.Optional(
|
|
23
|
+
Type.String({
|
|
24
|
+
default: "origin",
|
|
25
|
+
description: "Git remote name.",
|
|
26
|
+
}),
|
|
27
|
+
),
|
|
28
|
+
branch: Type.String({
|
|
29
|
+
description: "Local branch name to push.",
|
|
30
|
+
}),
|
|
31
|
+
remoteBranch: Type.Optional(
|
|
32
|
+
Type.String({
|
|
33
|
+
description:
|
|
34
|
+
"Remote branch name. Defaults to same as local branch.",
|
|
35
|
+
}),
|
|
36
|
+
),
|
|
37
|
+
confirm: Type.Optional(Type.Boolean({ default: false })),
|
|
38
|
+
dryRun: Type.Optional(Type.Boolean({ default: false })),
|
|
39
|
+
},
|
|
40
|
+
{ additionalProperties: false },
|
|
41
|
+
),
|
|
42
|
+
async execute(
|
|
43
|
+
_toolCallId,
|
|
44
|
+
params,
|
|
45
|
+
_signal,
|
|
46
|
+
_onUpdate,
|
|
47
|
+
ctx: ExtensionContext,
|
|
48
|
+
) {
|
|
49
|
+
try {
|
|
50
|
+
requireSetup(ctx.cwd);
|
|
51
|
+
} catch {
|
|
52
|
+
return setupRequiredResult();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const config = loadConfig(ctx.cwd);
|
|
56
|
+
const remote = params.remote ?? "origin";
|
|
57
|
+
const remoteBranch = params.remoteBranch ?? params.branch;
|
|
58
|
+
|
|
59
|
+
let projectId: number;
|
|
60
|
+
try {
|
|
61
|
+
const projectPath = await resolveProject(undefined, ctx.cwd);
|
|
62
|
+
projectId = await resolveProjectId(projectPath);
|
|
63
|
+
} catch (err) {
|
|
64
|
+
return {
|
|
65
|
+
content: [
|
|
66
|
+
{
|
|
67
|
+
type: "text",
|
|
68
|
+
text: `❌ Could not resolve project ID for branch protection checks: ${String(err instanceof Error ? err.message : err)}`,
|
|
69
|
+
},
|
|
70
|
+
],
|
|
71
|
+
details: { success: false, error: "project_resolution_failed" },
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Step 1: Check protected branches
|
|
76
|
+
let isProtected = false;
|
|
77
|
+
try {
|
|
78
|
+
await glab(
|
|
79
|
+
[
|
|
80
|
+
"api",
|
|
81
|
+
`projects/${projectId}/protected_branches/${encodeURIComponent(remoteBranch)}`,
|
|
82
|
+
],
|
|
83
|
+
ctx.cwd,
|
|
84
|
+
);
|
|
85
|
+
isProtected = true;
|
|
86
|
+
} catch {
|
|
87
|
+
// Not protected or API error — treat as unprotected
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const reprotectionNote = isProtected
|
|
91
|
+
? `\n⚠️ Branch \`${remoteBranch}\` is **protected**. Will temporarily unprotect, force-push, then re-protect.`
|
|
92
|
+
: "";
|
|
93
|
+
|
|
94
|
+
const preview =
|
|
95
|
+
`Force-push \`${params.branch}\` to \`${remote}/${remoteBranch}\`` +
|
|
96
|
+
reprotectionNote;
|
|
97
|
+
|
|
98
|
+
const blocked = requireConfirm(preview, {
|
|
99
|
+
confirm: params.confirm,
|
|
100
|
+
dryRun: params.dryRun,
|
|
101
|
+
});
|
|
102
|
+
if (blocked) return blocked;
|
|
103
|
+
|
|
104
|
+
// Step 2: Temporarily unprotect if needed
|
|
105
|
+
if (isProtected) {
|
|
106
|
+
try {
|
|
107
|
+
await glab(
|
|
108
|
+
[
|
|
109
|
+
"api",
|
|
110
|
+
"-X",
|
|
111
|
+
"DELETE",
|
|
112
|
+
`projects/${projectId}/protected_branches/${encodeURIComponent(remoteBranch)}`,
|
|
113
|
+
],
|
|
114
|
+
ctx.cwd,
|
|
115
|
+
);
|
|
116
|
+
} catch (err) {
|
|
117
|
+
return {
|
|
118
|
+
content: [
|
|
119
|
+
{
|
|
120
|
+
type: "text",
|
|
121
|
+
text: `❌ Failed to unprotect branch \`${remoteBranch}\`: ${String(err instanceof Error ? err.message : err)}\n\nManual unprotection required before force-push.`,
|
|
122
|
+
},
|
|
123
|
+
],
|
|
124
|
+
details: { success: false, error: "unprotect_failed" },
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Step 3: Force push
|
|
130
|
+
try {
|
|
131
|
+
await runGitPush(
|
|
132
|
+
["push", "--force-with-lease", remote, `${params.branch}:${remoteBranch}`],
|
|
133
|
+
ctx.cwd,
|
|
134
|
+
);
|
|
135
|
+
} catch (err) {
|
|
136
|
+
// Attempt re-protection even if push fails
|
|
137
|
+
if (isProtected) {
|
|
138
|
+
await reprotectBranch(
|
|
139
|
+
ctx.cwd,
|
|
140
|
+
projectId,
|
|
141
|
+
remoteBranch,
|
|
142
|
+
config.safety.forcePushReprotectAlways,
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
return {
|
|
146
|
+
content: [
|
|
147
|
+
{
|
|
148
|
+
type: "text",
|
|
149
|
+
text: `❌ Force-push failed: ${String(err instanceof Error ? err.message : err)}`,
|
|
150
|
+
},
|
|
151
|
+
],
|
|
152
|
+
details: { success: false, error: "push_failed" },
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Step 4: Re-protect if we unprotected
|
|
157
|
+
if (isProtected) {
|
|
158
|
+
const reprotectionOk = await reprotectBranch(
|
|
159
|
+
ctx.cwd,
|
|
160
|
+
projectId,
|
|
161
|
+
remoteBranch,
|
|
162
|
+
config.safety.forcePushReprotectAlways,
|
|
163
|
+
);
|
|
164
|
+
if (!reprotectionOk) {
|
|
165
|
+
return {
|
|
166
|
+
content: [
|
|
167
|
+
{
|
|
168
|
+
type: "text",
|
|
169
|
+
text: `✅ Force-push succeeded, but **re-protection failed** for \`${remoteBranch}\`. Manually re-protect this branch.`,
|
|
170
|
+
},
|
|
171
|
+
],
|
|
172
|
+
details: {
|
|
173
|
+
success: true,
|
|
174
|
+
warning: "reprotection_failed",
|
|
175
|
+
branch: remoteBranch,
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
content: [
|
|
183
|
+
{
|
|
184
|
+
type: "text",
|
|
185
|
+
text: `✅ Force-push complete: \`${params.branch}\` → \`${remote}/${remoteBranch}\`${isProtected ? "\nBranch re-protected." : ""}`,
|
|
186
|
+
},
|
|
187
|
+
],
|
|
188
|
+
details: { success: true, branch: remoteBranch, wasProtected: isProtected },
|
|
189
|
+
};
|
|
190
|
+
},
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function reprotectBranch(
|
|
195
|
+
cwd: string | undefined,
|
|
196
|
+
projectId: number,
|
|
197
|
+
branch: string,
|
|
198
|
+
alwaysReprotect: boolean,
|
|
199
|
+
): Promise<boolean> {
|
|
200
|
+
if (!alwaysReprotect) return true;
|
|
201
|
+
|
|
202
|
+
try {
|
|
203
|
+
await glab(
|
|
204
|
+
[
|
|
205
|
+
"api",
|
|
206
|
+
"-X",
|
|
207
|
+
"POST",
|
|
208
|
+
`projects/${projectId}/protected_branches`,
|
|
209
|
+
"-f",
|
|
210
|
+
`name=${branch}`,
|
|
211
|
+
"-f",
|
|
212
|
+
"push_access_level=30",
|
|
213
|
+
"-f",
|
|
214
|
+
"merge_access_level=30",
|
|
215
|
+
],
|
|
216
|
+
cwd,
|
|
217
|
+
);
|
|
218
|
+
return true;
|
|
219
|
+
} catch {
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async function runGitPush(
|
|
225
|
+
args: string[],
|
|
226
|
+
cwd?: string,
|
|
227
|
+
): Promise<void> {
|
|
228
|
+
await new Promise<void>((resolve, reject) => {
|
|
229
|
+
const child = spawn("git", args, {
|
|
230
|
+
cwd,
|
|
231
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
232
|
+
});
|
|
233
|
+
let stderr = "";
|
|
234
|
+
child.stderr.on("data", (d) => {
|
|
235
|
+
stderr += String(d);
|
|
236
|
+
});
|
|
237
|
+
child.on("error", reject);
|
|
238
|
+
child.on("close", (code) => {
|
|
239
|
+
if (code === 0) {
|
|
240
|
+
resolve();
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
reject(new Error(stderr.trim() || `git push exited with code ${code ?? -1}`));
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Type } from "typebox";
|
|
3
|
+
import { requireSetup, setupRequiredResult } from "../lib/errors.js";
|
|
4
|
+
import { glab } from "../lib/glab.js";
|
|
5
|
+
import { requireConfirm } from "../lib/confirm.js";
|
|
6
|
+
import { resolveProject } from "../lib/projectFallback.js";
|
|
7
|
+
import { resolveProjectId } from "../lib/resolveProjectId.js";
|
|
8
|
+
import { OptionalProject } from "../lib/schemas.js";
|
|
9
|
+
|
|
10
|
+
export function registerGitlabIssueClose(pi: ExtensionAPI) {
|
|
11
|
+
pi.registerTool({
|
|
12
|
+
name: "gitlab_issue_close",
|
|
13
|
+
label: "Close Issue",
|
|
14
|
+
description: "Close an open issue. Requires confirmation.",
|
|
15
|
+
parameters: Type.Object(
|
|
16
|
+
{
|
|
17
|
+
project: OptionalProject,
|
|
18
|
+
issueId: Type.Number({ description: "Issue IID" }),
|
|
19
|
+
confirm: Type.Optional(Type.Boolean({ default: false })),
|
|
20
|
+
dryRun: Type.Optional(Type.Boolean({ default: false })),
|
|
21
|
+
},
|
|
22
|
+
{ additionalProperties: false },
|
|
23
|
+
),
|
|
24
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx: ExtensionContext) {
|
|
25
|
+
try {
|
|
26
|
+
requireSetup(ctx.cwd);
|
|
27
|
+
} catch {
|
|
28
|
+
return setupRequiredResult();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const cwd = ctx.cwd;
|
|
32
|
+
const projectPath = await resolveProject(params.project, cwd);
|
|
33
|
+
const projectId = await resolveProjectId(projectPath);
|
|
34
|
+
|
|
35
|
+
const issue = (await glab([
|
|
36
|
+
"api",
|
|
37
|
+
`projects/${projectId}/issues/${params.issueId}`,
|
|
38
|
+
])) as Record<string, unknown>;
|
|
39
|
+
|
|
40
|
+
const preview = `Close issue #${params.issueId}: **${issue.title}**\nProject: \`${projectPath}\``;
|
|
41
|
+
const blocked = requireConfirm(preview, { confirm: params.confirm, dryRun: params.dryRun });
|
|
42
|
+
if (blocked) return blocked;
|
|
43
|
+
|
|
44
|
+
const result = await glab([
|
|
45
|
+
"api",
|
|
46
|
+
"-X",
|
|
47
|
+
"PUT",
|
|
48
|
+
`projects/${projectId}/issues/${params.issueId}`,
|
|
49
|
+
"-f",
|
|
50
|
+
"state_event=close",
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
content: [
|
|
55
|
+
{
|
|
56
|
+
type: "text",
|
|
57
|
+
text: `✅ Issue #${params.issueId} closed\n${(result as Record<string, unknown>).web_url ?? ""}`,
|
|
58
|
+
},
|
|
59
|
+
],
|
|
60
|
+
details: { success: true, issue: result },
|
|
61
|
+
};
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Type } from "typebox";
|
|
3
|
+
import { requireSetup, setupRequiredResult } from "../lib/errors.js";
|
|
4
|
+
import { glab } from "../lib/glab.js";
|
|
5
|
+
import { requireConfirm } from "../lib/confirm.js";
|
|
6
|
+
import { resolveProject } from "../lib/projectFallback.js";
|
|
7
|
+
import { resolveProjectId } from "../lib/resolveProjectId.js";
|
|
8
|
+
import { OptionalProject } from "../lib/schemas.js";
|
|
9
|
+
|
|
10
|
+
export function registerGitlabIssueCreate(pi: ExtensionAPI) {
|
|
11
|
+
pi.registerTool({
|
|
12
|
+
name: "gitlab_issue_create",
|
|
13
|
+
label: "Create Issue",
|
|
14
|
+
description: "Create a new issue. Requires confirmation.",
|
|
15
|
+
parameters: Type.Object(
|
|
16
|
+
{
|
|
17
|
+
project: OptionalProject,
|
|
18
|
+
title: Type.String({ description: "Issue title" }),
|
|
19
|
+
description: Type.Optional(Type.String({ description: "Issue description" })),
|
|
20
|
+
labels: Type.Optional(Type.Array(Type.String())),
|
|
21
|
+
assignee: Type.Optional(Type.String()),
|
|
22
|
+
milestone: Type.Optional(Type.String()),
|
|
23
|
+
confidential: Type.Optional(Type.Boolean({ default: false })),
|
|
24
|
+
confirm: Type.Optional(Type.Boolean({ default: false })),
|
|
25
|
+
dryRun: Type.Optional(Type.Boolean({ default: false })),
|
|
26
|
+
},
|
|
27
|
+
{ additionalProperties: false },
|
|
28
|
+
),
|
|
29
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx: ExtensionContext) {
|
|
30
|
+
try {
|
|
31
|
+
requireSetup(ctx.cwd);
|
|
32
|
+
} catch {
|
|
33
|
+
return setupRequiredResult();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const cwd = ctx.cwd;
|
|
37
|
+
const projectPath = await resolveProject(params.project, cwd);
|
|
38
|
+
const projectId = await resolveProjectId(projectPath);
|
|
39
|
+
|
|
40
|
+
const preview = `Create issue **${params.title}**\nProject: \`${projectPath}\`${params.labels?.length ? `\nLabels: ${params.labels.join(", ")}` : ""}`;
|
|
41
|
+
const blocked = requireConfirm(preview, { confirm: params.confirm, dryRun: params.dryRun });
|
|
42
|
+
if (blocked) return blocked;
|
|
43
|
+
|
|
44
|
+
const body: Record<string, unknown> = { title: params.title };
|
|
45
|
+
if (params.description) body.description = params.description;
|
|
46
|
+
if (params.labels?.length) body.labels = params.labels.join(",");
|
|
47
|
+
if (params.assignee) body.assignee_ids = params.assignee;
|
|
48
|
+
if (params.milestone) body.milestone_id = params.milestone;
|
|
49
|
+
if (params.confidential) body.confidential = true;
|
|
50
|
+
|
|
51
|
+
const result = await glab([
|
|
52
|
+
"api",
|
|
53
|
+
"-X",
|
|
54
|
+
"POST",
|
|
55
|
+
`projects/${projectId}/issues`,
|
|
56
|
+
...Object.entries(body).flatMap(([k, v]) => ["-f", `${k}=${v}`]),
|
|
57
|
+
]);
|
|
58
|
+
|
|
59
|
+
const issue = result as Record<string, unknown>;
|
|
60
|
+
return {
|
|
61
|
+
content: [
|
|
62
|
+
{
|
|
63
|
+
type: "text",
|
|
64
|
+
text: `✅ Issue created: #${issue.iid} — ${issue.title}\n${issue.web_url ?? ""}`,
|
|
65
|
+
},
|
|
66
|
+
],
|
|
67
|
+
details: { success: true, issue },
|
|
68
|
+
};
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ExtensionAPI,
|
|
3
|
+
ExtensionContext,
|
|
4
|
+
} from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { Type } from "typebox";
|
|
6
|
+
import { requireSetup, setupRequiredResult } from "../lib/errors.js";
|
|
7
|
+
import { glab } from "../lib/glab.js";
|
|
8
|
+
import { limitRows } from "../lib/pagination.js";
|
|
9
|
+
import { resolveProject } from "../lib/projectFallback.js";
|
|
10
|
+
import { resolveProjectId } from "../lib/resolveProjectId.js";
|
|
11
|
+
import { MaxRows, OptionalProject } from "../lib/schemas.js";
|
|
12
|
+
|
|
13
|
+
interface Issue {
|
|
14
|
+
iid: number;
|
|
15
|
+
title: string;
|
|
16
|
+
state: string;
|
|
17
|
+
author?: { name?: string };
|
|
18
|
+
labels?: string[];
|
|
19
|
+
milestone?: { title?: string };
|
|
20
|
+
confidential?: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function registerGitlabIssueList(pi: ExtensionAPI) {
|
|
24
|
+
pi.registerTool({
|
|
25
|
+
name: "gitlab_issue_list",
|
|
26
|
+
label: "List Issues",
|
|
27
|
+
description: "List GitLab issues with optional filters.",
|
|
28
|
+
parameters: Type.Object(
|
|
29
|
+
{
|
|
30
|
+
project: OptionalProject,
|
|
31
|
+
state: Type.Optional(
|
|
32
|
+
Type.Union(
|
|
33
|
+
[
|
|
34
|
+
Type.Literal("opened"),
|
|
35
|
+
Type.Literal("closed"),
|
|
36
|
+
Type.Literal("all"),
|
|
37
|
+
],
|
|
38
|
+
{ default: "opened" },
|
|
39
|
+
),
|
|
40
|
+
),
|
|
41
|
+
labels: Type.Optional(Type.Array(Type.String())),
|
|
42
|
+
milestone: Type.Optional(Type.String()),
|
|
43
|
+
author: Type.Optional(Type.String()),
|
|
44
|
+
assignee: Type.Optional(Type.String()),
|
|
45
|
+
confidential: Type.Optional(Type.Boolean()),
|
|
46
|
+
search: Type.Optional(Type.String()),
|
|
47
|
+
maxRows: MaxRows,
|
|
48
|
+
},
|
|
49
|
+
{ additionalProperties: false },
|
|
50
|
+
),
|
|
51
|
+
async execute(
|
|
52
|
+
_toolCallId,
|
|
53
|
+
params,
|
|
54
|
+
_signal,
|
|
55
|
+
_onUpdate,
|
|
56
|
+
ctx: ExtensionContext,
|
|
57
|
+
) {
|
|
58
|
+
try {
|
|
59
|
+
requireSetup(ctx.cwd);
|
|
60
|
+
} catch {
|
|
61
|
+
return setupRequiredResult();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const projectPath = await resolveProject(params.project, ctx.cwd);
|
|
65
|
+
const projectId = await resolveProjectId(projectPath);
|
|
66
|
+
|
|
67
|
+
const query = new URLSearchParams();
|
|
68
|
+
query.set("state", params.state ?? "opened");
|
|
69
|
+
if (params.labels?.length) query.set("labels", params.labels.join(","));
|
|
70
|
+
if (params.milestone) query.set("milestone_title", params.milestone);
|
|
71
|
+
if (params.author) query.set("author_username", params.author);
|
|
72
|
+
if (params.assignee) query.set("assignee_username", params.assignee);
|
|
73
|
+
if (params.confidential !== undefined)
|
|
74
|
+
query.set("confidential", String(params.confidential));
|
|
75
|
+
if (params.search) query.set("search", params.search);
|
|
76
|
+
query.set("per_page", "100");
|
|
77
|
+
|
|
78
|
+
const issues = (await glab([
|
|
79
|
+
"api",
|
|
80
|
+
"--paginate",
|
|
81
|
+
`projects/${projectId}/issues?${query.toString()}`,
|
|
82
|
+
])) as Issue[];
|
|
83
|
+
|
|
84
|
+
const limited = limitRows(issues, params.maxRows ?? 25);
|
|
85
|
+
if (limited.length === 0) {
|
|
86
|
+
return {
|
|
87
|
+
content: [{ type: "text", text: "No issues found." }],
|
|
88
|
+
details: { success: true, count: 0, issues: [] },
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const lines = [
|
|
93
|
+
"| IID | Title | State | Author | Labels | Milestone |",
|
|
94
|
+
"|---|---|---|---|---|---|",
|
|
95
|
+
];
|
|
96
|
+
for (const issue of limited) {
|
|
97
|
+
const labels = issue.labels?.join(", ") ?? "-";
|
|
98
|
+
const milestone = issue.milestone?.title ?? "-";
|
|
99
|
+
lines.push(
|
|
100
|
+
`| ${issue.iid} | ${issue.title}${issue.confidential ? " 🔒" : ""} | ${issue.state} | ${issue.author?.name ?? "-"} | ${labels} | ${milestone} |`,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
content: [{ type: "text", text: lines.join("\n") }],
|
|
106
|
+
details: { success: true, count: limited.length, issues: limited },
|
|
107
|
+
};
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ExtensionAPI,
|
|
3
|
+
ExtensionContext,
|
|
4
|
+
} from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { Type } from "typebox";
|
|
6
|
+
import { requireSetup, setupRequiredResult } from "../lib/errors.js";
|
|
7
|
+
import { glab } from "../lib/glab.js";
|
|
8
|
+
import { resolveProject } from "../lib/projectFallback.js";
|
|
9
|
+
import { redact } from "../lib/redact.js";
|
|
10
|
+
import { resolveProjectId } from "../lib/resolveProjectId.js";
|
|
11
|
+
import { OptionalProject } from "../lib/schemas.js";
|
|
12
|
+
|
|
13
|
+
export function registerGitlabJobLogs(pi: ExtensionAPI) {
|
|
14
|
+
pi.registerTool({
|
|
15
|
+
name: "gitlab_job_logs",
|
|
16
|
+
label: "Job Logs",
|
|
17
|
+
description:
|
|
18
|
+
"Fetch CI job log output. Redacts common secret patterns by default and truncates to the tail.",
|
|
19
|
+
parameters: Type.Object(
|
|
20
|
+
{
|
|
21
|
+
project: OptionalProject,
|
|
22
|
+
jobId: Type.Number(),
|
|
23
|
+
tail: Type.Optional(Type.Number({ default: 200, maximum: 5000 })),
|
|
24
|
+
redact: Type.Optional(
|
|
25
|
+
Type.Boolean({
|
|
26
|
+
default: true,
|
|
27
|
+
description:
|
|
28
|
+
"Disable to view raw logs for debugging. Use with care.",
|
|
29
|
+
}),
|
|
30
|
+
),
|
|
31
|
+
},
|
|
32
|
+
{ additionalProperties: false },
|
|
33
|
+
),
|
|
34
|
+
async execute(
|
|
35
|
+
_toolCallId,
|
|
36
|
+
params,
|
|
37
|
+
_signal,
|
|
38
|
+
_onUpdate,
|
|
39
|
+
ctx: ExtensionContext,
|
|
40
|
+
) {
|
|
41
|
+
try {
|
|
42
|
+
requireSetup(ctx.cwd);
|
|
43
|
+
} catch {
|
|
44
|
+
return setupRequiredResult();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const projectPath = await resolveProject(params.project, ctx.cwd);
|
|
48
|
+
const projectId = await resolveProjectId(projectPath);
|
|
49
|
+
|
|
50
|
+
const logText = (await glab([
|
|
51
|
+
"api",
|
|
52
|
+
`projects/${projectId}/jobs/${params.jobId}/trace`,
|
|
53
|
+
])) as string;
|
|
54
|
+
|
|
55
|
+
const tailCount = params.tail ?? 200;
|
|
56
|
+
const lines = logText.split("\n");
|
|
57
|
+
const tailLines = lines.slice(-tailCount);
|
|
58
|
+
let output = tailLines.join("\n");
|
|
59
|
+
|
|
60
|
+
if (params.redact !== false) {
|
|
61
|
+
output = redact(output);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
content: [
|
|
66
|
+
{
|
|
67
|
+
type: "text",
|
|
68
|
+
text:
|
|
69
|
+
"```text\n" +
|
|
70
|
+
output +
|
|
71
|
+
"\n```\n\n*Showing last " +
|
|
72
|
+
tailLines.length +
|
|
73
|
+
" of " +
|
|
74
|
+
lines.length +
|
|
75
|
+
" lines" +
|
|
76
|
+
(params.redact !== false ? " (redacted)" : "") +
|
|
77
|
+
"*",
|
|
78
|
+
},
|
|
79
|
+
],
|
|
80
|
+
details: {
|
|
81
|
+
success: true,
|
|
82
|
+
jobId: params.jobId,
|
|
83
|
+
lines: tailLines.length,
|
|
84
|
+
total: lines.length,
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ExtensionAPI,
|
|
3
|
+
ExtensionContext,
|
|
4
|
+
} from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { Type } from "typebox";
|
|
6
|
+
import { requireSetup, setupRequiredResult } from "../lib/errors.js";
|
|
7
|
+
import { glab } from "../lib/glab.js";
|
|
8
|
+
import { requireConfirm } from "../lib/confirm.js";
|
|
9
|
+
import { resolveProject } from "../lib/projectFallback.js";
|
|
10
|
+
import { resolveProjectId } from "../lib/resolveProjectId.js";
|
|
11
|
+
import { OptionalProject } from "../lib/schemas.js";
|
|
12
|
+
|
|
13
|
+
interface BulkResult {
|
|
14
|
+
mrIid: number;
|
|
15
|
+
success: boolean;
|
|
16
|
+
message: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function registerGitlabMrBulkApprove(pi: ExtensionAPI) {
|
|
20
|
+
pi.registerTool({
|
|
21
|
+
name: "gitlab_mr_bulk_approve",
|
|
22
|
+
label: "Bulk Approve MRs",
|
|
23
|
+
description:
|
|
24
|
+
"Approve multiple merge requests in a single operation. Requires confirmation.",
|
|
25
|
+
parameters: Type.Object(
|
|
26
|
+
{
|
|
27
|
+
project: OptionalProject,
|
|
28
|
+
mrIds: Type.Array(Type.Number(), {
|
|
29
|
+
description: "List of MR IIDs to approve",
|
|
30
|
+
minItems: 1,
|
|
31
|
+
}),
|
|
32
|
+
confirm: Type.Optional(Type.Boolean({ default: false })),
|
|
33
|
+
dryRun: Type.Optional(Type.Boolean({ default: false })),
|
|
34
|
+
},
|
|
35
|
+
{ additionalProperties: false },
|
|
36
|
+
),
|
|
37
|
+
async execute(
|
|
38
|
+
_toolCallId,
|
|
39
|
+
params,
|
|
40
|
+
_signal,
|
|
41
|
+
_onUpdate,
|
|
42
|
+
ctx: ExtensionContext,
|
|
43
|
+
) {
|
|
44
|
+
try {
|
|
45
|
+
requireSetup(ctx.cwd);
|
|
46
|
+
} catch {
|
|
47
|
+
return setupRequiredResult();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const projectPath = await resolveProject(params.project, ctx.cwd);
|
|
51
|
+
const projectId = await resolveProjectId(projectPath);
|
|
52
|
+
|
|
53
|
+
const preview = `Approve ${params.mrIds.length} MR(s) in \`${projectPath}\`\nMRs: ${params.mrIds.map((id) => `!${id}`).join(", ")}`;
|
|
54
|
+
const blocked = requireConfirm(preview, {
|
|
55
|
+
confirm: params.confirm,
|
|
56
|
+
dryRun: params.dryRun,
|
|
57
|
+
});
|
|
58
|
+
if (blocked) return blocked;
|
|
59
|
+
|
|
60
|
+
const results: BulkResult[] = [];
|
|
61
|
+
for (const mrId of params.mrIds) {
|
|
62
|
+
try {
|
|
63
|
+
await glab([
|
|
64
|
+
"api",
|
|
65
|
+
"-X",
|
|
66
|
+
"POST",
|
|
67
|
+
`projects/${projectId}/merge_requests/${mrId}/approve`,
|
|
68
|
+
]);
|
|
69
|
+
results.push({
|
|
70
|
+
mrIid: mrId,
|
|
71
|
+
success: true,
|
|
72
|
+
message: "approved",
|
|
73
|
+
});
|
|
74
|
+
} catch (err) {
|
|
75
|
+
results.push({
|
|
76
|
+
mrIid: mrId,
|
|
77
|
+
success: false,
|
|
78
|
+
message: String(err instanceof Error ? err.message : err),
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const succeeded = results.filter((r) => r.success);
|
|
84
|
+
const failed = results.filter((r) => !r.success);
|
|
85
|
+
|
|
86
|
+
const lines: string[] = [];
|
|
87
|
+
if (succeeded.length > 0) {
|
|
88
|
+
lines.push(`✅ Approved ${succeeded.length} MR(s): ${succeeded.map((r) => `!${r.mrIid}`).join(", ")}`);
|
|
89
|
+
}
|
|
90
|
+
if (failed.length > 0) {
|
|
91
|
+
lines.push(`\n❌ Failed ${failed.length}:`);
|
|
92
|
+
for (const f of failed) {
|
|
93
|
+
lines.push(` - !${f.mrIid}: ${f.message}`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
content: [{ type: "text", text: lines.join("\n") }],
|
|
99
|
+
details: {
|
|
100
|
+
success: failed.length === 0,
|
|
101
|
+
total: params.mrIds.length,
|
|
102
|
+
approved: succeeded.length,
|
|
103
|
+
failed: failed.length,
|
|
104
|
+
results,
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
}
|