feat(api-tools): native TS tools for Jira/Confluence/GitLab, drop MCP bridge
This commit is contained in:
@@ -1,35 +0,0 @@
|
||||
{
|
||||
"//": "MCP server registry consumed by extensions/mcp-bridge.ts. All servers are DISABLED by default — enable one, set its token env var, then restart pi. URLs are pulled from config/company.json (single source of truth); tokens from env. Templates: {config:KEY} → company.json value, {env:VAR} → environment variable. Requires VPN/creds and the launcher (uvx/npx) to actually connect.",
|
||||
"servers": {
|
||||
"jira": {
|
||||
"enabled": false,
|
||||
"description": "Jira (Server/DC) через mcp-atlassian (Python, запускается uvx)",
|
||||
"command": "uvx",
|
||||
"args": ["mcp-atlassian"],
|
||||
"env": {
|
||||
"JIRA_URL": "{config:trackerUrl}",
|
||||
"JIRA_PERSONAL_TOKEN": "{env:JIRA_TOKEN}"
|
||||
}
|
||||
},
|
||||
"confluence": {
|
||||
"enabled": false,
|
||||
"description": "Confluence (Server/DC) через mcp-atlassian",
|
||||
"command": "uvx",
|
||||
"args": ["mcp-atlassian"],
|
||||
"env": {
|
||||
"CONFLUENCE_URL": "{config:docsUrl}",
|
||||
"CONFLUENCE_PERSONAL_TOKEN": "{env:CONFLUENCE_TOKEN}"
|
||||
}
|
||||
},
|
||||
"gitlab": {
|
||||
"enabled": false,
|
||||
"description": "GitLab (self-hosted) через @zereight/mcp-gitlab (запускается npx)",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@zereight/mcp-gitlab"],
|
||||
"env": {
|
||||
"GITLAB_API_URL": "https://{config:gitHost}/api/v4",
|
||||
"GITLAB_PERSONAL_ACCESS_TOKEN": "{env:GITLAB_TOKEN}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-47
@@ -1,47 +0,0 @@
|
||||
# MCP-интеграции (Jira / Confluence / GitLab)
|
||||
|
||||
Pi не имеет встроенного MCP — его добавляет расширение `extensions/mcp-bridge.ts`. Оно читает
|
||||
`config/mcp.json`, поднимает **включённые** серверы (stdio) и регистрирует их инструменты в pi.
|
||||
**По умолчанию все серверы выключены.** Статус — команда `/mcp-status`.
|
||||
|
||||
## Как устроено
|
||||
|
||||
- Серверы объявлены в `config/mcp.json`. URL берутся из `config/company.json` (`{config:trackerUrl}`,
|
||||
`{config:docsUrl}`, `{config:gitHost}`), токены — из окружения (`{env:JIRA_TOKEN}` и т.д.).
|
||||
В коде и конфиге нет захардкоженных адресов и секретов.
|
||||
- Bridge подставляет значения при запуске сервера. Если URL/токен не заданы — сервер помечается
|
||||
`missing-env` и пропускается.
|
||||
|
||||
## Предпосылки (нужны только при включении)
|
||||
|
||||
- **VPN** и доступ к `jira.mvideo.ru` / `wiki.mvideo.ru` / `gitlab.tech.mvideo.ru`.
|
||||
- MCP SDK: `npm i @modelcontextprotocol/sdk` в пакете (в `optionalDependencies`; ставится только
|
||||
при включении MCP).
|
||||
- Лаунчеры серверов: `uvx` (для `mcp-atlassian`) и/или `npx` (для `@zereight/mcp-gitlab`).
|
||||
- Токены в окружении (см. `~/.config/pi-kit/env.sh`).
|
||||
|
||||
## Включение
|
||||
|
||||
1. В `config/mcp.json` поставь `"enabled": true` нужному серверу.
|
||||
2. Задай токен в окружении:
|
||||
- Jira: `JIRA_TOKEN` (PAT Server/DC).
|
||||
- Confluence: `CONFLUENCE_TOKEN` (PAT Server/DC).
|
||||
- GitLab: `GITLAB_TOKEN` (scope `api`).
|
||||
3. Установи SDK (если ещё нет): `npm i @modelcontextprotocol/sdk`.
|
||||
4. Перезапусти `pi`. Проверь: `/mcp-status` → `connected` и число tools.
|
||||
|
||||
## Серверы
|
||||
|
||||
| Сервер | Пакет | URL из | Токен |
|
||||
|---|---|---|---|
|
||||
| `jira` | `mcp-atlassian` (uvx) | `trackerUrl` | `JIRA_TOKEN` |
|
||||
| `confluence` | `mcp-atlassian` (uvx) | `docsUrl` | `CONFLUENCE_TOKEN` |
|
||||
| `gitlab` | `@zereight/mcp-gitlab` (npx) | `gitHost` → `/api/v4` | `GITLAB_TOKEN` |
|
||||
|
||||
<!-- TODO: сверить с корпоративной политикой — какие MCP-серверы одобрены, разрешён ли uvx/npx
|
||||
на машинах, или нужен предустановленный/зеркалированный образ. -->
|
||||
|
||||
## Безопасность
|
||||
|
||||
MCP-серверы — сторонние процессы с полным доступом к твоим токенам. Включай только одобренные
|
||||
образы. Инструменты MCP проходят через guardrails pi (permission-gate/audit) как обычные вызовы.
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
---
|
||||
name: confluence
|
||||
description: Используй, когда нужно найти или написать документацию в корпоративном Confluence — поискать страницы, прочитать содержимое, создать новую страницу. Применяй при вопросах «где документация», «есть ли дока по», «помогить найти в вики», «создай страницу», а также в команде /rfc.
|
||||
---
|
||||
|
||||
# Работа с документацией (Confluence)
|
||||
|
||||
Скилл покрывает поиск и создание страниц в корпоративной вики. Адрес и токен берутся
|
||||
автоматически — ничего не хардкодь и не спрашивай.
|
||||
|
||||
## Откуда адрес и токен
|
||||
|
||||
- **Адрес** — из `docsUrl` в `config/company.json` (в контекст сессии его инжектит
|
||||
расширение `company-context`). Можно переопределить переменной `CONFLUENCE_URL`.
|
||||
- **Токен** — `CONFLUENCE_TOKEN` (personal access token). Задаётся в
|
||||
`~/.config/pi-kit/env.sh`. Не подставляй его в команды и не логируй.
|
||||
|
||||
## Инструменты
|
||||
|
||||
Доступны как нативные инструменты pi (регистрирует `extensions/confluence-tools.ts`):
|
||||
|
||||
- **`confluence_search`** — поиск страниц по CQL-запросу.
|
||||
Примеры CQL:
|
||||
- `text = "деплой"` — по тексту;
|
||||
- `title = "RFC"` — по заголовку;
|
||||
- `space = TECHRUN AND type = page` — в конкретном пространстве;
|
||||
- сложное: `(text = "kubernetes" OR title = "k8s") AND space = SRE`.
|
||||
- **`confluence_page_get`** — получить страницу по ID (с `expandBody` — вернуть текст).
|
||||
- **`confluence_page_create`** — создать страницу в пространстве (body — HTML в Storage
|
||||
format или обычный текст; укажи `parentId` для вложенности).
|
||||
|
||||
## Соглашения
|
||||
|
||||
- **Сначала ищи, потом создавай.** Прежде чем писать новую страницу — `confluence_search`
|
||||
по теме, чтобы не дублировать существующую.
|
||||
- **Храни адреса страниц** в ответах — верни URL созданной/найденной страницы пользователю.
|
||||
- Если инструмент вернул ошибку «проверь токен» — попроси пользователя проверить
|
||||
`CONFLUENCE_TOKEN`, не выдавай сам токен.
|
||||
- Ключи пространств (`spaceKey`) — реальные значения посмотри через `confluence_search`; если
|
||||
запросили создать страницу, а пространство неизвестно — уточни у пользователя.
|
||||
|
||||
## Полезное
|
||||
|
||||
- Для RFC/ADR часто нужен свежий поиск в пространстве архитектуры — используй
|
||||
`confluence_search` с `space =` нужного пространства. Если карта документации
|
||||
(`docs-map`) ещё с TODO — не выдумывай пространства, спроси пользователя или начни с
|
||||
поиска, чтобы определить реальные.
|
||||
@@ -1,71 +1,52 @@
|
||||
---
|
||||
name: jira-workflow
|
||||
description: Используй, когда нужно работать с корпоративным трекером задач (Jira) — прочитать тикет по ключу, добавить комментарий, приложить ссылку на MR, разобраться в статусах и переходах. Применяй при упоминании ключей задач, «тикет», «задача в трекере», «переведи в статус», а также в командах /bugfix и /feature.
|
||||
description: Используй, когда нужно работать с корпоративным трекером задач (Jira) — прочитать тикет по ключу, создать/обновить задачу, добавить комментарий, перевести по статусам, найти по JQL, приложить ссылку на MR. Применяй при упоминании ключей задач, «тикет», «задача в трекере», «переведи в статус», а также в командах /bugfix и /feature.
|
||||
---
|
||||
|
||||
# Работа с трекером задач (Jira)
|
||||
|
||||
Скилл описывает, как читать и обновлять задачи в нашем трекере через REST API.
|
||||
Jira работает через **нативные инструменты pi** (регистрируют `extensions/jira-tools.ts`).
|
||||
Никаких bash-скриптов и MCP — вызывай инструменты напрямую.
|
||||
|
||||
## Откуда берутся адрес и токен
|
||||
## Откуда адрес и токен
|
||||
|
||||
**Никогда не хардкодь и не спрашивай их отдельно — они уже есть в окружении.**
|
||||
- **Адрес трекера** — из `trackerUrl` в `config/company.json` (в контекст сессии инжектит
|
||||
`company-context`). Можно переопределить `TRACKER_URL`.
|
||||
- **Токен** — `JIRA_TOKEN` (personal access token) из `~/.config/pi-kit/env.sh`.
|
||||
- Токен не подставляй в команды и не логируй — инструменты читают его сами.
|
||||
|
||||
- **Базовый адрес трекера** — из переменной окружения `TRACKER_URL`. Если она не задана,
|
||||
возьми адрес трекера из корпоративного контекста сессии (его инжектит расширение
|
||||
`company-context`).
|
||||
- **Токен доступа** — из переменной окружения `JIRA_TOKEN` (personal access token).
|
||||
- Токен в команды и логи не подставляй в открытом виде — скрипты читают его из окружения сами.
|
||||
## Инструменты
|
||||
|
||||
Для создания merge request в GitLab дополнительно нужен `GITLAB_TOKEN` (токен с scope `api`);
|
||||
хост и путь проекта скрипт определяет из `git remote origin` (или `GITLAB_HOST`) — адреса не хардкодятся.
|
||||
- **`jira_issue_get(key)`** — получить тикет (summary, status, description).
|
||||
- **`jira_issue_create(projectKey, summary, issuetype, ...)`** — создать задачу.
|
||||
- **`jira_issue_update(key, {summary?, description?, status?, ...})`** — обновить поля,
|
||||
`status` переводит по доступным переходам.
|
||||
- **`jira_issue_comment(key, text)`** — добавить комментарий.
|
||||
- **`jira_issue_transition(key, target)`** — перевести в статус
|
||||
(`To Do` / `In Progress` / `Review` / `Done`, поддерживаются русские синонимы).
|
||||
- **`jira_search(jql, maxResults?)`** — поиск по JQL.
|
||||
- **`jira_link_mr(key, mrUrl, title?)`** — привязать ссылку на MR (комментарием).
|
||||
|
||||
Быстрая проверка окружения:
|
||||
## Типовой поток багфикса (/bugfix)
|
||||
|
||||
```bash
|
||||
: "${TRACKER_URL:?установи TRACKER_URL или возьми адрес из корпоративного контекста}"
|
||||
: "${JIRA_TOKEN:?установи JIRA_TOKEN (personal access token трекера)}"
|
||||
```
|
||||
|
||||
## Готовые скрипты
|
||||
|
||||
Все скрипты лежат в `scripts/` и читают `TRACKER_URL` и `JIRA_TOKEN` из окружения:
|
||||
|
||||
- `./scripts/get-issue.sh <KEY>` — получить тикет (поля summary, status, description).
|
||||
- `./scripts/add-comment.sh <KEY> "<текст>"` — добавить комментарий.
|
||||
- `./scripts/link-mr.sh <KEY> "<url-MR>" ["<заголовок>"]` — приложить ссылку на MR
|
||||
(добавляется комментарием со ссылкой; при доступном remote link API — см. TODO в скрипте).
|
||||
- `./scripts/create-mr.sh "<title>" [target] [source] [jira-key]` — создать merge request в
|
||||
GitLab (по текущей ветке), вывести его URL и, если передан `jira-key` и заданы
|
||||
`TRACKER_URL`/`JIRA_TOKEN`, автоматически приложить ссылку к тикету.
|
||||
|
||||
Пример:
|
||||
|
||||
```bash
|
||||
./scripts/get-issue.sh PROJ-123
|
||||
./scripts/add-comment.sh PROJ-123 "Начал работу, воспроизвёл баг"
|
||||
./scripts/link-mr.sh PROJ-123 "https://<git-host>/ai/foo/-/merge_requests/42" "Fix PROJ-123"
|
||||
|
||||
# создать MR из текущей ветки в main и сразу привязать к тикету:
|
||||
GITLAB_TOKEN=... ./scripts/create-mr.sh "Fix PROJ-123: NPE" main "" PROJ-123
|
||||
```
|
||||
|
||||
Флаги окружения `create-mr.sh`: `GITLAB_TOKEN` (обязателен), `GITLAB_HOST` (override хоста),
|
||||
`MR_PUSH=0` (не пушить ветку), `MR_DRY_RUN=1` (показать запрос без вызова API).
|
||||
MR из защищённой/релизной ветки (`main`/`master`/`release-*`) скрипт создавать откажется.
|
||||
1. `jira_issue_get <KEY>` — прочитать постановку.
|
||||
2. `jira_issue_transition <KEY> "In Progress"` — при старте работы.
|
||||
3. Создать ветку/фикс (языковые стандарты — в профильном скилле).
|
||||
4. Перед мержем — `gitlab_mr_create` (см. скилл gitlab) и
|
||||
`jira_link_mr <KEY> <url-MR>` — привязать MR к тикету.
|
||||
5. `jira_issue_transition <KEY> "Review"` — после выставления MR.
|
||||
6. `jira_issue_transition <KEY> "Done"` — после мержа.
|
||||
|
||||
## Соглашения
|
||||
|
||||
- **Формат ключей задач:** вида `PROJ-123` (буквенный префикс проекта + номер).
|
||||
`<!-- TODO: перечислить реальные префиксы проектов -->`
|
||||
- **Статусы и переходы:** To Do → In Progress → Review → Done.
|
||||
- **Кто и когда меняет статус:** разработчик переводит тикет: в **In Progress** — при старте работы;
|
||||
в **Review** — при выставлении MR (в тикет добавляется ссылка на MR); в **Done** — после мержа.
|
||||
- **Что писать в комментарии:** при старте — кратко о начале работы; при готовности — ссылку на MR
|
||||
(используй `create-mr.sh`, который сразу приложит ссылку, либо `link-mr.sh`).
|
||||
- **Формат ключей:** `PROJ-123` (префикс проекта + номер).
|
||||
- **Статусы:** To Do → In Progress → Review → Done.
|
||||
- **Переходы:** при старте — In Progress; при готовности MR — Review (с ссылкой на MR);
|
||||
после мержа — Done.
|
||||
- Если `jira_issue_update` со `status` не смог найти переход — инструмент вернёт список
|
||||
доступных статусов; уточни нужный.
|
||||
|
||||
## Полезное
|
||||
|
||||
- ID переходов статуса можно получить через `GET /rest/api/2/issue/<KEY>/transitions`,
|
||||
выполнить переход — `POST` туда же с `{"transition":{"id":"<id>"}}`.
|
||||
- Если API возвращает 401/403 — проверь `JIRA_TOKEN` и права. Не логируй сам токен.
|
||||
- `jira_search` удобен для «что в работе у меня»: `jira_search('assignee = currentUser() AND status != Done')`.
|
||||
- Токен лежит в `~/.config/pi-kit/env.sh` (chmod 600).
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# add-comment.sh <ISSUE_KEY> "<comment text>"
|
||||
# Adds a comment to a tracker issue.
|
||||
# Reads TRACKER_URL and JIRA_TOKEN from the environment. No secrets are hardcoded.
|
||||
set -euo pipefail
|
||||
|
||||
KEY="${1:?usage: add-comment.sh <ISSUE_KEY> \"<text>\"}"
|
||||
TEXT="${2:?usage: add-comment.sh <ISSUE_KEY> \"<text>\"}"
|
||||
: "${TRACKER_URL:?set TRACKER_URL (or take the tracker address from the session corporate context)}"
|
||||
: "${JIRA_TOKEN:?set JIRA_TOKEN (tracker personal access token)}"
|
||||
|
||||
BASE="${TRACKER_URL%/}"
|
||||
|
||||
# Build JSON body safely (jq escapes the text); fall back to a naive body if jq is absent.
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
BODY="$(jq -n --arg b "$TEXT" '{body: $b}')"
|
||||
else
|
||||
BODY="{\"body\": \"${TEXT//\"/\\\"}\"}"
|
||||
fi
|
||||
|
||||
curl -fsS -X POST \
|
||||
-H "Authorization: Bearer ${JIRA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$BODY" \
|
||||
"${BASE}/rest/api/2/issue/${KEY}/comment" >/dev/null
|
||||
|
||||
echo "Comment added to ${KEY}"
|
||||
@@ -1,112 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# create-mr.sh "<title>" [target-branch] [source-branch] [jira-key]
|
||||
#
|
||||
# Creates a GitLab merge request for the current repository via the GitLab API.
|
||||
# Host and project path are derived from `git remote origin` (or GITLAB_HOST),
|
||||
# so no URLs are hardcoded. If a Jira key is given and TRACKER_URL/JIRA_TOKEN are
|
||||
# set, the MR link is also attached to the ticket via link-mr.sh.
|
||||
#
|
||||
# Env:
|
||||
# GITLAB_TOKEN (required) GitLab personal/project access token with api scope
|
||||
# GITLAB_HOST (optional) override host, e.g. git.codelab.vc
|
||||
# MR_PUSH (optional) 0 to skip pushing the source branch (default: push)
|
||||
# MR_DRY_RUN (optional) 1 to print the request and exit without calling the API
|
||||
#
|
||||
# Examples:
|
||||
# ./create-mr.sh "Fix PROJ-123: null pointer" main
|
||||
# ./create-mr.sh "Add feature X" main feature/PROJ-42-x PROJ-42
|
||||
set -euo pipefail
|
||||
|
||||
TITLE="${1:?usage: create-mr.sh \"<title>\" [target-branch] [source-branch] [jira-key]}"
|
||||
TARGET="${2:-main}"
|
||||
SOURCE="${3:-$(git rev-parse --abbrev-ref HEAD)}"
|
||||
JIRA_KEY="${4:-}"
|
||||
|
||||
: "${GITLAB_TOKEN:?set GITLAB_TOKEN (GitLab access token with api scope)}"
|
||||
|
||||
# --- Refuse to open an MR *from* a protected branch (safety). ----------------
|
||||
case "$SOURCE" in
|
||||
main | master | release-* | release/*)
|
||||
echo "Refusing: source branch '$SOURCE' looks protected/release. Create a feature branch first." >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# --- Derive host and project path from the origin remote. -------------------
|
||||
URL="$(git config --get remote.origin.url)"
|
||||
URL="${URL%.git}"
|
||||
case "$URL" in
|
||||
*://*) # scheme://[user@]host[:port]/path
|
||||
REST="${URL#*://}"
|
||||
REST="${REST#*@}"
|
||||
HOST_PORT="${REST%%/*}"
|
||||
PATH_NS="${REST#*/}"
|
||||
HOST="${HOST_PORT%%:*}"
|
||||
;;
|
||||
*@*:*) # scp-like: user@host:path
|
||||
REST="${URL#*@}"
|
||||
HOST="${REST%%:*}"
|
||||
PATH_NS="${REST#*:}"
|
||||
;;
|
||||
*)
|
||||
echo "Cannot parse origin remote URL: $URL" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
HOST="${GITLAB_HOST:-$HOST}"
|
||||
if [ -z "$HOST" ] || [ -z "$PATH_NS" ]; then
|
||||
echo "Could not determine GitLab host/project from origin ($URL). Set GITLAB_HOST." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# URL-encode the project path (only '/' needs encoding for a namespaced path).
|
||||
PROJECT_ENC="${PATH_NS//\//%2F}"
|
||||
API="https://${HOST}/api/v4/projects/${PROJECT_ENC}/merge_requests"
|
||||
|
||||
# --- Build request body (jq escapes; fall back to naive JSON). --------------
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
BODY="$(jq -n \
|
||||
--arg s "$SOURCE" --arg t "$TARGET" --arg title "$TITLE" \
|
||||
'{source_branch:$s, target_branch:$t, title:$title, remove_source_branch:true, squash:true}')"
|
||||
else
|
||||
BODY="{\"source_branch\":\"${SOURCE}\",\"target_branch\":\"${TARGET}\",\"title\":\"${TITLE//\"/\\\"}\",\"remove_source_branch\":true,\"squash\":true}"
|
||||
fi
|
||||
|
||||
if [ "${MR_DRY_RUN:-0}" = "1" ]; then
|
||||
echo "DRY RUN"
|
||||
echo "POST ${API}"
|
||||
echo "body: ${BODY}"
|
||||
echo "would push: $([ "${MR_PUSH:-1}" = "1" ] && echo "git push -u origin ${SOURCE}" || echo "(skipped)")"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# --- Ensure the source branch exists on the remote. -------------------------
|
||||
if [ "${MR_PUSH:-1}" = "1" ]; then
|
||||
git push -u origin "$SOURCE"
|
||||
fi
|
||||
|
||||
# --- Create the MR. ---------------------------------------------------------
|
||||
RESPONSE="$(curl -fsS -X POST \
|
||||
-H "PRIVATE-TOKEN: ${GITLAB_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$BODY" "$API")"
|
||||
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
WEB_URL="$(printf '%s' "$RESPONSE" | jq -r '.web_url // empty')"
|
||||
else
|
||||
WEB_URL="$(printf '%s' "$RESPONSE" | sed -n 's/.*"web_url":"\([^"]*\)".*/\1/p' | head -1)"
|
||||
fi
|
||||
|
||||
if [ -z "$WEB_URL" ]; then
|
||||
echo "MR request sent, but could not parse web_url from response:" >&2
|
||||
echo "$RESPONSE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "MR created: $WEB_URL"
|
||||
|
||||
# --- Optionally attach the MR link to the Jira ticket. ----------------------
|
||||
if [ -n "$JIRA_KEY" ] && [ -n "${TRACKER_URL:-}" ] && [ -n "${JIRA_TOKEN:-}" ]; then
|
||||
"$(dirname "$0")/link-mr.sh" "$JIRA_KEY" "$WEB_URL" "$TITLE"
|
||||
fi
|
||||
@@ -1,17 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# get-issue.sh <ISSUE_KEY>
|
||||
# Prints summary, status and description of a tracker issue.
|
||||
# Reads TRACKER_URL and JIRA_TOKEN from the environment. No secrets are hardcoded.
|
||||
set -euo pipefail
|
||||
|
||||
KEY="${1:?usage: get-issue.sh <ISSUE_KEY>}"
|
||||
: "${TRACKER_URL:?set TRACKER_URL (or take the tracker address from the session corporate context)}"
|
||||
: "${JIRA_TOKEN:?set JIRA_TOKEN (tracker personal access token)}"
|
||||
|
||||
BASE="${TRACKER_URL%/}"
|
||||
|
||||
curl -fsS \
|
||||
-H "Authorization: Bearer ${JIRA_TOKEN}" \
|
||||
-H "Accept: application/json" \
|
||||
"${BASE}/rest/api/2/issue/${KEY}?fields=summary,status,description" \
|
||||
| { jq -r '"\(.key)\t\(.fields.status.name)\n\(.fields.summary)\n\n\(.fields.description // "")"' 2>/dev/null || cat; }
|
||||
@@ -1,22 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# link-mr.sh <ISSUE_KEY> "<mr-url>" ["<title>"]
|
||||
# Attaches a merge request link to a tracker issue.
|
||||
# Default implementation posts a comment with the link (works on any Jira).
|
||||
# Reads TRACKER_URL and JIRA_TOKEN from the environment. No secrets are hardcoded.
|
||||
set -euo pipefail
|
||||
|
||||
KEY="${1:?usage: link-mr.sh <ISSUE_KEY> \"<mr-url>\" [\"<title>\"]}"
|
||||
URL="${2:?usage: link-mr.sh <ISSUE_KEY> \"<mr-url>\" [\"<title>\"]}"
|
||||
TITLE="${3:-Merge request}"
|
||||
: "${TRACKER_URL:?set TRACKER_URL (or take the tracker address from the session corporate context)}"
|
||||
: "${JIRA_TOKEN:?set JIRA_TOKEN (tracker personal access token)}"
|
||||
|
||||
BASE="${TRACKER_URL%/}"
|
||||
|
||||
# Default: comment with the MR link.
|
||||
COMMENT="${TITLE}: ${URL}"
|
||||
exec "$(dirname "$0")/add-comment.sh" "$KEY" "$COMMENT"
|
||||
|
||||
# TODO: если в компании используется remote issue link API, заменить на:
|
||||
# POST ${BASE}/rest/api/2/issue/${KEY}/remotelink
|
||||
# с телом {"object":{"url":"<URL>","title":"<TITLE>"}}
|
||||
Reference in New Issue
Block a user