feat(api-tools): native TS tools for Jira/Confluence/GitLab, drop MCP bridge
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Confluence tools — native pi tools over the corporate wiki REST API.
|
||||
* Thin wrappers around lib/confluence.ts.
|
||||
*
|
||||
* CONFIGURE: config/company.json → docsUrl; env.sh → CONFLUENCE_TOKEN.
|
||||
* DISABLE: remove/rename this file from extensions/.
|
||||
*/
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
import { search, pageGet, pageCreate } from "./lib/confluence.ts";
|
||||
|
||||
export default function confluenceToolsExtension(pi: ExtensionAPI) {
|
||||
const register = (name: string, description: string, parameters: Record<string, unknown>, run: (p: any) => Promise<unknown>) => {
|
||||
pi.registerTool({
|
||||
name,
|
||||
label: name,
|
||||
description,
|
||||
parameters: parameters as any,
|
||||
async execute(_id: string, params: unknown) {
|
||||
try {
|
||||
const out = await run(params ?? {});
|
||||
return { content: [{ type: "text", text: JSON.stringify(out ?? {}, null, 2) }], details: {} };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return { content: [{ type: "text", text: `confluence error: ${msg}` }], isError: true, details: {} };
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
register(
|
||||
"confluence_search",
|
||||
"Найти страницы в Confluence по CQL-запросу (текст/заголовок/пространство).",
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
cql: { type: "string", description: 'CQL, например text="деплой" AND space = TECHRUN' },
|
||||
limit: { type: "number", description: "Макс. результатов (default 25)" },
|
||||
expandBody: { type: "boolean", description: "Вернуть полный текст страниц" },
|
||||
},
|
||||
required: ["cql"],
|
||||
},
|
||||
async (p) => search(p.cql, { limit: p.limit, expandBody: p.expandBody }),
|
||||
);
|
||||
|
||||
register(
|
||||
"confluence_page_get",
|
||||
"Получить страницу Confluence по ID (с содержимым, если expandBody).",
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string", description: "ID страницы" },
|
||||
expandBody: { type: "boolean", description: "Вернуть тело страницы" },
|
||||
},
|
||||
required: ["id"],
|
||||
},
|
||||
async (p) => pageGet(p.id, { expandBody: p.expandBody }),
|
||||
);
|
||||
|
||||
register(
|
||||
"confluence_page_create",
|
||||
"Создать страницу в Confluence. body — HTML в Storage format.",
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
spaceKey: { type: "string", description: "Ключ пространства" },
|
||||
title: { type: "string", description: "Заголовок страницы" },
|
||||
body: { type: "string", description: "Содержимое страницы (HTML Storage format или текст)" },
|
||||
parentId: { type: "string", description: "ID родительской страницы (для вложенности)" },
|
||||
},
|
||||
required: ["spaceKey", "title", "body"],
|
||||
},
|
||||
async (p) => pageCreate(p),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* GitLab tools — native pi tools over the self-hosted GitLab REST API (v4).
|
||||
* Thin wrappers around lib/gitlab.ts.
|
||||
*
|
||||
* CONFIGURE: config/company.json → gitHost; env.sh → GITLAB_TOKEN (scope api).
|
||||
* DISABLE: remove/rename this file from extensions/.
|
||||
*/
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
import { mrCreate, pipelineStatus, codeSearch, deriveProjectFromOrigin } from "./lib/gitlab.ts";
|
||||
|
||||
export default function gitlabToolsExtension(pi: ExtensionAPI) {
|
||||
const register = (name: string, description: string, parameters: Record<string, unknown>, run: (p: any) => Promise<unknown>) => {
|
||||
pi.registerTool({
|
||||
name,
|
||||
label: name,
|
||||
description,
|
||||
parameters: parameters as any,
|
||||
async execute(_id: string, params: unknown) {
|
||||
try {
|
||||
const out = await run(params ?? {});
|
||||
return { content: [{ type: "text", text: JSON.stringify(out ?? {}, null, 2) }], details: {} };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return { content: [{ type: "text", text: `gitlab error: ${msg}` }], isError: true, details: {} };
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
register(
|
||||
"gitlab_mr_create",
|
||||
"Создать merge request в GitLab. Проект определяется из git remote origin (или укажи project).",
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
title: { type: "string", description: "Заголовок MR" },
|
||||
sourceBranch: { type: "string", description: "Ветка-источник (по умолчанию текущая)" },
|
||||
targetBranch: { type: "string", description: "Целевая ветка (по умолчанию main)" },
|
||||
project: { type: "string", description: "Группа/репозиторий, например group/name. Если пусто — берётся из origin" },
|
||||
removeSourceBranch: { type: "boolean", description: "Удалить ветку после мержа (default true)" },
|
||||
mergeWhenPipelineSucceeds: { type: "boolean", description: "Мержить после успешного пайплайна" },
|
||||
},
|
||||
required: ["title"],
|
||||
},
|
||||
async (p) => mrCreate({ title: p.title, sourceBranch: p.sourceBranch || undefined, targetBranch: p.targetBranch, project: p.project, removeSourceBranch: p.removeSourceBranch, mergeWhenPipelineSucceeds: p.mergeWhenPipelineSucceeds }),
|
||||
);
|
||||
|
||||
register(
|
||||
"gitlab_pipeline_status",
|
||||
"Показать статусы CI/CD пайплайнов проекта GitLab (по ветке).",
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
project: { type: "string", description: "Группа/репозиторий (обязательно; например group/name)" },
|
||||
ref: { type: "string", description: "Ветка/тег, по которому фильтровать" },
|
||||
perPage: { type: "number", description: "Сколько последних пайплайнов (default 10)" },
|
||||
},
|
||||
required: ["project"],
|
||||
},
|
||||
async (p) => pipelineStatus(p.project, { ref: p.ref, perPage: p.perPage }),
|
||||
);
|
||||
|
||||
register(
|
||||
"gitlab_code_search",
|
||||
"Поиск по коду в GitLab (blobs). Требует scope api/read_api у токена.",
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
query: { type: "string", description: "Строка поиска (до 512 символов)" },
|
||||
project: { type: "string", description: "Ограничить поиск группой/репозиторием" },
|
||||
perPage: { type: "number", description: "Макс. результатов (default 20)" },
|
||||
},
|
||||
required: ["query"],
|
||||
},
|
||||
async (p) => codeSearch(p.query, { project: p.project, perPage: p.perPage }),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Jira tools — native pi tools over the tracker REST API
|
||||
* (Jira Server/DC, rest/api/2). Thin wrappers around lib/jira.ts.
|
||||
*
|
||||
* No MCP and no bash: these are first-class tools the agent can call directly.
|
||||
* URL comes from config/company.json; JIRA_TOKEN from the env.
|
||||
*
|
||||
* CONFIGURE: config/company.json → trackerUrl; env.sh → JIRA_TOKEN.
|
||||
* DISABLE: remove/rename this file from extensions/.
|
||||
*/
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
import {
|
||||
issueGet,
|
||||
issueCreate,
|
||||
issueUpdate,
|
||||
issueComment,
|
||||
setStatus,
|
||||
search,
|
||||
linkMr,
|
||||
} from "./lib/jira.ts";
|
||||
|
||||
function tool(name: string, description: string, parameters: Record<string, unknown>, run: (params: any) => Promise<unknown>) {
|
||||
return { name, description, parameters, run };
|
||||
}
|
||||
|
||||
export default function jiraToolsExtension(pi: ExtensionAPI) {
|
||||
const tools = [
|
||||
tool(
|
||||
"jira_issue_get",
|
||||
"Получить тикет Jira по ключу (summary, status, description).",
|
||||
{ type: "object", properties: { key: { type: "string", description: "Ключ задачи, например PROJ-123" } }, required: ["key"] },
|
||||
async (p) => issueGet(p.key),
|
||||
),
|
||||
tool(
|
||||
"jira_issue_create",
|
||||
"Создать задачу в Jira.",
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
projectKey: { type: "string", description: "Ключ проекта, например PROJ" },
|
||||
summary: { type: "string", description: "Заголовок задачи" },
|
||||
issuetype: { type: "string", description: "Тип: Bug, Task, Story и т.п." },
|
||||
description: { type: "string", description: "Описание (Markdown/Plain)" },
|
||||
priority: { type: "string", description: "Приоритет" },
|
||||
labels: { type: "array", items: { type: "string" } },
|
||||
assignee: { type: "string", description: "Исполнитель" },
|
||||
},
|
||||
required: ["projectKey", "summary", "issuetype"],
|
||||
},
|
||||
async (p) => issueCreate(p),
|
||||
),
|
||||
tool(
|
||||
"jira_issue_update",
|
||||
"Обновить поля задачи Jira (summary/description/status и т.п.). Status переводит по доступным переходам.",
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
key: { type: "string", description: "Ключ задачи" },
|
||||
summary: { type: "string" },
|
||||
description: { type: "string" },
|
||||
priority: { type: "string" },
|
||||
labels: { type: "array", items: { type: "string" } },
|
||||
assignee: { type: "string" },
|
||||
status: { type: "string", description: "To Do / In Progress / Review / Done" },
|
||||
},
|
||||
required: ["key"],
|
||||
},
|
||||
async (p) => issueUpdate(p.key, p),
|
||||
),
|
||||
tool(
|
||||
"jira_issue_comment",
|
||||
"Добавить комментарий к тикету Jira.",
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
key: { type: "string", description: "Ключ задачи" },
|
||||
text: { type: "string", description: "Текст комментария" },
|
||||
},
|
||||
required: ["key", "text"],
|
||||
},
|
||||
async (p) => issueComment(p.key, p.text),
|
||||
),
|
||||
tool(
|
||||
"jira_issue_transition",
|
||||
"Перевести тикет Jira в статус (To Do / In Progress / Review / Done).",
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
key: { type: "string", description: "Ключ задачи" },
|
||||
target: { type: "string", description: "Целевой статус: To Do, In Progress, Review, Done" },
|
||||
},
|
||||
required: ["key", "target"],
|
||||
},
|
||||
async (p) => setStatus(p.key, p.target),
|
||||
),
|
||||
tool(
|
||||
"jira_search",
|
||||
"Найти тикеты Jira по JQL-запросу.",
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
jql: { type: "string", description: "JQL, например 'project = PROJ AND status != Done'" },
|
||||
maxResults: { type: "number", description: "Макс. число результатов" },
|
||||
},
|
||||
required: ["jql"],
|
||||
},
|
||||
async (p) => search(p.jql, p.maxResults),
|
||||
),
|
||||
tool(
|
||||
"jira_link_mr",
|
||||
"Привязать ссылку на merge request к тикету Jira (комментарий со ссылкой).",
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
key: { type: "string", description: "Ключ задачи" },
|
||||
mrUrl: { type: "string", description: "URL merge request" },
|
||||
title: { type: "string", description: "Заголовок MR (по умолчанию 'Merge request')" },
|
||||
},
|
||||
required: ["key", "mrUrl"],
|
||||
},
|
||||
async (p) => linkMr(p.key, p.mrUrl, p.title),
|
||||
),
|
||||
];
|
||||
|
||||
for (const t of tools) {
|
||||
pi.registerTool({
|
||||
name: t.name,
|
||||
label: t.name,
|
||||
description: t.description,
|
||||
parameters: t.parameters as any,
|
||||
async execute(_id: string, params: unknown) {
|
||||
try {
|
||||
const out = await t.run(params ?? {});
|
||||
return { content: [{ type: "text", text: JSON.stringify(out ?? {}, null, 2) }], details: {} };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return {
|
||||
content: [{ type: "text", text: `jira error: ${msg}` }],
|
||||
isError: true,
|
||||
details: {},
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Shared HTTP helpers for the Jira / Confluence / GitLab REST API tools.
|
||||
*
|
||||
* Everything here is pure (no pi API) so it can be unit-tested directly
|
||||
* (see test/api.test.ts). URLs come from config/company.json — the single
|
||||
* source of truth — and tokens from the environment. Nothing is hardcoded.
|
||||
*
|
||||
* If a required URL/token is missing the request throws a typed ERR with a
|
||||
* clear, human message instead of guessing values.
|
||||
*
|
||||
* CONFIGURE: edit config/company.json (trackerUrl, docsUrl, gitHost) and set
|
||||
* env vars JIRA_TOKEN / CONFLUENCE_TOKEN / GITLAB_TOKEN.
|
||||
*/
|
||||
|
||||
/** A network/config failure carrying a message fit for the user. */
|
||||
export class ApiError extends Error {
|
||||
status?: number;
|
||||
constructor(message: string, status?: number) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
export interface RequestOptions {
|
||||
method?: "GET" | "POST" | "PUT" | "DELETE";
|
||||
query?: Record<string, string | number | boolean | undefined>;
|
||||
body?: unknown;
|
||||
/** Override the injectable fetch (tests). Defaults to global fetch. */
|
||||
fetchImpl?: typeof fetch;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
/** Trim a base URL so it has no trailing slash. */
|
||||
function base(url: string): string {
|
||||
return url.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
/** Build a URL with query params; undefined/empty values are dropped. */
|
||||
export function buildUrl(
|
||||
baseUrl: string,
|
||||
path: string,
|
||||
query?: RequestOptions["query"],
|
||||
): string {
|
||||
const q = Object.entries(query ?? {}).filter(([, v]) => v !== undefined && v !== "");
|
||||
const qs = q.length ? "?" + q.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`).join("&") : "";
|
||||
return `${base(baseUrl)}${path}${qs}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform an authenticated JSON request against a corporate API.
|
||||
* `baseUrl` and `token` are resolved by the caller per service.
|
||||
*/
|
||||
export async function apiRequest(
|
||||
baseUrl: string,
|
||||
token: string,
|
||||
path: string,
|
||||
opts: RequestOptions = {},
|
||||
): Promise<unknown> {
|
||||
const { method = "GET", query, body, timeoutMs = 15000 } = opts;
|
||||
const fetchImpl = opts.fetchImpl ?? fetch;
|
||||
|
||||
const url = buildUrl(baseUrl, path, query);
|
||||
const headers: Record<string, string> = {
|
||||
Accept: "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
};
|
||||
if (body !== undefined) headers["Content-Type"] = "application/json";
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetchImpl(url, {
|
||||
method,
|
||||
headers,
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
throw new ApiError(`Сетевой сбой при обращении к ${baseUrl}: ${msg}`, 0);
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
// Try to surface a short API-provided error message.
|
||||
let detail = `HTTP ${res.status}`;
|
||||
try {
|
||||
const data = (await res.json()) as Record<string, unknown>;
|
||||
const m = data?.message ?? data?.error_description ?? data?.error;
|
||||
if (typeof m === "string") detail += `: ${m}`;
|
||||
} catch {
|
||||
// non-JSON error body — keep the HTTP status only.
|
||||
}
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
detail += " — проверь токен и права доступа (не логируй сам токен).";
|
||||
}
|
||||
throw new ApiError(`Запрос к ${baseUrl}${path} вернул ${detail}`, res.status);
|
||||
}
|
||||
|
||||
if (res.status === 204) return null;
|
||||
return res.json().catch(() => null);
|
||||
}
|
||||
|
||||
/** Resolve a required token from the environment, throwing a helpful error. */
|
||||
export function requireToken(name: string, hint: string): string {
|
||||
const v = process.env[name];
|
||||
if (!v || v === "" || v === "TODO") {
|
||||
throw new ApiError(
|
||||
`Не задан $${name} — ${hint}. Задай его в ~/.config/pi-kit/env.sh (или в окружении) и перезапусти pi.`,
|
||||
);
|
||||
}
|
||||
return v;
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Pure REST helpers for the corporate wiki (Confluence Server/DC, rest/api).
|
||||
* `docsUrl` comes from config/company.json; `CONFLUENCE_TOKEN` from the env.
|
||||
* No pi API — unit-testable.
|
||||
*/
|
||||
import { apiRequest, requireToken, ApiError } from "./atlassian.ts";
|
||||
import { readLocalConfig } from "./company-config.ts";
|
||||
import * as path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
const CONFIG_PATH = path.join(HERE, "..", "..", "config", "company.json");
|
||||
|
||||
/** Resolve the Confluence base URL from company.json (env override for labs). */
|
||||
export function confluenceBaseUrl(): string {
|
||||
const cfg = readLocalConfig(CONFIG_PATH);
|
||||
const fromCfg = cfg.docsUrl && cfg.docsUrl !== "TODO" ? cfg.docsUrl : "";
|
||||
const url = (process.env.CONFLUENCE_URL && process.env.CONFLUENCE_URL !== "TODO" ? process.env.CONFLUENCE_URL : fromCfg).replace(/\/+$/, "");
|
||||
if (!url) throw new ApiError("Не задан docsUrl в config/company.json и нет CONFLUENCE_URL.");
|
||||
return url;
|
||||
}
|
||||
|
||||
function token(): string {
|
||||
return requireToken(
|
||||
"CONFLUENCE_TOKEN",
|
||||
"personal access token Confluence. Где брать: Профиль → Security → API tokens / Personal Access Tokens → создать",
|
||||
);
|
||||
}
|
||||
|
||||
export interface Page {
|
||||
id: string;
|
||||
title: string;
|
||||
url?: string;
|
||||
body?: string;
|
||||
spaceKey?: string;
|
||||
}
|
||||
|
||||
export interface SearchResult {
|
||||
pages: Page[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
/** CQL search for Confluence pages. `cql` supports the standard text/title/space operators. */
|
||||
export async function search(cql: string, opts: { limit?: number; expandBody?: boolean } = {}): Promise<SearchResult> {
|
||||
const base = confluenceBaseUrl();
|
||||
if (!cql) throw new ApiError("search требует CQL-запрос (например 'text=\"foo\"').");
|
||||
const data: any = await apiRequest(base, token(), "/rest/api/content/search", {
|
||||
query: { cql, limit: opts.limit ?? 25, expand: opts.expandBody ? "body.view" : undefined },
|
||||
});
|
||||
const pages: Page[] = (data?.results ?? []).map((p: any) => ({
|
||||
id: p.id,
|
||||
title: p.title ?? "",
|
||||
spaceKey: p.space?.key,
|
||||
body: opts.expandBody ? (p.body?.view?.value ?? "") : undefined,
|
||||
url: `${base}/spaces/${p.space?.key ?? "~self"}/pages/${p.id}`,
|
||||
}));
|
||||
return { pages, total: data?.size ?? pages.length };
|
||||
}
|
||||
|
||||
export async function pageGet(id: string, opts: { expandBody?: boolean } = {}): Promise<Page> {
|
||||
const base = confluenceBaseUrl();
|
||||
if (!id) throw new ApiError("pageGet требует id страницы.");
|
||||
const data: any = await apiRequest(base, token(), `/rest/api/content/${encodeURIComponent(id)}`, {
|
||||
query: opts.expandBody ? { expand: "body.view,version" } : undefined,
|
||||
});
|
||||
return {
|
||||
id: data?.id ?? id,
|
||||
title: data?.title ?? "",
|
||||
spaceKey: data?.space?.key,
|
||||
body: opts.expandBody ? (data?.body?.view?.value ?? "") : undefined,
|
||||
url: `${base}/spaces/${data?.space?.key ?? "~self"}/pages/${data?.id ?? id}`,
|
||||
};
|
||||
}
|
||||
|
||||
export interface PageCreateInput {
|
||||
spaceKey: string;
|
||||
title: string;
|
||||
body: string; // HTML (Storage format) or text
|
||||
parentId?: string;
|
||||
representation?: "storage" | "editor" | "wiki";
|
||||
}
|
||||
|
||||
/** Create a new Confluence page. `body` is HTML in Storage format by default. */
|
||||
export async function pageCreate(input: PageCreateInput): Promise<Page> {
|
||||
const base = confluenceBaseUrl();
|
||||
if (!input.spaceKey || !input.title || !input.body) {
|
||||
throw new ApiError("pageCreate требует spaceKey, title и body.");
|
||||
}
|
||||
const rep = input.representation ?? "storage";
|
||||
const body: Record<string, unknown> = {
|
||||
type: "page",
|
||||
title: input.title,
|
||||
space: { key: input.spaceKey },
|
||||
body: { [rep]: { value: input.body, representation: rep } },
|
||||
};
|
||||
if (input.parentId) (body as any).ancestors = [{ id: input.parentId }];
|
||||
|
||||
const data: any = await apiRequest(base, token(), "/rest/api/content", { method: "POST", body });
|
||||
return {
|
||||
id: data?.id ?? "",
|
||||
title: data?.title ?? input.title,
|
||||
spaceKey: data?.space?.key ?? input.spaceKey,
|
||||
url: `${base}/spaces/${input.spaceKey}/pages/${data?.id ?? ""}`,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Pure REST helpers for the corporate GitLab (self-hosted, API v4).
|
||||
* `gitHost` comes from config/company.json; `GITLAB_TOKEN` from the env.
|
||||
* No pi API — unit-testable.
|
||||
*/
|
||||
import { apiRequest, requireToken, ApiError } from "./atlassian.ts";
|
||||
import { readLocalConfig } from "./company-config.ts";
|
||||
import * as path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
const CONFIG_PATH = path.join(HERE, "..", "..", "config", "company.json");
|
||||
|
||||
/** Resolve the GitLab API base URL from company.json (env override for labs). */
|
||||
export function gitlabBaseUrl(): string {
|
||||
const cfg = readLocalConfig(CONFIG_PATH);
|
||||
const host = process.env.GITLAB_HOST || (cfg.gitHost && cfg.gitHost !== "TODO" ? cfg.gitHost : "");
|
||||
if (!host) throw new ApiError("Не задан gitHost в config/company.json и нет GITLAB_HOST.");
|
||||
return `https://${host.replace(/^https?:\/\//, "").replace(/\/+$/, "")}/api/v4`;
|
||||
}
|
||||
|
||||
function token(): string {
|
||||
return requireToken(
|
||||
"GITLAB_TOKEN",
|
||||
"personal/project access token GitLab со scope api или read_api. Где брать: Настройки → Access Tokens → Personal (scope: api)",
|
||||
);
|
||||
}
|
||||
|
||||
/** Namespaced project path (user/repo) -> URL-encoded API project id. */
|
||||
export function projectId(project: string): string {
|
||||
if (!project) throw new ApiError("Укажи проект (namespace/repo или его ID).");
|
||||
if (/^\d+$/.test(project)) return project;
|
||||
return encodeURIComponent(project.replace(/^https?:\/\/([^/]+)\//, "").replace(/\.git$/, ""));
|
||||
}
|
||||
|
||||
/** Parse `git remote origin` in cwd into a namespaced project path, or null. */
|
||||
export function deriveProjectFromOrigin(): string | null {
|
||||
try {
|
||||
// Keep this synchronous & offline-safe; execSync is lazy-loaded so the
|
||||
// module can be imported in tests without a git repo.
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const { execFileSync } = require("node:child_process") as typeof import("node:child_process");
|
||||
const url = execFileSync("git", ["config", "--get", "remote.origin.url"], { encoding: "utf8" })
|
||||
.toString()
|
||||
.trim();
|
||||
if (!url) return null;
|
||||
// Support both scp-style (git@host:path) and scheme-style (https://host/path).
|
||||
let rest = url.replace(/\.git$/, "");
|
||||
rest = rest.replace(/^[a-z]+:\/\//i, "").replace(/^[^@]*@/, "");
|
||||
rest = rest.replace(/^[^:/]+[:/]/, ""); // drop host[:port]
|
||||
return rest.split("/").filter(Boolean).join("/") || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export interface MrCreateInput {
|
||||
title: string;
|
||||
sourceBranch: string;
|
||||
targetBranch?: string;
|
||||
project?: string; // namespace/repo; falls back to origin when omitted
|
||||
removeSourceBranch?: boolean;
|
||||
mergeWhenPipelineSucceeds?: boolean;
|
||||
squash?: boolean;
|
||||
}
|
||||
|
||||
export async function mrCreate(input: MrCreateInput): Promise<{ url: string; iid?: number }> {
|
||||
const base = gitlabBaseUrl();
|
||||
if (!input.title || !input.sourceBranch) throw new ApiError("mrCreate требует title и sourceBranch.");
|
||||
const project = input.project || deriveProjectFromOrigin();
|
||||
if (!project) throw new ApiError("Не удалось определить проект. Укажи project (namespace/repo).");
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
source_branch: input.sourceBranch,
|
||||
target_branch: input.targetBranch ?? "main",
|
||||
title: input.title,
|
||||
remove_source_branch: input.removeSourceBranch ?? true,
|
||||
squash: input.squash ?? true,
|
||||
};
|
||||
if (input.mergeWhenPipelineSucceeds) body.merge_when_pipeline_succeeds = true;
|
||||
|
||||
const data: any = await apiRequest(base, token(), `/projects/${projectId(project)}/merge_requests`, {
|
||||
method: "POST",
|
||||
body,
|
||||
});
|
||||
return { url: data?.web_url ?? "", iid: data?.iid };
|
||||
}
|
||||
|
||||
/** List pipelines for a project (optionally filtered by ref). */
|
||||
export async function pipelineStatus(
|
||||
project: string,
|
||||
opts: { ref?: string; perPage?: number } = {},
|
||||
): Promise<Array<{ id: number; ref: string; status: string; created_at: string; web_url: string }>> {
|
||||
const base = gitlabBaseUrl();
|
||||
if (!project) throw new ApiError("pipelineStatus требует project (namespace/repo).");
|
||||
const data: any = await apiRequest(base, token(), `/projects/${projectId(project)}/pipelines`, {
|
||||
query: { ref: opts.ref, per_page: opts.perPage ?? 10 },
|
||||
});
|
||||
return (data ?? []).map((p: any) => ({
|
||||
id: p.id,
|
||||
ref: p.ref ?? "",
|
||||
status: p.status ?? "unknown",
|
||||
created_at: p.created_at ?? "",
|
||||
web_url: p.web_url ?? "",
|
||||
}));
|
||||
}
|
||||
|
||||
/** GitLab code search (blobs). Requires `read_api`/`api` scope; a 403 is surfaced clearly. */
|
||||
export async function codeSearch(
|
||||
query: string,
|
||||
opts: { project?: string; perPage?: number } = {},
|
||||
): Promise<Array<{ path: string; filename: string; project: string; ref: string; code: string }>> {
|
||||
const base = gitlabBaseUrl();
|
||||
if (!query) throw new ApiError("codeSearch требует строку поиска (query).");
|
||||
const q: Record<string, string | number> = {
|
||||
scope: "blobs",
|
||||
search: query,
|
||||
per_page: opts.perPage ?? 20,
|
||||
};
|
||||
if (opts.project) q.project_id = projectId(opts.project);
|
||||
const data: any = await apiRequest(base, token(), "/search", { query: q });
|
||||
return (data ?? []).map((b: any) => ({
|
||||
path: b.path ?? "",
|
||||
filename: b.filename ?? "",
|
||||
project: b.project_id ? String(b.project_id) : "",
|
||||
ref: b.ref ?? "",
|
||||
code: b.data ?? b.content ?? "",
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* Pure REST helpers for the corporate tracker (Jira Server/DC, API v2).
|
||||
* `trackerUrl` comes from config/company.json; `JIRA_TOKEN` from the env.
|
||||
* No pi API — unit-testable (see test/api.test.ts).
|
||||
*/
|
||||
import { apiRequest, requireToken, ApiError } from "./atlassian.ts";
|
||||
import { readLocalConfig } from "./company-config.ts";
|
||||
import * as path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
const CONFIG_PATH = path.join(HERE, "..", "..", "config", "company.json");
|
||||
|
||||
/** Resolve the tracker base URL — prefer company.json; env override wins. */
|
||||
export function jiraBaseUrl(): string {
|
||||
const cfg = readLocalConfig(CONFIG_PATH);
|
||||
const fromCfg = cfg.trackerUrl && cfg.trackerUrl !== "TODO" ? cfg.trackerUrl : "";
|
||||
// env TRACKER_URL may override the packaged default for local labs.
|
||||
return (process.env.TRACKER_URL && process.env.TRACKER_URL !== "TODO" ? process.env.TRACKER_URL : fromCfg).replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
function token(): string {
|
||||
return requireToken(
|
||||
"JIRA_TOKEN",
|
||||
"personal access token трекера. Где брать: Профиль → Безопасность → Personal Access Tokens → создать",
|
||||
);
|
||||
}
|
||||
|
||||
export interface Issue {
|
||||
key: string;
|
||||
summary: string;
|
||||
status: string;
|
||||
description?: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export interface JqlResult {
|
||||
issues: Issue[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
// --- Endpoint helpers -------------------------------------------------------
|
||||
|
||||
export async function issueGet(key: string): Promise<Issue> {
|
||||
if (!key) throw new ApiError("Укажи ключ задачи (например PROJ-123).");
|
||||
const base = jiraBaseUrl();
|
||||
if (!base) throw new ApiError("Не задан trackerUrl в config/company.json и нет TRACKER_URL.");
|
||||
const data: any = await apiRequest(base, token(), `/rest/api/2/issue/${encodeURIComponent(key)}`, {
|
||||
query: { fields: "summary,status,description" },
|
||||
});
|
||||
return {
|
||||
key: data?.key ?? key,
|
||||
summary: data?.fields?.summary ?? "",
|
||||
status: data?.fields?.status?.name ?? "",
|
||||
description: data?.fields?.description ?? "",
|
||||
url: `${base}/browse/${data?.key ?? key}`,
|
||||
};
|
||||
}
|
||||
|
||||
export interface IssueCreateInput {
|
||||
projectKey: string;
|
||||
summary: string;
|
||||
issuetype: string; // e.g. "Bug", "Task", "Story"
|
||||
description?: string;
|
||||
priority?: string;
|
||||
labels?: string[];
|
||||
assignee?: string;
|
||||
}
|
||||
|
||||
export async function issueCreate(input: IssueCreateInput): Promise<Issue> {
|
||||
const base = jiraBaseUrl();
|
||||
if (!base) throw new ApiError("Не задан trackerUrl в config/company.json и нет TRACKER_URL.");
|
||||
if (!input.projectKey || !input.summary || !input.issuetype) {
|
||||
throw new ApiError("Для создания задачи нужны projectKey, summary и issuetype.");
|
||||
}
|
||||
|
||||
const fields: Record<string, unknown> = {
|
||||
project: { key: input.projectKey },
|
||||
summary: input.summary,
|
||||
issuetype: { name: input.issuetype },
|
||||
};
|
||||
if (input.description) fields.description = input.description;
|
||||
if (input.priority) fields.priority = { name: input.priority };
|
||||
if (input.labels?.length) fields.labels = input.labels;
|
||||
if (input.assignee) fields.assignee = { name: input.assignee };
|
||||
|
||||
const data: any = await apiRequest(base, token(), "/rest/api/2/issue", {
|
||||
method: "POST",
|
||||
body: { fields },
|
||||
});
|
||||
return {
|
||||
key: data?.key ?? "",
|
||||
summary: input.summary,
|
||||
status: "To Do",
|
||||
url: `${base}/browse/${data?.key ?? ""}`,
|
||||
};
|
||||
}
|
||||
|
||||
export interface IssueUpdateInput {
|
||||
summary?: string;
|
||||
description?: string;
|
||||
priority?: string;
|
||||
labels?: string[];
|
||||
assignee?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export async function issueUpdate(key: string, input: IssueUpdateInput): Promise<Issue> {
|
||||
const base = jiraBaseUrl();
|
||||
if (!base) throw new ApiError("Не задан trackerUrl в config/company.json и нет TRACKER_URL.");
|
||||
if (!key) throw new ApiError("Укажи ключ задачи.");
|
||||
|
||||
const fields: Record<string, unknown> = {};
|
||||
if (input.summary !== undefined) fields.summary = input.summary;
|
||||
if (input.description !== undefined) fields.description = input.description;
|
||||
if (input.priority !== undefined) fields.priority = { name: input.priority };
|
||||
if (input.labels !== undefined) fields.labels = input.labels;
|
||||
if (input.assignee !== undefined) fields.assignee = { name: input.assignee };
|
||||
|
||||
if (Object.keys(fields).length > 0) {
|
||||
await apiRequest(base, token(), `/rest/api/2/issue/${encodeURIComponent(key)}`, {
|
||||
method: "PUT",
|
||||
body: { fields },
|
||||
});
|
||||
}
|
||||
|
||||
// Optional status transition in the same call (otherwise fetch current).
|
||||
if (input.status && input.status.toLowerCase() !== "noop") {
|
||||
await setStatus(key, input.status);
|
||||
}
|
||||
return issueGet(key);
|
||||
}
|
||||
|
||||
/** Translate a readable target to a known transition id, then apply it. */
|
||||
export async function setStatus(key: string, target: string): Promise<string> {
|
||||
const base = jiraBaseUrl();
|
||||
if (!base) throw new ApiError("Не задан trackerUrl в config/company.json и нет TRACKER_URL.");
|
||||
const t = target.toLowerCase();
|
||||
const alias: Record<string, string> = {
|
||||
"todo": "To Do", "to do": "To Do", "не начато": "To Do", "backlog": "To Do",
|
||||
"in progress": "In Progress", "в работе": "In Progress",
|
||||
"review": "Review", "in review": "Review", "на ревью": "Review",
|
||||
"done": "Done", "готово": "Done",
|
||||
};
|
||||
const resolved = alias[t] ?? target;
|
||||
|
||||
// Fetch available transitions and find one whose name matches.
|
||||
const data: any = await apiRequest(base, token(), `/rest/api/2/issue/${encodeURIComponent(key)}/transitions`);
|
||||
const transitions: Array<{ id: string; name: string }> = data?.transitions ?? [];
|
||||
if (transitions.length === 0) {
|
||||
throw new ApiError(`У задачи ${key} нет доступных переходов (или нет прав). Текущий статус не меняю.`);
|
||||
}
|
||||
const norm = (s: string) => s.toLowerCase().trim();
|
||||
const found = transitions.find((x) => norm(x.name) === norm(resolved));
|
||||
if (!found) {
|
||||
const names = transitions.map((x) => x.name).join(", ");
|
||||
throw new ApiError(`Нет перехода в «${resolved}». Доступны: ${names}.`);
|
||||
}
|
||||
await apiRequest(base, token(), `/rest/api/2/issue/${encodeURIComponent(key)}/transitions`, {
|
||||
method: "POST",
|
||||
body: { transition: { id: found.id } },
|
||||
});
|
||||
return found.name;
|
||||
}
|
||||
|
||||
export async function issueComment(key: string, text: string): Promise<{ id?: string; url?: string }> {
|
||||
const base = jiraBaseUrl();
|
||||
if (!base) throw new ApiError("Не задан trackerUrl в config/company.json и нет TRACKER_URL.");
|
||||
if (!key || !text) throw new ApiError("comment требует key и текст.");
|
||||
const data: any = await apiRequest(base, token(), `/rest/api/2/issue/${encodeURIComponent(key)}/comment`, {
|
||||
method: "POST",
|
||||
body: { body: text },
|
||||
});
|
||||
return { id: data?.id, url: `${base}/browse/${key}` };
|
||||
}
|
||||
|
||||
/** JQL search; returns a compact issue list. */
|
||||
export async function search(jql: string, maxResults = 20): Promise<JqlResult> {
|
||||
const base = jiraBaseUrl();
|
||||
if (!base) throw new ApiError("Не задан trackerUrl в config/company.json и нет TRACKER_URL.");
|
||||
if (!jql) throw new ApiError("search требует SQL-запрос (JQL).");
|
||||
const data: any = await apiRequest(base, token(), "/rest/api/2/search", {
|
||||
query: { jql, maxResults, fields: "summary,status" },
|
||||
});
|
||||
const issues: Issue[] = (data?.issues ?? []).map((it: any) => ({
|
||||
key: it.key,
|
||||
summary: it.fields?.summary ?? "",
|
||||
status: it.fields?.status?.name ?? "",
|
||||
url: `${base}/browse/${it.key}`,
|
||||
}));
|
||||
return { issues, total: data?.total ?? issues.length };
|
||||
}
|
||||
|
||||
/** Attach an MR link to a ticket (default: comment with the link). */
|
||||
export async function linkMr(key: string, mrUrl: string, title = "Merge request"): Promise<{ id?: string }> {
|
||||
const base = jiraBaseUrl();
|
||||
if (!base) throw new ApiError("Не задан trackerUrl в config/company.json и нет TRACKER_URL.");
|
||||
if (!key || !mrUrl) throw new ApiError("linkMr требует key и url MR.");
|
||||
const res = await issueComment(key, `${title}: ${mrUrl}`);
|
||||
return res;
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
/**
|
||||
* MCP Bridge Extension
|
||||
*
|
||||
* WHAT: Pi has no built-in MCP. This bridge reads config/mcp.json, and for each
|
||||
* ENABLED server launches it (stdio) via the MCP SDK and registers its
|
||||
* tools into pi. URLs come from config/company.json ({config:KEY}), tokens
|
||||
* from the environment ({env:VAR}) — nothing hardcoded. All servers are
|
||||
* disabled by default; `/mcp-status` shows their state.
|
||||
*
|
||||
* ENABLE: set a server's "enabled": true in config/mcp.json, provide its token
|
||||
* env var, install the MCP SDK once (`npm i @modelcontextprotocol/sdk` in
|
||||
* the package, or globally) and the launcher (uvx/npx), then restart pi.
|
||||
* Requires network access to the corporate services (VPN).
|
||||
*
|
||||
* DISABLE: keep servers disabled, or remove/rename this file.
|
||||
*/
|
||||
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
const MCP_CONFIG = path.join(HERE, "..", "config", "mcp.json");
|
||||
const COMPANY_CONFIG = path.join(HERE, "..", "config", "company.json");
|
||||
const CONNECT_TIMEOUT_MS = 8000;
|
||||
|
||||
interface ServerSpec {
|
||||
enabled?: boolean;
|
||||
description?: string;
|
||||
command: string;
|
||||
args?: string[];
|
||||
env?: Record<string, string>;
|
||||
}
|
||||
|
||||
type Status = "disabled" | "connected" | "error" | "no-sdk" | "missing-env";
|
||||
interface ServerState {
|
||||
description: string;
|
||||
status: Status;
|
||||
tools: string[];
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
function readJson(file: string): Record<string, unknown> {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/** Replace {config:KEY} and {env:VAR} tokens; returns null if any resolve empty. */
|
||||
function resolveEnv(
|
||||
env: Record<string, string>,
|
||||
company: Record<string, unknown>,
|
||||
): Record<string, string> | null {
|
||||
const out: Record<string, string> = {};
|
||||
for (const [key, template] of Object.entries(env)) {
|
||||
let missing = false;
|
||||
const value = template.replace(/\{(config|env):([^}]+)\}/g, (_m, kind, name) => {
|
||||
const v = kind === "config" ? company[name] : process.env[name];
|
||||
if (typeof v !== "string" || v === "" || v === "TODO") missing = true;
|
||||
return typeof v === "string" ? v : "";
|
||||
});
|
||||
if (missing) return null;
|
||||
out[key] = value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export default async function mcpBridgeExtension(pi: ExtensionAPI) {
|
||||
const mcpCfg = readJson(MCP_CONFIG);
|
||||
const company = readJson(COMPANY_CONFIG);
|
||||
const servers = (mcpCfg.servers ?? {}) as Record<string, ServerSpec>;
|
||||
const state = new Map<string, ServerState>();
|
||||
const closers: Array<() => Promise<void> | void> = [];
|
||||
|
||||
// Seed status for every configured server.
|
||||
for (const [key, spec] of Object.entries(servers)) {
|
||||
state.set(key, { description: spec.description ?? key, status: spec.enabled ? "error" : "disabled", tools: [] });
|
||||
}
|
||||
|
||||
pi.registerCommand("mcp-status", {
|
||||
description: "Показать сконфигурированные MCP-серверы (Jira/Confluence/GitLab) и их статус",
|
||||
handler: async (_args, ctx) => {
|
||||
const lines: string[] = [];
|
||||
for (const [key, s] of state) {
|
||||
const t = s.tools.length ? ` — ${s.tools.length} tools` : "";
|
||||
lines.push(`${key}: ${s.status}${t}${s.detail ? ` (${s.detail})` : ""}`);
|
||||
lines.push(` ${s.description}`);
|
||||
}
|
||||
if (lines.length === 0) lines.push("MCP-серверы не сконфигурированы (config/mcp.json).");
|
||||
lines.push("", "Включить: enabled:true в config/mcp.json + токен в env + перезапуск pi.");
|
||||
if (ctx.hasUI) await ctx.ui.select("MCP-серверы", lines);
|
||||
else ctx.ui.notify(lines.join("\n"), "info");
|
||||
},
|
||||
});
|
||||
|
||||
// Connect enabled servers. Nothing runs here when all are disabled (default).
|
||||
for (const [key, spec] of Object.entries(servers)) {
|
||||
if (!spec.enabled) continue;
|
||||
const st = state.get(key)!;
|
||||
|
||||
const env = resolveEnv(spec.env ?? {}, company);
|
||||
if (env === null) {
|
||||
st.status = "missing-env";
|
||||
st.detail = "нет URL/токена (config/env)";
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
// Dynamically import so a missing SDK never breaks loading for others.
|
||||
const { Client } = await import("@modelcontextprotocol/sdk/client/index.js");
|
||||
const { StdioClientTransport } = await import("@modelcontextprotocol/sdk/client/stdio.js");
|
||||
|
||||
const transport = new StdioClientTransport({
|
||||
command: spec.command,
|
||||
args: spec.args ?? [],
|
||||
env: { ...process.env, ...env } as Record<string, string>,
|
||||
});
|
||||
const client = new Client({ name: `pi-kit-${key}`, version: "0.1.0" }, { capabilities: {} });
|
||||
await Promise.race([
|
||||
client.connect(transport),
|
||||
new Promise((_r, reject) => setTimeout(() => reject(new Error("timeout")), CONNECT_TIMEOUT_MS)),
|
||||
]);
|
||||
closers.push(() => client.close().catch(() => {}));
|
||||
|
||||
const { tools } = await client.listTools();
|
||||
for (const tool of tools ?? []) {
|
||||
const toolName = `${key}_${tool.name}`;
|
||||
pi.registerTool({
|
||||
name: toolName,
|
||||
label: toolName,
|
||||
description: tool.description ?? `${key} MCP tool ${tool.name}`,
|
||||
// MCP inputSchema is JSON Schema; pi accepts a JSON-Schema-shaped object.
|
||||
parameters: (tool.inputSchema as unknown) ?? { type: "object", properties: {} },
|
||||
async execute(_id: string, params: unknown) {
|
||||
const res: any = await client.callTool({ name: tool.name, arguments: (params ?? {}) as Record<string, unknown> });
|
||||
return { content: res.content ?? [{ type: "text", text: JSON.stringify(res) }], details: {} };
|
||||
},
|
||||
});
|
||||
st.tools.push(toolName);
|
||||
}
|
||||
st.status = "connected";
|
||||
// Connection status is visible via /mcp-status (no ctx in the factory).
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
st.status = /Cannot find( package)?|ERR_MODULE_NOT_FOUND|@modelcontextprotocol/.test(msg) ? "no-sdk" : "error";
|
||||
st.detail = st.status === "no-sdk" ? "установи @modelcontextprotocol/sdk" : msg.slice(0, 80);
|
||||
}
|
||||
}
|
||||
|
||||
pi.on("session_shutdown", async () => {
|
||||
for (const close of closers) await close();
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user