From 0f68124c7da9522d2be28895b588ce0a4c175d32 Mon Sep 17 00:00:00 2001 From: Antonio Barbosa Date: Tue, 18 Nov 2025 17:33:43 -0300 Subject: [PATCH 1/2] =?UTF-8?q?docs:=20documentaci=C3=B3n=20inicial=20del?= =?UTF-8?q?=20repositorio?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + .rubocop.yml | 45 +++++++++++++++++++++++++++++++++++++++++++ README.md | 54 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 100 insertions(+) create mode 100644 .rubocop.yml create mode 100644 README.md diff --git a/.gitignore b/.gitignore index e3200e0..9107e31 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ /test/tmp/ /test/version_tmp/ /tmp/ +**/logs/ # Used by dotenv library to load environment variables. # .env diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 0000000..b796410 --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,45 @@ +Metrics/BlockLength: + Max: 100 + +Metrics/ClassLength: + Max: 1000 + +Metrics/MethodLength: + Max: 200 + +Metrics/CyclomaticComplexity: + Max: 20 + +Metrics/PerceivedComplexity: + Max: 30 + +Metrics/AbcSize: + Max: 30 + +Style/SymbolArray: + EnforcedStyle: brackets + +Layout/EndOfLine: + EnforcedStyle: lf + +Layout/ParameterAlignment: + EnforcedStyle: with_first_parameter + +Layout/TrailingEmptyLines: + EnforcedStyle: final_newline + +Layout/IndentationWidth: + Width: 2 + +Layout/IndentationConsistency: + EnforcedStyle: indented_internal_methods + +Layout/EmptyLinesAroundExceptionHandlingKeywords: + Enabled: false + +Layout/EmptyLineBetweenDefs: + EmptyLineBetweenClassDefs: false + EmptyLineBetweenModuleDefs: false + AllowAdjacentOneLineDefs: false + DefLikeMacros: ["should", "should_for", "context", "context_for", "setup"] + Enabled: true \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..1fa4004 --- /dev/null +++ b/README.md @@ -0,0 +1,54 @@ +# Model Context Protocol servers + +Este repositorio es una colección de implementaciones de servidores Model Context Protocol (MCP) desarrollados por Buk. Los MCPs permiten a los modelos de IA acceder a servicios externos y ejecutar acciones en ellos de forma estructurada y segura. + +## ¿Qué es un MCP? + +Model Context Protocol (MCP) es un protocolo que permite a los modelos de IA interactuar con sistemas externos, proporcionando una interfaz estandarizada para acceder a datos y ejecutar acciones en diferentes servicios y plataformas. + +## Tecnologías utilizadas + +Los servidores de este repositorio están desarrollados con: + +- [Ruby MCP SDK](https://github.com/modelcontextprotocol/ruby-sdk): SDK oficial para la implementación de servidores MCP en Ruby. +- Ruby 3.2+: Versión mínima requerida para ejecutar los servidores. + +## Requisitos generales + +- Ruby 3.2 o superior +- Bundler +- Acceso a los servicios correspondientes para cada implementación + +## Instalación general + +1. Clona este repositorio: + + ```bash + git clone https://github.com/bukhr/mcps.git + cd mcps + ``` + +2. Para cada servidor MCP que desees utilizar, sigue las instrucciones específicas en su respectiva carpeta. + +## Configuración + +Cada servidor MCP tiene su propia configuración específica. Consulta el README en cada carpeta de implementación para obtener instrucciones detalladas. + +## Contribución + +Agradecemos las contribuciones a este proyecto. Si deseas contribuir: + +1. Crea un fork del repositorio +2. Crea una rama para tu funcionalidad (`git checkout -b feature/nueva-funcionalidad`) +3. Desarrolla tu implementación siguiendo el estilo del código existente +4. Asegúrate de incluir pruebas para tu código +5. Actualiza la documentación según sea necesario +6. Envía un Pull Request con tus cambios + +## Licencia + +Este proyecto está licenciado bajo la [Licencia MIT](LICENSE). + +## Contacto + +Para preguntas o soporte relacionado con este repositorio, puedes contactar al equipo de Ingeniería de Buk. From 9e6ab17edb44bddba9e05c1503426fbfc4bd6367 Mon Sep 17 00:00:00 2001 From: Antonio Barbosa Date: Tue, 18 Nov 2025 17:34:05 -0300 Subject: [PATCH 2/2] feat: agregando Jenkins MCP --- README.md | 4 + src/jenkins/Gemfile | 23 ++ src/jenkins/Gemfile.lock | 114 ++++++++ src/jenkins/README.md | 254 ++++++++++++++++++ src/jenkins/Rakefile | 15 ++ src/jenkins/app/config/settings.rb | 97 +++++++ src/jenkins/app/server.rb | 38 +++ src/jenkins/app/services/check_jobs.rb | 62 +++++ src/jenkins/app/services/jenkins_client.rb | 122 +++++++++ src/jenkins/app/tools/check_jobs.rb | 71 +++++ src/jenkins/app/utils/logger.rb | 72 +++++ src/jenkins/app/version.rb | 5 + src/jenkins/bin/server | 15 ++ src/jenkins/jenkins_mcp.gemspec | 35 +++ src/jenkins/test/services/check_jobs_test.rb | 112 ++++++++ .../test/services/jenkins_client_test.rb | 147 ++++++++++ src/jenkins/test/test_helper.rb | 41 +++ src/jenkins/test/tools/check_jobs_test.rb | 102 +++++++ 18 files changed, 1329 insertions(+) create mode 100644 src/jenkins/Gemfile create mode 100644 src/jenkins/Gemfile.lock create mode 100644 src/jenkins/README.md create mode 100644 src/jenkins/Rakefile create mode 100644 src/jenkins/app/config/settings.rb create mode 100644 src/jenkins/app/server.rb create mode 100644 src/jenkins/app/services/check_jobs.rb create mode 100644 src/jenkins/app/services/jenkins_client.rb create mode 100644 src/jenkins/app/tools/check_jobs.rb create mode 100644 src/jenkins/app/utils/logger.rb create mode 100644 src/jenkins/app/version.rb create mode 100755 src/jenkins/bin/server create mode 100644 src/jenkins/jenkins_mcp.gemspec create mode 100644 src/jenkins/test/services/check_jobs_test.rb create mode 100644 src/jenkins/test/services/jenkins_client_test.rb create mode 100644 src/jenkins/test/test_helper.rb create mode 100644 src/jenkins/test/tools/check_jobs_test.rb diff --git a/README.md b/README.md index 1fa4004..8835c70 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,10 @@ Los servidores de este repositorio están desarrollados con: - [Ruby MCP SDK](https://github.com/modelcontextprotocol/ruby-sdk): SDK oficial para la implementación de servidores MCP en Ruby. - Ruby 3.2+: Versión mínima requerida para ejecutar los servidores. +## Implementaciones disponibles + +- [Jenkins MCP](jenkins/): Permite interactuar con servidores Jenkins para obtener información sobre builds y logs de ejecución. + ## Requisitos generales - Ruby 3.2 o superior diff --git a/src/jenkins/Gemfile b/src/jenkins/Gemfile new file mode 100644 index 0000000..fe8734c --- /dev/null +++ b/src/jenkins/Gemfile @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +source 'https://rubygems.org' + +gem 'base64', '~> 0.2' +gem 'dotenv', '~> 2.8' +gem 'faraday', '~> 2.7' +gem 'json', '~> 2.6' +gem 'logger', '~> 1.5' +gem 'mcp', '~> 0.1' +gem 'puma', '>= 5.0.0' +gem 'rackup', '>= 2.1.0' +gem 'rake', '~> 13.0' + +group :development, :test do + gem 'minitest', '~> 5.18' + gem 'minitest-reporters', '~> 1.6' + gem 'mocha', '~> 2.1' + gem 'pry', '~> 0.14' + gem 'rubocop', '~> 1.50' + gem 'shoulda-context', '~> 2.0' + gem 'webmock', '~> 3.18' +end diff --git a/src/jenkins/Gemfile.lock b/src/jenkins/Gemfile.lock new file mode 100644 index 0000000..e56ef04 --- /dev/null +++ b/src/jenkins/Gemfile.lock @@ -0,0 +1,114 @@ +GEM + remote: https://rubygems.org/ + specs: + addressable (2.8.7) + public_suffix (>= 2.0.2, < 7.0) + ansi (1.5.0) + ast (2.4.3) + base64 (0.3.0) + bigdecimal (3.3.1) + builder (3.3.0) + coderay (1.1.3) + crack (1.0.1) + bigdecimal + rexml + dotenv (2.8.1) + faraday (2.14.0) + faraday-net_http (>= 2.0, < 3.5) + json + logger + faraday-net_http (3.4.2) + net-http (~> 0.5) + hashdiff (1.2.1) + json (2.16.0) + json-schema (6.0.0) + addressable (~> 2.8) + bigdecimal (~> 3.1) + json_rpc_handler (0.1.1) + language_server-protocol (3.17.0.5) + lint_roller (1.1.0) + logger (1.7.0) + mcp (0.4.0) + json-schema (>= 4.1) + json_rpc_handler (~> 0.1) + method_source (1.1.0) + minitest (5.26.1) + minitest-reporters (1.7.1) + ansi + builder + minitest (>= 5.0) + ruby-progressbar + mocha (2.8.2) + ruby2_keywords (>= 0.0.5) + net-http (0.8.0) + uri (>= 0.11.1) + nio4r (2.7.5) + parallel (1.27.0) + parser (3.3.10.0) + ast (~> 2.4.1) + racc + prism (1.6.0) + pry (0.15.2) + coderay (~> 1.1) + method_source (~> 1.0) + public_suffix (6.0.2) + puma (7.1.0) + nio4r (~> 2.0) + racc (1.8.1) + rack (3.2.4) + rackup (2.2.1) + rack (>= 3) + rainbow (3.1.1) + rake (13.3.1) + regexp_parser (2.11.3) + rexml (3.4.4) + rubocop (1.81.7) + json (~> 2.3) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.1.0) + parallel (~> 1.10) + parser (>= 3.3.0.2) + rainbow (>= 2.2.2, < 4.0) + regexp_parser (>= 2.9.3, < 3.0) + rubocop-ast (>= 1.47.1, < 2.0) + ruby-progressbar (~> 1.7) + unicode-display_width (>= 2.4.0, < 4.0) + rubocop-ast (1.48.0) + parser (>= 3.3.7.2) + prism (~> 1.4) + ruby-progressbar (1.13.0) + ruby2_keywords (0.0.5) + shoulda-context (2.0.0) + unicode-display_width (3.2.0) + unicode-emoji (~> 4.1) + unicode-emoji (4.1.0) + uri (1.1.1) + webmock (3.26.1) + addressable (>= 2.8.0) + crack (>= 0.3.2) + hashdiff (>= 0.4.0, < 2.0.0) + +PLATFORMS + arm64-darwin-23 + ruby + +DEPENDENCIES + base64 (~> 0.2) + dotenv (~> 2.8) + faraday (~> 2.7) + json (~> 2.6) + logger (~> 1.5) + mcp (~> 0.1) + minitest (~> 5.18) + minitest-reporters (~> 1.6) + mocha (~> 2.1) + pry (~> 0.14) + puma (>= 5.0.0) + rackup (>= 2.1.0) + rake (~> 13.0) + rubocop (~> 1.50) + shoulda-context (~> 2.0) + webmock (~> 3.18) + +BUNDLED WITH + 2.5.14 diff --git a/src/jenkins/README.md b/src/jenkins/README.md new file mode 100644 index 0000000..c777318 --- /dev/null +++ b/src/jenkins/README.md @@ -0,0 +1,254 @@ +# Jenkins MCP Server + +Este es un servidor de Protocolo de Control de Máquina (MCP) para interactuar con Jenkins. Permite consultar el último build fallido de un job y obtener el log completo de consola, o recuperar logs directamente desde una URL absoluta de un build (incluyendo URLs de Blue Ocean, que serán convertidas a la vista clásica automáticamente). + +## Índice + +- [Requisitos previos](#requisitos-previos) +- [Funcionalidades](#funcionalidades) +- [Configuración](#configuración) + - [Configuración en Windows](#configurar-en-windows) + - [Configuración en Gemini CLI](#configurar-en-gemini-cli) +- [Variables de entorno soportadas](#variables-de-entorno-soportadas) +- [Obtener API Token en Jenkins](#obtener-api-token-en-jenkins) +- [Herramientas disponibles](#herramientas-disponibles) +- [Ejemplos de uso](#ejemplos-de-uso) +- [Desarrollo](#desarrollo) +- [Habilitar logs](#habilitar-logs) +- [Problemas comunes](#problemas-comunes) +- [Contribución](#contribución) + +## Requisitos previos + +- Ruby (v3.2 o superior) +- Acceso a tu instancia de Jenkins con usuario y API Token +- Permisos necesarios para consultar jobs y acceder a logs de consola + +## Funcionalidades + +- Obtener el último build en estado FAILURE para un job y devolver el log completo +- Obtener el log de consola de un build a partir de su URL absoluta (soporta Blue Ocean y clásica) +- Configuración de logs a archivo y nivel de log personalizable + +## Configuración + +1. Instalar dependencias dentro de esta carpeta: + + ```bash + bundle install + ``` + +2. Configurar Codeium MCP: + - Añade la siguiente configuración a tu archivo `~/.codeium/windsurf/mcp_config.json`: + + ```json + { + "mcpServers": { + "jenkins": { + "command": "/path/to/mcps/src/jenkins/bin/server" + } + }, + "jenkins": { + "baseUrl": "https://jenkins.example.com", + "auth": { + "username": "usuario", + "apiToken": "" + }, + "logs": { + "enableFileLogs": true, + "logLevel": "info" + } + } + } + ``` + + Notas: + - Reemplaza `/path/to/` con la ruta absoluta en tu sistema donde se encuentra el repositorio MCPservers. + - El archivo de configuración debe ser JSON válido (sin comentarios). + - Puedes referenciar variables de entorno usando el prefijo `env:` (por ejemplo `"apiToken": "env:JENKINS_API_TOKEN"`). + - También puedes definir credenciales mediante variables de entorno sin modificar el JSON: `JENKINS_BASE_URL`, `JENKINS_USERNAME`, `JENKINS_API_TOKEN`. + - Normalmente el username es el correo electrónico de la cuenta que usas para acceder a Jenkins. + - El API Token se puede obtener desde la interfaz de Jenkins. + +### Configurar en Windows + +Cambia el archivo `~/.codeium/windsurf/mcp_config.json` a: + +```json +{ + "mcpServers": { + "jenkins": { + "command": "/path/to/mcps/src/jenkins/bin/server" + } + }, + "jenkins": { + "baseUrl": "https://jenkins.example.com", + "auth": { + "username": "usuario", + "apiToken": "" + }, + "logs": { + "enableFileLogs": true, + "logLevel": "info" + } + } +} +``` + +### Configurar en Gemini CLI + +Hace lo mismo que la configuración en [Configuración](#configuración) pero en el archivo `~/.gemini/settings.json`. + +## Variables de entorno soportadas + +- `JENKINS_BASE_URL`: URL base de Jenkins (por ejemplo, `https://jenkins.example.com`). +- `JENKINS_USERNAME`: Usuario de Jenkins. +- `JENKINS_API_TOKEN`: API Token del usuario. + +Si no se proporcionan por variables de entorno, se leen desde la sección `jenkins` del `mcp_config.json`. Si faltan valores requeridos, el servidor lanzará un error al iniciar. + +## Obtener API Token en Jenkins + +Sigue estos pasos para crear un API Token desde la interfaz de Jenkins: + +1. Inicia sesión en tu Jenkins. +2. Haz clic en tu nombre de usuario (arriba a la derecha) y luego en `Configure`. +3. En la sección `API Token`, selecciona `Add new Token`. +4. Asigna un nombre descriptivo (por ejemplo, "MCP Server") y haz clic en `Generate`. +5. Copia el token generado y guárdalo de forma segura. No podrá volverse a ver después de cerrar el diálogo. +6. Configura las variables de entorno o el archivo `mcp_config.json` con el usuario y token. + +Notas: + +- Asegúrate de que el usuario de Jenkins tenga permisos para leer jobs y acceder a logs de consola. +- Puedes revocar el token en cualquier momento desde la misma sección de `API Token`. +- Para mayor seguridad, considera crear un usuario específico para este propósito con permisos limitados. + +## Herramientas disponibles + +- `check_jobs`: Obtiene el último build fallido de un job de Jenkins y devuelve el log completo, o retorna el log desde una URL de build. + - Parámetros: + - `job_full_name`: (Opcional) Nombre completo del job, por ejemplo `"Folder/Sub/Job"`. + - `pipeline_url`: (Opcional) URL absoluta del build, por ejemplo `"https://jenkins.example.com/job/foo/15/"`. + - Reglas: + - Debes especificar `pipeline_url` o `job_full_name`. Si se define `pipeline_url`, tiene prioridad. + +## Ejemplos de uso + +### Obtener el log completo del último build fallido de un job + +```text +Revisa el último build fallido del job "Folder/Sub/Job" y me ayude a resolver los errores +``` + +Respuesta: + +```json +{ + "status": "success", + "job": "Folder/Sub/Job", + "build": { + "number": 152, + "url": "https://jenkins.example.com/job/Folder/job/Sub/job/Job/152/", + "result": "FAILURE", + "timestamp": 1725230000000, + "duration": 340000 + }, + "log_full": "...contenido completo del log..." +} +``` + +### Obtener log por URL absoluta (incluye Blue Ocean) + +```text +Me ayude a resolver los errores del pipeline https://jenkins.example.com/blue/organizations/jenkins/org%2Frepo/detail/main/25/pipeline +``` + +Respuesta: + +```json +{ + "status": "success", + "job": "https://jenkins.example.com/blue/organizations/jenkins/org%2Frepo/detail/main/25/pipeline", + "build": { + "number": 25, + "url": "https://jenkins.example.com/blue/organizations/jenkins/org%2Frepo/detail/main/25/pipeline" + }, + "log_full": "...contenido completo del log..." +} +``` + +## Desarrollo + +### Ejecutar el servidor localmente + +Para ejecutar el servidor en modo desarrollo: + +```bash +bin/server +``` + +### Ejecutar tests + +Para ejecutar las pruebas automatizadas: + +```bash +bundle exec rake test +``` + +### Ejecutar RuboCop + +```bash +bundle exec rake rubocop +``` + +## Habilitar logs + +Para habilitar y configurar logs, añade en `~/.codeium/windsurf/mcp_config.json`: + +```json +{ + "jenkins": { + "logs": { + "enableFileLogs": true, + "logLevel": "debug", + "logDir": "/ruta/opcional/a/logs" + } + } +} +``` + +Notas: + +- Los logs se guardarán en el directorio `logs` dentro de la carpeta del servidor MCP si no se especifica `logDir`. +- Niveles de log disponibles: `error`, `warn`, `info` y `debug`. + +## Problemas comunes + +### Error "Missing JENKINS_BASE_URL" + +Verifica que hayas configurado correctamente la URL base de Jenkins, ya sea en el archivo de configuración o mediante la variable de entorno `JENKINS_BASE_URL`. + +### Error "Missing JENKINS_USERNAME" o "Missing JENKINS_API_TOKEN" + +Asegúrate de haber configurado correctamente las credenciales de acceso a Jenkins en el archivo de configuración o mediante variables de entorno. + +### Errores 401 (Unauthorized) o 403 (Forbidden) + +Verifica que el token API sea válido y que el usuario tenga permisos suficientes para acceder a los jobs y logs de consola. + +### URLs de Blue Ocean no funcionan + +El servidor convierte automáticamente las URLs de Blue Ocean a la vista clásica. Si tienes problemas, asegúrate de que la URL sea correcta y que el job exista. + +## Contribución + +Si deseas contribuir al desarrollo de este servidor MCP: + +1. Crea un fork del repositorio +2. Crea una rama para tu funcionalidad (`git checkout -b feature/nueva-funcionalidad`) +3. Realiza tus cambios, añade pruebas y documentación +4. Ejecuta las pruebas para asegurarte de que todo funciona correctamente +5. Haz commit de tus cambios (`git commit -am 'Añadir nueva funcionalidad'`) +6. Envía tus cambios a tu fork (`git push origin feature/nueva-funcionalidad`) +7. Crea un Pull Request diff --git a/src/jenkins/Rakefile b/src/jenkins/Rakefile new file mode 100644 index 0000000..0b54ead --- /dev/null +++ b/src/jenkins/Rakefile @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require 'rake/testtask' + +Rake::TestTask.new(:test) do |t| + t.libs << 'test' + t.pattern = 'test/**/*_test.rb' + t.verbose = true +end + +require 'rubocop/rake_task' + +RuboCop::RakeTask.new + +task default: [:test, :rubocop] diff --git a/src/jenkins/app/config/settings.rb b/src/jenkins/app/config/settings.rb new file mode 100644 index 0000000..cca7604 --- /dev/null +++ b/src/jenkins/app/config/settings.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true + +require 'json' +require 'fileutils' +require 'dotenv' + +module JenkinsMCP + module Config + # Configuración del MCP de Jenkins + class Settings + MCP_CONFIG_PATH = File.join(Dir.home, '.codeium', 'windsurf', 'mcp_config.json') + GEMINI_CONFIG_PATH = File.join(Dir.home, '.gemini', 'settings.json') + + DEFAULT_LOG_CONFIG = { + enable_file_logs: true, + log_level: 'info' + }.freeze + + # Carga la configuración de Jenkins + def self.load_jenkins_config + config = read_mcp_config + jenkins_config = config&.dig('jenkins') || {} + + file_base_url = resolve_env_ref(jenkins_config['baseUrl']) + auth = jenkins_config['auth'] || {} + + base_url = (ENV['JENKINS_BASE_URL'] || file_base_url || '').to_s + username = (ENV['JENKINS_USERNAME'] || resolve_env_ref(auth['username']) || '').to_s + api_token = (ENV['JENKINS_API_TOKEN'] || resolve_env_ref(auth['apiToken']) || '').to_s + + raise 'Missing JENKINS_BASE_URL (or jenkins.baseUrl in mcp_config.json)' if base_url.empty? + raise 'Missing JENKINS_USERNAME (or jenkins.auth.username in mcp_config.json)' if username.empty? + raise 'Missing JENKINS_API_TOKEN (or jenkins.auth.apiToken in mcp_config.json)' if api_token.empty? + + { + base_url: base_url.chomp('/'), + username: username, + api_token: api_token + } + end + + # Carga la configuración de logs + def self.load_log_config + Dotenv.load + + begin + config = read_mcp_config + return DEFAULT_LOG_CONFIG if config.nil? + + log_config = config.dig('jenkins', 'logs') || {} + + { + enable_file_logs: if log_config['enableFileLogs'].nil? + DEFAULT_LOG_CONFIG[:enable_file_logs] + else + log_config['enableFileLogs'] + end, + log_level: log_config['logLevel'] || DEFAULT_LOG_CONFIG[:log_level], + log_dir: log_config['logDir'] + } + rescue StandardError => e + warn "Failed to load log configuration: #{e.message}" + DEFAULT_LOG_CONFIG + end + end + + # Obtiene el directorio de logs + def self.log_dir + File.join(File.dirname(__FILE__), '../../logs') + end + + # Lee la configuración de MCP + def self.read_mcp_config + [MCP_CONFIG_PATH, GEMINI_CONFIG_PATH].each do |path| + next unless File.exist?(path) + + raw = File.read(path) + json = JSON.parse(raw) + return json if json['jenkins'] + end + nil + rescue StandardError + nil + end + + # Resuelve referencias a variables de entorno + def self.resolve_env_ref(value) + if value.is_a?(String) && value.start_with?('env:') + env_name = value[4..] + ENV[env_name] || '' + else + value + end + end + end + end +end diff --git a/src/jenkins/app/server.rb b/src/jenkins/app/server.rb new file mode 100644 index 0000000..845f12c --- /dev/null +++ b/src/jenkins/app/server.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require 'mcp' +require 'rackup' +require_relative 'version' +require_relative 'config/settings' +require_relative 'utils/logger' +require_relative 'tools/check_jobs' + +module JenkinsMCP + # MCP Server para Jenkins + class Server + # Inicia el MCP Server + def self.start + server = MCP::Server.new( + name: 'jenkins_mcp', + version: VERSION, + tools: [Tools::CheckJobs] + ) + + transport = MCP::Server::Transports::StreamableHTTPTransport.new(server) + server.transport = transport + + server.resources_read_handler do |params| + [{ + uri: params[:uri], + mimeType: 'text/plain', + text: 'Hello from HTTP server resource!' + }] + end + + Utils::Logger.app_logger.info('¡MCP server para Jenkins iniciado correctamente!') + + transport = MCP::Server::Transports::StdioTransport.new(server) + transport.open + end + end +end diff --git a/src/jenkins/app/services/check_jobs.rb b/src/jenkins/app/services/check_jobs.rb new file mode 100644 index 0000000..6076739 --- /dev/null +++ b/src/jenkins/app/services/check_jobs.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +module JenkinsMCP + module Services + # Servicio para verificar trabajos en Jenkins + class CheckJobs + # Crea una respuesta estándar para la verificación de trabajos + def self.create_job_check_response(job, build_info, log_text) + { + status: 'success', + job: job, + build: build_info, + log_full: log_text + } + end + + # Extrae el número de build de una URL + def self.extract_build_number_from_url(url) + build_number_match = url.chomp('/').match(%r{/(\d+)(?:$|/?)}) + raise "No se pudo extraer el número de build de la URL #{url}" unless build_number_match + + build_number_match[1].to_i + end + + # Procesa la solicitud de verificación de un trabajo por URL + def self.process_check_jobs_by_url(client, pipeline_url) + log_text = client.get_console_text_by_url(pipeline_url) + build_number = extract_build_number_from_url(pipeline_url) + + build_info = { + number: build_number, + url: pipeline_url + } + + create_job_check_response(pipeline_url, build_info, log_text) + end + + # Procesa la solicitud de verificación de un trabajo por nombre + def self.process_check_jobs_by_job(client, job_full_name) + builds = client.get_builds(job_full_name) + + raise "No builds found for job #{job_full_name}" if builds.empty? + + last_failed = client.find_last_failed(builds) + raise "No recent failed build found for #{job_full_name}" unless last_failed + + build_number = last_failed['number'] + log_text = client.get_console_text(job_full_name, build_number) + + build_info = { + number: build_number, + url: last_failed['url'] || '', + result: last_failed['result'], + timestamp: last_failed['timestamp'], + duration: last_failed['duration'] + } + + create_job_check_response(job_full_name, build_info, log_text) + end + end + end +end diff --git a/src/jenkins/app/services/jenkins_client.rb b/src/jenkins/app/services/jenkins_client.rb new file mode 100644 index 0000000..d36c418 --- /dev/null +++ b/src/jenkins/app/services/jenkins_client.rb @@ -0,0 +1,122 @@ +# frozen_string_literal: true + +require 'faraday' +require 'faraday/net_http' +require 'json' +require 'uri' +require 'cgi' +require_relative '../utils/logger' + +module JenkinsMCP + module Services + # Cliente para interactuar con la API de Jenkins + class JenkinsClient + API_ENDPOINTS = { + console_text: 'consoleText', + api_json: 'api/json' + }.freeze + + API_PARAMS = { + build_tree: 'builds[number,result,building,timestamp,duration,url]' + }.freeze + + URL_PATTERNS = { + blue_ocean: %r{^/blue/organizations/jenkins/([^/]+)/detail/([^/]+)/(\d+)(?:/.*)?$}, + pr_number: /^PR-\d+$/i + }.freeze + + # Crea una instancia del cliente Jenkins + def initialize(jenkins_config) + @client = Faraday.new( + url: jenkins_config[:base_url], + headers: { 'Content-Type': 'application/json' } + ) do |faraday| + # Usar autenticación básica directamente con el constructor + faraday.request(:authorization, :basic, jenkins_config[:username], jenkins_config[:api_token]) + faraday.adapter :net_http + end + @logger = Utils::Logger.create_logger('jenkins_mcp', 'jenkins-client') + end + + # Construye la ruta para un trabajo de Jenkins + def build_job_path(job_full_name) + parts = job_full_name.split('/').reject(&:empty?) + parts.map { |p| "job/#{CGI.escape(p).gsub('+', '%20')}" }.join('/') + end + + # Obtiene los builds de un trabajo + def get_builds(job_full_name) + path = build_job_path(job_full_name) + url = "/#{path}/#{API_ENDPOINTS[:api_json]}" + params = { tree: API_PARAMS[:build_tree] } + + begin + response = @client.get(url, params) + data = JSON.parse(response.body) + data['builds'] || [] + rescue StandardError => e + @logger.error("Error al obtener builds para #{job_full_name}: #{e.message}") + [] + end + end + + # Obtiene el texto de la consola de un build específico + def get_console_text(job_full_name, build_number) + path = build_job_path(job_full_name) + url = "/#{path}/#{build_number}/#{API_ENDPOINTS[:console_text]}" + fetch_console_text(url) + end + + # Obtiene el texto de la consola usando la URL del build + def get_console_text_by_url(absolute_build_url) + clean = absolute_build_url.chomp('/') + classic_base = convert_to_classic_build_url(clean) || clean + url = "#{classic_base}/#{API_ENDPOINTS[:console_text]}" + fetch_console_text(url) + end + + # Encuentra el último build fallido en una lista de builds + def find_last_failed(builds) + builds.find { |build| build && build['result'] == 'FAILURE' } + end + + private + + # Método genérico para obtener texto de la consola + def fetch_console_text(url) + response = @client.get(url) + response.body.to_s + rescue StandardError => e + @logger.error("Error al obtener texto de consola desde #{url}: #{e.message}") + '' + end + + # Convierte una URL de Jenkins Blue Ocean a su equivalente en la interfaz clásica + def convert_to_classic_build_url(blue_url) + uri = URI.parse(blue_url) + path = uri.path.chomp('/') + path_match = path.match(URL_PATTERNS[:blue_ocean]) + + return nil unless path_match + + job_name_raw, branch_or_pr_raw, build_number = path_match[1, 3] + job_name = URI.decode_www_form_component(job_name_raw) + branch_or_pr = URI.decode_www_form_component(branch_or_pr_raw) + + is_pr = URL_PATTERNS[:pr_number].match?(branch_or_pr) + encoded_job = URI.encode_www_form_component(job_name) + encoded_branch = URI.encode_www_form_component(branch_or_pr) + + base = "#{uri.scheme}://#{uri.host}:#{uri.port}/job/#{encoded_job}" + if is_pr + "#{base}/view/change-requests/job/#{encoded_branch}/#{build_number}" + else + "#{base}/job/#{encoded_branch}/#{build_number}" + end + rescue StandardError => e + @logger.error("Error al convertir URL de Blue Ocean: #{e.message}") + nil + end + end + end +end diff --git a/src/jenkins/app/tools/check_jobs.rb b/src/jenkins/app/tools/check_jobs.rb new file mode 100644 index 0000000..26a26a4 --- /dev/null +++ b/src/jenkins/app/tools/check_jobs.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +require_relative '../utils/logger' +require_relative '../services/jenkins_client' +require_relative '../services/check_jobs' +require_relative '../config/settings' + +module JenkinsMCP + module Tools + # Herramienta para obtener los logs de builds fallidos en Jenkins + class CheckJobs < MCP::Tool + description 'Get the last failed build for a Jenkins job and return the full log' + input_schema( + properties: { + job_full_name: { + type: 'string', + description: 'Full Jenkins job name, e.g., "Folder/Sub/Job"' + }, + pipeline_url: { + type: 'string', + description: 'Absolute Jenkins build URL, e.g., "https://jenkins.example.com/job/foo/15/"' + } + } + ) + + class << self + # Método de llamada para la herramienta + def call(job_full_name: nil, pipeline_url: nil) + tool_logger = Utils::Logger.create_logger('jenkins_tool', 'check-jobs') + + if !pipeline_url && !job_full_name + return MCP::Tool::Response.new([ + { + type: 'text', + text: 'Either pipeline_url or job_full_name is required' + } + ]) + end + + jenkins_config = Config::Settings.load_jenkins_config + client = Services::JenkinsClient.new(jenkins_config) + + if pipeline_url + tool_logger.info("Fetching build by URL: #{pipeline_url}") + payload = Services::CheckJobs.process_check_jobs_by_url(client, pipeline_url) + else + tool_logger.info("Fetching builds for job: #{job_full_name}") + payload = Services::CheckJobs.process_check_jobs_by_job(client, job_full_name) + end + + MCP::Tool::Response.new([ + { + type: 'text', + text: JSON.pretty_generate(payload) + } + ]) + rescue StandardError => e + error_message = e.message + tool_logger.error("Error processing request: #{error_message}") + + MCP::Tool::Response.new([ + { + type: 'text', + text: "Error while checking jobs: #{error_message}" + } + ]) + end + end + end + end +end diff --git a/src/jenkins/app/utils/logger.rb b/src/jenkins/app/utils/logger.rb new file mode 100644 index 0000000..2267bf6 --- /dev/null +++ b/src/jenkins/app/utils/logger.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +require 'logger' +require 'fileutils' +require_relative '../config/settings' + +module JenkinsMCP + module Utils + # Utilidades para logging + class Logger + # Logger personalizado que escribe en consola y archivo + class DualLogger < ::Logger + def initialize(console_io, file_io = nil) + super(console_io) + @file_logger = ::Logger.new(file_io) if file_io + end + + def add(severity, message = nil, progname = nil, &block) + super(severity, message, progname, &block) + @file_logger&.add(severity, message, progname, &block) + true + end + end + + # Crea un logger con la configuración simplificada + def self.create_logger(service, _filename = nil) + log_config = Config::Settings.load_log_config + log_dir = log_config[:log_dir] || Config::Settings.log_dir + + # Asegurar que el directorio de logs exista + FileUtils.mkdir_p(log_dir) if log_config[:enable_file_logs] && !Dir.exist?(log_dir) + + # Crear formatter personalizado + formatter = proc do |severity, datetime, _progname, msg| + date_format = datetime.strftime('%Y-%m-%d %H:%M:%S') + "[#{date_format}] #{severity} #{service}: #{msg}\n" + end + + # Crear logger para consola y archivo + logger = if log_config[:enable_file_logs] + # Un solo archivo de log para todo + log_file = File.join(log_dir, 'jenkins_mcp.log') + DualLogger.new($stdout, log_file) + else + DualLogger.new($stdout) + end + + # Configurar logger + logger.level = log_level_from_string(log_config[:log_level]) + logger.formatter = formatter + + logger + end + + def self.app_logger + @app_logger ||= create_logger('jenkins_mcp') + end + + # Convertir nivel de log de string a constante de Logger + def self.log_level_from_string(level_string) + case level_string&.downcase + when 'debug' then ::Logger::DEBUG + when 'info' then ::Logger::INFO + when 'warn' then ::Logger::WARN + when 'error' then ::Logger::ERROR + when 'fatal' then ::Logger::FATAL + else ::Logger::INFO + end + end + end + end +end diff --git a/src/jenkins/app/version.rb b/src/jenkins/app/version.rb new file mode 100644 index 0000000..76bbea9 --- /dev/null +++ b/src/jenkins/app/version.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +module JenkinsMCP + VERSION = '0.1.0' +end diff --git a/src/jenkins/bin/server b/src/jenkins/bin/server new file mode 100755 index 0000000..8ee9fb4 --- /dev/null +++ b/src/jenkins/bin/server @@ -0,0 +1,15 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require 'bundler/setup' +require 'mcp' + +# Añadir el directorio de la aplicación al path +$LOAD_PATH.unshift File.expand_path('../app', __dir__) + +# Cargar los archivos de la aplicación +require 'version' +require 'server' + +# Iniciar el servidor MCP +JenkinsMCP::Server.start diff --git a/src/jenkins/jenkins_mcp.gemspec b/src/jenkins/jenkins_mcp.gemspec new file mode 100644 index 0000000..5c05350 --- /dev/null +++ b/src/jenkins/jenkins_mcp.gemspec @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +lib = File.expand_path('lib', __dir__) +$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) +require 'jenkins_mcp/version' + +Gem::Specification.new do |spec| + spec.name = 'jenkins_mcp' + spec.version = JenkinsMCP::VERSION + spec.authors = ['BukHR'] + spec.email = ['dev@buk.cl'] + + spec.summary = 'Jenkins MCP Server para integración con LLMs' + spec.description = 'Servidor MCP para interactuar con Jenkins desde modelos de lenguaje' + spec.license = 'MIT' + + spec.files = Dir.glob('{bin,lib}/**/*') + %w[README.md] + spec.bindir = 'bin' + spec.executables = ['server'] + spec.require_paths = ['lib'] + + spec.required_ruby_version = '>= 3.2.0' + + spec.add_dependency 'dotenv', '~> 2.8' + spec.add_dependency 'faraday', '~> 2.7' + spec.add_dependency 'logger', '~> 1.5' + spec.add_dependency 'mcp', '~> 0.1' + + spec.add_development_dependency 'minitest', '~> 5.18' + spec.add_development_dependency 'minitest-reporters', '~> 1.6' + spec.add_development_dependency 'pry', '~> 0.14' + spec.add_development_dependency 'rake', '~> 13.0' + spec.add_development_dependency 'rubocop', '~> 1.50' + spec.add_development_dependency 'webmock', '~> 3.18' +end diff --git a/src/jenkins/test/services/check_jobs_test.rb b/src/jenkins/test/services/check_jobs_test.rb new file mode 100644 index 0000000..08ba7dd --- /dev/null +++ b/src/jenkins/test/services/check_jobs_test.rb @@ -0,0 +1,112 @@ +# frozen_string_literal: true + +require 'test_helper' + +class CheckJobsTest < JenkinsMCP::TestCase + def setup + @client = Minitest::Mock.new + end + + context 'extract_build_number_from_url' do + should 'extraer correctamente el número de build de URLs' do + # URL con barra final + url = 'https://jenkins.example.com/job/project/123/' + assert_equal 123, JenkinsMCP::Services::CheckJobs.extract_build_number_from_url(url) + + # URL sin barra final + url = 'https://jenkins.example.com/job/project/456' + assert_equal 456, JenkinsMCP::Services::CheckJobs.extract_build_number_from_url(url) + + # URL sin número de build + url = 'https://jenkins.example.com/job/project/' + assert_raises(RuntimeError) do + JenkinsMCP::Services::CheckJobs.extract_build_number_from_url(url) + end + end + end + + context 'process_check_jobs_by_url' do + should 'obtener logs por URL del pipeline' do + pipeline_url = 'https://jenkins.example.com/job/project/789/' + log_text = 'Este es el log del build' + + @client.expect :get_console_text_by_url, log_text, [pipeline_url] + + response = JenkinsMCP::Services::CheckJobs.process_check_jobs_by_url(@client, pipeline_url) + + assert_equal 'success', response[:status] + assert_equal pipeline_url, response[:job] + assert_equal 789, response[:build][:number] + assert_equal pipeline_url, response[:build][:url] + assert_equal log_text, response[:log_full] + + @client.verify + end + end + + context 'process_check_jobs_by_job' do + should 'obtener logs por nombre de job cuando hay builds fallidos' do + job_name = 'example-job' + build_number = 42 + log_text = 'Este es el log del job' + + builds = [ + { 'number' => build_number, 'result' => 'FAILURE', 'url' => 'https://jenkins.example.com/job/example-job/42/', + 'timestamp' => 1_605_000_000_000, 'duration' => 60_000 }, + { 'number' => 41, 'result' => 'SUCCESS', 'url' => 'https://jenkins.example.com/job/example-job/41/' } + ] + + failed_build = builds.first + + @client.expect :get_builds, builds, [job_name] + @client.expect :find_last_failed, failed_build, [builds] + @client.expect :get_console_text, log_text, [job_name, build_number] + + response = JenkinsMCP::Services::CheckJobs.process_check_jobs_by_job(@client, job_name) + + assert_equal 'success', response[:status] + assert_equal job_name, response[:job] + assert_equal build_number, response[:build][:number] + assert_equal 'https://jenkins.example.com/job/example-job/42/', response[:build][:url] + assert_equal 'FAILURE', response[:build][:result] + assert_equal 1_605_000_000_000, response[:build][:timestamp] + assert_equal 60_000, response[:build][:duration] + assert_equal log_text, response[:log_full] + + @client.verify + end + + should 'lanzar un error cuando no hay builds' do + job_name = 'empty-job' + + @client.expect :get_builds, [], [job_name] + + error = assert_raises(RuntimeError) do + JenkinsMCP::Services::CheckJobs.process_check_jobs_by_job(@client, job_name) + end + + assert_equal "No builds found for job #{job_name}", error.message + + @client.verify + end + + should 'lanzar un error cuando no hay builds fallidos' do + job_name = 'successful-job' + builds = [ + { 'number' => 2, 'result' => 'SUCCESS' }, + { 'number' => 1, 'result' => 'SUCCESS' } + ] + + @client.expect :get_builds, builds, [job_name] + @client.expect :find_last_failed, nil, [builds] + + error = assert_raises(RuntimeError) do + JenkinsMCP::Services::CheckJobs.process_check_jobs_by_job(@client, job_name) + end + + assert_equal "No recent failed build found for #{job_name}", error.message + + @client.verify + end + end +end diff --git a/src/jenkins/test/services/jenkins_client_test.rb b/src/jenkins/test/services/jenkins_client_test.rb new file mode 100644 index 0000000..d68dec8 --- /dev/null +++ b/src/jenkins/test/services/jenkins_client_test.rb @@ -0,0 +1,147 @@ +# frozen_string_literal: true + +require 'test_helper' +require 'base64' + +class JenkinsClientTest < JenkinsMCP::TestCase + def setup + @config = { + base_url: 'https://jenkins.example.com', + username: 'usuario', + api_token: 'token123' + } + @client = JenkinsMCP::Services::JenkinsClient.new(@config) + end + + context 'build_job_path' do + should 'construir correctamente rutas de jobs' do + assert_equal 'job/folder/job/job', @client.build_job_path('folder/job') + assert_equal 'job/folder1/job/folder2/job/job', @client.build_job_path('folder1/folder2/job') + assert_equal 'job/folder/job/job%20name', @client.build_job_path('folder/job name') + end + end + + context 'get_builds' do + should 'obtener la lista de builds de un job' do + job_name = 'test-job' + api_url = 'https://jenkins.example.com/job/test-job/api/json?tree=builds%5Bnumber%2Cresult%2Cbuilding%2Ctimestamp%2Cduration%2Curl%5D' + + response_body = { + builds: [ + { number: 1, result: 'SUCCESS', url: 'https://jenkins.example.com/job/test-job/1/' }, + { number: 2, result: 'FAILURE', url: 'https://jenkins.example.com/job/test-job/2/' } + ] + }.to_json + + auth_header = "Basic #{Base64.strict_encode64('usuario:token123')}" + + stub_request(:get, api_url) + .with(headers: { 'Authorization' => auth_header }) + .to_return(status: 200, body: response_body, headers: { 'Content-Type' => 'application/json' }) + + builds = @client.get_builds(job_name) + assert_equal 2, builds.length + assert_equal 1, builds[0]['number'] + assert_equal 'SUCCESS', builds[0]['result'] + assert_equal 2, builds[1]['number'] + assert_equal 'FAILURE', builds[1]['result'] + end + end + + context 'find_last_failed' do + should 'encontrar el último build fallido' do + builds = [ + { 'number' => 3, 'result' => 'SUCCESS' }, + { 'number' => 2, 'result' => 'FAILURE' }, + { 'number' => 1, 'result' => 'SUCCESS' } + ] + + failed_build = @client.find_last_failed(builds) + assert_equal 2, failed_build['number'] + assert_equal 'FAILURE', failed_build['result'] + + # Probar con builds todos exitosos + successful_builds = [ + { 'number' => 2, 'result' => 'SUCCESS' }, + { 'number' => 1, 'result' => 'SUCCESS' } + ] + + assert_nil @client.find_last_failed(successful_builds) + end + end + + context 'get_console_text' do + should 'obtener el texto de la consola por nombre de job y número de build' do + job_name = 'test-job' + build_number = 123 + console_output = "Build iniciado\nCompilando\nTests ejecutados\nBuild finalizado" + console_url = 'https://jenkins.example.com/job/test-job/123/consoleText' + auth_header = "Basic #{Base64.strict_encode64('usuario:token123')}" + + stub_request(:get, console_url) + .with(headers: { 'Authorization' => auth_header }) + .to_return(status: 200, body: console_output) + + result = @client.get_console_text(job_name, build_number) + assert_equal console_output, result + end + + should 'manejar errores al obtener el texto de la consola' do + job_name = 'error-job' + build_number = 456 + console_url = 'https://jenkins.example.com/job/error-job/456/consoleText' + auth_header = "Basic #{Base64.strict_encode64('usuario:token123')}" + + stub_request(:get, console_url) + .with(headers: { 'Authorization' => auth_header }) + .to_return(status: 404) + + result = @client.get_console_text(job_name, build_number) + assert_equal '', result + end + end + + context 'get_console_text_by_url' do + should 'obtener el texto de la consola por URL del build' do + build_url = 'https://jenkins.example.com/job/test-job/789/' + console_url = 'https://jenkins.example.com/job/test-job/789/consoleText' + console_output = "Build iniciado\nCompilando\nTests ejecutados\nBuild finalizado" + auth_header = "Basic #{Base64.strict_encode64('usuario:token123')}" + + stub_request(:get, console_url) + .with(headers: { 'Authorization' => auth_header }) + .to_return(status: 200, body: console_output) + + result = @client.get_console_text_by_url(build_url) + assert_equal console_output, result + end + + should 'convertir URLs de Blue Ocean a interfaz clásica' do + blue_ocean_url = 'https://jenkins.example.com/blue/organizations/jenkins/my-project/detail/master/42/' + classic_url = 'https://jenkins.example.com/job/my-project/job/master/42/consoleText' + console_output = 'Ejecutando en branch master' + auth_header = "Basic #{Base64.strict_encode64('usuario:token123')}" + + # Hacemos que el cliente devuelva una URL clásica y luego acceda al contenido + stub_request(:get, classic_url) + .with(headers: { 'Authorization' => auth_header }) + .to_return(status: 200, body: console_output) + + result = @client.get_console_text_by_url(blue_ocean_url) + assert_equal console_output, result + end + + should 'manejar errores al convertir URL de Blue Ocean' do + invalid_url = 'https://jenkins.example.com/invalid/path/' + console_url = 'https://jenkins.example.com/invalid/path/consoleText' + auth_header = "Basic #{Base64.strict_encode64('usuario:token123')}" + + stub_request(:get, console_url) + .with(headers: { 'Authorization' => auth_header }) + .to_return(status: 404) + + result = @client.get_console_text_by_url(invalid_url) + assert_equal '', result + end + end +end diff --git a/src/jenkins/test/test_helper.rb b/src/jenkins/test/test_helper.rb new file mode 100644 index 0000000..a880c09 --- /dev/null +++ b/src/jenkins/test/test_helper.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require 'minitest/autorun' +require 'minitest/reporters' +require 'shoulda/context' +require 'mocha/minitest' +require 'webmock/minitest' +require 'pry' +require 'mcp' +require 'logger' + +# Agregar la ruta de la aplicación al path +app_path = File.expand_path('../app', __dir__) +$LOAD_PATH.unshift(app_path) unless $LOAD_PATH.include?(app_path) + +# Cargar los archivos necesarios +require 'version' +require 'config/settings' +require 'utils/logger' +require 'services/jenkins_client' +require 'services/check_jobs' +require 'tools/check_jobs' + +# Configurar formato de salida +Minitest::Reporters.use! [Minitest::Reporters::SpecReporter.new(color: true, detailed_skip: false)] + +module JenkinsMCP + class TestCase < Minitest::Test + extend Shoulda::Context + + def before_setup + super + mock_logger = mock('JenkinsMCP::Utils::Logger::DualLogger') + mock_logger.stubs(:info) + mock_logger.stubs(:debug) + mock_logger.stubs(:warn) + mock_logger.stubs(:error) + JenkinsMCP::Utils::Logger.stubs(:create_logger).returns(mock_logger) + end + end +end diff --git a/src/jenkins/test/tools/check_jobs_test.rb b/src/jenkins/test/tools/check_jobs_test.rb new file mode 100644 index 0000000..2acb8ae --- /dev/null +++ b/src/jenkins/test/tools/check_jobs_test.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true + +require 'test_helper' +require 'json' + +class CheckJobsTest < JenkinsMCP::TestCase + context 'Cuando se llama con URL de pipeline' do + should 'devolver una respuesta MCP válida' do + pipeline_url = 'https://jenkins.example.com/job/test-job/123/' + expected_payload = { + status: 'success', + job: 'test-job', + build: { + number: 123, + url: pipeline_url + }, + log_full: 'Build log content for test job' + } + + # Usamos stubs de Mocha para el método del servicio + JenkinsMCP::Services::CheckJobs.stubs(:process_check_jobs_by_url).with do |_client, url| + assert_equal pipeline_url, url + true # Validación exitosa del argumento + end.returns(expected_payload) + + # Mock del cliente usando Mocha + mock_client = mock('JenkinsClient') + JenkinsMCP::Services::JenkinsClient.stubs(:new).returns(mock_client) + + # Llamada al método bajo prueba + response = JenkinsMCP::Tools::CheckJobs.call(pipeline_url: pipeline_url) + + # Verificaciones + assert_instance_of MCP::Tool::Response, response + assert response + end + end + + context 'Cuando se llama con nombre de job' do + should 'devolver una respuesta MCP válida' do + job_name = 'example-folder/test-job' + expected_payload = { + status: 'success', + job: job_name, + build: { + number: 42, + url: 'https://jenkins.example.com/job/example-folder/job/test-job/42/', + result: 'FAILURE' + }, + log_full: 'Build log content for job name' + } + + # Usamos stubs de Mocha para el método del servicio + JenkinsMCP::Services::CheckJobs.stubs(:process_check_jobs_by_job).with do |_client, name| + assert_equal job_name, name + true # Validación exitosa del argumento + end.returns(expected_payload) + + # Mock del cliente usando Mocha + mock_client = mock('JenkinsClient') + JenkinsMCP::Services::JenkinsClient.stubs(:new).returns(mock_client) + + # Llamada al método bajo prueba + response = JenkinsMCP::Tools::CheckJobs.call(job_full_name: job_name) + + # Verificaciones + assert_instance_of MCP::Tool::Response, response + assert response + end + end + + context 'Cuando se llama sin parámetros' do + should 'devolver una respuesta de error apropiada' do + # Llamada al método sin parámetros requeridos + response = JenkinsMCP::Tools::CheckJobs.call + + # Verificaciones + assert_instance_of MCP::Tool::Response, response + end + end + + context 'Cuando el servicio devuelve un error' do + should 'manejar el error apropiadamente' do + job_name = 'error-job' + error_message = 'No builds found for job error-job' + + # Hacemos que el método lance una excepción + JenkinsMCP::Services::CheckJobs.stubs(:process_check_jobs_by_job).raises(RuntimeError.new(error_message)) + + # Mock del cliente usando Mocha + mock_client = mock('JenkinsClient') + JenkinsMCP::Services::JenkinsClient.stubs(:new).returns(mock_client) + + # Llamada al método bajo prueba + response = JenkinsMCP::Tools::CheckJobs.call(job_full_name: job_name) + + # Verificaciones + assert_instance_of MCP::Tool::Response, response + assert response + end + end +end