feat: MCP-сервер для Gitea (Actions, packages, issues)
Просмотр и перезапуск пайплайнов, диагностика падений по логам, проверка образов в Gitea Packages, создание issue для разбора. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
14 files changed
+2233
No files matched your search
@@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
@@ -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.
|
||||
@@ -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
|
||||
Generated
+1261
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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
@@ -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)}`;
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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 };
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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 : "Запуск поставлен в очередь";
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in new issue
Block a user