feat(api-tools): native TS tools for Jira/Confluence/GitLab, drop MCP bridge

This commit is contained in:
Aleksey Shakhmatov
2026-08-06 14:43:10 +03:00
parent 035bededb0
commit 8b273c9a72
16 changed files with 925 additions and 469 deletions
+74
View File
@@ -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),
);
}
+77
View File
@@ -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 }),
);
}
+146
View File
@@ -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: {},
};
}
},
});
}
}
+112
View File
@@ -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;
}
+105
View File
@@ -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 ?? ""}`,
};
}
+129
View File
@@ -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 ?? "",
}));
}
+201
View File
@@ -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;
}
-156
View File
@@ -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();
});
}