feat: MCP-сервер для Gitea (Actions, packages, issues)

Просмотр и перезапуск пайплайнов, диагностика падений по логам,
проверка образов в Gitea Packages, создание issue для разбора.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
vasyanskandClaude Sonnet 5.5 committed 2026-10-23 09:34:01 +07:00
commit 04ce6ebdab
14 files changed
+2233

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
node_modules
dist
.env
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 gitea-mcp contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+88
View File
@@ -0,0 +1,88 @@
# gitea-mcp
MCP-сервер (stdio) для [Gitea](https://about.gitea.com): подключается к репозиториям, смотрит и перезапускает пайплайны Gitea Actions, разбирает причины падений, проверяет сборку образов в Gitea Packages и заводит issue для разбора.
Работает с любым MCP-клиентом (Claude Code, Claude Desktop, Cursor и др.). Без внешних зависимостей, кроме MCP SDK и zod.
## Возможности
| Область | Tools |
|---|---|
| Репозитории | `gitea_list_repos`, `gitea_get_repo`, `gitea_get_file` |
| Просмотр пайплайнов | `gitea_list_runs`, `gitea_get_run`, `gitea_list_jobs`, `gitea_get_job_logs` (tail / grep) |
| Диагностика | `gitea_diagnose_run` — упавшие jobs и шаги, блоки ошибок из логов, вероятные причины и способы починки |
| Управление | `gitea_rerun_run`, `gitea_rerun_job`, `gitea_rerun_failed_jobs`, `gitea_cancel_run`, `gitea_dispatch_workflow` |
| Образы и пакеты | `gitea_list_packages`, `gitea_check_image` — есть ли тег, когда собран, digest, свежесть относительно `built_after` |
| Issues | `gitea_list_issues`, `gitea_get_issue`, `gitea_create_issue`, `gitea_comment_issue`, `gitea_create_issue_from_run` |
`gitea_create_issue_from_run` собирает диагностику упавшего run в issue и не создаёт дубликат, если открытый issue по этому run уже есть.
## Требования
- Node.js ≥ 20
- Gitea ≥ 1.24 (REST API для Actions: runs, jobs, logs, rerun)
- Токен доступа (Settings → Applications) со scope: repository (read/write), issue (read/write), package (read)
## Установка
```bash
git clone <url этого репозитория> gitea-mcp
cd gitea-mcp
npm install
npm run build
```
## Настройка
| Переменная | Описание |
|---|---|
| `GITEA_URL` | Адрес инстанса, например `https://gitea.example.com` |
| `GITEA_TOKEN` | Токен доступа |
| `GITEA_READONLY` | `true` — отключить все изменяющие операции (rerun, cancel, dispatch, создание issue и комментариев) |
| `GITEA_TIMEOUT_MS` | Таймаут запроса, по умолчанию `30000` |
### Claude Code
```bash
claude mcp add gitea -s user \
-e GITEA_URL=https://gitea.example.com \
-e GITEA_TOKEN=<токен> \
-- node /абсолютный/путь/к/gitea-mcp/dist/index.js
```
### Другие клиенты (JSON-конфиг)
```json
{
"mcpServers": {
"gitea": {
"command": "node",
"args": ["/абсолютный/путь/к/gitea-mcp/dist/index.js"],
"env": {
"GITEA_URL": "https://gitea.example.com",
"GITEA_TOKEN": "<токен>"
}
}
}
}
```
## Безопасность
- Токен берётся только из переменной окружения и никуда не логируется.
- Для работы «только на просмотр» используйте `GITEA_READONLY=true` и токен без write-прав.
- Логи перед выдачей модели и записью в issue проходят маскировку типовых секретов (пароли, токены, Bearer, приватные ключи, пароли в URL). Маскировка работает по шаблонам и может пропустить нестандартный секрет — просматривайте issue перед публикацией в открытых репозиториях.
- Причины падений в `gitea_diagnose_run` — эвристика по тексту лога, а не точный диагноз; рядом всегда приводятся сами блоки ошибок для проверки.
## Разработка
```bash
npm run dev # tsc --watch
npm run build
```
Структура: `src/client.ts` — HTTP-клиент Gitea, `src/diagnose.ts` — анализ и маскировка логов, `src/runs.ts` — общая логика run/job, `src/tools.ts`, `src/packages.ts`, `src/issues.ts` — регистрация tools.
## Лицензия
MIT
+1261
View File
File diff suppressed because it is too large. Load diff
+40
View File
@@ -0,0 +1,40 @@
{
"name": "gitea-mcp",
"version": "0.1.0",
"description": "MCP server for Gitea: repos, Actions runs, logs, rerun, failure diagnosis",
"type": "module",
"bin": {
"gitea-mcp": "dist/index.js"
},
"main": "dist/index.js",
"engines": {
"node": ">=20"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.29.0",
"zod": "^3.25.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.6.0"
},
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "tsc --watch",
"prepare": "tsc"
},
"license": "MIT",
"keywords": [
"mcp",
"gitea",
"gitea-actions",
"ci",
"model-context-protocol"
],
"files": [
"dist",
"README.md",
"LICENSE"
]
}
+94
View File
@@ -0,0 +1,94 @@
export interface Config {
baseUrl: string;
token: string;
readonly: boolean;
timeoutMs: number;
}
export function loadConfig(env = process.env): Config {
const url = env.GITEA_URL?.trim();
const token = env.GITEA_TOKEN?.trim();
if (!url) throw new Error("GITEA_URL не задан (например https://gitea.example.com)");
if (!token) throw new Error("GITEA_TOKEN не задан (Settings → Applications → Access Token)");
return {
baseUrl: url.replace(/\/+$/, "").replace(/\/api\/v1$/, ""),
token,
readonly: /^(1|true|yes)$/i.test(env.GITEA_READONLY ?? ""),
timeoutMs: Number(env.GITEA_TIMEOUT_MS ?? 30000),
};
}
export class GiteaError extends Error {
constructor(
public status: number,
message: string,
) {
super(message);
}
}
type Query = Record<string, string | number | undefined>;
export class GiteaClient {
constructor(private cfg: Config) {}
get readonly() {
return this.cfg.readonly;
}
private async request(method: string, path: string, query?: Query, body?: unknown): Promise<Response> {
const url = new URL(`${this.cfg.baseUrl}/api/v1${path}`);
for (const [k, v] of Object.entries(query ?? {})) {
if (v !== undefined && v !== "") url.searchParams.set(k, String(v));
}
const res = await fetch(url, {
method,
headers: {
Authorization: `token ${this.cfg.token}`,
Accept: "application/json",
...(body !== undefined ? { "Content-Type": "application/json" } : {}),
},
body: body !== undefined ? JSON.stringify(body) : undefined,
signal: AbortSignal.timeout(this.cfg.timeoutMs),
});
if (!res.ok) {
const text = await res.text().catch(() => "");
let msg = text.slice(0, 500);
try {
msg = JSON.parse(text).message ?? msg;
} catch {
/* не JSON */
}
throw new GiteaError(res.status, `Gitea ${method} ${path} → ${res.status}${msg ? `: ${msg}` : ""}${hint(res.status)}`);
}
return res;
}
async json<T = any>(method: string, path: string, query?: Query, body?: unknown): Promise<T> {
const res = await this.request(method, path, query, body);
const text = await res.text();
return (text ? JSON.parse(text) : {}) as T;
}
async text(path: string, query?: Query): Promise<string> {
const res = await this.request("GET", path, query);
return res.text();
}
}
function hint(status: number): string {
switch (status) {
case 401:
return " (токен неверный или просрочен)";
case 403:
return " (нужны scope: read/write:repository, а для Actions — доступ к репо)";
case 404:
return " (нет репо/run/job, нет прав, либо версия Gitea не поддерживает этот Actions API — нужна ≥1.24)";
default:
return "";
}
}
/** Безопасная подстановка сегмента пути. */
export const seg = (v: string | number) => encodeURIComponent(String(v));
export const repoPath = (owner: string, repo: string) => `/repos/${seg(owner)}/${seg(repo)}`;
+108
View File
@@ -0,0 +1,108 @@
/** Эвристический разбор логов упавшего job: ищем строки с ошибками, берём контекст, подбираем подсказки. */
const ERROR_RE =
/(\berror\b|\bfailed\b|\bfailure\b|\bfatal\b|exception|traceback|panic|denied|unauthorized|forbidden|not found|no such file|cannot |can't |unable to|timed? ?out|exit(ed)? (with )?(code|status) [1-9]|\bkilled\b|oom|npm err|✗|##\[error\])/i;
// После этих строк найденная ошибка — шум (например, "0 errors")
const NOISE_RE = /\b0 (errors?|failed|failures)\b|error_?count[=:]\s*0|no errors/i;
interface Hint {
re: RegExp;
cause: string;
fix: string;
}
const HINTS: Hint[] = [
{ re: /no space left on device/i, cause: "Закончилось место на раннере", fix: "docker system prune -af / очистить cache и _work на раннере, увеличить диск" },
{ re: /exec format error/i, cause: "Несовпадение архитектуры образа и раннера (arm64/amd64)", fix: "Собирать с --platform под архитектуру раннера или buildx multi-arch" },
{ re: /(manifest unknown|pull access denied|repository does not exist|requested access to the resource is denied)/i, cause: "Образ не найден или нет доступа к registry", fix: "Проверить имя/тег образа, docker login и токен registry в secrets" },
{ re: /(unauthorized|401|authentication required|invalid (credentials|token))/i, cause: "Ошибка аутентификации", fix: "Проверить секреты репо/орг (токен просрочен или не передан в env)" },
{ re: /(403|forbidden|permission denied)/i, cause: "Недостаточно прав", fix: "Проверить scope токена и права пользователя; для файлов — chmod/владельца на раннере" },
{ re: /(connection refused|no route to host|network is unreachable|dial tcp|could not resolve host|temporary failure in name resolution)/i, cause: "Сеть/DNS: сервис недоступен из раннера", fix: "Проверить DNS, firewall, что сервис поднят и доступен с раннера (в docker-раннере — сеть job-контейнера)" },
{ re: /(timed? ?out|deadline exceeded|context deadline)/i, cause: "Таймаут", fix: "Проверить доступность внешнего ресурса; увеличить timeout-minutes / таймаут команды" },
{ re: /(oomkilled|out of memory|\bkilled\b|exit code 137)/i, cause: "Процесс убит по памяти (OOM, exit 137)", fix: "Увеличить память раннера/контейнера, ограничить параллелизм сборки" },
{ re: /(npm err!|yarn error|pnpm.*err_)/i, cause: "Ошибка менеджера пакетов JS", fix: "Смотреть первую npm ERR! строку; чистый npm ci, проверить lock-файл и версию node" },
{ re: /(module not found|cannot find module|modulenotfounderror|no module named)/i, cause: "Не найдена зависимость/модуль", fix: "Проверить установку зависимостей и lock-файл, рабочую директорию шага" },
{ re: /(\bFAILED\b.*test|--- FAIL|assertionerror|tests? failed|\d+ failing)/i, cause: "Падение тестов", fix: "Смотреть имя упавшего теста и assertion выше по логу" },
{ re: /(lint|eslint|golangci|flake8|ruff|prettier).*(error|problem|fail)/i, cause: "Линтер нашёл нарушения", fix: "Запустить линтер локально, поправить найденное" },
{ re: /(yaml|workflow).*(syntax|invalid|unmarshal|unknown)|failed to parse workflow/i, cause: "Ошибка в workflow-файле", fix: "Проверить синтаксис YAML и поддержку фич в Gitea Actions (act_runner)" },
{ re: /(unable to resolve action|failed to clone|action.*not found|repository not found)/i, cause: "Не удалось получить action", fix: "Использовать полный URL action (https://github.com/...) или настроить DEFAULT_ACTIONS_URL" },
{ re: /(no matching (runner|job)|waiting for a runner|no runner)/i, cause: "Нет подходящего раннера", fix: "Сверить runs-on с labels зарегистрированных раннеров, проверить что раннер online" },
{ re: /(cert|x509|ssl).*(verify|unknown authority|expired|invalid)/i, cause: "Проблема с TLS-сертификатом", fix: "Проверить срок/цепочку сертификата; добавить CA в раннер" },
{ re: /(rate limit|too many requests|429)/i, cause: "Rate limit внешнего сервиса", fix: "Авторизоваться (docker login), использовать mirror/кэш" },
{ re: /(helm|kubectl|kubernetes).*(error|failed|forbidden)|\bUPGRADE FAILED\b|ImagePullBackOff|CrashLoopBackOff/i, cause: "Ошибка деплоя в Kubernetes/Helm", fix: "Смотреть kubectl describe/logs пода, события namespace; проверить kubeconfig-секрет и values" },
];
export interface ErrorBlock {
line: number;
text: string;
}
export function stripAnsi(s: string): string {
return s.replace(/\u001b\[[0-9;?]*[ -/]*[@-~]/g, "");
}
/** Маскирует типовые секреты: логи уходят в контекст модели и в тексты issue. */
export function redact(s: string): string {
return s
.replace(/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?(-----END [A-Z ]*PRIVATE KEY-----|$)/g, "***PRIVATE KEY***")
.replace(/\b(password|passwd|pwd|secret|token|api[_-]?key|access[_-]?key|authorization)(["']?\s*[=:]\s*["']?)(?!\*)([^\s"',;]{4,})/gi, "$1$2***")
.replace(/\bBearer\s+[A-Za-z0-9._~+/=-]{16,}/g, "Bearer ***")
.replace(/\b(ghp_|gho_|glpat-|xox[bap]-)[A-Za-z0-9_-]{10,}/g, "$1***")
.replace(/\bAKIA[0-9A-Z]{16}\b/g, "AKIA***")
.replace(/(:\/\/[^:/\s]+:)[^@/\s]+@/g, "$1***@");
}
export function tail(text: string, lines: number): string {
const arr = text.split("\n");
return arr.length <= lines ? text : arr.slice(-lines).join("\n");
}
export function grepLog(text: string, pattern: string, context = 3, maxBlocks = 20): string {
const re = new RegExp(pattern, "i");
return blocks(text.split("\n"), (l) => re.test(l), context, maxBlocks).join("\n---\n");
}
function blocks(lines: string[], match: (l: string) => boolean, context: number, max: number): string[] {
const hits: number[] = [];
lines.forEach((l, i) => match(l) && hits.push(i));
const out: string[] = [];
let i = 0;
while (i < hits.length && out.length < max) {
let start = Math.max(0, hits[i] - context);
let end = Math.min(lines.length - 1, hits[i] + context);
// слить близкие попадания в один блок
while (i + 1 < hits.length && hits[i + 1] - context <= end + 1) {
i++;
end = Math.min(lines.length - 1, hits[i] + context);
}
out.push(
lines
.slice(start, end + 1)
.map((l, k) => `${start + k + 1}: ${l}`)
.join("\n"),
);
i++;
}
return out;
}
export interface Diagnosis {
errorBlocks: string[];
causes: { cause: string; fix: string }[];
tail: string;
}
export function analyzeLog(rawLog: string, opts: { tailLines?: number; context?: number; maxBlocks?: number } = {}): Diagnosis {
const log = redact(stripAnsi(rawLog));
const lines = log.split("\n");
const errorBlocks = blocks(lines, (l) => ERROR_RE.test(l) && !NOISE_RE.test(l), opts.context ?? 4, opts.maxBlocks ?? 5);
// Подсказки ищем по последним 400 строкам + найденным блокам: причина обычно в конце
const haystack = [...errorBlocks, tail(log, 400)].join("\n");
const causes = HINTS.filter((h) => h.re.test(haystack)).slice(0, 4).map(({ cause, fix }) => ({ cause, fix }));
return { errorBlocks, causes, tail: tail(log, opts.tailLines ?? 40) };
}
export function truncate(s: string, max = 20000): string {
return s.length <= max ? s : `…[обрезано ${s.length - max} симв. в начале]\n${s.slice(-max)}`;
}
+45
View File
@@ -0,0 +1,45 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { z } from "zod";
import { GiteaClient } from "./client.js";
export const owner = z.string().min(1).describe("Владелец репозитория (user или org)");
export const repo = z.string().min(1).describe("Имя репозитория");
export const limit = (def: number) => z.number().int().min(1).max(50).default(def).describe("Сколько записей вернуть");
export const READ = { readOnlyHint: true, openWorldHint: true } as const;
export const WRITE = { readOnlyHint: false, destructiveHint: false, openWorldHint: true } as const;
export const ok = (data: unknown): CallToolResult => ({
content: [{ type: "text" as const, text: typeof data === "string" ? data : JSON.stringify(data, null, 2) }],
});
export const fail = (e: unknown): CallToolResult => ({
isError: true,
content: [{ type: "text" as const, text: e instanceof Error ? e.message : String(e) }],
});
export type ToolFn = <S extends z.ZodRawShape>(
name: string,
description: string,
schema: S,
annotations: typeof READ | typeof WRITE,
handler: (args: z.infer<z.ZodObject<S>>) => Promise<unknown>,
) => void;
/** Единая обработка ошибок + запрет записи в readonly-режиме. */
export function makeTool(server: McpServer, gitea: GiteaClient): ToolFn {
return (name, description, schema, annotations, handler) => {
// дженерик S не выводится SDK-типами при обёртке — приводим к any только на границе
const cb = async (args: any): Promise<CallToolResult> => {
try {
if (!annotations.readOnlyHint && gitea.readonly) {
throw new Error("Сервер запущен с GITEA_READONLY=true — изменяющие операции отключены");
}
return ok(await handler(args));
} catch (e) {
return fail(e);
}
};
(server.registerTool as any).call(server, name, { description, inputSchema: schema, annotations }, cb);
};
}
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env node
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { GiteaClient, loadConfig } from "./client.js";
import { registerIssueTools } from "./issues.js";
import { registerPackageTools } from "./packages.js";
import { registerTools } from "./tools.js";
async function main() {
const cfg = loadConfig();
const server = new McpServer({ name: "gitea-mcp", version: "0.1.0" });
const gitea = new GiteaClient(cfg);
registerTools(server, gitea);
registerPackageTools(server, gitea);
registerIssueTools(server, gitea);
await server.connect(new StdioServerTransport());
// stdout занят протоколом — логи только в stderr, без токена
console.error(`gitea-mcp: ${cfg.baseUrl}${cfg.readonly ? " (readonly)" : ""}`);
}
main().catch((e) => {
console.error("gitea-mcp: ", e instanceof Error ? e.message : e);
process.exit(1);
});
+157
View File
@@ -0,0 +1,157 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { GiteaClient, repoPath, seg } from "./client.js";
import { redact, truncate } from "./diagnose.js";
import { READ, WRITE, limit, makeTool, owner, repo } from "./helpers.js";
import { diagnoseRun } from "./runs.js";
const slimIssue = (i: any) => ({
number: i.number,
title: i.title,
state: i.state,
labels: (i.labels ?? []).map((l: any) => l.name),
assignees: (i.assignees ?? []).map((a: any) => a.login),
author: i.user?.login,
comments: i.comments,
updated: i.updated_at,
url: i.html_url,
});
const MAX_BODY = 30000;
/** Имена лейблов → id (Gitea при создании issue принимает только id). */
async function resolveLabels(gitea: GiteaClient, o: string, r: string, names?: string[]) {
if (!names?.length) return { ids: [] as number[], unknown: [] as string[] };
const all: any[] = await gitea.json("GET", `${repoPath(o, r)}/labels`, { limit: 50 });
const ids: number[] = [];
const unknown: string[] = [];
for (const n of names) {
const l = all.find((x) => x.name.toLowerCase() === n.toLowerCase());
l ? ids.push(l.id) : unknown.push(n);
}
return { ids, unknown };
}
const labels = z.array(z.string()).optional().describe("Имена существующих лейблов репо");
const assignees = z.array(z.string()).optional().describe("Логины исполнителей");
const index = z.number().int().positive().describe("Номер issue");
export function registerIssueTools(server: McpServer, gitea: GiteaClient) {
const tool = makeTool(server, gitea);
tool(
"gitea_list_issues",
"Список issue репозитория.",
{
owner,
repo,
state: z.enum(["open", "closed", "all"]).default("open"),
query: z.string().optional().describe("Поиск по тексту"),
labels: z.string().optional().describe("Лейблы через запятую"),
limit: limit(20),
},
READ,
async ({ owner, repo, state, query, labels, limit }) => {
const res: any[] = await gitea.json("GET", `${repoPath(owner, repo)}/issues`, { state, q: query, labels, type: "issues", limit });
return res.map(slimIssue);
},
);
tool(
"gitea_get_issue",
"Issue с текстом и комментариями.",
{ owner, repo, index },
READ,
async ({ owner, repo, index }) => {
const base = `${repoPath(owner, repo)}/issues/${seg(index)}`;
const [issue, comments] = await Promise.all([gitea.json("GET", base), gitea.json<any[]>("GET", `${base}/comments`)]);
return {
...slimIssue(issue),
body: truncate(issue.body ?? "", 20000),
comments_list: comments.slice(-20).map((c) => ({ author: c.user?.login, created: c.created_at, body: truncate(c.body ?? "", 5000) })),
};
},
);
tool(
"gitea_create_issue",
"Создать issue (например, для разбора проблемы).",
{
owner,
repo,
title: z.string().min(1).max(255),
body: z.string().max(MAX_BODY).default(""),
labels,
assignees,
},
WRITE,
async ({ owner, repo, title, body, labels, assignees }) => {
const { ids, unknown } = await resolveLabels(gitea, owner, repo, labels);
const issue = await gitea.json("POST", `${repoPath(owner, repo)}/issues`, undefined, { title, body, labels: ids, assignees });
return { ...slimIssue(issue), skipped_unknown_labels: unknown.length ? unknown : undefined };
},
);
tool(
"gitea_comment_issue",
"Добавить комментарий в issue.",
{ owner, repo, index, body: z.string().min(1).max(MAX_BODY) },
WRITE,
async ({ owner, repo, index, body }) => {
const c = await gitea.json("POST", `${repoPath(owner, repo)}/issues/${seg(index)}/comments`, undefined, { body });
return { id: c.id, url: c.html_url };
},
);
tool(
"gitea_create_issue_from_run",
"Завести issue для разбора упавшего пайплайна: сам собирает диагностику (упавшие jobs/steps, блоки ошибок из логов с маскировкой секретов, вероятные причины). Если открытый issue про этот run уже есть — новый не создаёт.",
{
owner,
repo,
run: z.number().int().positive().optional().describe("ID run; без него — последний упавший"),
branch: z.string().optional().describe("Ветка для поиска последнего упавшего run"),
note: z.string().max(5000).optional().describe("Дополнительный комментарий в начало issue"),
labels,
assignees,
},
WRITE,
async ({ owner, repo, run, branch, note, labels, assignees }) => {
const d = await diagnoseRun(gitea, owner, repo, { run, branch, maxJobs: 3 });
if (typeof d === "string") return d;
if (d.failed_jobs_total === 0) return `Run #${d.run.number} не упал (${d.run.conclusion || d.run.status}) — issue не создан`;
const title = `CI: упал run #${d.run.number}${d.run.workflow ? ` (${d.run.workflow})` : ""} на ${d.run.branch}`;
const existing: any[] = await gitea.json("GET", `${repoPath(owner, repo)}/issues`, { state: "open", q: `run #${d.run.number}`, type: "issues", limit: 20 });
const dup = existing.find((i) => i.title === title);
if (dup) return { created: false, reason: "issue по этому run уже открыт", issue: slimIssue(dup) };
const parts: string[] = [];
if (note) parts.push(note, "");
parts.push(
`**Run:** ${d.run.url ?? `#${d.run.number}`} · событие \`${d.run.event}\` · ветка \`${d.run.branch}\` · коммит \`${d.run.sha}\` · автор ${d.run.actor ?? "?"}`,
"",
"**Jobs:** " + d.jobs_summary.join("; "),
"",
);
for (const j of d.diagnosis) {
parts.push(`## Job \`${j.job}\``);
if (j.failed_steps?.length) parts.push(`Упавшие шаги: ${j.failed_steps.join(", ")}`);
if (j.causes?.length) {
parts.push("", "**Вероятные причины (эвристика):**");
for (const c of j.causes) parts.push(`- ${c.cause} → ${c.fix}`);
}
if (j.errorBlocks?.length) parts.push("", "**Ошибки в логе:**", "```", redact(j.errorBlocks.slice(0, 3).join("\n---\n")), "```");
else if (j.tail) parts.push("", "**Конец лога:**", "```", redact(j.tail), "```");
if (j.error) parts.push(`Лог недоступен: ${j.error}`);
parts.push("");
}
parts.push("_Создано автоматически gitea-mcp из логов run; причины — предположение, не диагноз._");
const body = truncate(parts.join("\n"), MAX_BODY);
const { ids, unknown } = await resolveLabels(gitea, owner, repo, labels);
const issue = await gitea.json("POST", `${repoPath(owner, repo)}/issues`, undefined, { title, body, labels: ids, assignees });
return { created: true, issue: slimIssue(issue), skipped_unknown_labels: unknown.length ? unknown : undefined };
},
);
}
+82
View File
@@ -0,0 +1,82 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { GiteaClient, seg } from "./client.js";
import { READ, limit, makeTool } from "./helpers.js";
const slimPkg = (p: any) => ({
name: p.name,
version: p.version,
type: p.type,
created: p.created_at,
repo: p.repository?.full_name,
creator: p.creator?.login,
url: p.html_url,
});
const byNewest = (a: any, b: any) => Date.parse(b.created_at) - Date.parse(a.created_at);
export function registerPackageTools(server: McpServer, gitea: GiteaClient) {
const tool = makeTool(server, gitea);
const pkgOwner = z.string().min(1).describe("Владелец пакетов (user или org)");
tool(
"gitea_list_packages",
"Пакеты/образы владельца в Gitea Packages (новые первыми). Каждая запись — одна версия (для container — тег или digest).",
{
owner: pkgOwner,
type: z.string().default("container").describe("container, npm, generic, helm, pypi…"),
query: z.string().optional().describe("Фильтр по имени"),
limit: limit(20),
},
READ,
async ({ owner, type, query, limit }) => {
const res: any[] = await gitea.json("GET", `/packages/${seg(owner)}`, { type, q: query, limit: 50 });
return res.sort(byNewest).slice(0, limit).map(slimPkg);
},
);
tool(
"gitea_check_image",
"Проверить, что образ собран и запушен: есть ли версия/тег в Gitea Packages, когда создан, digest и размер. built_after — ISO-время (например старт run): помогает отличить свежую сборку от старой.",
{
owner: pkgOwner,
image: z.string().min(1).describe("Имя образа (container package), напр. myapp или myorg/myapp"),
tag: z.string().optional().describe("Тег или sha (точное совпадение; для короткого sha — префикс). Без него — последние версии"),
built_after: z.string().datetime({ offset: true }).optional().describe("ISO-время: собрано ли после него"),
type: z.string().default("container"),
},
READ,
async ({ owner, image, tag, built_after, type }) => {
const all: any[] = await gitea.json("GET", `/packages/${seg(owner)}`, { type, q: image, limit: 50 });
const versions = all.filter((p) => p.name === image).sort(byNewest);
if (!versions.length) {
const similar = [...new Set(all.map((p) => p.name))].slice(0, 10);
return { exists: false, image, hint: "Образ с таким именем не найден (проверьте имя, права токена на packages)", similar_names: similar };
}
const matches = tag ? versions.filter((p) => p.version === tag || (tag.length >= 7 && p.version.startsWith(tag))) : versions.slice(0, 5);
const threshold = built_after ? Date.parse(built_after) : undefined;
const out = [];
for (const p of matches.slice(0, 5)) {
let files: any[] = [];
try {
files = await gitea.json("GET", `/packages/${seg(owner)}/${seg(type)}/${seg(image)}/${seg(p.version)}/files`);
} catch {
/* файлы не критичны */
}
out.push({
...slimPkg(p),
fresh: threshold === undefined ? undefined : Date.parse(p.created_at) >= threshold,
files: files.slice(0, 5).map((f) => ({ name: f.name, sha256: f.sha256, size: f.size })),
});
}
return {
exists: matches.length > 0,
image,
tag,
latest_version: slimPkg(versions[0]),
matches: out,
hint: tag && !matches.length ? `Тега "${tag}" нет. Доступные: ${versions.slice(0, 8).map((p) => p.version).join(", ")}` : undefined,
};
},
);
}
+87
View File
@@ -0,0 +1,87 @@
import { GiteaClient, repoPath, seg } from "./client.js";
import { analyzeLog } from "./diagnose.js";
export const slimRun = (r: any) => ({
id: r.id,
number: r.run_number,
attempt: r.run_attempt,
title: r.display_title ?? r.name,
workflow: r.path ?? r.workflow_id,
status: r.status,
conclusion: r.conclusion,
event: r.event,
branch: r.head_branch,
sha: r.head_sha?.slice(0, 8),
actor: r.actor?.login,
started: r.run_started_at ?? r.started_at,
completed: r.completed_at,
url: r.html_url,
});
export const slimJob = (j: any) => ({
id: j.id,
name: j.name,
status: j.status,
conclusion: j.conclusion,
runner: j.runner_name,
started: j.started_at,
completed: j.completed_at,
steps: (j.steps ?? []).map((s: any) => ({ n: s.number, name: s.name, status: s.status, conclusion: s.conclusion })),
url: j.html_url,
});
export const isFailed = (j: any) => ["failure", "cancelled", "timed_out"].includes(j.conclusion) || j.status === "failure";
export interface RunDiagnosis {
run: ReturnType<typeof slimRun>;
jobs_summary: string[];
failed_jobs_total: number;
diagnosis: any[];
note: string;
}
/** Разбор run: упавшие jobs/steps + анализ логов. Без run берёт последний упавший. */
export async function diagnoseRun(
gitea: GiteaClient,
owner: string,
repo: string,
opts: { run?: number; branch?: string; maxJobs?: number },
): Promise<RunDiagnosis | string> {
const base = repoPath(owner, repo);
let r: any;
if (opts.run) r = await gitea.json("GET", `${base}/actions/runs/${seg(opts.run)}`);
else {
const list = await gitea.json("GET", `${base}/actions/runs`, { status: "failure", branch: opts.branch, limit: 1 });
r = list.workflow_runs?.[0];
if (!r) return "Упавших run не найдено";
}
const jobs: any[] = (await gitea.json("GET", `${base}/actions/runs/${seg(r.id)}/jobs`, { limit: 50 })).jobs ?? [];
const failed = jobs.filter(isFailed);
const diagnosis = [];
for (const j of failed.slice(0, opts.maxJobs ?? 3)) {
let analysis: object;
try {
analysis = analyzeLog(await gitea.text(`${base}/actions/jobs/${seg(j.id)}/logs`));
} catch (e) {
analysis = { error: e instanceof Error ? e.message : String(e) };
}
diagnosis.push({
job: j.name,
job_id: j.id,
runner: j.runner_name,
failed_steps: (j.steps ?? []).filter(isFailed).map((s: any) => `${s.number}. ${s.name}`),
...analysis,
});
}
return {
run: slimRun(r),
jobs_summary: jobs.map((j) => `${j.name}: ${j.conclusion || j.status}`),
failed_jobs_total: failed.length,
diagnosis,
note:
failed.length === 0
? `Упавших jobs нет — run ${r.conclusion || r.status}. Если ждёт раннера — проверьте labels (runs-on) и онлайн раннеров.`
: "Причины — эвристика по тексту лога; подтверждайте блоками ошибок и gitea_get_job_logs(grep).",
};
}
+210
View File
@@ -0,0 +1,210 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { GiteaClient, repoPath, seg } from "./client.js";
import { grepLog, redact, stripAnsi, tail, truncate } from "./diagnose.js";
import { READ, WRITE, limit, makeTool, owner, repo } from "./helpers.js";
import { diagnoseRun, isFailed, slimJob, slimRun } from "./runs.js";
const runId = z.number().int().positive().describe("ID run (поле id из gitea_list_runs)");
const jobId = z.number().int().positive().describe("ID job (поле id из gitea_list_jobs)");
export function registerTools(server: McpServer, gitea: GiteaClient) {
const tool = makeTool(server, gitea);
// ───────────── Репозитории ─────────────
tool(
"gitea_list_repos",
"Поиск репозиториев, доступных токену. Без query вернёт все доступные.",
{
query: z.string().optional().describe("Подстрока в имени/описании"),
owner: z.string().optional().describe("Оставить только репо этого владельца"),
limit: limit(30),
},
READ,
async ({ query, owner: o, limit }) => {
const res = await gitea.json("GET", "/repos/search", { q: query, limit: o ? 50 : limit, sort: "updated", order: "desc" });
let repos: any[] = res.data ?? [];
if (o) repos = repos.filter((r) => r.owner?.login?.toLowerCase() === o.toLowerCase());
return repos.slice(0, limit).map((r) => ({
full_name: r.full_name,
description: r.description,
default_branch: r.default_branch,
private: r.private,
archived: r.archived,
has_actions: r.has_actions,
updated: r.updated_at,
url: r.html_url,
}));
},
);
tool("gitea_get_repo", "Информация о репозитории и ветки.", { owner, repo }, READ, async ({ owner, repo }) => {
const [info, branches] = await Promise.all([
gitea.json("GET", repoPath(owner, repo)),
gitea.json<any[]>("GET", `${repoPath(owner, repo)}/branches`, { limit: 30 }),
]);
return {
full_name: info.full_name,
description: info.description,
default_branch: info.default_branch,
private: info.private,
archived: info.archived,
has_actions: info.has_actions,
open_issues: info.open_issues_count,
open_prs: info.open_pr_counter,
updated: info.updated_at,
url: info.html_url,
branches: branches.map((b) => ({ name: b.name, sha: b.commit?.id?.slice(0, 8), protected: b.protected })),
};
});
tool(
"gitea_get_file",
"Прочитать файл или список каталога из репозитория (например .gitea/workflows/ci.yml).",
{
owner,
repo,
path: z.string().default("").describe("Путь к файлу/каталогу, пусто = корень"),
ref: z.string().optional().describe("Ветка/тег/sha, по умолчанию default branch"),
},
READ,
async ({ owner, repo, path, ref }) => {
const p = path.split("/").filter(Boolean).map(seg).join("/");
const res = await gitea.json("GET", `${repoPath(owner, repo)}/contents/${p}`, { ref });
if (Array.isArray(res)) return res.map((f) => ({ name: f.name, type: f.type, size: f.size, path: f.path }));
if (res.encoding === "base64") return truncate(Buffer.from(res.content, "base64").toString("utf8"), 40000);
return res;
},
);
// ───────────── Пайплайны: просмотр ─────────────
tool(
"gitea_list_runs",
"Список запусков Gitea Actions (новые первыми). Фильтры по статусу/ветке/событию.",
{
owner,
repo,
status: z
.enum(["queued", "in_progress", "success", "failure", "cancelled", "skipped", "waiting", "completed"])
.optional()
.describe("Фильтр по статусу/результату"),
branch: z.string().optional(),
event: z.string().optional().describe("push, pull_request, schedule, workflow_dispatch…"),
head_sha: z.string().optional(),
limit: limit(10),
},
READ,
async ({ owner, repo, status, branch, event, head_sha, limit }) => {
const res = await gitea.json("GET", `${repoPath(owner, repo)}/actions/runs`, { status, branch, event, head_sha, limit });
return { total: res.total_count, runs: (res.workflow_runs ?? []).map(slimRun) };
},
);
tool("gitea_get_run", "Детали одного run.", { owner, repo, run: runId }, READ, async ({ owner, repo, run }) =>
slimRun(await gitea.json("GET", `${repoPath(owner, repo)}/actions/runs/${seg(run)}`)),
);
tool("gitea_list_jobs", "Jobs и шаги (steps) run с их статусами.", { owner, repo, run: runId }, READ, async ({ owner, repo, run }) => {
const res = await gitea.json("GET", `${repoPath(owner, repo)}/actions/runs/${seg(run)}/jobs`, { limit: 50 });
return (res.jobs ?? []).map(slimJob);
});
tool(
"gitea_get_job_logs",
"Лог job. По умолчанию последние 200 строк; grep — только блоки вокруг совпадений regex.",
{
owner,
repo,
job: jobId,
tail_lines: z.number().int().min(1).max(5000).default(200).describe("Сколько последних строк вернуть"),
grep: z.string().optional().describe("Regex (case-insensitive); вместо tail вернёт совпадения с контекстом"),
context: z.number().int().min(0).max(20).default(3).describe("Строк контекста вокруг совпадения"),
},
READ,
async ({ owner, repo, job, tail_lines, grep, context }) => {
const log = redact(stripAnsi(await gitea.text(`${repoPath(owner, repo)}/actions/jobs/${seg(job)}/logs`)));
if (grep) return truncate(grepLog(log, grep, context) || "совпадений нет");
return truncate(tail(log, tail_lines));
},
);
// ───────────── Пайплайны: диагностика ─────────────
tool(
"gitea_diagnose_run",
"Разобрать упавший run: находит failed jobs/steps, вытаскивает блоки с ошибками из логов и даёт вероятные причины и способы починки. Начинать с него, когда пайплайн красный.",
{
owner,
repo,
run: runId.optional().describe("ID run; если не задан — последний упавший run (опционально branch)"),
branch: z.string().optional().describe("Ветка для поиска последнего упавшего run"),
max_jobs: z.number().int().min(1).max(10).default(3).describe("Сколько упавших jobs разбирать"),
},
READ,
async ({ owner, repo, run, branch, max_jobs }) => diagnoseRun(gitea, owner, repo, { run, branch, maxJobs: max_jobs }),
);
// ───────────── Пайплайны: управление ─────────────
tool("gitea_rerun_run", "Перезапустить весь run целиком.", { owner, repo, run: runId }, WRITE, async ({ owner, repo, run }) =>
slimRun(await gitea.json("POST", `${repoPath(owner, repo)}/actions/runs/${seg(run)}/rerun`)),
);
tool(
"gitea_rerun_job",
"Перезапустить один job (вместе с зависимыми от него).",
{ owner, repo, run: runId, job: jobId },
WRITE,
async ({ owner, repo, run, job }) =>
slimJob(await gitea.json("POST", `${repoPath(owner, repo)}/actions/runs/${seg(run)}/jobs/${seg(job)}/rerun`)),
);
tool(
"gitea_rerun_failed_jobs",
"Перезапустить только упавшие jobs run (успешные не трогает).",
{ owner, repo, run: runId },
WRITE,
async ({ owner, repo, run }) => {
const base = repoPath(owner, repo);
const jobs: any[] = (await gitea.json("GET", `${base}/actions/runs/${seg(run)}/jobs`, { limit: 50 })).jobs ?? [];
const failed = jobs.filter(isFailed);
if (!failed.length) return "Упавших jobs нет";
const restarted: string[] = [];
for (const j of failed) {
// уже перезапущенные как зависимые от предыдущего перезапуска пропускаем
const cur = await gitea.json("GET", `${base}/actions/runs/${seg(run)}/jobs`, { limit: 50 });
const state = (cur.jobs as any[]).find((x) => x.id === j.id);
if (state && !isFailed(state)) continue;
await gitea.json("POST", `${base}/actions/runs/${seg(run)}/jobs/${seg(j.id)}/rerun`);
restarted.push(j.name);
}
return { restarted };
},
);
tool("gitea_cancel_run", "Отменить выполняющийся run.", { owner, repo, run: runId }, WRITE, async ({ owner, repo, run }) =>
slimRun(await gitea.json("POST", `${repoPath(owner, repo)}/actions/runs/${seg(run)}/cancel`)),
);
tool(
"gitea_dispatch_workflow",
"Запустить workflow вручную (workflow_dispatch) на ветке/теге. В workflow должен быть триггер workflow_dispatch.",
{
owner,
repo,
workflow: z.string().min(1).describe("Имя файла workflow, например ci.yml"),
ref: z.string().min(1).describe("Ветка или тег"),
inputs: z.record(z.string()).optional().describe("Входные параметры workflow"),
},
WRITE,
async ({ owner, repo, workflow, ref, inputs }) => {
const res = await gitea.json("POST", `${repoPath(owner, repo)}/actions/workflows/${seg(workflow)}/dispatches`, undefined, {
ref,
inputs: inputs ?? {},
});
return Object.keys(res).length ? res : "Запуск поставлен в очередь";
},
);
}
+13
View File
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src"]
}