refactor(extensions): simplify lib helpers, tool registration and kit-doctor

Behavior-preserving cleanups:
- extract shared resolveBaseUrl helper for jira/confluence base-URL logic
- extract shared registerTool helper; port jira/gitlab/confluence tools to it
- make scanSecrets declarative (filter/map)
- collapse ok/warn pair into status() and drop double existsSync in kit-doctor

Typecheck clean; npm test 71 PASS, 0 FAIL.
This commit is contained in:
Aleksey Shakhmatov
2026-08-07 00:24:04 +03:00
parent 88eb02e17f
commit 0c6e590273
9 changed files with 212 additions and 202 deletions
+41 -53
View File
@@ -6,69 +6,57 @@
* DISABLE: remove/rename this file from extensions/.
*/
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { registerTool, type ToolSpec } from "./lib/tools.ts";
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-запросу (текст/заголовок/пространство).",
const tools: ToolSpec[] = [
{
type: "object",
properties: {
cql: { type: "string", description: 'CQL, например text="деплой" AND space = TECHRUN' },
limit: { type: "number", description: "Макс. результатов (default 25)" },
expandBody: { type: "boolean", description: "Вернуть полный текст страниц" },
name: "confluence_search",
description: "Найти страницы в Confluence по CQL-запросу (текст/заголовок/пространство).",
errorPrefix: "confluence",
parameters: {
type: "object",
properties: {
cql: { type: "string", description: 'CQL, например text="деплой" AND space = TECHRUN' },
limit: { type: "number", description: "Макс. результатов (default 25)" },
expandBody: { type: "boolean", description: "Вернуть полный текст страниц" },
},
required: ["cql"],
},
required: ["cql"],
run: async (p) => search(p.cql, { limit: p.limit, expandBody: p.expandBody }),
},
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: "Вернуть тело страницы" },
name: "confluence_page_get",
description: "Получить страницу Confluence по ID (с содержимым, если expandBody).",
errorPrefix: "confluence",
parameters: {
type: "object",
properties: {
id: { type: "string", description: "ID страницы" },
expandBody: { type: "boolean", description: "Вернуть тело страницы" },
},
required: ["id"],
},
required: ["id"],
run: async (p) => pageGet(p.id, { expandBody: p.expandBody }),
},
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 родительской страницы (для вложенности)" },
name: "confluence_page_create",
description: "Создать страницу в Confluence. body — HTML в Storage format.",
errorPrefix: "confluence",
parameters: {
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"],
},
required: ["spaceKey", "title", "body"],
run: async (p) => pageCreate(p),
},
async (p) => pageCreate(p),
);
];
for (const t of tools) registerTool(pi, t);
}
+53 -57
View File
@@ -6,72 +6,68 @@
* 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";
import { registerTool, type ToolSpec } from "./lib/tools.ts";
import { mrCreate, pipelineStatus, codeSearch } 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).",
const tools: ToolSpec[] = [
{
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: "Мержить после успешного пайплайна" },
name: "gitlab_mr_create",
description: "Создать merge request в GitLab. Проект определяется из git remote origin (или укажи project).",
errorPrefix: "gitlab",
parameters: {
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"],
},
required: ["title"],
run: async (p) =>
mrCreate({
title: p.title,
sourceBranch: p.sourceBranch || undefined,
targetBranch: p.targetBranch,
project: p.project,
removeSourceBranch: p.removeSourceBranch,
mergeWhenPipelineSucceeds: p.mergeWhenPipelineSucceeds,
}),
},
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)" },
name: "gitlab_pipeline_status",
description: "Показать статусы CI/CD пайплайнов проекта GitLab (по ветке).",
errorPrefix: "gitlab",
parameters: {
type: "object",
properties: {
project: { type: "string", description: "Группа/репозиторий (обязательно; например group/name)" },
ref: { type: "string", description: "Ветка/тег, по которому фильтровать" },
perPage: { type: "number", description: "Сколько последних пайплайнов (default 10)" },
},
required: ["project"],
},
required: ["project"],
run: async (p) => pipelineStatus(p.project, { ref: p.ref, perPage: p.perPage }),
},
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)" },
name: "gitlab_code_search",
description: "Поиск по коду в GitLab (blobs). Требует scope api/read_api у токена.",
errorPrefix: "gitlab",
parameters: {
type: "object",
properties: {
query: { type: "string", description: "Строка поиска (до 512 символов)" },
project: { type: "string", description: "Ограничить поиск группой/репозиторием" },
perPage: { type: "number", description: "Макс. результатов (default 20)" },
},
required: ["query"],
},
required: ["query"],
run: async (p) => codeSearch(p.query, { project: p.project, perPage: p.perPage }),
},
async (p) => codeSearch(p.query, { project: p.project, perPage: p.perPage }),
);
];
for (const t of tools) registerTool(pi, t);
}
+52 -68
View File
@@ -9,6 +9,7 @@
* DISABLE: remove/rename this file from extensions/.
*/
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { registerTool, type ToolSpec } from "./lib/tools.ts";
import {
issueGet,
issueCreate,
@@ -19,22 +20,20 @@ import {
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.",
{
const tools: ToolSpec[] = [
{
name: "jira_issue_get",
description: "Получить тикет Jira по ключу (summary, status, description).",
errorPrefix: "jira",
parameters: { type: "object", properties: { key: { type: "string", description: "Ключ задачи, например PROJ-123" } }, required: ["key"] },
run: async (p) => issueGet(p.key),
},
{
name: "jira_issue_create",
description: "Создать задачу в Jira.",
errorPrefix: "jira",
parameters: {
type: "object",
properties: {
projectKey: { type: "string", description: "Ключ проекта, например PROJ" },
@@ -47,12 +46,13 @@ export default function jiraToolsExtension(pi: ExtensionAPI) {
},
required: ["projectKey", "summary", "issuetype"],
},
async (p) => issueCreate(p),
),
tool(
"jira_issue_update",
"Обновить поля задачи Jira (summary/description/status и т.п.). Status переводит по доступным переходам.",
{
run: async (p) => issueCreate(p),
},
{
name: "jira_issue_update",
description: "Обновить поля задачи Jira (summary/description/status и т.п.). Status переводит по доступным переходам.",
errorPrefix: "jira",
parameters: {
type: "object",
properties: {
key: { type: "string", description: "Ключ задачи" },
@@ -65,12 +65,13 @@ export default function jiraToolsExtension(pi: ExtensionAPI) {
},
required: ["key"],
},
async (p) => issueUpdate(p.key, p),
),
tool(
"jira_issue_comment",
"Добавить комментарий к тикету Jira.",
{
run: async (p) => issueUpdate(p.key, p),
},
{
name: "jira_issue_comment",
description: "Добавить комментарий к тикету Jira.",
errorPrefix: "jira",
parameters: {
type: "object",
properties: {
key: { type: "string", description: "Ключ задачи" },
@@ -78,12 +79,13 @@ export default function jiraToolsExtension(pi: ExtensionAPI) {
},
required: ["key", "text"],
},
async (p) => issueComment(p.key, p.text),
),
tool(
"jira_issue_transition",
"Перевести тикет Jira в статус (To Do / In Progress / Review / Done).",
{
run: async (p) => issueComment(p.key, p.text),
},
{
name: "jira_issue_transition",
description: "Перевести тикет Jira в статус (To Do / In Progress / Review / Done).",
errorPrefix: "jira",
parameters: {
type: "object",
properties: {
key: { type: "string", description: "Ключ задачи" },
@@ -91,12 +93,13 @@ export default function jiraToolsExtension(pi: ExtensionAPI) {
},
required: ["key", "target"],
},
async (p) => setStatus(p.key, p.target),
),
tool(
"jira_search",
"Найти тикеты Jira по JQL-запросу.",
{
run: async (p) => setStatus(p.key, p.target),
},
{
name: "jira_search",
description: "Найти тикеты Jira по JQL-запросу.",
errorPrefix: "jira",
parameters: {
type: "object",
properties: {
jql: { type: "string", description: "JQL, например 'project = PROJ AND status != Done'" },
@@ -104,12 +107,13 @@ export default function jiraToolsExtension(pi: ExtensionAPI) {
},
required: ["jql"],
},
async (p) => search(p.jql, p.maxResults),
),
tool(
"jira_link_mr",
"Привязать ссылку на merge request к тикету Jira (комментарий со ссылкой).",
{
run: async (p) => search(p.jql, p.maxResults),
},
{
name: "jira_link_mr",
description: "Привязать ссылку на merge request к тикету Jira (комментарий со ссылкой).",
errorPrefix: "jira",
parameters: {
type: "object",
properties: {
key: { type: "string", description: "Ключ задачи" },
@@ -118,29 +122,9 @@ export default function jiraToolsExtension(pi: ExtensionAPI) {
},
required: ["key", "mrUrl"],
},
async (p) => linkMr(p.key, p.mrUrl, p.title),
),
run: 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: {},
};
}
},
});
}
for (const t of tools) registerTool(pi, t);
}
+11 -11
View File
@@ -63,40 +63,40 @@ export default function kitCliExtension(pi: ExtensionAPI) {
pi.registerCommand("kit-doctor", {
description: "Проверка окружения pi-kit (node, версия, конфиг, env, токены)",
handler: async (_args, ctx) => {
const ok = (s: string) => `✓ ${s}`;
const warn = (s: string) => `⚠ ${s}`;
const status = (ok: boolean, text: string) => `${ok ? "✓" : "⚠"} ${text}`;
const cfg = readConfig();
const lines: string[] = [];
lines.push(ok(`Node.js ${process.version}`));
lines.push(ok(`pi-kit v${readVersion()}${process.env.PI_KIT_CHANNEL ? ` (канал: ${process.env.PI_KIT_CHANNEL})` : ""}`));
lines.push(status(true, `Node.js ${process.version}`));
lines.push(status(true, `pi-kit v${readVersion()}${process.env.PI_KIT_CHANNEL ? ` (канал: ${process.env.PI_KIT_CHANNEL})` : ""}`));
// Config
const cfgOk = typeof cfg.gitHost === "string" && cfg.gitHost !== "TODO";
lines.push((cfgOk ? ok : warn)(`config: gitHost=${cfg.gitHost ?? "?"}, tracker=${cfg.trackerUrl ?? "?"}, docs=${cfg.docsUrl ?? "?"}`));
if (cfg.remoteConfigUrl) lines.push(ok(`remoteConfigUrl задан`));
lines.push(status(cfgOk, `config: gitHost=${cfg.gitHost ?? "?"}, tracker=${cfg.trackerUrl ?? "?"}, docs=${cfg.docsUrl ?? "?"}`));
if (cfg.remoteConfigUrl) lines.push(status(true, `remoteConfigUrl задан`));
// TODO-placeholder values are a readiness signal, not an error: warn so
// maintainers notice they haven't filled the single source of truth yet.
for (const [field, value] of Object.entries({ repoMap: cfg.repoMap, trackerUrl: cfg.trackerUrl, docsUrl: cfg.docsUrl })) {
if (typeof value !== "string" || value === "TODO" || value.trim() === "" || value.startsWith("TODO")) {
lines.push(warn(`config: ${field} не заполнен (TODO) — скиллы repo-map/docs-map/jira будут просить уточнить`));
lines.push(status(false, `config: ${field} не заполнен (TODO) — скиллы repo-map/docs-map/jira будут просить уточнить`));
}
}
// Env vars (presence only, never values)
for (const v of ["TRACKER_URL", "JIRA_TOKEN", "CONFLUENCE_TOKEN", "GITLAB_TOKEN"]) {
lines.push((process.env[v] ? ok : warn)(`${v} ${process.env[v] ? "задан" : "не задан"}`));
lines.push(status(Boolean(process.env[v]), `${v} ${process.env[v] ? "задан" : "не задан"}`));
}
const provider = PROVIDER_KEYS.find((k) => process.env[k]);
lines.push((provider ? ok : warn)(provider ? `LLM-ключ: ${provider}` : "LLM-ключ провайдера не найден"));
lines.push(status(Boolean(provider), provider ? `LLM-ключ: ${provider}` : "LLM-ключ провайдера не найден"));
// env.sh
lines.push((fs.existsSync(ENV_FILE) ? ok : warn)(`env.sh ${fs.existsSync(ENV_FILE) ? "есть" : "нет"} (${ENV_FILE})`));
const envShExists = fs.existsSync(ENV_FILE);
lines.push(status(envShExists, `env.sh ${envShExists ? "есть" : "нет"} (${ENV_FILE})`));
// Loaded commands/skills
const cmds = pi.getCommands();
const skills = cmds.filter((c) => c.source === "skill").length;
lines.push(ok(`команд загружено: ${cmds.length} (скилов: ${skills})`));
lines.push(status(true, `команд загружено: ${cmds.length} (скилов: ${skills})`));
const warns = lines.filter((l) => l.startsWith("⚠")).length;
lines.push("", warns === 0 ? "Итог: всё ок." : `Итог: предупреждений — ${warns}.`);
+12
View File
@@ -36,6 +36,18 @@ function base(url: string): string {
return url.replace(/\/+$/, "");
}
/**
* Resolve an effective base URL from company.json + an env override.
* The env var wins when set (and not "TODO"); otherwise the config value
* (if set and not "TODO") is used. Trailing slashes are trimmed. Returns ""
* when neither is configured; callers decide whether to throw.
*/
export function resolveBaseUrl(envName: string, cfgValue: string | null | undefined): string {
const fromCfg = cfgValue && cfgValue !== "TODO" ? cfgValue : "";
const env = process.env[envName];
return (env && env !== "TODO" ? env : fromCfg).replace(/\/+$/, "");
}
/** Build a URL with query params; undefined/empty values are dropped. */
export function buildUrl(
baseUrl: string,
+2 -3
View File
@@ -3,7 +3,7 @@
* `docsUrl` comes from config/company.json; `CONFLUENCE_TOKEN` from the env.
* No pi API — unit-testable.
*/
import { apiRequest, requireToken, ApiError } from "./atlassian.ts";
import { apiRequest, requireToken, ApiError, resolveBaseUrl } from "./atlassian.ts";
import { readLocalConfig } from "./company-config.ts";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
@@ -14,8 +14,7 @@ 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(/\/+$/, "");
const url = resolveBaseUrl("CONFLUENCE_URL", cfg.docsUrl);
if (!url) throw new ApiError("Не задан docsUrl в config/company.json и нет CONFLUENCE_URL.");
return url;
}
+3 -4
View File
@@ -3,7 +3,7 @@
* `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 { apiRequest, requireToken, ApiError, resolveBaseUrl } from "./atlassian.ts";
import { readLocalConfig } from "./company-config.ts";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
@@ -11,12 +11,11 @@ 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. */
/** Resolve the tracker base URL — company.json default, 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(/\/+$/, "");
return resolveBaseUrl("TRACKER_URL", cfg.trackerUrl);
}
function token(): string {
+2 -6
View File
@@ -29,12 +29,8 @@ export const SECRET_PATTERNS: SecretPattern[] = [
/** Return the names of secret patterns found in `text` (empty if none). */
export function scanSecrets(text: string): string[] {
if (!text) return [];
const hits: string[] = [];
for (const p of SECRET_PATTERNS) {
// Use a non-stateful copy (no global flag) to avoid lastIndex surprises.
if (p.re.test(text)) hits.push(p.name);
}
return hits;
// No pattern carries the /g flag, so `test` stays stateless and order is preserved.
return SECRET_PATTERNS.filter((p) => p.re.test(text)).map((p) => p.name);
}
/** Replace every secret occurrence with a labelled placeholder. */
+36
View File
@@ -0,0 +1,36 @@
/**
* Shared native pi tool registration.
* The three *-tools.ts extensions each register tools whose `execute` bodies
* are byte-identical apart from the error prefix in the failure payload.
* This helper DRYs that boilerplate; extensions only supply the spec.
*/
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
export interface ToolSpec {
name: string;
description: string;
parameters: Record<string, unknown>;
/** Prefix in the error payload, e.g. "jira" → "jira error: …". */
errorPrefix: string;
run: (params: any) => Promise<unknown>;
}
/** Register a native tool whose result/error payload is formatted uniformly. */
export function registerTool(pi: ExtensionAPI, spec: ToolSpec): void {
pi.registerTool({
name: spec.name,
label: spec.name,
description: spec.description,
parameters: spec.parameters as any,
async execute(_id: string, params: unknown) {
try {
const out = await spec.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: `${spec.errorPrefix} error: ${msg}` }], isError: true, details: {} };
}
},
});
}