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