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
-35
View File
@@ -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
View File
@@ -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) как обычные вызовы.
+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();
});
}
+47
View File
@@ -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 — не выдумывай пространства, спроси пользователя или начни с
поиска, чтобы определить реальные.
+34 -53
View File
@@ -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}"
-112
View File
@@ -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
-17
View File
@@ -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; }
-22
View File
@@ -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>"}}