@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,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Confirmation UX for mutating tools.
|
|
3
|
+
*
|
|
4
|
+
* Pattern:
|
|
5
|
+
* 1. Tool builds a preview string describing the mutation.
|
|
6
|
+
* 2. If `dryRun: true` → return preview only.
|
|
7
|
+
* 3. If `confirm: true` → execute the mutation.
|
|
8
|
+
* 4. Otherwise → return an error asking the user to set `confirm: true`.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export interface ConfirmResult {
|
|
12
|
+
content: Array<{ type: "text"; text: string }>;
|
|
13
|
+
details: Record<string, unknown>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function requireConfirm(
|
|
17
|
+
preview: string,
|
|
18
|
+
params: { dryRun?: boolean; confirm?: boolean },
|
|
19
|
+
): ConfirmResult | null {
|
|
20
|
+
if (params.dryRun) {
|
|
21
|
+
return {
|
|
22
|
+
content: [
|
|
23
|
+
{
|
|
24
|
+
type: "text",
|
|
25
|
+
text: `🔍 Preview\n\n${preview}\n\n_Set \`confirm: true\` to execute._`,
|
|
26
|
+
},
|
|
27
|
+
],
|
|
28
|
+
details: { success: true, preview, dryRun: true },
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (!params.confirm) {
|
|
33
|
+
return {
|
|
34
|
+
content: [
|
|
35
|
+
{
|
|
36
|
+
type: "text",
|
|
37
|
+
text:
|
|
38
|
+
`⚠️ Mutating operation requires confirmation.\n\n${preview}\n\n` +
|
|
39
|
+
`Set \`confirm: true\` to proceed, or \`dryRun: true\` to preview only.`,
|
|
40
|
+
},
|
|
41
|
+
],
|
|
42
|
+
details: { success: false, error: "confirmation_required", preview },
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return null;
|
|
47
|
+
}
|
package/src/lib/env.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Environment / config helpers for pi-gitlab.
|
|
3
|
+
*
|
|
4
|
+
* Phase 1 hard guard: tools must block until both PI_GITLAB_TOKEN and
|
|
5
|
+
* pi-gitlab config are present. Auto-seeding is intentionally
|
|
6
|
+
* removed from extension load; seeding now lives in explicit doctor/setup flows.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { checkSetup } from "../config/guard.js";
|
|
10
|
+
import {
|
|
11
|
+
GLOBAL_SETTINGS_PATH,
|
|
12
|
+
ensureConfig,
|
|
13
|
+
loadConfig,
|
|
14
|
+
} from "../config/loader.js";
|
|
15
|
+
|
|
16
|
+
export { GLOBAL_SETTINGS_PATH, ensureConfig, loadConfig };
|
|
17
|
+
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Token access
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
/** Get the effective GitLab token from env or config. */
|
|
23
|
+
export function getToken(): string | undefined {
|
|
24
|
+
const config = loadConfig();
|
|
25
|
+
const envKey = config.tokenEnv || "PI_GITLAB_TOKEN";
|
|
26
|
+
const val = process.env[envKey];
|
|
27
|
+
return val?.trim() ? val.trim() : undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// Derived helpers
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
/** Returns the effective API hostname from config (or GITLAB_HOST env override). */
|
|
35
|
+
export function getApiHost(): string {
|
|
36
|
+
if (process.env.GITLAB_HOST) return process.env.GITLAB_HOST;
|
|
37
|
+
return loadConfig().hostname;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Returns the SSH hostname for git remotes. */
|
|
41
|
+
export function getSshHost(): string {
|
|
42
|
+
return loadConfig().sshHostname;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Returns the SSH port for git remotes. */
|
|
46
|
+
export function getSshPort(): number {
|
|
47
|
+
return loadConfig().sshPort;
|
|
48
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// GlabError — pre-existing, used by lib/glab.ts
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
|
|
5
|
+
/** Structured error for glab CLI invocation failures. */
|
|
6
|
+
export class GlabError extends Error {
|
|
7
|
+
constructor(
|
|
8
|
+
message: string,
|
|
9
|
+
public readonly code: GlabErrorCode,
|
|
10
|
+
public readonly cause?: unknown,
|
|
11
|
+
public readonly stderr?: string,
|
|
12
|
+
public readonly exitCode?: number,
|
|
13
|
+
) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.name = "GlabError";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export enum GlabErrorCode {
|
|
20
|
+
NOT_FOUND = "NOT_FOUND",
|
|
21
|
+
EXEC_FAILED = "EXEC_FAILED",
|
|
22
|
+
PARSE_FAILED = "PARSE_FAILED",
|
|
23
|
+
VERSION_TOO_OLD = "VERSION_TOO_OLD",
|
|
24
|
+
AUTH_FAILED = "AUTH_FAILED",
|
|
25
|
+
NETWORK_ERROR = "NETWORK_ERROR",
|
|
26
|
+
API_ERROR = "API_ERROR",
|
|
27
|
+
UNKNOWN = "UNKNOWN",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Map a glab process error to a GlabError. */
|
|
31
|
+
export function mapGlabError(
|
|
32
|
+
err: unknown,
|
|
33
|
+
stderr?: string,
|
|
34
|
+
exitCode?: number,
|
|
35
|
+
): GlabError {
|
|
36
|
+
if (err instanceof GlabError) return err;
|
|
37
|
+
|
|
38
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
39
|
+
|
|
40
|
+
if (message.includes("command not found") || message.includes("ENOENT")) {
|
|
41
|
+
return new GlabError(
|
|
42
|
+
"glab CLI not found. Install glab >= 1.40.0 or run /gitlab-doctor.",
|
|
43
|
+
GlabErrorCode.NOT_FOUND,
|
|
44
|
+
err,
|
|
45
|
+
stderr,
|
|
46
|
+
exitCode,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (stderr) {
|
|
51
|
+
if (
|
|
52
|
+
stderr.includes("401") ||
|
|
53
|
+
stderr.includes("403") ||
|
|
54
|
+
stderr.includes("unauthorized") ||
|
|
55
|
+
stderr.includes("authentication required")
|
|
56
|
+
) {
|
|
57
|
+
return new GlabError(
|
|
58
|
+
"GitLab authentication failed. Check PI_GITLAB_TOKEN.",
|
|
59
|
+
GlabErrorCode.AUTH_FAILED,
|
|
60
|
+
err,
|
|
61
|
+
stderr,
|
|
62
|
+
exitCode,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
if (
|
|
66
|
+
stderr.includes("404") ||
|
|
67
|
+
stderr.includes("not found") ||
|
|
68
|
+
stderr.includes("Could not resolve")
|
|
69
|
+
) {
|
|
70
|
+
return new GlabError(
|
|
71
|
+
`GitLab API error: ${stderr.slice(0, 300)}`,
|
|
72
|
+
GlabErrorCode.API_ERROR,
|
|
73
|
+
err,
|
|
74
|
+
stderr,
|
|
75
|
+
exitCode,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
if (
|
|
79
|
+
stderr.includes("ETIMEDOUT") ||
|
|
80
|
+
stderr.includes("ECONNREFUSED") ||
|
|
81
|
+
stderr.includes("timeout") ||
|
|
82
|
+
stderr.includes("getaddrinfo")
|
|
83
|
+
) {
|
|
84
|
+
return new GlabError(
|
|
85
|
+
`GitLab network error: ${stderr.slice(0, 300)}`,
|
|
86
|
+
GlabErrorCode.NETWORK_ERROR,
|
|
87
|
+
err,
|
|
88
|
+
stderr,
|
|
89
|
+
exitCode,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return new GlabError(message, GlabErrorCode.UNKNOWN, err, stderr, exitCode);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
// SetupRequiredError — Phase 1C: setup guard integration
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
import { checkSetup } from "../config/guard.js";
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Error thrown when GitLab is not configured.
|
|
105
|
+
* Caught at the tool level; results in an actionable message to the agent.
|
|
106
|
+
*/
|
|
107
|
+
export class SetupRequiredError extends Error {
|
|
108
|
+
readonly type = "SetupRequiredError";
|
|
109
|
+
constructor(message: string) {
|
|
110
|
+
super(message);
|
|
111
|
+
this.name = "SetupRequiredError";
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Guard: throws SetupRequiredError if GitLab is not fully configured.
|
|
117
|
+
* Call at the top of each tool's execute() function.
|
|
118
|
+
*
|
|
119
|
+
* Phase 1 hard guard — blocks tool usage until both the token and
|
|
120
|
+
* explicit pi-gitlab config exist in prime-settings.json.
|
|
121
|
+
* Auto-seeding is intentionally removed from extension load; use
|
|
122
|
+
* /gitlab-doctor or explicit setup flows first.
|
|
123
|
+
*/
|
|
124
|
+
export function requireSetup(cwd?: string): void {
|
|
125
|
+
const status = checkSetup(cwd);
|
|
126
|
+
if (status.ready) return;
|
|
127
|
+
|
|
128
|
+
const message =
|
|
129
|
+
"GitLab is not configured for pi-gitlab. " +
|
|
130
|
+
"Run the `/gitlab-doctor` command to see what needs to be set up, " +
|
|
131
|
+
"then follow the guided setup to add your GitLab host and token to `prime-settings.json`.";
|
|
132
|
+
|
|
133
|
+
throw new SetupRequiredError(message);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Returns a tool-error result when GitLab has not been configured.
|
|
138
|
+
* Use in tool execute() when requireSetup() would throw.
|
|
139
|
+
*/
|
|
140
|
+
export function setupRequiredResult(): {
|
|
141
|
+
content: Array<{ type: "text"; text: string }>;
|
|
142
|
+
details: Record<string, unknown>;
|
|
143
|
+
} {
|
|
144
|
+
return {
|
|
145
|
+
content: [
|
|
146
|
+
{
|
|
147
|
+
type: "text" as const,
|
|
148
|
+
text:
|
|
149
|
+
"GitLab is not configured. " +
|
|
150
|
+
"Run `/gitlab-doctor` to see what needs to be set up, " +
|
|
151
|
+
"then follow the guided setup to add your GitLab host and token to `prime-settings.json`.",
|
|
152
|
+
},
|
|
153
|
+
],
|
|
154
|
+
details: { success: false, error: "setup_required" },
|
|
155
|
+
};
|
|
156
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
|
|
4
|
+
const execFileAsync = promisify(execFile);
|
|
5
|
+
|
|
6
|
+
export async function getGitRemoteProjectPath(
|
|
7
|
+
cwd?: string,
|
|
8
|
+
): Promise<string | undefined> {
|
|
9
|
+
try {
|
|
10
|
+
const { stdout } = await execFileAsync(
|
|
11
|
+
"git",
|
|
12
|
+
["remote", "get-url", "origin"],
|
|
13
|
+
{ cwd },
|
|
14
|
+
);
|
|
15
|
+
return parseGitUrl(stdout.trim());
|
|
16
|
+
} catch {
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function parseGitUrl(url: string): string | undefined {
|
|
22
|
+
if (url.startsWith("git@")) {
|
|
23
|
+
const idx = url.indexOf(":");
|
|
24
|
+
if (idx === -1) return undefined;
|
|
25
|
+
let path = url.slice(idx + 1);
|
|
26
|
+
// Strip SSH port prefix if present: git@host:2222/path
|
|
27
|
+
if (/^\d+\//.test(path)) {
|
|
28
|
+
path = path.replace(/^\d+\//, "");
|
|
29
|
+
}
|
|
30
|
+
return path.replace(/\.git$/, "");
|
|
31
|
+
}
|
|
32
|
+
if (url.startsWith("http")) {
|
|
33
|
+
try {
|
|
34
|
+
const u = new URL(url);
|
|
35
|
+
return u.pathname.replace(/^\//, "").replace(/\.git$/, "");
|
|
36
|
+
} catch {
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
package/src/lib/glab.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
|
|
4
|
+
const execFileAsync = promisify(execFile);
|
|
5
|
+
|
|
6
|
+
export async function glab(args: string[], cwd?: string): Promise<unknown> {
|
|
7
|
+
try {
|
|
8
|
+
const { stdout, stderr } = await execFileAsync("glab", args, {
|
|
9
|
+
cwd,
|
|
10
|
+
encoding: "utf-8",
|
|
11
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
12
|
+
env: process.env,
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
if (stderr?.trim() && !stdout.trim()) {
|
|
16
|
+
throw new Error(stderr.trim());
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const text = stdout.trim();
|
|
20
|
+
if (!text) return {};
|
|
21
|
+
try {
|
|
22
|
+
return JSON.parse(text);
|
|
23
|
+
} catch {
|
|
24
|
+
return text;
|
|
25
|
+
}
|
|
26
|
+
} catch (err: unknown) {
|
|
27
|
+
const message =
|
|
28
|
+
(err as { stderr?: string; stdout?: string; message?: string })?.stderr ||
|
|
29
|
+
(err as { stderr?: string; stdout?: string; message?: string })?.stdout ||
|
|
30
|
+
(err as { message?: string })?.message ||
|
|
31
|
+
"glab command failed";
|
|
32
|
+
throw new Error(String(message).trim());
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import type { ProjectCache } from "../config/types.js";
|
|
5
|
+
|
|
6
|
+
const CACHE_DIR = join(homedir(), ".pi", "agent", "cache", "pi-gitlab");
|
|
7
|
+
const CACHE_PATH = join(CACHE_DIR, "projects.json");
|
|
8
|
+
|
|
9
|
+
function readCache(): ProjectCache {
|
|
10
|
+
if (!existsSync(CACHE_PATH)) {
|
|
11
|
+
return { version: 1, entries: {} };
|
|
12
|
+
}
|
|
13
|
+
try {
|
|
14
|
+
const raw = readFileSync(CACHE_PATH, "utf-8");
|
|
15
|
+
return JSON.parse(raw) as ProjectCache;
|
|
16
|
+
} catch {
|
|
17
|
+
return { version: 1, entries: {} };
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function writeCache(cache: ProjectCache): void {
|
|
22
|
+
mkdirSync(CACHE_DIR, { recursive: true });
|
|
23
|
+
writeFileSync(CACHE_PATH, JSON.stringify(cache, null, 2));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function getCachedId(pathWithNamespace: string): number | undefined {
|
|
27
|
+
const cache = readCache();
|
|
28
|
+
return cache.entries[pathWithNamespace]?.id;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function setCachedId(pathWithNamespace: string, id: number): void {
|
|
32
|
+
const cache = readCache();
|
|
33
|
+
cache.entries[pathWithNamespace] = {
|
|
34
|
+
id,
|
|
35
|
+
pathWithNamespace,
|
|
36
|
+
resolvedAt: new Date().toISOString(),
|
|
37
|
+
};
|
|
38
|
+
writeCache(cache);
|
|
39
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { loadConfig } from "../config/loader.js";
|
|
2
|
+
import { getGitRemoteProjectPath } from "./gitRemoteParse.js";
|
|
3
|
+
|
|
4
|
+
export async function resolveProject(
|
|
5
|
+
project?: string,
|
|
6
|
+
cwd?: string,
|
|
7
|
+
): Promise<string> {
|
|
8
|
+
if (project) return project;
|
|
9
|
+
|
|
10
|
+
const gitPath = await getGitRemoteProjectPath(cwd);
|
|
11
|
+
if (gitPath) return gitPath;
|
|
12
|
+
|
|
13
|
+
const config = loadConfig(cwd);
|
|
14
|
+
if (config.defaultProjectPath) return config.defaultProjectPath;
|
|
15
|
+
|
|
16
|
+
throw new Error(
|
|
17
|
+
"No project specified and could not determine default from git remote or settings.",
|
|
18
|
+
);
|
|
19
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
const PATTERNS: Array<{ regex: RegExp; replacement: string }> = [
|
|
2
|
+
{ regex: /token=[\w-]+/gi, replacement: "token=***REDACTED***" },
|
|
3
|
+
{ regex: /Bearer\s+[\w-]+/gi, replacement: "Bearer ***REDACTED***" },
|
|
4
|
+
{ regex: /password=[^\s&]+/gi, replacement: "password=***REDACTED***" },
|
|
5
|
+
{ regex: /secret=[^\s&]+/gi, replacement: "secret=***REDACTED***" },
|
|
6
|
+
{ regex: /key=[A-Za-z0-9+/=]{20,}/gi, replacement: "key=***REDACTED***" },
|
|
7
|
+
{
|
|
8
|
+
regex: /AWS_ACCESS_KEY_ID=[A-Z0-9]{20}/gi,
|
|
9
|
+
replacement: "AWS_ACCESS_KEY_ID=***REDACTED***",
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
regex: /AWS_SECRET_ACCESS_KEY=[A-Za-z0-9/+=]{40}/gi,
|
|
13
|
+
replacement: "AWS_SECRET_ACCESS_KEY=***REDACTED***",
|
|
14
|
+
},
|
|
15
|
+
{ regex: /glpat-[a-zA-Z0-9-]{20}/g, replacement: "***REDACTED***" },
|
|
16
|
+
{ regex: /ghp_[a-zA-Z0-9]{36}/g, replacement: "***REDACTED***" },
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
export function redact(text: string): string {
|
|
20
|
+
return PATTERNS.reduce(
|
|
21
|
+
(acc, { regex, replacement }) => acc.replace(regex, replacement),
|
|
22
|
+
text,
|
|
23
|
+
);
|
|
24
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { glab } from "./glab.js";
|
|
2
|
+
import { getCachedId, setCachedId } from "./projectCache.js";
|
|
3
|
+
|
|
4
|
+
export async function resolveProjectId(
|
|
5
|
+
project: string,
|
|
6
|
+
force = false,
|
|
7
|
+
): Promise<number> {
|
|
8
|
+
const numeric = Number(project);
|
|
9
|
+
if (!Number.isNaN(numeric) && numeric > 0) {
|
|
10
|
+
try {
|
|
11
|
+
await glab(["api", `projects/${numeric}`]);
|
|
12
|
+
return numeric;
|
|
13
|
+
} catch {
|
|
14
|
+
throw new Error(`Project ID ${numeric} not found or not accessible.`);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (!force) {
|
|
19
|
+
const cached = getCachedId(project);
|
|
20
|
+
if (cached) return cached;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const lastSegment = project.split("/").pop() ?? project;
|
|
24
|
+
const results = (await glab([
|
|
25
|
+
"api",
|
|
26
|
+
"--paginate",
|
|
27
|
+
`projects?search=${encodeURIComponent(lastSegment)}&per_page=100`,
|
|
28
|
+
])) as Array<{ id: number; path_with_namespace: string }>;
|
|
29
|
+
|
|
30
|
+
const match = results.find((r) => r.path_with_namespace === project);
|
|
31
|
+
if (!match) {
|
|
32
|
+
throw new Error(`Project '${project}' not found.`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
setCachedId(project, match.id);
|
|
36
|
+
return match.id;
|
|
37
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { Type } from "typebox";
|
|
2
|
+
|
|
3
|
+
export const OptionalProject = Type.Optional(
|
|
4
|
+
Type.String({
|
|
5
|
+
description:
|
|
6
|
+
"Project path (e.g. 'agents/primecodex/packages/pi-gitlab') or numeric ID. Optional — falls back to CWD git remote, then settings default.",
|
|
7
|
+
}),
|
|
8
|
+
);
|
|
9
|
+
|
|
10
|
+
export const MaxRows = Type.Optional(
|
|
11
|
+
Type.Number({
|
|
12
|
+
default: 25,
|
|
13
|
+
maximum: 200,
|
|
14
|
+
description: "Maximum rows to return.",
|
|
15
|
+
}),
|
|
16
|
+
);
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Phase 1B Implementation Note
|
|
2
|
+
|
|
3
|
+
## Files implemented
|
|
4
|
+
- `src/lib/glab.ts` — glab CLI wrapper with JSON parsing and error handling
|
|
5
|
+
- `src/lib/env.ts` — token/config helpers (fixed broken `ensureConfig` import from parallel edit)
|
|
6
|
+
- `src/lib/projectCache.ts` — atomic read/write cache at `~/.pi/agent/cache/pi-gitlab/projects.json`
|
|
7
|
+
- `src/lib/gitRemoteParse.ts` — parse git remote URL to namespace/path
|
|
8
|
+
- `src/lib/projectFallback.ts` — resolve project from arg → git remote → settings default
|
|
9
|
+
- `src/lib/resolveProjectId.ts` — path → numeric ID via cache + paginated glab search
|
|
10
|
+
- `src/lib/pagination.ts` — client-side row limiting
|
|
11
|
+
- `src/lib/redact.ts` — regex-based secret redaction for job logs
|
|
12
|
+
- `src/lib/schemas.ts` — shared TypeBox parameter schemas
|
|
13
|
+
- `src/tools/gitlab_project_resolve.ts` — resolve + cache project ID
|
|
14
|
+
- `src/tools/gitlab_mr_list.ts` — filtered MR list with markdown table output
|
|
15
|
+
- `src/tools/gitlab_mr_view.ts` — MR metadata, discussions, optional diff
|
|
16
|
+
- `src/tools/gitlab_issue_list.ts` — filtered issue list with markdown table output
|
|
17
|
+
- `src/tools/gitlab_pipeline_status.ts` — pipeline + optional job status
|
|
18
|
+
- `src/tools/gitlab_job_logs.ts` — tail + redaction of CI job trace
|
|
19
|
+
- `src/tools/gitlab_api.ts` — raw passthrough with `:project` substitution; DELETE gated by `confirm:true`
|
|
20
|
+
|
|
21
|
+
## Design decisions
|
|
22
|
+
- All list tools use `glab api --paginate` with `per_page=100` then client-side `limitRows()` to respect `maxRows` param.
|
|
23
|
+
- All tools return `{ content: [{type: "text", text: markdown}], details: { success: true, ... } }` for Pi consumption.
|
|
24
|
+
- `gitlab_api` converts `body` into `-f key=value` args; nested values are JSON-stringified. Mutating verbs show a preview line.
|
|
25
|
+
- `gitlab_job_logs` fetches `/jobs/:id/trace` which returns plain text; `redact()` applies before tail truncation.
|
|
26
|
+
- No wiring/index/command/events/skills were edited per task constraint.
|
|
27
|
+
|
|
28
|
+
## Known limitations
|
|
29
|
+
- `gitlab_api` body handling via `-f` is best-effort for flat objects; complex nested bodies may need manual `glab api` invocation.
|
|
30
|
+
- `gitlab_mr_view` diff truncation is hard-limited to 2000 chars per file; no paging.
|
|
31
|
+
|
|
32
|
+
## Pre-existing errors (not in scope)
|
|
33
|
+
- `src/index.ts` and `src/commands/gitlab-doctor.ts` have compile errors from Phase 1A/1C parallel work.
|
|
@@ -0,0 +1,146 @@
|
|
|
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 { resolveProjectId } from "../lib/resolveProjectId.js";
|
|
10
|
+
|
|
11
|
+
const MUTATING_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
|
|
12
|
+
|
|
13
|
+
export function registerGitlabApi(pi: ExtensionAPI) {
|
|
14
|
+
pi.registerTool({
|
|
15
|
+
name: "gitlab_api",
|
|
16
|
+
label: "GitLab API Passthrough",
|
|
17
|
+
description:
|
|
18
|
+
"Raw glab api passthrough with automatic numeric-project-ID resolution. Mutating methods require explicit confirmation.",
|
|
19
|
+
parameters: Type.Object(
|
|
20
|
+
{
|
|
21
|
+
method: Type.Optional(
|
|
22
|
+
Type.Union(
|
|
23
|
+
[
|
|
24
|
+
Type.Literal("GET"),
|
|
25
|
+
Type.Literal("POST"),
|
|
26
|
+
Type.Literal("PUT"),
|
|
27
|
+
Type.Literal("PATCH"),
|
|
28
|
+
Type.Literal("DELETE"),
|
|
29
|
+
],
|
|
30
|
+
{ default: "GET" },
|
|
31
|
+
),
|
|
32
|
+
),
|
|
33
|
+
endpoint: Type.String({
|
|
34
|
+
description:
|
|
35
|
+
"Endpoint with optional ':project' placeholder. Use numeric IDs for other path segments.",
|
|
36
|
+
}),
|
|
37
|
+
project: Type.Optional(
|
|
38
|
+
Type.String({
|
|
39
|
+
description: "Required if endpoint contains ':project'.",
|
|
40
|
+
}),
|
|
41
|
+
),
|
|
42
|
+
body: Type.Optional(Type.Object({}, { additionalProperties: true })),
|
|
43
|
+
query: Type.Optional(Type.Object({}, { additionalProperties: true })),
|
|
44
|
+
confirm: Type.Optional(
|
|
45
|
+
Type.Boolean({
|
|
46
|
+
description:
|
|
47
|
+
"Explicit confirmation flag required for mutating methods (POST/PUT/PATCH/DELETE).",
|
|
48
|
+
}),
|
|
49
|
+
),
|
|
50
|
+
},
|
|
51
|
+
{ additionalProperties: false },
|
|
52
|
+
),
|
|
53
|
+
async execute(
|
|
54
|
+
_toolCallId,
|
|
55
|
+
params,
|
|
56
|
+
_signal,
|
|
57
|
+
_onUpdate,
|
|
58
|
+
ctx: ExtensionContext,
|
|
59
|
+
) {
|
|
60
|
+
try {
|
|
61
|
+
requireSetup(ctx.cwd);
|
|
62
|
+
} catch {
|
|
63
|
+
return setupRequiredResult();
|
|
64
|
+
}
|
|
65
|
+
const method = params.method ?? "GET";
|
|
66
|
+
const isMutating = MUTATING_METHODS.has(method);
|
|
67
|
+
|
|
68
|
+
if (isMutating && params.confirm !== true) {
|
|
69
|
+
return {
|
|
70
|
+
content: [
|
|
71
|
+
{
|
|
72
|
+
type: "text",
|
|
73
|
+
text:
|
|
74
|
+
"⚠️ Mutating GitLab API calls require explicit `confirm: true`. " +
|
|
75
|
+
"Set confirm:true to proceed, or use GET for read-only requests.",
|
|
76
|
+
},
|
|
77
|
+
],
|
|
78
|
+
details: { success: false, error: "confirmation_required" },
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
let endpoint = params.endpoint;
|
|
83
|
+
|
|
84
|
+
if (endpoint.includes(":project")) {
|
|
85
|
+
if (!params.project) {
|
|
86
|
+
return {
|
|
87
|
+
content: [
|
|
88
|
+
{
|
|
89
|
+
type: "text",
|
|
90
|
+
text: "Endpoint contains `:project` but no `project` parameter was provided.",
|
|
91
|
+
},
|
|
92
|
+
],
|
|
93
|
+
details: { success: false, error: "missing_project" },
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
const cwd = ctx.cwd;
|
|
97
|
+
const projectPath = await resolveProject(params.project, cwd);
|
|
98
|
+
const projectId = await resolveProjectId(projectPath);
|
|
99
|
+
endpoint = endpoint.replace(":project", String(projectId));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (params.query && Object.keys(params.query).length > 0) {
|
|
103
|
+
const qs = new URLSearchParams();
|
|
104
|
+
for (const [k, v] of Object.entries(params.query)) {
|
|
105
|
+
if (v !== undefined) qs.set(k, String(v));
|
|
106
|
+
}
|
|
107
|
+
endpoint = `${endpoint}${endpoint.includes("?") ? "&" : "?"}${qs.toString()}`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const args = ["api"];
|
|
111
|
+
if (method !== "GET") {
|
|
112
|
+
args.push("-X", method);
|
|
113
|
+
}
|
|
114
|
+
if (params.body && Object.keys(params.body).length > 0) {
|
|
115
|
+
for (const [k, v] of Object.entries(params.body)) {
|
|
116
|
+
if (v === undefined) continue;
|
|
117
|
+
args.push(
|
|
118
|
+
"-f",
|
|
119
|
+
`${k}=${typeof v === "string" ? v : JSON.stringify(v)}`,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
args.push(endpoint);
|
|
124
|
+
|
|
125
|
+
const result = await glab(args);
|
|
126
|
+
|
|
127
|
+
const text =
|
|
128
|
+
typeof result === "string" ? result : JSON.stringify(result, null, 2);
|
|
129
|
+
|
|
130
|
+
const preview =
|
|
131
|
+
isMutating && method !== "DELETE"
|
|
132
|
+
? `**${method}** \`${endpoint}\`\n\n`
|
|
133
|
+
: "";
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
content: [
|
|
137
|
+
{
|
|
138
|
+
type: "text",
|
|
139
|
+
text: `${preview}\`\`\`json\n${text.slice(0, 3000)}\n\`\`\``,
|
|
140
|
+
},
|
|
141
|
+
],
|
|
142
|
+
details: { success: true, method, endpoint, result },
|
|
143
|
+
};
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
}
|