From 8b273c9a7266ab0664e41a0f30aaf4b30e1246f1 Mon Sep 17 00:00:00 2001 From: Aleksey Shakhmatov Date: Thu, 6 Aug 2026 14:43:10 +0300 Subject: [PATCH] feat(api-tools): native TS tools for Jira/Confluence/GitLab, drop MCP bridge --- config/mcp.json | 35 ---- docs/mcp.md | 47 ----- extensions/confluence-tools.ts | 74 +++++++ extensions/gitlab-tools.ts | 77 ++++++++ extensions/jira-tools.ts | 146 ++++++++++++++ extensions/lib/atlassian.ts | 112 +++++++++++ extensions/lib/confluence.ts | 105 ++++++++++ extensions/lib/gitlab.ts | 129 +++++++++++++ extensions/lib/jira.ts | 201 ++++++++++++++++++++ extensions/mcp-bridge.ts | 156 --------------- skills/confluence/SKILL.md | 47 +++++ skills/jira-workflow/SKILL.md | 87 ++++----- skills/jira-workflow/scripts/add-comment.sh | 27 --- skills/jira-workflow/scripts/create-mr.sh | 112 ----------- skills/jira-workflow/scripts/get-issue.sh | 17 -- skills/jira-workflow/scripts/link-mr.sh | 22 --- 16 files changed, 925 insertions(+), 469 deletions(-) delete mode 100644 config/mcp.json delete mode 100644 docs/mcp.md create mode 100644 extensions/confluence-tools.ts create mode 100644 extensions/gitlab-tools.ts create mode 100644 extensions/jira-tools.ts create mode 100644 extensions/lib/atlassian.ts create mode 100644 extensions/lib/confluence.ts create mode 100644 extensions/lib/gitlab.ts create mode 100644 extensions/lib/jira.ts delete mode 100644 extensions/mcp-bridge.ts create mode 100644 skills/confluence/SKILL.md delete mode 100755 skills/jira-workflow/scripts/add-comment.sh delete mode 100755 skills/jira-workflow/scripts/create-mr.sh delete mode 100755 skills/jira-workflow/scripts/get-issue.sh delete mode 100755 skills/jira-workflow/scripts/link-mr.sh diff --git a/config/mcp.json b/config/mcp.json deleted file mode 100644 index 68b891a..0000000 --- a/config/mcp.json +++ /dev/null @@ -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}" - } - } - } -} diff --git a/docs/mcp.md b/docs/mcp.md deleted file mode 100644 index 04b4341..0000000 --- a/docs/mcp.md +++ /dev/null @@ -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` | - - - -## Безопасность - -MCP-серверы — сторонние процессы с полным доступом к твоим токенам. Включай только одобренные -образы. Инструменты MCP проходят через guardrails pi (permission-gate/audit) как обычные вызовы. diff --git a/extensions/confluence-tools.ts b/extensions/confluence-tools.ts new file mode 100644 index 0000000..6f5e705 --- /dev/null +++ b/extensions/confluence-tools.ts @@ -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, run: (p: any) => Promise) => { + 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), + ); +} diff --git a/extensions/gitlab-tools.ts b/extensions/gitlab-tools.ts new file mode 100644 index 0000000..aba3038 --- /dev/null +++ b/extensions/gitlab-tools.ts @@ -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, run: (p: any) => Promise) => { + 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 }), + ); +} diff --git a/extensions/jira-tools.ts b/extensions/jira-tools.ts new file mode 100644 index 0000000..779b845 --- /dev/null +++ b/extensions/jira-tools.ts @@ -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, run: (params: any) => Promise) { + 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: {}, + }; + } + }, + }); + } +} diff --git a/extensions/lib/atlassian.ts b/extensions/lib/atlassian.ts new file mode 100644 index 0000000..0483ca0 --- /dev/null +++ b/extensions/lib/atlassian.ts @@ -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; + 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 { + const { method = "GET", query, body, timeoutMs = 15000 } = opts; + const fetchImpl = opts.fetchImpl ?? fetch; + + const url = buildUrl(baseUrl, path, query); + const headers: Record = { + 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; + 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; +} diff --git a/extensions/lib/confluence.ts b/extensions/lib/confluence.ts new file mode 100644 index 0000000..4d9b5b4 --- /dev/null +++ b/extensions/lib/confluence.ts @@ -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 { + 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 { + 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 { + 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 = { + 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 ?? ""}`, + }; +} diff --git a/extensions/lib/gitlab.ts b/extensions/lib/gitlab.ts new file mode 100644 index 0000000..b2257a8 --- /dev/null +++ b/extensions/lib/gitlab.ts @@ -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 = { + 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> { + 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> { + const base = gitlabBaseUrl(); + if (!query) throw new ApiError("codeSearch требует строку поиска (query)."); + const q: Record = { + 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 ?? "", + })); +} diff --git a/extensions/lib/jira.ts b/extensions/lib/jira.ts new file mode 100644 index 0000000..16a9323 --- /dev/null +++ b/extensions/lib/jira.ts @@ -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 { + 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 { + 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 = { + 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 { + const base = jiraBaseUrl(); + if (!base) throw new ApiError("Не задан trackerUrl в config/company.json и нет TRACKER_URL."); + if (!key) throw new ApiError("Укажи ключ задачи."); + + const fields: Record = {}; + 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 { + const base = jiraBaseUrl(); + if (!base) throw new ApiError("Не задан trackerUrl в config/company.json и нет TRACKER_URL."); + const t = target.toLowerCase(); + const alias: Record = { + "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 { + 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; +} diff --git a/extensions/mcp-bridge.ts b/extensions/mcp-bridge.ts deleted file mode 100644 index 61b1aaf..0000000 --- a/extensions/mcp-bridge.ts +++ /dev/null @@ -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; -} - -type Status = "disabled" | "connected" | "error" | "no-sdk" | "missing-env"; -interface ServerState { - description: string; - status: Status; - tools: string[]; - detail?: string; -} - -function readJson(file: string): Record { - 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, - company: Record, -): Record | null { - const out: Record = {}; - 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; - const state = new Map(); - const closers: Array<() => Promise | 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, - }); - 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 }); - 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(); - }); -} diff --git a/skills/confluence/SKILL.md b/skills/confluence/SKILL.md new file mode 100644 index 0000000..fb6e788 --- /dev/null +++ b/skills/confluence/SKILL.md @@ -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 — не выдумывай пространства, спроси пользователя или начни с + поиска, чтобы определить реальные. diff --git a/skills/jira-workflow/SKILL.md b/skills/jira-workflow/SKILL.md index be78b8d..b77812f 100644 --- a/skills/jira-workflow/SKILL.md +++ b/skills/jira-workflow/SKILL.md @@ -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 ` — получить тикет (поля summary, status, description). -- `./scripts/add-comment.sh "<текст>"` — добавить комментарий. -- `./scripts/link-mr.sh "" ["<заголовок>"]` — приложить ссылку на MR - (добавляется комментарием со ссылкой; при доступном remote link API — см. TODO в скрипте). -- `./scripts/create-mr.sh "" [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). diff --git a/skills/jira-workflow/scripts/add-comment.sh b/skills/jira-workflow/scripts/add-comment.sh deleted file mode 100755 index a735053..0000000 --- a/skills/jira-workflow/scripts/add-comment.sh +++ /dev/null @@ -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}" diff --git a/skills/jira-workflow/scripts/create-mr.sh b/skills/jira-workflow/scripts/create-mr.sh deleted file mode 100755 index 992d74b..0000000 --- a/skills/jira-workflow/scripts/create-mr.sh +++ /dev/null @@ -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 diff --git a/skills/jira-workflow/scripts/get-issue.sh b/skills/jira-workflow/scripts/get-issue.sh deleted file mode 100755 index 0b82d3d..0000000 --- a/skills/jira-workflow/scripts/get-issue.sh +++ /dev/null @@ -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; } diff --git a/skills/jira-workflow/scripts/link-mr.sh b/skills/jira-workflow/scripts/link-mr.sh deleted file mode 100755 index d7b9bb4..0000000 --- a/skills/jira-workflow/scripts/link-mr.sh +++ /dev/null @@ -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>"}}