commit 04ce6ebdabd00b10e573c54961cd74ac95aafd62 Author: Vassiliy Yegorov Date: Fri Oct 23 09:34:01 2026 +0700 feat: MCP-сервер для Gitea (Actions, packages, issues) Просмотр и перезапуск пайплайнов, диагностика падений по логам, проверка образов в Gitea Packages, создание issue для разбора. Co-Authored-By: Claude Sonnet 5.5 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9c97bbd --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules +dist +.env diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..60cba33 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..e5ac13b --- /dev/null +++ b/README.md @@ -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 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 diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..aebb814 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1261 @@ +{ + "name": "gitea-mcp", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "gitea-mcp", + "version": "0.1.0", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.29.0", + "zod": "^3.25.0" + }, + "bin": { + "gitea-mcp": "dist/index.js" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.6.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@hono/node-server": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.5.tgz", + "integrity": "sha512-MgP/I06GdvsilDspMk/qmRzpk8LOUStP6jFAbSRi40W09DhoyUn+Mj7eyKICPqeYKybOjVFLVIAQzvu1bfIa7Q==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "hono": "^4 || ^5.0.0-0" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.32.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.32.1.tgz", + "integrity": "sha512-2DdE+SJDtzLEEWzY1ZjY7Q+VcPhcV1KisD3zI4u0XZyktsjHum1mwbMI+JaulUBi2OZk+KJAi2uPXzxichPkdw==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9 || ^2.0.5", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@types/node": { + "version": "22.20.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.5.tgz", + "integrity": "sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/body-parser": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.4.0.tgz", + "integrity": "sha512-Ifk1KcLrxaGt1occQagQNFNNOqRt1LFuehEnHHzB8Z6cRgIxRU1TBaiIsUiCdA+gy5PL7Y1IX31DNKkRmxtXjQ==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.1.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.3", + "on-finished": "^2.4.1", + "qs": "^6.16.1", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-2.0.1.tgz", + "integrity": "sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz", + "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/express": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/express/-/express-5.3.0.tgz", + "integrity": "sha512-qCqy1xSoaugxOD4IVHb1ZWdkvCF2W3aE2okKouWj8VwJ+ztskSiXoBnCSJqfKT+cVPJhuJqe9YLbY425+Zh00g==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.3.0", + "content-disposition": "^2.0.1", + "content-type": "^2.0.0", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.8", + "qs": "^6.16.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.1.0", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.7.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.1.tgz", + "integrity": "sha512-2FXbqkygil3mZiXzEtM0IjE/UBmBNot0J1GEVrJT4JbrvwOSoEtqtO+wHbiMQELWe1eNvHoYRg2dkjOid+6JkA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/express/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.9.tgz", + "integrity": "sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.13.13", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.13.tgz", + "integrity": "sha512-CQ46U0ZkAGmbT/4UxdzzGJpacP2IeKgY4a5/tOI9AABbpOMfK739wfDXmv1usCk+3RkKj1hQy4/fjhiwa2xlrA==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.7.4", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.4.tgz", + "integrity": "sha512-DWBMKyeM25D70nrIiTkgQth59/G+naKOTep6huF9j+GmeK3DMD60Zvxg5d5qQnz+38AHgou6ean2LECX+lrKDg==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.13", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.13.tgz", + "integrity": "sha512-7TIg10ZE1x82YediN+lcrPNvGMKge9yFHQD+Qho/b8y2adfugU3AsvvHfxV0fzyFllT6l9xCjf4RWY9g/Z9PJQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", + "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", + "license": "MIT", + "dependencies": { + "content-type": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/negotiator/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.2.tgz", + "integrity": "sha512-ATWUv387i+nLD49EiDoOft5scf32Vsdz3ktrQJG9bi7jRFmaXTssvyvj1EmeKVVgvesbqUtvQQvaS2TpTukAZQ==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/qs": { + "version": "6.16.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.1.tgz", + "integrity": "sha512-5Px6J7rfHBAGUhNQ3es4YzcOdBBw0SDcVtThJml4RdnKw4tKs2epA0kPZ+K8X+WiHktT93RHVcQ/3ym5aTCgtg==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..9a046af --- /dev/null +++ b/package.json @@ -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" + ] +} diff --git a/src/client.ts b/src/client.ts new file mode 100644 index 0000000..3f94f10 --- /dev/null +++ b/src/client.ts @@ -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; + +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 { + 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(method: string, path: string, query?: Query, body?: unknown): Promise { + 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 { + 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)}`; diff --git a/src/diagnose.ts b/src/diagnose.ts new file mode 100644 index 0000000..83e5fc2 --- /dev/null +++ b/src/diagnose.ts @@ -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)}`; +} diff --git a/src/helpers.ts b/src/helpers.ts new file mode 100644 index 0000000..2c075a7 --- /dev/null +++ b/src/helpers.ts @@ -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 = ( + name: string, + description: string, + schema: S, + annotations: typeof READ | typeof WRITE, + handler: (args: z.infer>) => Promise, +) => 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 => { + 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); + }; +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..296a19a --- /dev/null +++ b/src/index.ts @@ -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); +}); diff --git a/src/issues.ts b/src/issues.ts new file mode 100644 index 0000000..c9734b9 --- /dev/null +++ b/src/issues.ts @@ -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("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 }; + }, + ); +} diff --git a/src/packages.ts b/src/packages.ts new file mode 100644 index 0000000..eb5d538 --- /dev/null +++ b/src/packages.ts @@ -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, + }; + }, + ); +} diff --git a/src/runs.ts b/src/runs.ts new file mode 100644 index 0000000..c8e0731 --- /dev/null +++ b/src/runs.ts @@ -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; + 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 { + 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).", + }; +} diff --git a/src/tools.ts b/src/tools.ts new file mode 100644 index 0000000..b0b281d --- /dev/null +++ b/src/tools.ts @@ -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("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 : "Запуск поставлен в очередь"; + }, + ); +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..2c1f0a5 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true + }, + "include": ["src"] +}