diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..860146b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,31 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Formato + run: test -z "$(gofmt -l .)" || (gofmt -l . && exit 1) + + - name: Vet + run: go vet ./... + + - name: Tests + run: go test -race ./... + + - name: Build + run: go build ./... diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d147fc9..8e1d04f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,7 +21,7 @@ jobs: - name: Setup Go uses: actions/setup-go@v5 with: - go-version: '1.23.x' + go-version-file: go.mod - name: Build binaries run: make build VERSION=${{ github.ref_name }} diff --git a/Makefile b/Makefile index 599c09b..b951d20 100644 --- a/Makefile +++ b/Makefile @@ -4,19 +4,30 @@ VERSION ?= dev DIST_DIR = dist -.PHONY: all build clean +LDFLAGS = -s -w -X azuredevops/azdevops.Version=$(VERSION) + +.PHONY: all build clean test vet install all: build build: clean @echo "🔧 Compilando binarios para múltiples plataformas..." - GOOS=linux GOARCH=amd64 go build -o $(DIST_DIR)/$(VERSION)/linux-amd64/$(APP_NAME) - GOOS=linux GOARCH=arm64 go build -o $(DIST_DIR)/$(VERSION)/linux-arm64/$(APP_NAME) - GOOS=darwin GOARCH=amd64 go build -o $(DIST_DIR)/$(VERSION)/darwin-amd64/$(APP_NAME) - GOOS=darwin GOARCH=arm64 go build -o $(DIST_DIR)/$(VERSION)/darwin-arm64/$(APP_NAME) - GOOS=windows GOARCH=amd64 go build -o $(DIST_DIR)/$(VERSION)/windows-amd64/$(APP_NAME).exe + GOOS=linux GOARCH=amd64 go build -ldflags "$(LDFLAGS)" -o $(DIST_DIR)/$(VERSION)/linux-amd64/$(APP_NAME) + GOOS=linux GOARCH=arm64 go build -ldflags "$(LDFLAGS)" -o $(DIST_DIR)/$(VERSION)/linux-arm64/$(APP_NAME) + GOOS=darwin GOARCH=amd64 go build -ldflags "$(LDFLAGS)" -o $(DIST_DIR)/$(VERSION)/darwin-amd64/$(APP_NAME) + GOOS=darwin GOARCH=arm64 go build -ldflags "$(LDFLAGS)" -o $(DIST_DIR)/$(VERSION)/darwin-arm64/$(APP_NAME) + GOOS=windows GOARCH=amd64 go build -ldflags "$(LDFLAGS)" -o $(DIST_DIR)/$(VERSION)/windows-amd64/$(APP_NAME).exe @echo "✅ Binarios generados en la carpeta $(DIST_DIR)" +install: + go install -ldflags "$(LDFLAGS)" . + +test: + go test ./... + +vet: + go vet ./... + clean: @echo "🧹 Limpiando binarios anteriores..." rm -rf $(DIST_DIR)/* diff --git a/README.md b/README.md index e0ee646..047d8ce 100644 --- a/README.md +++ b/README.md @@ -1,166 +1,282 @@ - # Azure DevOps CLI -Esta es una herramienta de línea de comandos (CLI) no oficial para interactuar con Azure DevOps. Simplifica la gestión de recursos de Azure DevOps, como los *Variable Groups*, directamente desde tu terminal. +Herramienta de línea de comandos (CLI) no oficial para interactuar con Azure DevOps. Simplifica la gestión de *Variable Groups*, *Pipelines*, aprobaciones, *work items*, conexiones de servicio, agentes y *grupos de seguridad* desde tu terminal, tanto de forma **interactiva** (menús y asistentes) como en **scripts y CI**. ## Características - * **Gestión de Variable Groups**: - * **Crear**: Crea nuevos *Variable Groups* con variables y secretos. - * **Obtener**: Recupera *Variable Groups* por su nombre en formato JSON. - * **Actualizar**: Agrega o modifica variables en uno o más *Variable Groups* existentes. - * **Eliminar**: Borra *Variable Groups* completos o variables específicas dentro de ellos. - * **Gestión de Pipelines**: Comandos para trabajar con pipelines de Azure DevOps. - -## Prerrequisitos - -Antes de empezar, necesitas configurar las siguientes variables de entorno para la autenticación: - - * `AZURE_ORG`: El nombre de tu organización de Azure DevOps. - * `AZURE_PROJECT`: El nombre de tu proyecto de Azure DevOps. - * `AZURE_PAT`: Tu Token de Acceso Personal (PAT) de Azure DevOps. +* **Modo interactivo**: ejecuta `azdevops` sin argumentos para navegar por menús. Además, cualquier comando al que le falte un dato te lo pregunta (con listas filtrables de Variable Groups, pipelines, repositorios, ejecuciones…). +* **Perfiles**: guarda organización, proyecto y PAT con `azdevops login` (el PAT va al **llavero del sistema** cuando está disponible) y cambia entre varios con `azdevops config use`. +* **Variable Groups**: listar, consultar, crear, actualizar, eliminar (grupos o variables), asignar permisos, **exportar/importar** desde `.env` o JSON, **copiar** (incluso a otro proyecto) y **comparar** (`diff`). +* **Pipelines**: listar con su último resultado, crear, actualizar, eliminar, **ejecutar con progreso en vivo**, ver ejecuciones, estado por etapas, **logs** (incluyendo solo las tareas fallidas) y cancelar. +* **Aprobaciones y environments**: ver aprobaciones de despliegue pendientes y aprobarlas o rechazarlas; listar environments y su historial de despliegues. +* **Work items (Boards)**: listar los tuyos, consultar, crear, cambiar estado, comentar, reasignar y eliminar. +* **Conexiones de servicio y agentes**: listar, ver qué pipelines usan una conexión, compartirla con otros proyectos; ver pools y agentes (en línea, trabajo actual) y habilitarlos o deshabilitarlos. +* **Archivos seguros**: listar, ver quién y qué pipelines pueden usarlos, subir, autorizar pipelines, asignar roles, eliminar y **reemplazar el contenido conservando permisos, roles y aprobaciones**. +* **Extensiones**: `azdevops extension init` crea una extensión lista para compilar, probar y empaquetar (tareas TypeScript con azure-pipelines-task-lib, jest, ncc, azde-scripts, configuraciones dev/release, iconos y UUIDs), y `add-task`, `bump`, `validate`, `reset-ids`, `pack` y `publish` la mantienen. +* **`azdevops open`**: abre en el navegador el proyecto, un pipeline, una ejecución, un work item, etc. +* **Seguridad**: listar y buscar grupos, ver miembros, agregar y quitar miembros. +* **Salida** en tabla, JSON, YAML o TSV (`-o`), con filtros **JMESPath** (`--query`, igual que en `az`). Los mensajes de estado van a *stderr* y los datos a *stdout*. +* **Autocompletado** de comandos, flags, nombres de Variable Groups e IDs de pipelines. +* Cliente HTTP robusto: errores legibles de la API, detección de PAT inválido/expirado, reintentos ante *throttling* (429) y errores 5xx, y `--debug` para ver cada petición. ## Instalación -### Desde Binarios - -Puedes compilar los binarios para diferentes plataformas utilizando el `Makefile` incluido. - -1. Clona el repositorio: - - ```bash - git clone https://github.com/henryd24/azure_devops_cli.git - cd azure_devops_cli - ``` - -2. Ejecuta el comando `make`: - - ```bash - make build - ``` - - Esto generará los binarios en el directorio `dist/` para Linux, macOS y Windows. - -### Con Docker - -El proyecto incluye un `Dockerfile` para crear un entorno de desarrollo con Go. - -## Uso - -El comando raíz es `azdevops`. A partir de ahí, puedes usar los subcomandos `variables` y `pipelines`. - -### Ejemplos con `variables` - - * **Crear un nuevo Variable Group**: - - ```bash - azdevops variables create --name "MiGrupoDeVariables" --description "Un grupo de ejemplo" --variables "clave1=valor1" "secret:claveSecreta=valorSecreto" - ``` +### Desde las releases - * Usa el prefijo `secret:` para crear variables secretas. +Descarga el binario para tu plataforma desde la página de *Releases* del repositorio y colócalo en tu `PATH`. - * **Obtener un Variable Group**: +### Desde el código - ```bash - azdevops variables get --name "MiGrupoDeVariables" - ``` - También puedes jugar con expreciones como `MiGrupoDeVariables*` lo que retornara aquellos que contengan dicha estructura, devolviendo no solo aquel que tiene el nombre exacto - ```bash - azdevops variables get --name "MiGrupoDeVariables*" - ``` +```bash +git clone https://github.com/henryd24/azure_devops_cli.git +cd azure_devops_cli +make install # instala azdevops en $GOPATH/bin +make build VERSION=v0.1.0 # o genera binarios para Linux, macOS y Windows en dist/ +``` - * **Actualizar un Variable Group agregando nuevas variables**: +## Autenticación - ```bash - azdevops variables update --name "MiGrupoDeVariables" --variables "clave2=valorNuevo,secret:otraClave=otroSecreto" - ``` - También podras actualizar varias al tiempo - ```bash - azdevops variables update --name "MiGrupoDeVariables" --name "MiGrupoDeVariables2" --variables "clave2=valorNuevo,secret:otraClave=otroSecreto" - ``` +La forma más cómoda es guardar un perfil: - * **Eliminar variables específicas de un grupo**: +```bash +azdevops login # asistente: organización, PAT y selección de proyecto +azdevops login --profile cliente2 # un segundo perfil +azdevops config view # lista perfiles (PAT enmascarado) y la configuración efectiva +azdevops config use cliente2 # cambia el perfil por defecto +``` - ```bash - azdevops variables delete --name "MiGrupoDeVariables" --variables "clave1,otraClave" - ``` +El perfil se guarda en `~/.config/azdevops/config.json` con permisos `0600`. El PAT se guarda en el **llavero del sistema** (Keychain en macOS, Credential Manager en Windows, Secret Service/GNOME Keyring en Linux) si está disponible; si no, en ese archivo. Puedes forzarlo con `--store keyring` o `--store file`. - * **Eliminar un Variable Group completo**: +También puedes usar variables de entorno (ideal para CI) o flags: - ```bash - azdevops variables delete --name "MiGrupoDeVariables" --yes - ``` +| Dato | Flag | Variable de entorno | +|--------------|-------------|---------------------| +| Organización | `--org` | `AZURE_ORG` | +| Proyecto | `--project` | `AZURE_PROJECT` | +| PAT | `--pat` | `AZURE_PAT` | +| Perfil | `--profile` | `AZDEVOPS_PROFILE` | - * Usa `--yes` o `-y` para confirmar la eliminación sin que se te pregunte. +Precedencia: flags › perfil indicado con `--profile` › variables de entorno › perfil por defecto. - * **Agregar permisos a un Variable Group**: +## Modo interactivo - ```bash - azdevops variables set-permissions --variable-groups "MiGrupoDeVariables" --users "usuario1@ejemplo.com" --group "GroupName" --role "Reader" - ``` +```bash +azdevops # abre el menú (también: azdevops interactive) +azdevops pipelines run # te deja elegir el pipeline, la rama, parámetros y si esperar +azdevops variables update # eliges los grupos y agregas variables una a una (las secretas no se muestran) +``` - * El grupo de seguridad debe existir previamente y este debe ser sin el prefijo `[]\`. +Los prompts solo aparecen en una terminal. Se desactivan con `--no-input`, con `AZDEVOPS_NO_INPUT=1` o cuando existe la variable `CI`; en ese caso, si falta un dato obligatorio el comando falla con un mensaje claro. ---- - -### Ejemplos con `pipelines` - - * **Crear un nuevo pipeline**: - - ```bash - azdevops pipelines create --name "MiPipeline" --repo-type "azureReposGit" --repo-name "mi-repo" --branch "main" --yaml-path ".azure-pipelines.yml" --folder "\\" --service-connection "id-conexion" - ``` - * Los parámetros `--name`, `--repo-type`, `--repo-name`, `--branch` y `--yaml-path` son obligatorios. - * Puedes especificar la carpeta y la conexión de servicio si lo necesitas. - - * **Obtener un pipeline por nombre**: - - ```bash - azdevops pipelines get --name "MiPipelinePrefix*" - azdevops pipelines get --name "MiPipeline" - ``` - - * **Actualizar un pipeline existente**: - - ```bash - azdevops pipelines update --id 123 --name "NuevoNombre" --yaml-path ".azure-pipelines.yml" --repo-name "mi-repo-actualizado" --service-connection "nuevo-id-conexion" - ``` - * Puedes actualizar solo los campos que necesites, los demás se mantienen igual. - - * **Eliminar un pipeline**: - - ```bash - azdevops pipelines delete --id 123 - ``` - - * **Iniciar un pipeline y esperar a que finalice:** - ```bash - azdevops pipelines run --id 123 --wait - # Iniciar un pipeline pasando parámetros y variables (incluyendo secretos): - azdevops pipelines run --id 123 --param "imageTag=1.2.3" --var "deployEnv=staging" --var "secret:apiKey=un_valor_muy_secreto_aqui" - ``` - -### Ejemplos con `security` - - * **Listar grupos de seguridad**: - - ```bash - azdevops security list-groups - ``` - - * **Buscar un grupo de seguridad por nombre**: - - ```bash - azdevops security search-group --name "MiGrupo" - ``` - - * **Agregar un miembro a un grupo de seguridad**: - - ```bash - azdevops security add-member --target-group "MiGrupoDestino" --target-group "MiGrupoDestino2" --user "usuario1@ejemplo.com" --group "MiGrupo" - ``` +## Uso ---- +### Variable Groups (`variables`, alias `vg`) + +```bash +azdevops variables list # tabla con todos los grupos +azdevops variables list --filter "app-*" -o json +azdevops variables get --name "MiGrupo" # JSON (admite comodines: "MiGrupo*") +azdevops variables get --id 42 -o table # tabla de variables (secretas enmascaradas) + +azdevops variables create --name MiGrupo -d "Descripción" -v clave1=valor1 -v secret:token=abc +azdevops variables update --name MiGrupo --name OtroGrupo -v "clave2=nuevo,secret:otra=xyz" +azdevops variables delete --name MiGrupo --variables clave1,otra +azdevops variables delete --name MiGrupo --yes # elimina el grupo completo + +azdevops variables set-permissions --variable MiGrupo --user ana@empresa.com --group Devs --role Reader + +azdevops variables export --name MiGrupo > .env # o --format json --file vars.json +azdevops variables import --name MiGrupo --file .env --secret DB_PASSWORD +azdevops variables import --name NuevoGrupo --file vars.json --create + +azdevops variables copy --from app-dev --to app-qa +azdevops variables copy --from app-dev --to app-dev --to-project OtroProyecto --secret-value DB_PASS=xxx +azdevops variables diff app-dev app-prod # solo lo que cambia +azdevops variables diff app-prod app-prod --project-b Otro --all +azdevops variables diff app-dev app-prod --exit-code # código 1 si hay diferencias (CI) +``` + +* Usa el prefijo `secret:` para crear variables secretas. +* Los grupos de seguridad se indican sin el prefijo `[Proyecto]\`. +* La API no devuelve el valor de las variables secretas: `export` las deja comentadas, `copy` te pide su valor (o usa `--secret-value`) y `diff` solo compara si existen. + +### Pipelines (`pipelines`, alias `pl`) + +```bash +azdevops pipelines list # nombre, carpeta y último resultado +azdevops pipelines get --name "MiPipeline*" +azdevops pipelines get --id 123 + +azdevops pipelines create --name MiPipeline --repo-name mi-repo --yaml-path azure-pipelines.yml --branch main +azdevops pipelines create -n MiPipeline -t gitHub -r org/repo -p .azure/ci.yml -s +azdevops pipelines update --id 123 --new-name NuevoNombre --branch develop +azdevops pipelines delete --id 123 --yes + +# Ejecutar (por --id o --name) y esperar mostrando la etapa en curso +azdevops pipelines run --name MiPipeline --branch feature/x --wait --timeout 30m +azdevops pipelines run --id 123 --param imageTag=1.2.3 --var deployEnv=staging --var secret:apiKey=xxx + +azdevops pipelines runs --id 123 --top 10 # ejecuciones recientes +azdevops pipelines status --build-id 4567 # estado + etapas/jobs +azdevops pipelines status --build-id 4567 --watch # seguirla hasta que termine +azdevops pipelines logs --build-id 4567 --failed # logs de las tareas que fallaron +azdevops pipelines logs --build-id 4567 --log-id 12 +azdevops pipelines cancel --build-id 4567 +``` + +Con `--wait`, el comando termina con código **1** si la ejecución falla o se cancela y muestra las tareas con error, lo que permite usarlo como paso de CI. + +### Aprobaciones y environments + +```bash +azdevops approvals list # pendientes +azdevops approvals list --state all -o json +azdevops approvals approve --id --comment "OK para prod" +azdevops approvals approve # elegir de la lista +azdevops approvals reject --id --comment "Falta validar" --yes + +azdevops environments list +azdevops environments deployments --name produccion --top 10 +``` + +### Work items (`workitems`, alias `wi`) + +```bash +azdevops wi list # tus work items abiertos +azdevops wi list --type Bug --state Active --assigned-to any +azdevops wi list --search "login" --all-states +azdevops wi list --wiql "SELECT [System.Id] FROM WorkItems WHERE [System.Tags] CONTAINS 'urgente'" +azdevops wi get 123 +azdevops wi create --type Bug --title "Falla el login" --assigned-to @me --tags "frontend; urgente" +azdevops wi create --type Task --title "Escribir tests" --parent 123 +azdevops wi update 123 --state Active --comment "Empiezo con esto" +azdevops wi update # eliges el work item y qué cambiar +azdevops wi delete 123 --yes # a la papelera (recuperable) +``` + +### Conexiones de servicio (`service-connections`, alias `sc`) y agentes + +```bash +azdevops sc list --type azurerm +azdevops sc get --name DockerHub +azdevops sc history --name DockerHub # qué pipelines la usaron +azdevops sc share --name DockerHub --with-project OtroProyecto + +azdevops agents pools +azdevops agents list --pool MiPool # estado, versión, trabajo actual y último +azdevops agents disable --pool MiPool --agent build-01 --yes +azdevops agents enable --pool MiPool --agent build-01 +``` + +### Archivos seguros (`securefiles`, alias `sf`) + +```bash +azdevops sf list +azdevops sf get --name cert.pfx # pipelines autorizados, roles y aprobaciones +azdevops sf upload ./cert.pfx --pipeline 12 --pipeline 15 +azdevops sf upload ./npmrc --authorize-all-pipelines +azdevops sf authorize --name cert.pfx --pipeline 20 # --revoke para quitar, --all-pipelines=true|false +azdevops sf set-role --name cert.pfx --group Devs --role User +azdevops sf delete --name cert.pfx --yes + +azdevops sf replace ./cert-2026.pfx --name cert.pfx --dry-run # muestra qué se conservará +azdevops sf replace ./cert-2026.pfx --name cert.pfx --yes +``` + +`replace` actualiza el contenido de un archivo seguro (por ejemplo, un certificado renovado) conservando su nombre, propiedades, pipelines autorizados (o el acceso abierto), roles asignados, herencia de permisos y aprobaciones/checks. Como Azure DevOps no permite cambiar el contenido ni tener dos archivos con el mismo nombre, renombra el actual, sube el nuevo, copia la configuración y elimina el anterior (`--keep-old` lo conserva renombrado). Si algo falla, revierte los cambios. El archivo nuevo tiene otro ID: los pipelines YAML lo referencian por nombre y siguen funcionando. + +### Extensiones de Azure DevOps (`extension`, alias `ext`) + +```bash +azdevops ext init # asistente: nombre, publisher, tareas, conexión de servicio… +azdevops ext init mi-ext --name "Mi Ext" --publisher hendamm --task deploy --task rollback \ + --endpoint --endpoint-name "Mi API" --install --git --yes + +azdevops ext add-task otra-tarea --category Deploy --endpoint # nueva tarea con UUID nuevo, registrada en vss-extension.json +azdevops ext bump # patch de todas las tareas + config/dev.json +azdevops ext bump --level minor --release # minor de tareas + config/release.json +azdevops ext validate # ids repetidos, contribuciones, iconos, versiones +azdevops ext reset-ids # UUIDs nuevos (al copiar otra extensión como base) +azdevops ext uuid -n 3 +azdevops ext pack # .vsix de dev (--release, --rev-version) +azdevops ext publish # publica la versión dev y la comparte con tu organización +``` + +Estructura generada: + +``` +vss-extension.json manifiesto (tareas, conexión de servicio opcional, iconos) +config/dev.json overrides del paquete privado (id-dev, publisher dev-) +config/release.json overrides del paquete público +images/ icono de la extensión y de la conexión de servicio +scripts/tasks.js ejecuta un script de npm en todas las tareas +src/tasks// + task.json UUID nuevo, inputs de ejemplo, Node20_1 y Node24 + src/.ts punto de entrada; src/utils/inputs.ts lee y valida los inputs + src/__tests__/ tests con jest (azure-pipelines-task-lib mockeado) + .env-test variables para ejecutarla localmente (npm run test-local) + package.json build (tsc), test (jest), package (ncc → tasks//index.js) +``` + +Scripts del proyecto: `npm install` (instala todas las tareas con azde-scripts), `npm test`, `npm run build`, `npm run pack:dev`, `npm run pack`, `npm run packupversion[:dev]`. `publish` necesita un PAT con el scope *Marketplace (Publish)* (`--token` o `AZURE_MARKETPLACE_TOKEN`). Los comandos que editan JSON (`add-task`, `bump`, `reset-ids`) conservan el formato y el orden de tus archivos. + +### Abrir en el navegador + +```bash +azdevops open # el proyecto +azdevops open pipeline MiPipeline +azdevops open run 4567 +azdevops open variables app-dev +azdevops open workitem 42 --print # solo imprime la URL +``` + +### Seguridad (`security`) + +```bash +azdevops security list-groups --search devs -o table +azdevops security list-groups --project-only +azdevops security search-group --name "MiGrupo" +azdevops security list-members --group "MiGrupo" +azdevops security add-member --target-group Destino --target-group Destino2 --user ana@empresa.com --group MiGrupo +azdevops security remove-member --target-group Destino --user ana@empresa.com --yes +``` + +## Scripting + +* `-o table|json|yaml|tsv`: los comandos de consulta (`get`, `search-group`, `list-groups`) devuelven JSON por defecto; los listados devuelven tabla. +* `--query` (`-q`) aplica una consulta [JMESPath](https://jmespath.org) sobre los datos, igual que `az`: + + ```bash + azdevops pipelines list -q "[?latestBuild.result=='failed'].{id:id, nombre:name}" -o yaml + azdevops variables list -q "[].[id,name]" -o tsv | while IFS=$'\t' read id name; do ...; done + ID=$(azdevops wi create --type Task --title "x" -q id -o tsv) + ``` +* Los datos van a *stdout* y los mensajes (✔, !, ✖) a *stderr*: `azdevops variables get -n MiGrupo | jq '.[0].variables'`. +* Códigos de salida: `0` éxito, `1` error, `130` cancelado por el usuario. +* `--debug` (o `AZDEVOPS_DEBUG=1`) muestra cada petición HTTP con su código y duración. +* `AZDEVOPS_BASE_URL` / `AZDEVOPS_VSSPS_URL` permiten apuntar a otro host (por ejemplo, para pruebas). + +## Autocompletado + +```bash +# bash +source <(azdevops completion bash) +# zsh +azdevops completion zsh > "${fpath[1]}/_azdevops" +# PowerShell +azdevops completion powershell | Out-String | Invoke-Expression +``` + +Completa también nombres de Variable Groups (`--name`) e IDs de pipelines (`--id`) consultando tu proyecto. + +## Desarrollo + +```bash +make test # go test ./... +make vet +``` + +Estructura: `azdevops/` contiene el cliente y las llamadas a la API (sin E/S de consola), `cmd/` los comandos de cobra, `internal/ui` los prompts y el formateo (tablas, JSON/YAML/TSV, JMESPath), e `internal/config` los perfiles y el llavero. El menú interactivo se genera a partir del árbol de comandos, así que los comandos nuevos aparecen en él automáticamente. ## Licencia @@ -168,4 +284,4 @@ Este proyecto está bajo la Licencia Pública General de GNU v3.0. Consulta el a ## Contribuciones -Las contribuciones son bienvenidas. Si deseas colaborar, por favor abre un *issue* para discutir tus ideas o envía un *pull request* con tus cambios. +Las contribuciones son bienvenidas. Si deseas colaborar, abre un *issue* para discutir tus ideas o envía un *pull request* con tus cambios. diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 89666bd..2cec7b1 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,25 +1,73 @@ -# Release v0.0.2-beta +# Release v0.1.0 -Esta versión expande significativamente las capacidades de la CLI, introduciendo la ejecución de pipelines y una suite completa para la gestión de permisos y grupos de seguridad. Se ha puesto un foco especial en la optimización de las llamadas a la API para un rendimiento eficiente. +Esta versión convierte la CLI en una herramienta interactiva y mucho más completa, y reescribe su núcleo para que sea más fiable en scripts y CI. -## ✨ Funcionalidades Nuevas +## ✨ Novedades -### Ejecución de Pipelines (`pipelines run`) +### Modo interactivo +- `azdevops` sin argumentos (o `azdevops interactive`) abre un menú navegable generado a partir de los comandos. +- Todos los comandos preguntan los datos que falten: listas filtrables de Variable Groups, pipelines, ejecuciones, repositorios, conexiones de servicio y grupos de seguridad; los valores secretos se piden sin mostrarse. +- Confirmaciones antes de operaciones destructivas. Se desactiva con `--no-input`, `AZDEVOPS_NO_INPUT` o `CI`. -Se ha añadido un nuevo comando `run` al subcomando `pipelines` que permite: -- **Iniciar un pipeline** por su ID de definición. -- **Esperar su finalización** con el flag `--wait` para flujos de trabajo síncronos. -- **Pasar parámetros** de plantilla YAML usando el flag `--param`. -- **Inyectar variables** en tiempo de ejecución con el flag `--var`, con soporte para variables secretas usando el prefijo `secret:`. +### Perfiles y autenticación +- `azdevops login` valida las credenciales (incluida la identidad, para no aceptar un PAT inválido en organizaciones con proyectos públicos) y las guarda en un perfil. +- El PAT se guarda en el **llavero del sistema** (Keychain, Credential Manager, Secret Service) cuando está disponible (`--store auto|keyring|file`). +- `azdevops config view | use | delete | path` y flags globales `--org`, `--project`, `--pat`, `--profile`. -### Gestión de Seguridad y Permisos (`security`) +### Variable Groups +- `variables list` (tabla con conteo de variables y secretas). +- `variables get --id` y `-o table` con valores secretos enmascarados. +- `variables export` / `variables import` desde `.env` o JSON (`--secret`, `--create`). +- `variables copy`: copia un grupo (también a otro proyecto) pidiendo los valores secretos o con `--secret-value`; `--merge` para fusionar en uno existente. +- `variables diff`: compara dos grupos (dev vs prod, o entre proyectos) con `--exit-code` para CI. -Se introduce un nuevo conjunto de comandos de alto nivel para gestionar la seguridad: -- **`security list-groups`**: Lista todos los grupos de seguridad. Incluye un flag `--search` para filtrar los resultados y maneja la paginación automáticamente para obtener la lista completa. -- **`security search-group`**: Busca un grupo específico por nombre de forma eficiente, ideal para scripts. -- **`security add-member`**: Agrega uno o más usuarios y/o grupos a uno o más grupos de seguridad en una sola operación. +### Pipelines +- `pipelines list` con el último resultado de cada pipeline. +- `pipelines run`: `--name`, `--branch`, `--timeout`, progreso en vivo con la etapa en curso, resumen de tareas fallidas y código de salida 1 si la ejecución falla. +- `pipelines runs`, `pipelines status [--watch]`, `pipelines logs [--failed | --log-id]` y `pipelines cancel`. +- `pipelines update --branch` y `pipelines create --branch` (ahora sí se aplica la rama por defecto). -### Permisos para Variable Groups (`variables set-permissions`) +### Seguridad +- `security list-groups --search` y `--project-only`. +- `security list-members` y `security remove-member`. -Se añade un comando para gestionar los roles en los *Variable Groups*: -- **`variables set-permissions`**: Asigna los roles de **Reader**, **User**, o **Administrator** a múltiples usuarios y/o grupos en múltiples *Variable Groups* de forma masiva. +### Aprobaciones y environments +- `approvals list | approve | reject` para las aprobaciones de despliegue (con selección múltiple en modo interactivo). +- `environments list | deployments`. + +### Work items (Boards) +- `workitems list | get | create | update | delete` (alias `wi`): por defecto lista tus work items abiertos; filtros por tipo, estado, texto, área o WIQL; `create --parent`, `--assigned-to @me`, `update --state --comment` y cambio de estado guiado. + +### Conexiones de servicio y agentes +- `service-connections list | get | history | share` (alias `sc`). +- `agents pools | list | enable | disable`: estado de los agentes, trabajo actual y último resultado. + +### Archivos seguros +- `securefiles list | get | upload | delete | authorize | set-role` (alias `sf`). +- `securefiles replace`: reemplaza el contenido conservando nombre, propiedades, pipelines autorizados, roles asignados, herencia de permisos y aprobaciones/checks, con `--dry-run`, `--keep-old` y reversión automática si algo falla. + +### Extensiones de Azure DevOps +- `extension init`: crea una extensión lista para usar (tareas TypeScript, jest, ncc, azde-scripts, tfx, config dev/release, iconos de ejemplo, UUIDs y conexión de servicio opcional), en modo guiado o con flags. +- `extension add-task | bump | validate | reset-ids | uuid | pack | publish` para mantenerla, editando los JSON sin alterar su formato. + +### Navegador +- `azdevops open [project|pipeline|run|variables|workitem|repo|approvals|…]`, con `--print` para solo obtener la URL. + +### General +- Salida `-o table|json|yaml|tsv` y `--query` con JMESPath (como en `az`), autocompletado dinámico (nombres de Variable Groups, IDs de pipelines), `--version`, `--debug`. + +## 🛠 Mejoras y correcciones +- Cliente HTTP centralizado: se valida el código de respuesta de **todas** las llamadas (antes muchas `GET` ignoraban errores), mensajes de error legibles de la API, detección de PAT inválido (respuesta 203 de Azure DevOps) y reintentos ante 429/5xx. +- Los nombres con espacios o caracteres especiales ahora se codifican correctamente en las URLs. +- `pipelines run --wait` ya no se queda esperando para siempre ni devuelve éxito si falla la consulta de estado. +- `variables set-permissions` aplica a todos los grupos que coinciden con el nombre (antes solo al primero). +- `pipelines update` ya no falla con definiciones sin `process`/`repository`. +- Se corrigieron fugas de conexiones (`defer` dentro de bucles). +- CI con `gofmt`, `go vet`, tests y build; la versión se inyecta en el binario al compilar. + +## ⚠️ Cambios de comportamiento +- Los mensajes de estado (✔/✖) se escriben en *stderr*; *stdout* queda solo para datos. +- `pipelines run --wait` devuelve código 1 si la ejecución termina en `failed` o `canceled`. +- Sin terminal, las eliminaciones exigen `--yes` en lugar de leer la confirmación de stdin. +- `variables get` y `pipelines get --name` devuelven error (código 1) si no encuentran resultados. +- `pipelines get-by-id` queda obsoleto: usa `pipelines get --id`. diff --git a/azdevops/agent/agent.go b/azdevops/agent/agent.go new file mode 100644 index 0000000..03d5464 --- /dev/null +++ b/azdevops/agent/agent.go @@ -0,0 +1,60 @@ +package agent + +import ( + "azuredevops/azdevops" + "azuredevops/models" + "fmt" + "net/url" + "sort" + "strings" +) + +// ListPools lista los agent pools de la organización. +func ListPools(client *azdevops.Client) ([]models.AgentPool, error) { + var result struct { + Value []models.AgentPool `json:"value"` + } + if err := client.Do("GET", client.OrgURL("distributedtask/pools", url.Values{"api-version": {"7.1"}}), nil, &result); err != nil { + return nil, fmt.Errorf("error al listar los agent pools: %w", err) + } + sort.Slice(result.Value, func(i, j int) bool { return result.Value[i].Name < result.Value[j].Name }) + return result.Value, nil +} + +// FindPool busca un pool por nombre (sin distinguir mayúsculas) o ID. +func FindPool(client *azdevops.Client, nameOrID string) (*models.AgentPool, error) { + pools, err := ListPools(client) + if err != nil { + return nil, err + } + for i, p := range pools { + if strings.EqualFold(p.Name, nameOrID) || fmt.Sprint(p.ID) == nameOrID { + return &pools[i], nil + } + } + return nil, fmt.Errorf("no se encontró el agent pool '%s'", nameOrID) +} + +// ListAgents lista los agentes de un pool con su trabajo actual y el último completado. +func ListAgents(client *azdevops.Client, poolID int) ([]models.Agent, error) { + q := url.Values{"api-version": {"7.1"}, "includeAssignedRequest": {"true"}, "includeLastCompletedRequest": {"true"}} + var result struct { + Value []models.Agent `json:"value"` + } + if err := client.Do("GET", client.OrgURL(fmt.Sprintf("distributedtask/pools/%d/agents", poolID), q), nil, &result); err != nil { + return nil, fmt.Errorf("error al listar los agentes: %w", err) + } + sort.Slice(result.Value, func(i, j int) bool { return result.Value[i].Name < result.Value[j].Name }) + return result.Value, nil +} + +// SetAgentEnabled habilita o deshabilita un agente. +func SetAgentEnabled(client *azdevops.Client, poolID, agentID int, enabled bool) (*models.Agent, error) { + var updated models.Agent + body := map[string]any{"id": agentID, "enabled": enabled} + u := client.OrgURL(fmt.Sprintf("distributedtask/pools/%d/agents/%d", poolID, agentID), url.Values{"api-version": {"7.1"}}) + if err := client.Do("PATCH", u, body, &updated); err != nil { + return nil, fmt.Errorf("error al actualizar el agente: %w", err) + } + return &updated, nil +} diff --git a/azdevops/client.go b/azdevops/client.go index 0a0567a..d0a83d2 100644 --- a/azdevops/client.go +++ b/azdevops/client.go @@ -1,44 +1,311 @@ package azdevops import ( + "bytes" "encoding/base64" - "log" + "encoding/json" + "errors" + "fmt" + "io" "net/http" + "net/url" "os" + "strconv" + "strings" "time" ) +const ( + DefaultBaseURL = "https://dev.azure.com" + DefaultVSSPSBaseURL = "https://vssps.dev.azure.com" +) + +// Version se inyecta en tiempo de compilación (ver Makefile) y se usa en el User-Agent. +var Version = "dev" + type Client struct { Org string Project string PAT string HTTP *http.Client + + // BaseURL y VSSPSBaseURL permiten apuntar a otro host (por ejemplo en pruebas). + BaseURL string + VSSPSBaseURL string + + // MaxRetries es el número de reintentos ante 429/5xx. + MaxRetries int + // Debug imprime cada petición en stderr. + Debug bool } func NewClient(org, project, pat string) *Client { return &Client{ - Org: org, - Project: project, - PAT: pat, - HTTP: &http.Client{Timeout: 10 * time.Second}, + Org: org, + Project: project, + PAT: pat, + HTTP: &http.Client{Timeout: 30 * time.Second}, + BaseURL: DefaultBaseURL, + VSSPSBaseURL: DefaultVSSPSBaseURL, + MaxRetries: 3, } } -func GetClientFromEnv() *Client { +// GetClientFromEnv construye un cliente a partir de AZURE_ORG, AZURE_PROJECT y AZURE_PAT. +func GetClientFromEnv() (*Client, error) { org := os.Getenv("AZURE_ORG") project := os.Getenv("AZURE_PROJECT") pat := os.Getenv("AZURE_PAT") if org == "" || project == "" || pat == "" { - log.Fatal("AZURE_ORG, AZURE_PROJECT y AZURE_PAT deben estar definidos en las variables de entorno") + return nil, errors.New("AZURE_ORG, AZURE_PROJECT y AZURE_PAT deben estar definidos en las variables de entorno") } - return NewClient(org, project, pat) + return NewClient(org, project, pat), nil } -func (c *Client) authHeader() string { - return "Basic " + base64.StdEncoding.EncodeToString([]byte(":"+c.PAT)) -} func (c *Client) AuthHeader() string { return "Basic " + base64.StdEncoding.EncodeToString([]byte(":"+c.PAT)) } + +// ProjectURL arma https://dev.azure.com/{org}/{project}/_apis/{path}?{query}. +func (c *Client) ProjectURL(path string, query url.Values) string { + return buildURL(fmt.Sprintf("%s/%s/%s/_apis/%s", c.BaseURL, url.PathEscape(c.Org), url.PathEscape(c.Project), path), query) +} + +// OrgURL arma https://dev.azure.com/{org}/_apis/{path}?{query}. +func (c *Client) OrgURL(path string, query url.Values) string { + return buildURL(fmt.Sprintf("%s/%s/_apis/%s", c.BaseURL, url.PathEscape(c.Org), path), query) +} + +// VSSPSURL arma https://vssps.dev.azure.com/{org}/_apis/{path}?{query}. +func (c *Client) VSSPSURL(path string, query url.Values) string { + return buildURL(fmt.Sprintf("%s/%s/_apis/%s", c.VSSPSBaseURL, url.PathEscape(c.Org), path), query) +} + +// WebURL arma una URL del portal web del proyecto, p. ej. "_build?definitionId=1". +func (c *Client) WebURL(path string) string { + return fmt.Sprintf("%s/%s/%s/%s", c.BaseURL, url.PathEscape(c.Org), url.PathEscape(c.Project), path) +} + +// VariableGroupWebURL devuelve el enlace del portal a un Variable Group. +func (c *Client) VariableGroupWebURL(id int) string { + return c.WebURL(fmt.Sprintf("_library?itemType=variableGroups&view=VariableGroupView&variableGroupId=%d", id)) +} + +func buildURL(base string, query url.Values) string { + if len(query) == 0 { + return base + } + return base + "?" + query.Encode() +} + +// APIError representa una respuesta de error de la API de Azure DevOps. +type APIError struct { + StatusCode int + Message string + TypeKey string + Method string + URL string +} + +func (e *APIError) Error() string { + msg := e.Message + if msg == "" { + msg = http.StatusText(e.StatusCode) + } + return fmt.Sprintf("Azure DevOps respondió %d: %s", e.StatusCode, msg) +} + +// IsNotFound indica si el error es un 404 de la API. +func IsNotFound(err error) bool { + var apiErr *APIError + return errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusNotFound +} + +// Do ejecuta una petición contra la API. body (si no es nil) se serializa a JSON; +// out (si no es nil) recibe la respuesta decodificada. Un *[]byte o *string en out +// recibe el cuerpo sin decodificar. +func (c *Client) Do(method, rawURL string, body, out any) error { + return c.DoContentType(method, rawURL, "application/json", body, out) +} + +// DoContentType es como Do pero permite otro Content-Type (p. ej. application/json-patch+json). +// Si body es []byte se envía tal cual (p. ej. un archivo con application/octet-stream). +func (c *Client) DoContentType(method, rawURL, contentType string, body, out any) error { + var payload []byte + if raw, ok := body.([]byte); ok { + payload = raw + } else if body != nil { + var err error + if payload, err = json.Marshal(body); err != nil { + return fmt.Errorf("error al serializar el payload: %w", err) + } + } + + var resp *http.Response + var respBody []byte + for attempt := 0; ; attempt++ { + req, err := http.NewRequest(method, rawURL, bytes.NewReader(payload)) + if err != nil { + return fmt.Errorf("petición inválida: %w", err) + } + req.Header.Set("Authorization", c.AuthHeader()) + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", "azdevops-cli/"+Version) + if body != nil { + req.Header.Set("Content-Type", contentType) + } + + start := time.Now() + resp, err = c.HTTP.Do(req) + if err != nil { + if attempt < c.MaxRetries && isIdempotent(method) { + c.debugf("%s %s -> error de red (%v), reintentando", method, rawURL, err) + time.Sleep(backoff(attempt, nil)) + continue + } + return fmt.Errorf("la solicitud %s %s falló: %w", method, rawURL, err) + } + respBody, err = io.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + return fmt.Errorf("error al leer la respuesta: %w", err) + } + c.debugf("%s %s -> %d (%s)", method, rawURL, resp.StatusCode, time.Since(start).Round(time.Millisecond)) + + if shouldRetry(method, resp.StatusCode) && attempt < c.MaxRetries { + time.Sleep(backoff(attempt, resp)) + continue + } + break + } + + if err := checkResponse(method, rawURL, resp, respBody); err != nil { + return err + } + + if out == nil || len(respBody) == 0 { + return nil + } + switch o := out.(type) { + case *[]byte: + *o = respBody + return nil + case *string: + *o = string(respBody) + return nil + } + if err := json.Unmarshal(respBody, out); err != nil { + return fmt.Errorf("error al decodificar la respuesta de %s: %w", rawURL, err) + } + return nil +} + +// GetWithHeaders hace un GET como Do pero devuelve también las cabeceras (paginación). +func (c *Client) GetWithHeaders(rawURL string, out any) (http.Header, error) { + req, err := http.NewRequest(http.MethodGet, rawURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", c.AuthHeader()) + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", "azdevops-cli/"+Version) + + resp, err := c.HTTP.Do(req) + if err != nil { + return nil, fmt.Errorf("la solicitud GET %s falló: %w", rawURL, err) + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + c.debugf("GET %s -> %d", rawURL, resp.StatusCode) + if err := checkResponse(http.MethodGet, rawURL, resp, body); err != nil { + return nil, err + } + if out != nil && len(body) > 0 { + if err := json.Unmarshal(body, out); err != nil { + return nil, fmt.Errorf("error al decodificar la respuesta: %w", err) + } + } + return resp.Header, nil +} + +func checkResponse(method, rawURL string, resp *http.Response, body []byte) error { + // Con un PAT inválido Azure DevOps responde 203 con la página HTML de login. + if resp.StatusCode == http.StatusNonAuthoritativeInfo || + (resp.StatusCode < 300 && strings.HasPrefix(resp.Header.Get("Content-Type"), "text/html")) { + return &APIError{StatusCode: http.StatusUnauthorized, Method: method, URL: rawURL, + Message: "autenticación fallida: el PAT es inválido, expiró o no tiene acceso a la organización"} + } + if resp.StatusCode < 300 { + return nil + } + + apiErr := &APIError{StatusCode: resp.StatusCode, Method: method, URL: rawURL} + var parsed struct { + Message string `json:"message"` + TypeKey string `json:"typeKey"` + } + if json.Unmarshal(body, &parsed) == nil && parsed.Message != "" { + apiErr.Message = parsed.Message + apiErr.TypeKey = parsed.TypeKey + } else { + apiErr.Message = strings.TrimSpace(truncate(string(body), 300)) + } + switch resp.StatusCode { + case http.StatusUnauthorized: + apiErr.Message = "no autorizado: revisa tu PAT (" + apiErr.Message + ")" + case http.StatusForbidden: + apiErr.Message = "acceso denegado: el PAT no tiene los permisos/scopes necesarios (" + apiErr.Message + ")" + } + return apiErr +} + +func isIdempotent(method string) bool { + switch method { + case http.MethodGet, http.MethodHead, http.MethodPut, http.MethodDelete, http.MethodOptions: + return true + } + return false +} + +func shouldRetry(method string, status int) bool { + if status == http.StatusTooManyRequests { + return true + } + return status >= 500 && isIdempotent(method) +} + +func backoff(attempt int, resp *http.Response) time.Duration { + if resp != nil { + if s := resp.Header.Get("Retry-After"); s != "" { + if secs, err := strconv.Atoi(s); err == nil && secs >= 0 { + return time.Duration(secs) * time.Second + } + } + } + return time.Duration(1<login")) + }) + err := c.Do("GET", c.ProjectURL("x", nil), nil, &struct{}{}) + if err == nil || !strings.Contains(err.Error(), "PAT") { + t.Fatalf("se esperaba error de autenticación, got %v", err) + } +} + +func TestDoRetriesOn429(t *testing.T) { + var calls atomic.Int32 + c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + if calls.Add(1) == 1 { + w.Header().Set("Retry-After", "0") + w.WriteHeader(http.StatusTooManyRequests) + return + } + w.Write([]byte(`{}`)) + }) + if err := c.Do("POST", c.ProjectURL("x", nil), map[string]int{}, nil); err != nil { + t.Fatal(err) + } + if calls.Load() != 2 { + t.Errorf("llamadas = %d, se esperaban 2", calls.Load()) + } +} + +func TestDoDoesNotRetryPOSTOn500(t *testing.T) { + var calls atomic.Int32 + c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + w.WriteHeader(http.StatusInternalServerError) + }) + if err := c.Do("POST", c.ProjectURL("x", nil), nil, nil); err == nil { + t.Fatal("se esperaba error") + } + if calls.Load() != 1 { + t.Errorf("un POST no debe reintentarse ante 500 (llamadas = %d)", calls.Load()) + } +} + +func TestDoContentTypeRawBody(t *testing.T) { + c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + if ct := r.Header.Get("Content-Type"); ct != "application/octet-stream" { + t.Errorf("Content-Type = %s", ct) + } + b := make([]byte, 10) + n, _ := r.Body.Read(b) + if string(b[:n]) != "\x00bin" { + t.Errorf("body = %q", b[:n]) + } + w.Write([]byte(`{}`)) + }) + if err := c.DoContentType("POST", c.ProjectURL("x", nil), "application/octet-stream", []byte("\x00bin"), nil); err != nil { + t.Fatal(err) + } +} diff --git a/azdevops/environment/environment.go b/azdevops/environment/environment.go new file mode 100644 index 0000000..32fd2ad --- /dev/null +++ b/azdevops/environment/environment.go @@ -0,0 +1,71 @@ +package environment + +import ( + "azuredevops/azdevops" + "azuredevops/models" + "fmt" + "net/url" + "sort" + "strconv" +) + +// ListEnvironments lista los environments del proyecto. +func ListEnvironments(client *azdevops.Client) ([]models.Environment, error) { + var result struct { + Value []models.Environment `json:"value"` + } + q := url.Values{"api-version": {"7.1"}, "$top": {"500"}} + if err := client.Do("GET", client.ProjectURL("pipelines/environments", q), nil, &result); err != nil { + return nil, fmt.Errorf("error al listar los environments: %w", err) + } + sort.Slice(result.Value, func(i, j int) bool { return result.Value[i].Name < result.Value[j].Name }) + return result.Value, nil +} + +// ListDeployments devuelve los despliegues más recientes de un environment. +func ListDeployments(client *azdevops.Client, environmentID, top int) ([]models.EnvironmentDeployment, error) { + var result struct { + Value []models.EnvironmentDeployment `json:"value"` + } + q := url.Values{"api-version": {"7.1"}, "top": {strconv.Itoa(top)}} + path := fmt.Sprintf("pipelines/environments/%d/environmentdeploymentrecords", environmentID) + if err := client.Do("GET", client.ProjectURL(path, q), nil, &result); err != nil { + return nil, fmt.Errorf("error al listar los despliegues: %w", err) + } + return result.Value, nil +} + +// ListApprovals lista las aprobaciones del proyecto en el estado indicado (p. ej. "pending"). +func ListApprovals(client *azdevops.Client, state string) ([]models.Approval, error) { + q := url.Values{"api-version": {"7.1"}, "$expand": {"steps"}} + if state != "" { + q.Set("state", state) + } + var result struct { + Value []models.Approval `json:"value"` + } + if err := client.Do("GET", client.ProjectURL("pipelines/approvals", q), nil, &result); err != nil { + return nil, fmt.Errorf("error al listar las aprobaciones: %w", err) + } + return result.Value, nil +} + +// UpdateApprovals aprueba o rechaza (status "approved" / "rejected") las aprobaciones indicadas. +func UpdateApprovals(client *azdevops.Client, ids []string, status, comment string) ([]models.Approval, error) { + type update struct { + ApprovalID string `json:"approvalId"` + Status string `json:"status"` + Comment string `json:"comment,omitempty"` + } + body := make([]update, len(ids)) + for i, id := range ids { + body[i] = update{ApprovalID: id, Status: status, Comment: comment} + } + var result struct { + Value []models.Approval `json:"value"` + } + if err := client.Do("PATCH", client.ProjectURL("pipelines/approvals", url.Values{"api-version": {"7.1"}}), body, &result); err != nil { + return nil, fmt.Errorf("error al actualizar las aprobaciones: %w", err) + } + return result.Value, nil +} diff --git a/azdevops/git/repos.go b/azdevops/git/repos.go new file mode 100644 index 0000000..a623d29 --- /dev/null +++ b/azdevops/git/repos.go @@ -0,0 +1,30 @@ +package git + +import ( + "azuredevops/azdevops" + "azuredevops/models" + "net/url" + "sort" +) + +// ListRepositories lista los repositorios Git (Azure Repos) del proyecto. +func ListRepositories(client *azdevops.Client) ([]models.GitRepository, error) { + var result struct { + Value []models.GitRepository `json:"value"` + } + if err := client.Do("GET", client.ProjectURL("git/repositories", url.Values{"api-version": {"7.1"}}), nil, &result); err != nil { + return nil, err + } + sort.Slice(result.Value, func(i, j int) bool { return result.Value[i].Name < result.Value[j].Name }) + return result.Value, nil +} + +// GetRepository obtiene un repositorio por nombre o ID. +func GetRepository(client *azdevops.Client, nameOrID string) (*models.GitRepository, error) { + var repo models.GitRepository + u := client.ProjectURL("git/repositories/"+url.PathEscape(nameOrID), url.Values{"api-version": {"7.1"}}) + if err := client.Do("GET", u, nil, &repo); err != nil { + return nil, err + } + return &repo, nil +} diff --git a/azdevops/organization/get_project.go b/azdevops/organization/get_project.go deleted file mode 100644 index 92922d5..0000000 --- a/azdevops/organization/get_project.go +++ /dev/null @@ -1,51 +0,0 @@ -package organization - -import ( - "azuredevops/azdevops" - "azuredevops/models" - "encoding/json" - "fmt" - "net/http" -) - -func GetOrganizationInfo(client *azdevops.Client) (*models.Project, error) { - url := fmt.Sprintf("https://dev.azure.com/%s/_apis/projects/%s?api-version=7.1", client.Org, client.Project) - req, _ := http.NewRequest("GET", url, nil) - req.Header.Add("Authorization", client.AuthHeader()) - - resp, err := client.HTTP.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - var result models.Project - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return nil, err - } - - return &result, nil -} - -func GetProjectDescriptor(client *azdevops.Client) (string, error) { - project, err := GetOrganizationInfo(client) - if err != nil { - return "", err - } - url := fmt.Sprintf("https://vssps.dev.azure.com/%s/_apis/graph/descriptors/%s", client.Org, project.ID) - req, _ := http.NewRequest("GET", url, nil) - req.Header.Add("Authorization", client.AuthHeader()) - - resp, err := client.HTTP.Do(req) - if err != nil { - return "", err - } - defer resp.Body.Close() - - var result models.GraphDescriptor - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return "", fmt.Errorf("error al decodificar la respuesta del descriptor: %w", err) - } - - return result.ID, nil -} diff --git a/azdevops/organization/project.go b/azdevops/organization/project.go new file mode 100644 index 0000000..b828b39 --- /dev/null +++ b/azdevops/organization/project.go @@ -0,0 +1,76 @@ +package organization + +import ( + "azuredevops/azdevops" + "azuredevops/models" + "fmt" + "net/url" + "strings" +) + +// GetProject devuelve la información del proyecto configurado en el cliente. +func GetProject(client *azdevops.Client) (*models.Project, error) { + var result models.Project + u := client.OrgURL("projects/"+url.PathEscape(client.Project), url.Values{"api-version": {"7.1"}}) + if err := client.Do("GET", u, nil, &result); err != nil { + return nil, fmt.Errorf("no se pudo obtener el proyecto '%s': %w", client.Project, err) + } + return &result, nil +} + +// ListProjects devuelve los proyectos de la organización. +func ListProjects(client *azdevops.Client) ([]models.Project, error) { + var result struct { + Value []models.Project `json:"value"` + } + u := client.OrgURL("projects", url.Values{"api-version": {"7.1"}, "$top": {"500"}}) + if err := client.Do("GET", u, nil, &result); err != nil { + return nil, err + } + return result.Value, nil +} + +// GetProjectDescriptor devuelve el descriptor de Graph del proyecto. +func GetProjectDescriptor(client *azdevops.Client) (string, error) { + project, err := GetProject(client) + if err != nil { + return "", err + } + var result models.GraphDescriptor + if err := client.Do("GET", client.VSSPSURL("graph/descriptors/"+project.ID, nil), nil, &result); err != nil { + return "", fmt.Errorf("error al obtener el descriptor del proyecto: %w", err) + } + return result.ID, nil +} + +// User es el usuario autenticado con el PAT. +type User struct { + ID string + DisplayName string + Email string +} + +// CurrentUser devuelve el usuario dueño del PAT. +func CurrentUser(client *azdevops.Client) (*User, error) { + var data struct { + AuthenticatedUser struct { + ID string `json:"id"` + Descriptor string `json:"descriptor"` + ProviderDisplayName string `json:"providerDisplayName"` + Properties struct { + Account struct { + Value string `json:"$value"` + } `json:"Account"` + } `json:"properties"` + } `json:"authenticatedUser"` + } + if err := client.Do("GET", client.OrgURL("connectionData", nil), nil, &data); err != nil { + return nil, fmt.Errorf("no se pudo obtener el usuario actual: %w", err) + } + u := data.AuthenticatedUser + // En organizaciones con proyectos públicos un PAT inválido no da 401: la sesión es anónima. + if strings.HasPrefix(u.Descriptor, "System:PublicAccess") { + return nil, &azdevops.APIError{StatusCode: 401, Message: "autenticación fallida: el PAT es inválido o expiró (acceso anónimo)"} + } + return &User{ID: u.ID, DisplayName: u.ProviderDisplayName, Email: u.Properties.Account.Value}, nil +} diff --git a/azdevops/pipeline/builds.go b/azdevops/pipeline/builds.go new file mode 100644 index 0000000..7f84c53 --- /dev/null +++ b/azdevops/pipeline/builds.go @@ -0,0 +1,76 @@ +package pipeline + +import ( + "azuredevops/azdevops" + "azuredevops/models" + "fmt" + "net/url" + "strconv" + "strings" +) + +// GetBuildByID obtiene una ejecución (build) por su ID. +func GetBuildByID(client *azdevops.Client, buildID int) (*models.Build, error) { + var build models.Build + u := client.ProjectURL(fmt.Sprintf("build/builds/%d", buildID), url.Values{"api-version": {"7.1"}}) + if err := client.Do("GET", u, nil, &build); err != nil { + return nil, err + } + return &build, nil +} + +// ListBuilds lista las ejecuciones más recientes. definitionID y branch son opcionales. +func ListBuilds(client *azdevops.Client, definitionID, top int, branch string) ([]models.Build, error) { + q := url.Values{"api-version": {"7.1"}, "queryOrder": {"queueTimeDescending"}} + if top > 0 { + q.Set("$top", strconv.Itoa(top)) + } + if definitionID > 0 { + q.Set("definitions", strconv.Itoa(definitionID)) + } + if branch != "" { + if !strings.HasPrefix(branch, "refs/") { + branch = "refs/heads/" + branch + } + q.Set("branchName", branch) + } + var result struct { + Value []models.Build `json:"value"` + } + if err := client.Do("GET", client.ProjectURL("build/builds", q), nil, &result); err != nil { + return nil, fmt.Errorf("error al listar las ejecuciones: %w", err) + } + return result.Value, nil +} + +// CancelBuild solicita la cancelación de una ejecución en curso. +func CancelBuild(client *azdevops.Client, buildID int) error { + u := client.ProjectURL(fmt.Sprintf("build/builds/%d", buildID), url.Values{"api-version": {"7.1"}}) + if err := client.Do("PATCH", u, map[string]string{"status": "cancelling"}, nil); err != nil { + return fmt.Errorf("error al cancelar el build %d: %w", buildID, err) + } + return nil +} + +// GetTimeline devuelve las etapas, jobs y tareas de una ejecución. +func GetTimeline(client *azdevops.Client, buildID int) (*models.Timeline, error) { + var timeline models.Timeline + u := client.ProjectURL(fmt.Sprintf("build/builds/%d/timeline", buildID), url.Values{"api-version": {"7.1"}}) + if err := client.Do("GET", u, nil, &timeline); err != nil { + return nil, fmt.Errorf("error al obtener el timeline del build %d: %w", buildID, err) + } + return &timeline, nil +} + +// GetLog devuelve el texto de un log de una ejecución. +func GetLog(client *azdevops.Client, buildID, logID int) (string, error) { + // Con Accept: application/json la API devuelve las líneas en un arreglo. + var result struct { + Value []string `json:"value"` + } + u := client.ProjectURL(fmt.Sprintf("build/builds/%d/logs/%d", buildID, logID), url.Values{"api-version": {"7.1"}}) + if err := client.Do("GET", u, nil, &result); err != nil { + return "", fmt.Errorf("error al obtener el log %d del build %d: %w", logID, buildID, err) + } + return strings.Join(result.Value, "\n"), nil +} diff --git a/azdevops/pipeline/create.go b/azdevops/pipeline/create.go index 3f9874d..1ff58c1 100644 --- a/azdevops/pipeline/create.go +++ b/azdevops/pipeline/create.go @@ -2,66 +2,59 @@ package pipeline import ( "azuredevops/azdevops" + "azuredevops/azdevops/git" "azuredevops/models" - "bytes" - "encoding/json" "fmt" - "io" - "net/http" + "net/url" ) -func CreatePipeline(client *azdevops.Client, name, repoType, repoName, branch, yamlPath, folder, serviceConnectionID string) (*models.Pipeline, error) { - repo := models.PipelineRepository{ - FullName: repoName, - Type: repoType, - } +type CreateOptions struct { + Name string + RepoType string // "azureReposGit" o "gitHub" + RepoName string // para GitHub: "org/repo" + Branch string // rama por defecto (opcional) + YAMLPath string + Folder string + ServiceConnectionID string +} - if serviceConnectionID != "" { - repo.Connection = &models.Properties{ - ID: serviceConnectionID, +// CreatePipeline crea un pipeline YAML. Si se indica Branch, se fija como rama por defecto. +func CreatePipeline(client *azdevops.Client, opts CreateOptions) (*models.Pipeline, error) { + repo := models.PipelineRepository{ + FullName: opts.RepoName, + Type: opts.RepoType, + } + if opts.RepoType == "azureReposGit" { + // La API espera el ID del repositorio para Azure Repos. + if r, err := git.GetRepository(client, opts.RepoName); err == nil { + repo.ID = r.ID + repo.Name = r.Name } } - - config := models.Configuration{ - Type: "yaml", - Path: yamlPath, - Repository: repo, + if opts.ServiceConnectionID != "" { + repo.Connection = &models.Properties{ID: opts.ServiceConnectionID} } - pipeline := models.PipelineCreate{ - Name: name, - Folder: folder, - Configuration: config, + body := models.PipelineCreate{ + Name: opts.Name, + Folder: opts.Folder, + Configuration: models.Configuration{ + Type: "yaml", + Path: opts.YAMLPath, + Repository: repo, + }, } - payload, err := json.Marshal(pipeline) - if err != nil { - return nil, fmt.Errorf("error al serializar el payload: %w", err) + var created models.Pipeline + u := client.ProjectURL("pipelines", url.Values{"api-version": {"7.1-preview.1"}}) + if err := client.Do("POST", u, body, &created); err != nil { + return nil, fmt.Errorf("error al crear el pipeline: %w", err) } - url := fmt.Sprintf("https://dev.azure.com/%s/%s/_apis/pipelines?api-version=7.1-preview.1", client.Org, client.Project) - req, _ := http.NewRequest("POST", url, bytes.NewBuffer(payload)) - req.Header.Set("Authorization", client.AuthHeader()) - req.Header.Set("Content-Type", "application/json") - - resp, err := client.HTTP.Do(req) - if err != nil { - return nil, fmt.Errorf("la solicitud falló: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("error al crear el pipeline: %s", string(body)) - } - - var newPipeline models.Pipeline - if err := json.NewDecoder(resp.Body).Decode(&newPipeline); err != nil { - return nil, fmt.Errorf("error al decodificar la respuesta: %w", err) + if opts.Branch != "" { + if _, err := UpdateBuildDefinition(client, created.ID, UpdateOptions{Branch: opts.Branch}); err != nil { + return &created, fmt.Errorf("pipeline creado (ID %d) pero no se pudo fijar la rama por defecto: %w", created.ID, err) + } } - - fmt.Printf("✔ Pipeline '%s' creado con éxito (ID: %d)\n", newPipeline.Name, newPipeline.ID) - fmt.Printf("🔗 %s\n", newPipeline.Links.Web.Href) - - return &newPipeline, nil + return &created, nil } diff --git a/azdevops/pipeline/delete.go b/azdevops/pipeline/delete.go index 1d13bf4..bcdf141 100644 --- a/azdevops/pipeline/delete.go +++ b/azdevops/pipeline/delete.go @@ -3,73 +3,44 @@ package pipeline import ( "azuredevops/azdevops" "azuredevops/models" - "encoding/json" "fmt" - "net/http" + "net/url" "strconv" + "strings" ) -func DeletePipeline(client *azdevops.Client, pipelineID int, autoconfirmation bool) error { +// DeletePipeline elimina un pipeline junto con sus retenciones. Devuelve cuántas +// retenciones se eliminaron. +func DeletePipeline(client *azdevops.Client, pipelineID int) (int, error) { leases, err := getRetentionLeases(client, pipelineID) if err != nil { - return fmt.Errorf("no se pudieron obtener las retenciones: %w", err) + return 0, fmt.Errorf("no se pudieron obtener las retenciones: %w", err) } if len(leases) > 0 { - fmt.Printf("Se encontraron %d retenciones en el pipeline %d. Eliminando...\n", len(leases), pipelineID) - leaseIds := "" + ids := make([]string, len(leases)) for i, lease := range leases { - leaseIds += strconv.Itoa(lease.LeaseID) - if i < len(leases)-1 { - leaseIds += "," - } + ids[i] = strconv.Itoa(lease.LeaseID) } - - url := fmt.Sprintf("https://dev.azure.com/%s/%s/_apis/build/retention/leases?ids=%s&api-version=7.1-preview.1", client.Org, client.Project, leaseIds) - req, _ := http.NewRequest("DELETE", url, nil) - req.Header.Add("Authorization", client.AuthHeader()) - resp, err := client.HTTP.Do(req) - if err != nil { - return fmt.Errorf("error al eliminar las retenciones: %w", err) - } - resp.Body.Close() - if resp.StatusCode != http.StatusNoContent { - return fmt.Errorf("código de estado inesperado al eliminar retenciones: %d", resp.StatusCode) + q := url.Values{"ids": {strings.Join(ids, ",")}, "api-version": {"7.1-preview.1"}} + if err := client.Do("DELETE", client.ProjectURL("build/retention/leases", q), nil, nil); err != nil { + return 0, fmt.Errorf("error al eliminar las retenciones: %w", err) } - fmt.Println("✔ Retenciones eliminadas con éxito.") } - url := fmt.Sprintf("https://dev.azure.com/%s/%s/_apis/build/definitions/%d?api-version=7.1", client.Org, client.Project, pipelineID) - req, _ := http.NewRequest("DELETE", url, nil) - req.Header.Add("Authorization", client.AuthHeader()) - resp, err := client.HTTP.Do(req) - if err != nil { - return fmt.Errorf("error al eliminar el pipeline: %w", err) + u := client.ProjectURL(fmt.Sprintf("build/definitions/%d", pipelineID), url.Values{"api-version": {"7.1"}}) + if err := client.Do("DELETE", u, nil, nil); err != nil { + return len(leases), fmt.Errorf("error al eliminar el pipeline %d: %w", pipelineID, err) } - defer resp.Body.Close() - if resp.StatusCode != http.StatusNoContent { - return fmt.Errorf("código de estado inesperado al eliminar el pipeline: %d", resp.StatusCode) - } - - fmt.Printf("✔ Pipeline con ID %d eliminado correctamente.\n", pipelineID) - return nil + return len(leases), nil } func getRetentionLeases(client *azdevops.Client, pipelineID int) ([]models.RetentionLease, error) { - url := fmt.Sprintf("https://dev.azure.com/%s/%s/_apis/build/retention/leases?definitionId=%d&api-version=7.1-preview.1", client.Org, client.Project, pipelineID) - req, _ := http.NewRequest("GET", url, nil) - req.Header.Add("Authorization", client.AuthHeader()) - - resp, err := client.HTTP.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - + q := url.Values{"definitionId": {strconv.Itoa(pipelineID)}, "api-version": {"7.1-preview.1"}} var result struct { Value []models.RetentionLease `json:"value"` } - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + if err := client.Do("GET", client.ProjectURL("build/retention/leases", q), nil, &result); err != nil { return nil, err } return result.Value, nil diff --git a/azdevops/pipeline/get.go b/azdevops/pipeline/get.go index 387281e..5d7507c 100644 --- a/azdevops/pipeline/get.go +++ b/azdevops/pipeline/get.go @@ -3,52 +3,54 @@ package pipeline import ( "azuredevops/azdevops" "azuredevops/models" - "encoding/json" "fmt" - "net/http" + "net/url" + "sort" ) -func GetBuildDefinitionByName(client *azdevops.Client, name string) ([]models.BuildDefinition, error) { - url := fmt.Sprintf("https://dev.azure.com/%s/%s/_apis/build/definitions?name=%s&api-version=7.1", client.Org, client.Project, name) - req, _ := http.NewRequest("GET", url, nil) - req.Header.Add("Authorization", client.AuthHeader()) - - resp, err := client.HTTP.Do(req) - if err != nil { - return nil, err +// ListDefinitions lista los pipelines del proyecto. name admite comodines y folder +// filtra por carpeta (p. ej. "\\infra"). Ambos son opcionales. +func ListDefinitions(client *azdevops.Client, name, folder string) ([]models.BuildDefinition, error) { + q := url.Values{"api-version": {"7.1"}, "includeLatestBuilds": {"true"}} + if name != "" { + q.Set("name", name) + } + if folder != "" { + q.Set("path", folder) } - defer resp.Body.Close() - var result struct { Value []models.BuildDefinition `json:"value"` - Count int `json:"count"` } - - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return nil, fmt.Errorf("error al decodificar la lista de definiciones: %w", err) + if err := client.Do("GET", client.ProjectURL("build/definitions", q), nil, &result); err != nil { + return nil, fmt.Errorf("error al listar los pipelines: %w", err) } + sort.Slice(result.Value, func(i, j int) bool { + return deref(result.Value[i].Path)+deref(result.Value[i].Name) < deref(result.Value[j].Path)+deref(result.Value[j].Name) + }) + return result.Value, nil +} - if result.Count > 0 { - return result.Value, nil +// GetBuildDefinitionByName busca pipelines por nombre (admite comodines). +func GetBuildDefinitionByName(client *azdevops.Client, name string) ([]models.BuildDefinition, error) { + if name == "" { + return nil, fmt.Errorf("el nombre del pipeline no puede estar vacío") } - - return nil, nil + return ListDefinitions(client, name, "") } +// GetBuildDefinitionByID obtiene la definición completa de un pipeline. func GetBuildDefinitionByID(client *azdevops.Client, id int) (*models.BuildDefinition, error) { - url := fmt.Sprintf("https://dev.azure.com/%s/%s/_apis/build/definitions/%d?api-version=7.1", client.Org, client.Project, id) - req, _ := http.NewRequest("GET", url, nil) - req.Header.Add("Authorization", client.AuthHeader()) - - resp, err := client.HTTP.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - var definition models.BuildDefinition - if err := json.NewDecoder(resp.Body).Decode(&definition); err != nil { - return nil, fmt.Errorf("error al decodificar la definición: %w", err) + u := client.ProjectURL(fmt.Sprintf("build/definitions/%d", id), url.Values{"api-version": {"7.1"}}) + if err := client.Do("GET", u, nil, &definition); err != nil { + return nil, fmt.Errorf("error al obtener el pipeline %d: %w", id, err) } return &definition, nil } + +func deref(s *string) string { + if s == nil { + return "" + } + return *s +} diff --git a/azdevops/pipeline/run.go b/azdevops/pipeline/run.go index 5009fae..da7af34 100644 --- a/azdevops/pipeline/run.go +++ b/azdevops/pipeline/run.go @@ -3,93 +3,73 @@ package pipeline import ( "azuredevops/azdevops" "azuredevops/models" - "bytes" - - "encoding/json" "fmt" - "io" - "net/http" + "net/url" + "strings" "time" ) -func RunPipeline(client *azdevops.Client, pipelineID int, wait bool, params map[string]string, vars map[string]models.BuildVariable) (*models.Build, error) { - payloadData := models.BuildRunPayload{} +type RunOptions struct { + Branch string + Params map[string]string + Vars map[string]models.BuildVariable +} - if len(params) > 0 { - payloadData.TemplateParameters = params - } - if len(vars) > 0 { - payloadData.Variables = vars +// RunPipeline inicia una ejecución del pipeline. +func RunPipeline(client *azdevops.Client, pipelineID int, opts RunOptions) (*models.PipelineRun, error) { + payload := models.BuildRunPayload{} + if len(opts.Params) > 0 { + payload.TemplateParameters = opts.Params } - - payload, err := json.Marshal(payloadData) - if err != nil { - return nil, fmt.Errorf("error al serializar el payload: %w", err) + if len(opts.Vars) > 0 { + payload.Variables = opts.Vars } - - url := fmt.Sprintf("https://dev.azure.com/%s/%s/_apis/pipelines/%d/runs?api-version=7.1", client.Org, client.Project, pipelineID) - req, _ := http.NewRequest("POST", url, bytes.NewBuffer(payload)) - req.Header.Set("Authorization", client.AuthHeader()) - req.Header.Set("Content-Type", "application/json") - - resp, err := client.HTTP.Do(req) - if err != nil { - return nil, fmt.Errorf("la solicitud para iniciar el build falló: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("error al iniciar el build: %s", string(body)) + if opts.Branch != "" { + ref := opts.Branch + if !strings.HasPrefix(ref, "refs/") { + ref = "refs/heads/" + ref + } + payload.Resources = &models.RunResources{ + Repositories: map[string]models.RunRepository{"self": {RefName: ref}}, + } } - var buildRun models.Build - if err := json.NewDecoder(resp.Body).Decode(&buildRun); err != nil { - return nil, fmt.Errorf("error al decodificar la respuesta del build: %w", err) + var run models.PipelineRun + u := client.ProjectURL(fmt.Sprintf("pipelines/%d/runs", pipelineID), url.Values{"api-version": {"7.1"}}) + if err := client.Do("POST", u, payload, &run); err != nil { + return nil, fmt.Errorf("error al iniciar el pipeline %d: %w", pipelineID, err) } + return &run, nil +} - fmt.Printf("✔ Pipeline iniciado con éxito (Build ID: %d)\n", buildRun.ID) - fmt.Printf("🔗 URL de la ejecución: %s\n", buildRun.Links.Web.Href) - - if !wait { - return &buildRun, nil +// WaitForBuild consulta el build cada interval hasta que termina o se agota timeout +// (0 = sin límite). onUpdate (opcional) se invoca en cada consulta. +func WaitForBuild(client *azdevops.Client, buildID int, interval, timeout time.Duration, onUpdate func(*models.Build)) (*models.Build, error) { + var deadline time.Time + if timeout > 0 { + deadline = time.Now().Add(timeout) } - - fmt.Print("Esperando a que el pipeline finalice...") + failures := 0 for { - time.Sleep(10 * time.Second) - - latestBuild, err := GetBuildByID(client, buildRun.ID) + build, err := GetBuildByID(client, buildID) if err != nil { - fmt.Println("\nError al obtener el estado del build:", err) - break + // Toleramos errores transitorios aislados mientras esperamos. + failures++ + if failures >= 5 { + return nil, fmt.Errorf("no se pudo obtener el estado del build %d: %w", buildID, err) + } + } else { + failures = 0 + if onUpdate != nil { + onUpdate(build) + } + if build.Status == "completed" { + return build, nil + } } - - if latestBuild.Status == "completed" { - fmt.Println("\n✔ Pipeline finalizado.") - fmt.Printf("Resultado: %s\n", latestBuild.Result) - return latestBuild, nil + if !deadline.IsZero() && time.Now().After(deadline) { + return build, fmt.Errorf("se agotó el tiempo de espera (%s) para el build %d", timeout, buildID) } - fmt.Print(".") - } - - return &buildRun, nil -} - -func GetBuildByID(client *azdevops.Client, buildID int) (*models.Build, error) { - url := fmt.Sprintf("https://dev.azure.com/%s/%s/_apis/build/builds/%d?api-version=7.1", client.Org, client.Project, buildID) - req, _ := http.NewRequest("GET", url, nil) - req.Header.Add("Authorization", client.AuthHeader()) - - resp, err := client.HTTP.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - var build models.Build - if err := json.NewDecoder(resp.Body).Decode(&build); err != nil { - return nil, fmt.Errorf("error al decodificar la definición: %w", err) + time.Sleep(interval) } - return &build, nil } diff --git a/azdevops/pipeline/update.go b/azdevops/pipeline/update.go index 5e36433..4f57f10 100644 --- a/azdevops/pipeline/update.go +++ b/azdevops/pipeline/update.go @@ -3,67 +3,66 @@ package pipeline import ( "azuredevops/azdevops" "azuredevops/models" - "bytes" - "encoding/json" "fmt" - "io" - "net/http" + "net/url" + "strings" ) -func UpdateBuildDefinition(client *azdevops.Client, id int, newName, yamlPath, repoName, serviceConnectionID string) (*models.BuildDefinition, error) { - currentDef, err := GetBuildDefinitionByID(client, id) +type UpdateOptions struct { + NewName string + YAMLPath string + RepoName string + Branch string + ServiceConnectionID string +} + +func (o UpdateOptions) IsEmpty() bool { + return o == UpdateOptions{} +} + +// UpdateBuildDefinition actualiza solo los campos indicados de un pipeline. +func UpdateBuildDefinition(client *azdevops.Client, id int, opts UpdateOptions) (*models.BuildDefinition, error) { + def, err := GetBuildDefinitionByID(client, id) if err != nil { - return nil, fmt.Errorf("no se pudo obtener la definición actual para actualizar: %w", err) + return nil, err } - if newName != "" { - currentDef.Name = &newName - } - if yamlPath != "" { - currentDef.Process.YAMLFilename = &yamlPath + if opts.NewName != "" { + def.Name = &opts.NewName } - if repoName != "" { - currentDef.Repository.Name = &repoName - currentDef.Repository.ID = &repoName // Para GitHub, el ID y el Nombre son "org/repo" + if opts.YAMLPath != "" { + if def.Process == nil { + def.Process = &models.Process{} + } + def.Process.YAMLFilename = &opts.YAMLPath } - if serviceConnectionID != "" { - if currentDef.Repository.Properties == nil { - currentDef.Repository.Properties = &models.RepositoryProperties{} + if opts.RepoName != "" || opts.Branch != "" || opts.ServiceConnectionID != "" { + if def.Repository == nil { + def.Repository = &models.BuildDefinitionRepository{} } - currentDef.Repository.Properties.ConnectedServiceID = &serviceConnectionID } - - payload, err := json.Marshal(currentDef) - if err != nil { - return nil, fmt.Errorf("error al serializar el payload de actualización: %w", err) + if opts.RepoName != "" { + def.Repository.Name = &opts.RepoName + def.Repository.ID = &opts.RepoName // Para GitHub, el ID y el Nombre son "org/repo" } - - url := fmt.Sprintf("https://dev.azure.com/%s/%s/_apis/build/definitions/%d?api-version=7.1", client.Org, client.Project, id) - req, _ := http.NewRequest("PUT", url, bytes.NewBuffer(payload)) - req.Header.Set("Authorization", client.AuthHeader()) - req.Header.Set("Content-Type", "application/json") - - resp, err := client.HTTP.Do(req) - if err != nil { - return nil, fmt.Errorf("la solicitud de actualización falló: %w", err) - } - defer resp.Body.Close() - body, _ := io.ReadAll(resp.Body) - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("error al actualizar la definición: %s", string(body)) + if opts.Branch != "" { + branch := opts.Branch + if !strings.HasPrefix(branch, "refs/") { + branch = "refs/heads/" + branch + } + def.Repository.DefaultBranch = &branch } - - var updatedDef models.BuildDefinition - if err := json.Unmarshal(body, &updatedDef); err != nil { - return nil, fmt.Errorf("error al decodificar la respuesta de actualización: %w", err) + if opts.ServiceConnectionID != "" { + if def.Repository.Properties == nil { + def.Repository.Properties = &models.RepositoryProperties{} + } + def.Repository.Properties.ConnectedServiceID = &opts.ServiceConnectionID } - name := "" - - if updatedDef.Name != nil { - name = *updatedDef.Name + var updated models.BuildDefinition + u := client.ProjectURL(fmt.Sprintf("build/definitions/%d", id), url.Values{"api-version": {"7.1"}}) + if err := client.Do("PUT", u, def, &updated); err != nil { + return nil, fmt.Errorf("error al actualizar el pipeline %d: %w", id, err) } - fmt.Printf("✔ Pipeline '%s' (ID: %d) actualizado con éxito.\n", name, id) - return &updatedDef, nil + return &updated, nil } diff --git a/azdevops/securefile/replace.go b/azdevops/securefile/replace.go new file mode 100644 index 0000000..fb7c478 --- /dev/null +++ b/azdevops/securefile/replace.go @@ -0,0 +1,208 @@ +package securefile + +import ( + "azuredevops/azdevops" + "azuredevops/models" + "errors" + "fmt" + "strings" + "time" +) + +// Snapshot es la configuración de acceso de un secure file que se conserva al reemplazarlo. +type Snapshot struct { + File models.SecureFile `json:"file"` + Permissions models.ResourcePipelinePermissions `json:"pipelinePermissions"` + Roles []models.ResourceRoleAssignment `json:"roles"` + Checks []models.CheckConfiguration `json:"checks"` +} + +// TakeSnapshot lee pipelines autorizados, roles y checks de un archivo. +func TakeSnapshot(client *azdevops.Client, projectID string, file models.SecureFile) (*Snapshot, error) { + perms, err := GetPipelinePermissions(client, file.ID) + if err != nil { + return nil, err + } + roles, err := GetRoles(client, projectID, file.ID) + if err != nil { + return nil, err + } + checks, err := GetChecks(client, file.ID) + if err != nil { + return nil, err + } + return &Snapshot{File: file, Permissions: *perms, Roles: roles, Checks: checks}, nil +} + +// AssignedRoles devuelve los roles asignados directamente (no heredados). +func (s Snapshot) AssignedRoles() []models.ResourceRoleAssignment { + var out []models.ResourceRoleAssignment + for _, r := range s.Roles { + if r.Access == "assigned" { + out = append(out, r) + } + } + return out +} + +// Inherits indica si el archivo hereda permisos del proyecto. Si la herencia está +// desactivada la API no devuelve roles "inherited". +func (s Snapshot) Inherits() bool { + for _, r := range s.Roles { + if r.Access == "inherited" { + return true + } + } + return false +} + +// ReplaceOptions controla el reemplazo. +type ReplaceOptions struct { + // KeepOld conserva el archivo anterior (renombrado) en lugar de eliminarlo. + KeepOld bool + // SelfID es el ID de la identidad que ejecuta el reemplazo. Azure DevOps no permite + // cambiar el rol propio y quien sube el archivo queda como Administrator, así que + // su rol no se copia. + SelfID string + // Progress recibe una descripción de cada paso (opcional). + Progress func(step string) +} + +// ReplaceResult describe el resultado del reemplazo. +type ReplaceResult struct { + New models.SecureFile `json:"new"` + OldID string `json:"oldId"` + OldRenamedTo string `json:"oldRenamedTo,omitempty"` + OldDeleted bool `json:"oldDeleted"` + Pipelines []int `json:"pipelines"` + AllPipelines bool `json:"allPipelines"` + Roles int `json:"roles"` + Inherits bool `json:"inheritsPermissions"` + Checks int `json:"checks"` + DeleteOldError string `json:"deleteOldError,omitempty"` + // Warnings son diferencias que no se pudieron conservar. + Warnings []string `json:"warnings,omitempty"` +} + +// Replace sustituye el contenido de un secure file conservando su nombre, sus +// propiedades, los pipelines autorizados, los roles asignados, la herencia de +// permisos y los checks/aprobaciones. +// +// Azure DevOps no permite cambiar el contenido de un secure file ni tener dos con el +// mismo nombre, así que el proceso es: tomar una foto de la configuración, renombrar +// el archivo actual, subir el nuevo con el nombre original, copiar la configuración y +// eliminar el anterior. Si algo falla antes de eliminar el anterior, se revierte +// (se borra el nuevo y el anterior recupera su nombre). +func Replace(client *azdevops.Client, projectID string, snap *Snapshot, data []byte, opts ReplaceOptions) (result *ReplaceResult, err error) { + progress := func(format string, a ...any) { + if opts.Progress != nil { + opts.Progress(fmt.Sprintf(format, a...)) + } + } + old := snap.File + result = &ReplaceResult{OldID: old.ID, AllPipelines: snap.Permissions.OpenToAllPipelines(), Inherits: snap.Inherits()} + + // 1. Renombrar el archivo actual para liberar el nombre. + tmpName := fmt.Sprintf("%s.replaced-%s", old.Name, time.Now().UTC().Format("20060102T150405")) + progress("Renombrando '%s' a '%s'", old.Name, tmpName) + renamed := old + renamed.Name = tmpName + if _, err := Update(client, renamed); err != nil { + return nil, err + } + result.OldRenamedTo = tmpName + + var created *models.SecureFile + rollback := func(cause error) error { + errs := []error{cause} + if created != nil { + progress("Revirtiendo: eliminando el archivo nuevo") + if err := Delete(client, created.ID); err != nil { + errs = append(errs, fmt.Errorf("no se pudo eliminar el archivo nuevo %s: %w", created.ID, err)) + } + } + progress("Revirtiendo: restaurando el nombre '%s'", old.Name) + if _, err := Update(client, old); err != nil { + errs = append(errs, fmt.Errorf("no se pudo restaurar el nombre del archivo original (ahora '%s'): %w", tmpName, err)) + } + return fmt.Errorf("reemplazo revertido: %w", errors.Join(errs...)) + } + + // 2. Subir el nuevo contenido con el nombre original. + progress("Subiendo el nuevo contenido como '%s'", old.Name) + if created, err = Upload(client, old.Name, data, result.AllPipelines); err != nil { + return nil, rollback(err) + } + result.New = *created + + // 3. Propiedades. + if len(old.Properties) > 0 { + progress("Copiando %d propiedad(es)", len(old.Properties)) + withProps := *created + withProps.Properties = old.Properties + updated, err := Update(client, withProps) + if err != nil { + return nil, rollback(err) + } + result.New = *updated + } + + // 4. Pipelines autorizados. + if ids := snap.Permissions.AuthorizedPipelineIDs(); len(ids) > 0 { + progress("Autorizando %d pipeline(s)", len(ids)) + if err := SetPipelinePermissions(client, created.ID, ids, true, nil); err != nil { + return nil, rollback(err) + } + result.Pipelines = ids + } + + // 5. Roles asignados y herencia. + var roles []models.SecurityRoleAssignment + for _, r := range snap.AssignedRoles() { + if opts.SelfID != "" && strings.EqualFold(r.Identity.ID, opts.SelfID) { + if r.Role.Name != "Administrator" { + result.Warnings = append(result.Warnings, fmt.Sprintf( + "tu identidad tenía el rol %s; en el archivo nuevo eres Administrator (Azure DevOps no permite cambiar el rol propio)", r.Role.Name)) + } + result.Roles++ + continue + } + roles = append(roles, models.SecurityRoleAssignment{RoleName: r.Role.Name, UserID: r.Identity.ID}) + } + if len(roles) > 0 { + progress("Copiando %d rol(es) asignado(s)", len(roles)) + if err := SetRoles(client, projectID, created.ID, roles); err != nil { + return nil, rollback(err) + } + result.Roles += len(roles) + } + if !result.Inherits { + progress("Desactivando la herencia de permisos") + if err := SetInheritance(client, projectID, created.ID, false); err != nil { + return nil, rollback(err) + } + } + + // 6. Checks y aprobaciones. + for _, check := range snap.Checks { + progress("Copiando el check '%s'", check.Type.Name) + if err := CopyCheck(client, check, created.ID, old.Name); err != nil { + return nil, rollback(err) + } + result.Checks++ + } + + // 7. Eliminar el archivo anterior (sus checks se eliminan con él). + if opts.KeepOld { + return result, nil + } + progress("Eliminando el archivo anterior") + if err := Delete(client, old.ID); err != nil { + // El nuevo ya está completo: no se revierte, solo se informa. + result.DeleteOldError = err.Error() + return result, nil + } + result.OldDeleted = true + result.OldRenamedTo = "" + return result, nil +} diff --git a/azdevops/securefile/replace_test.go b/azdevops/securefile/replace_test.go new file mode 100644 index 0000000..ade2e94 --- /dev/null +++ b/azdevops/securefile/replace_test.go @@ -0,0 +1,203 @@ +package securefile + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + + "azuredevops/azdevops" + "azuredevops/models" +) + +// fakeServer simula los endpoints de secure files necesarios para Replace. +type fakeServer struct { + mu sync.Mutex + files map[string]models.SecureFile + content map[string]string + nextID int + perms map[string][]int + roles map[string][]models.SecurityRoleAssignment + inherit map[string]bool + checks map[string]int + failOn string // fragmento de ruta+método que debe fallar + requests []string +} + +func newFake() *fakeServer { + return &fakeServer{files: map[string]models.SecureFile{}, content: map[string]string{}, perms: map[string][]int{}, + roles: map[string][]models.SecurityRoleAssignment{}, inherit: map[string]bool{}, checks: map[string]int{}} +} + +func (f *fakeServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { + f.mu.Lock() + defer f.mu.Unlock() + key := r.Method + " " + r.URL.Path + f.requests = append(f.requests, key) + if f.failOn != "" && strings.Contains(key, f.failOn) { + w.WriteHeader(500) + w.Write([]byte(`{"message":"falla simulada"}`)) + return + } + body, _ := io.ReadAll(r.Body) + parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/") + last := parts[len(parts)-1] + switch { + case strings.HasSuffix(r.URL.Path, "/distributedtask/securefiles") && r.Method == "POST": + name := r.URL.Query().Get("name") + for _, existing := range f.files { + if existing.Name == name { + w.WriteHeader(409) + return + } + } + f.nextID++ + id := "new-" + string(rune('0'+f.nextID)) + f.files[id] = models.SecureFile{ID: id, Name: name} + f.content[id] = string(body) + json.NewEncoder(w).Encode(f.files[id]) + case strings.Contains(r.URL.Path, "/distributedtask/securefiles/") && r.Method == "PATCH": + var sf models.SecureFile + json.Unmarshal(body, &sf) + f.files[last] = sf + json.NewEncoder(w).Encode(sf) + case strings.Contains(r.URL.Path, "/distributedtask/securefiles/") && r.Method == "DELETE": + delete(f.files, last) + case strings.Contains(r.URL.Path, "/pipelinepermissions/securefile/") && r.Method == "PATCH": + var p struct{ Pipelines []struct{ ID int } } + json.Unmarshal(body, &p) + for _, pl := range p.Pipelines { + f.perms[last] = append(f.perms[last], pl.ID) + } + w.Write([]byte(`{}`)) + case strings.Contains(r.URL.Path, "/roleassignments/") && r.Method == "PUT": + var roles []models.SecurityRoleAssignment + json.Unmarshal(body, &roles) + id := last[strings.Index(last, "$")+1:] + f.roles[id] = append(f.roles[id], roles...) + case strings.Contains(r.URL.Path, "/roleassignments/") && r.Method == "PATCH": + id := last[strings.Index(last, "$")+1:] + f.inherit[id] = r.URL.Query().Get("inheritPermissions") == "true" + case strings.HasSuffix(r.URL.Path, "/checks/configurations") && r.Method == "POST": + var c models.CheckConfiguration + json.Unmarshal(body, &c) + f.checks[c.Resource.ID]++ + w.Write([]byte(`{}`)) + default: + w.WriteHeader(404) + } +} + +func setup(t *testing.T) (*fakeServer, *azdevops.Client, *Snapshot) { + fake := newFake() + fake.files["old"] = models.SecureFile{ID: "old", Name: "cert.pfx", Properties: map[string]string{"env": "prod"}} + srv := httptest.NewServer(fake) + t.Cleanup(srv.Close) + client := azdevops.NewClient("org", "proj", "pat") + client.BaseURL = srv.URL + client.MaxRetries = 0 + + snap := &Snapshot{File: fake.files["old"]} + snap.Permissions.Pipelines = []models.PipelineAuthorization{{ID: 61, Authorized: true}, {ID: 62, Authorized: false}} + user := models.ResourceRoleAssignment{Identity: models.IdentityRef{ID: "u1"}, Access: "assigned"} + user.Role.Name = "User" + inherited := models.ResourceRoleAssignment{Identity: models.IdentityRef{ID: "g1"}, Access: "inherited"} + inherited.Role.Name = "Reader" + snap.Roles = []models.ResourceRoleAssignment{user, inherited} + var check models.CheckConfiguration + check.Type.Name = "Approval" + check.Settings = json.RawMessage(`{"approvers":[{"id":"u1"}]}`) + snap.Checks = []models.CheckConfiguration{check} + return fake, client, snap +} + +func TestReplacePreservesAccess(t *testing.T) { + fake, client, snap := setup(t) + res, err := Replace(client, "pid", snap, []byte("nuevo"), ReplaceOptions{}) + if err != nil { + t.Fatal(err) + } + newID := res.New.ID + if _, ok := fake.files["old"]; ok { + t.Error("el archivo anterior debería haberse eliminado") + } + got := fake.files[newID] + if got.Name != "cert.pfx" || got.Properties["env"] != "prod" || fake.content[newID] != "nuevo" { + t.Errorf("archivo nuevo incorrecto: %+v contenido=%q", got, fake.content[newID]) + } + if len(fake.perms[newID]) != 1 || fake.perms[newID][0] != 61 { + t.Errorf("pipelines autorizados = %v, se esperaba [61]", fake.perms[newID]) + } + if len(fake.roles[newID]) != 1 || fake.roles[newID][0].UserID != "u1" || fake.roles[newID][0].RoleName != "User" { + t.Errorf("roles = %+v", fake.roles[newID]) + } + if _, touched := fake.inherit[newID]; touched { + t.Error("con roles heredados no se debe tocar la herencia") + } + if fake.checks[newID] != 1 || res.Checks != 1 { + t.Errorf("checks copiados = %d", fake.checks[newID]) + } + if !res.OldDeleted { + t.Error("OldDeleted debería ser true") + } +} + +func TestReplaceSkipsSelfRole(t *testing.T) { + fake, client, snap := setup(t) + self := models.ResourceRoleAssignment{Identity: models.IdentityRef{ID: "me"}, Access: "assigned"} + self.Role.Name = "Reader" + snap.Roles = append(snap.Roles, self) + res, err := Replace(client, "pid", snap, []byte("x"), ReplaceOptions{SelfID: "ME"}) + if err != nil { + t.Fatal(err) + } + for _, r := range fake.roles[res.New.ID] { + if r.UserID == "me" { + t.Error("no se debe enviar el rol de la propia identidad") + } + } + if res.Roles != 2 || len(res.Warnings) != 1 { + t.Errorf("roles=%d warnings=%v", res.Roles, res.Warnings) + } +} + +func TestReplaceDisablesInheritance(t *testing.T) { + fake, client, snap := setup(t) + snap.Roles = snap.Roles[:1] // solo asignados => herencia desactivada + res, err := Replace(client, "pid", snap, []byte("x"), ReplaceOptions{KeepOld: true}) + if err != nil { + t.Fatal(err) + } + if v, ok := fake.inherit[res.New.ID]; !ok || v { + t.Error("debería desactivar la herencia en el archivo nuevo") + } + if old, ok := fake.files["old"]; !ok || !strings.HasPrefix(old.Name, "cert.pfx.replaced-") { + t.Errorf("con KeepOld el anterior debe quedar renombrado: %+v", old) + } +} + +func TestReplaceRollsBackOnFailure(t *testing.T) { + fake, client, snap := setup(t) + fake.failOn = "POST /org/proj/_apis/pipelines/checks" + _, err := Replace(client, "pid", snap, []byte("x"), ReplaceOptions{}) + if err == nil || !strings.Contains(err.Error(), "revertido") { + t.Fatalf("se esperaba error de reemplazo revertido, got %v", err) + } + if len(fake.files) != 1 || fake.files["old"].Name != "cert.pfx" { + t.Errorf("tras revertir solo debe quedar el original con su nombre: %+v", fake.files) + } +} + +func TestReplaceUploadFailureRestoresName(t *testing.T) { + fake, client, snap := setup(t) + fake.failOn = "POST /org/proj/_apis/distributedtask/securefiles" + if _, err := Replace(client, "pid", snap, []byte("x"), ReplaceOptions{}); err == nil { + t.Fatal("se esperaba error") + } + if fake.files["old"].Name != "cert.pfx" { + t.Errorf("el nombre original no se restauró: %+v", fake.files["old"]) + } +} diff --git a/azdevops/securefile/securefile.go b/azdevops/securefile/securefile.go new file mode 100644 index 0000000..9b23d94 --- /dev/null +++ b/azdevops/securefile/securefile.go @@ -0,0 +1,197 @@ +// Package securefile gestiona los archivos seguros (Library > Secure files). +package securefile + +import ( + "azuredevops/azdevops" + "azuredevops/models" + "fmt" + "net/url" + "sort" + "strconv" + "strings" +) + +const ( + apiVersion = "7.1-preview.1" + resourceType = "securefile" + roleScope = "distributedtask.securefile" +) + +// ValidRoles son los roles asignables en un secure file. +var ValidRoles = []string{"Reader", "User", "Administrator"} + +// List lista los secure files del proyecto. +func List(client *azdevops.Client) ([]models.SecureFile, error) { + var result struct { + Value []models.SecureFile `json:"value"` + } + if err := client.Do("GET", client.ProjectURL("distributedtask/securefiles", url.Values{"api-version": {apiVersion}}), nil, &result); err != nil { + return nil, fmt.Errorf("error al listar los secure files: %w", err) + } + sort.Slice(result.Value, func(i, j int) bool { + return strings.ToLower(result.Value[i].Name) < strings.ToLower(result.Value[j].Name) + }) + return result.Value, nil +} + +// Find busca un secure file por nombre exacto o ID. +func Find(client *azdevops.Client, nameOrID string) (*models.SecureFile, error) { + files, err := List(client) + if err != nil { + return nil, err + } + for i, f := range files { + if f.Name == nameOrID || f.ID == nameOrID { + return &files[i], nil + } + } + return nil, fmt.Errorf("no se encontró el secure file '%s'", nameOrID) +} + +// Upload sube un archivo nuevo. authorizeAll lo deja disponible para todos los pipelines. +func Upload(client *azdevops.Client, name string, data []byte, authorizeAll bool) (*models.SecureFile, error) { + q := url.Values{"api-version": {apiVersion}, "name": {name}} + if authorizeAll { + q.Set("authorizePipelines", "true") + } + var created models.SecureFile + if err := client.DoContentType("POST", client.ProjectURL("distributedtask/securefiles", q), "application/octet-stream", data, &created); err != nil { + return nil, fmt.Errorf("error al subir '%s': %w", name, err) + } + return &created, nil +} + +// Update cambia el nombre y las propiedades de un secure file (no su contenido). +func Update(client *azdevops.Client, file models.SecureFile) (*models.SecureFile, error) { + body := models.SecureFile{ID: file.ID, Name: file.Name, Properties: file.Properties} + var updated models.SecureFile + u := client.ProjectURL("distributedtask/securefiles/"+url.PathEscape(file.ID), url.Values{"api-version": {apiVersion}}) + if err := client.Do("PATCH", u, body, &updated); err != nil { + return nil, fmt.Errorf("error al actualizar '%s': %w", file.Name, err) + } + return &updated, nil +} + +// Delete elimina un secure file. +func Delete(client *azdevops.Client, id string) error { + u := client.ProjectURL("distributedtask/securefiles/"+url.PathEscape(id), url.Values{"api-version": {apiVersion}}) + if err := client.Do("DELETE", u, nil, nil); err != nil { + return fmt.Errorf("error al eliminar el secure file %s: %w", id, err) + } + return nil +} + +func permissionsURL(client *azdevops.Client, id string) string { + return client.ProjectURL("pipelines/pipelinepermissions/"+resourceType+"/"+url.PathEscape(id), url.Values{"api-version": {apiVersion}}) +} + +// GetPipelinePermissions devuelve qué pipelines pueden usar el archivo. +func GetPipelinePermissions(client *azdevops.Client, id string) (*models.ResourcePipelinePermissions, error) { + var perms models.ResourcePipelinePermissions + if err := client.Do("GET", permissionsURL(client, id), nil, &perms); err != nil { + return nil, fmt.Errorf("error al leer los pipelines autorizados: %w", err) + } + return &perms, nil +} + +// SetPipelinePermissions autoriza (o revoca) pipelines concretos y, si all no es nil, +// el acceso abierto para todos los pipelines. +func SetPipelinePermissions(client *azdevops.Client, id string, pipelineIDs []int, authorized bool, all *bool) error { + type pipelineRef struct { + ID int `json:"id"` + Authorized bool `json:"authorized"` + } + body := map[string]any{} + refs := make([]pipelineRef, len(pipelineIDs)) + for i, pid := range pipelineIDs { + refs[i] = pipelineRef{ID: pid, Authorized: authorized} + } + body["pipelines"] = refs + if all != nil { + body["allPipelines"] = map[string]bool{"authorized": *all} + } + if err := client.Do("PATCH", permissionsURL(client, id), body, nil); err != nil { + return fmt.Errorf("error al actualizar los pipelines autorizados: %w", err) + } + return nil +} + +func roleResource(projectID, fileID string) string { + return projectID + "$" + fileID +} + +func rolesURL(client *azdevops.Client, projectID, fileID string, q url.Values) string { + if q == nil { + q = url.Values{} + } + q.Set("api-version", apiVersion) + return client.OrgURL("securityroles/scopes/"+roleScope+"/roleassignments/resources/"+roleResource(projectID, fileID), q) +} + +// GetRoles devuelve los roles (asignados y heredados) del archivo. +func GetRoles(client *azdevops.Client, projectID, fileID string) ([]models.ResourceRoleAssignment, error) { + var result struct { + Value []models.ResourceRoleAssignment `json:"value"` + } + if err := client.Do("GET", rolesURL(client, projectID, fileID, nil), nil, &result); err != nil { + return nil, fmt.Errorf("error al leer los roles: %w", err) + } + return result.Value, nil +} + +// SetRoles asigna roles a identidades (IDs de usuario o grupo). +func SetRoles(client *azdevops.Client, projectID, fileID string, assignments []models.SecurityRoleAssignment) error { + if len(assignments) == 0 { + return nil + } + if err := client.Do("PUT", rolesURL(client, projectID, fileID, nil), assignments, nil); err != nil { + return fmt.Errorf("error al asignar roles: %w", err) + } + return nil +} + +// SetInheritance activa o desactiva la herencia de permisos del proyecto. +func SetInheritance(client *azdevops.Client, projectID, fileID string, inherit bool) error { + u := rolesURL(client, projectID, fileID, url.Values{"inheritPermissions": {strconv.FormatBool(inherit)}}) + if err := client.Do("PATCH", u, nil, nil); err != nil { + return fmt.Errorf("error al cambiar la herencia de permisos: %w", err) + } + return nil +} + +func checksURL(client *azdevops.Client, q url.Values) string { + q.Set("api-version", apiVersion) + return client.ProjectURL("pipelines/checks/configurations", q) +} + +// GetChecks devuelve las aprobaciones y checks configurados sobre el archivo. +func GetChecks(client *azdevops.Client, fileID string) ([]models.CheckConfiguration, error) { + var result struct { + Value []models.CheckConfiguration `json:"value"` + } + q := url.Values{"resourceType": {resourceType}, "resourceId": {fileID}, "$expand": {"settings"}} + if err := client.Do("GET", checksURL(client, q), nil, &result); err != nil { + return nil, fmt.Errorf("error al leer los checks: %w", err) + } + return result.Value, nil +} + +// CopyCheck crea en fileID un check igual a cfg. +func CopyCheck(client *azdevops.Client, cfg models.CheckConfiguration, fileID, fileName string) error { + var body models.CheckConfiguration + body.Type = cfg.Type + body.Settings = cfg.Settings + body.Timeout = cfg.Timeout + body.Resource.Type = resourceType + body.Resource.ID = fileID + body.Resource.Name = fileName + if err := client.Do("POST", checksURL(client, url.Values{}), body, nil); err != nil { + return fmt.Errorf("error al copiar el check '%s': %w", cfg.Type.Name, err) + } + return nil +} + +// DeleteCheck elimina un check. +func DeleteCheck(client *azdevops.Client, id int) error { + return client.Do("DELETE", client.ProjectURL(fmt.Sprintf("pipelines/checks/configurations/%d", id), url.Values{"api-version": {apiVersion}}), nil, nil) +} diff --git a/azdevops/security/groups.go b/azdevops/security/groups.go index 56883eb..9218034 100644 --- a/azdevops/security/groups.go +++ b/azdevops/security/groups.go @@ -3,73 +3,74 @@ package security import ( "azuredevops/azdevops" "azuredevops/models" - "encoding/json" "fmt" - "net/http" "net/url" + "sort" + "strings" ) -func ListGroups(client *azdevops.Client) ([]models.GraphGroup, error) { - var allGroups []models.GraphGroup - var continuationToken string - +// ListGroups lista los grupos de seguridad de la organización (o solo los del +// proyecto si scopeDescriptor no está vacío). search filtra por nombre sin +// distinguir mayúsculas. +func ListGroups(client *azdevops.Client, scopeDescriptor, search string) ([]models.GraphGroup, error) { + var all []models.GraphGroup + continuation := "" for { - url := fmt.Sprintf("https://vssps.dev.azure.com/%s/_apis/graph/groups?api-version=7.1-preview.1", client.Org) - if continuationToken != "" { - url = fmt.Sprintf("https://vssps.dev.azure.com/%s/_apis/graph/groups?continuationToken=%s&api-version=7.1-preview.1", client.Org, continuationToken) + q := url.Values{"api-version": {"7.1-preview.1"}} + if scopeDescriptor != "" { + q.Set("scopeDescriptor", scopeDescriptor) } - - req, _ := http.NewRequest("GET", url, nil) - req.Header.Add("Authorization", client.AuthHeader()) - - resp, err := client.HTTP.Do(req) - if err != nil { - return nil, err + if continuation != "" { + q.Set("continuationToken", continuation) } - defer resp.Body.Close() - - var result struct { + var page struct { Value []models.GraphGroup `json:"value"` } - - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return nil, fmt.Errorf("error al decodificar una página de grupos: %w", err) + headers, err := client.GetWithHeaders(client.VSSPSURL("graph/groups", q), &page) + if err != nil { + return nil, fmt.Errorf("error al listar los grupos: %w", err) } - - allGroups = append(allGroups, result.Value...) - - token := resp.Header.Get("X-MS-ContinuationToken") - if token == "" { + all = append(all, page.Value...) + continuation = headers.Get("X-MS-ContinuationToken") + if continuation == "" { break } - continuationToken = token } - return allGroups, nil + if search != "" { + needle := strings.ToLower(search) + filtered := all[:0] + for _, g := range all { + if strings.Contains(strings.ToLower(g.DisplayName), needle) || strings.Contains(strings.ToLower(g.PrincipalName), needle) { + filtered = append(filtered, g) + } + } + all = filtered + } + sort.Slice(all, func(i, j int) bool { return all[i].PrincipalName < all[j].PrincipalName }) + return all, nil } -func GetGroupByPrincipalName(client *azdevops.Client, principalName string) (*models.Identity, error) { - encodedFilterValue := url.QueryEscape(fmt.Sprintf("[%s]\\%s", client.Project, principalName)) - url := fmt.Sprintf("https://vssps.dev.azure.com/%s/_apis/identities?searchFilter=General&filterValue=%s&api-version=7.1-preview.1", client.Org, encodedFilterValue) - req, _ := http.NewRequest("GET", url, nil) - req.Header.Add("Authorization", client.AuthHeader()) - - resp, err := client.HTTP.Do(req) +// GetGroupByPrincipalName busca un grupo del proyecto por nombre (sin el prefijo "[Proyecto]\"). +func GetGroupByPrincipalName(client *azdevops.Client, name string) (*models.Identity, error) { + name = strings.TrimPrefix(name, "["+client.Project+"]\\") + identities, err := searchIdentities(client, fmt.Sprintf("[%s]\\%s", client.Project, name)) if err != nil { return nil, err } - defer resp.Body.Close() + if len(identities) == 0 { + return nil, fmt.Errorf("no se encontró el grupo '%s'", name) + } + return &identities[0], nil +} +func searchIdentities(client *azdevops.Client, filter string) ([]models.Identity, error) { + q := url.Values{"searchFilter": {"General"}, "filterValue": {filter}, "api-version": {"7.1-preview.1"}} var result struct { Value []models.Identity `json:"value"` } - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return nil, err - } - if len(result.Value) == 0 { - return nil, fmt.Errorf("no se encontró al grupo '%s'", principalName) + if err := client.Do("GET", client.VSSPSURL("identities", q), nil, &result); err != nil { + return nil, fmt.Errorf("error al buscar '%s': %w", filter, err) } - fmt.Printf("✔ Grupo encontrado: %s (ID: %s)\n", result.Value[0].DisplayName, result.Value[0].ID) - - return &result.Value[0], nil + return result.Value, nil } diff --git a/azdevops/security/members.go b/azdevops/security/members.go index 8e7894e..65061bd 100644 --- a/azdevops/security/members.go +++ b/azdevops/security/members.go @@ -3,83 +3,69 @@ package security import ( "azuredevops/azdevops" "azuredevops/models" - "encoding/json" "fmt" - "io" - "net/http" "net/url" + "sort" ) +// GetUserByPrincipalName busca un usuario por email/UPN. func GetUserByPrincipalName(client *azdevops.Client, principalName string) (*models.Identity, error) { - encodedFilterValue := url.QueryEscape(principalName) - url := fmt.Sprintf("https://vssps.dev.azure.com/%s/_apis/identities?searchFilter=General&filterValue=%s&api-version=7.1-preview.1", client.Org, encodedFilterValue) - req, _ := http.NewRequest("GET", url, nil) - req.Header.Add("Authorization", client.AuthHeader()) - - resp, err := client.HTTP.Do(req) + identities, err := searchIdentities(client, principalName) if err != nil { return nil, err } - defer resp.Body.Close() - - var result struct { - Value []models.Identity `json:"value"` - } - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return nil, err - } - if len(result.Value) == 0 { - return nil, fmt.Errorf("no se encontró al usuario '%s'", principalName) + if len(identities) == 0 { + return nil, fmt.Errorf("no se encontró el usuario '%s'", principalName) } - fmt.Printf("✔ Usuario encontrado: %s (ID: %s)\n", result.Value[0].DisplayName, result.Value[0].ID) - - return &result.Value[0], nil + return &identities[0], nil } -func AddUserToGroup(client *azdevops.Client, groupDescriptor, userPrincipalName string) (bool, error) { - userIdentity, err := GetUserByPrincipalName(client, userPrincipalName) - if err != nil { - return false, err - } - - url := fmt.Sprintf("https://vssps.dev.azure.com/%s/_apis/graph/memberships/%s/%s?api-version=7.1-preview.1", client.Org, userIdentity.SubjectDescriptor, groupDescriptor) +// AddMembership agrega subjectDescriptor (usuario o grupo) como miembro de containerDescriptor. +func AddMembership(client *azdevops.Client, containerDescriptor, subjectDescriptor string) error { + path := fmt.Sprintf("graph/memberships/%s/%s", subjectDescriptor, containerDescriptor) + return client.Do("PUT", client.VSSPSURL(path, url.Values{"api-version": {"7.1-preview.1"}}), nil, nil) +} - req, _ := http.NewRequest("PUT", url, nil) - req.Header.Set("Authorization", client.AuthHeader()) - req.Header.Set("Content-Type", "application/json") +// RemoveMembership quita subjectDescriptor de containerDescriptor. +func RemoveMembership(client *azdevops.Client, containerDescriptor, subjectDescriptor string) error { + path := fmt.Sprintf("graph/memberships/%s/%s", subjectDescriptor, containerDescriptor) + return client.Do("DELETE", client.VSSPSURL(path, url.Values{"api-version": {"7.1-preview.1"}}), nil, nil) +} - resp, err := client.HTTP.Do(req) - if err != nil { - return false, err +// ListMembers devuelve los miembros directos de un grupo. +func ListMembers(client *azdevops.Client, groupDescriptor string) ([]models.GraphSubject, error) { + q := url.Values{"direction": {"down"}, "api-version": {"7.1-preview.1"}} + var memberships struct { + Value []struct { + MemberDescriptor string `json:"memberDescriptor"` + } `json:"value"` } - defer resp.Body.Close() - - if resp.StatusCode >= 300 { - body, _ := io.ReadAll(resp.Body) - return false, fmt.Errorf("error al agregar el miembro: %s", string(body)) + if err := client.Do("GET", client.VSSPSURL("graph/memberships/"+groupDescriptor, q), nil, &memberships); err != nil { + return nil, fmt.Errorf("error al obtener los miembros: %w", err) } - - return true, nil -} - -func AddUserToGroupOptimized(client *azdevops.Client, groupDescriptor, userDescriptor string) (bool, error) { - url := fmt.Sprintf("https://vssps.dev.azure.com/%s/_apis/graph/memberships/%s/%s?api-version=7.1-preview.1", - client.Org, userDescriptor, groupDescriptor) - - req, _ := http.NewRequest("PUT", url, nil) - req.Header.Set("Authorization", client.AuthHeader()) - req.Header.Set("Content-Type", "application/json") - - resp, err := client.HTTP.Do(req) - if err != nil { - return false, err + if len(memberships.Value) == 0 { + return nil, nil } - defer resp.Body.Close() - if resp.StatusCode >= 300 { - body, _ := io.ReadAll(resp.Body) - return false, fmt.Errorf("error al agregar el miembro (código %d): %s", resp.StatusCode, string(body)) + type lookupKey struct { + Descriptor string `json:"descriptor"` } - - return true, nil + lookup := struct { + LookupKeys []lookupKey `json:"lookupKeys"` + }{} + for _, m := range memberships.Value { + lookup.LookupKeys = append(lookup.LookupKeys, lookupKey{m.MemberDescriptor}) + } + var subjects struct { + Value map[string]models.GraphSubject `json:"value"` + } + if err := client.Do("POST", client.VSSPSURL("graph/subjectlookup", url.Values{"api-version": {"7.1-preview.1"}}), lookup, &subjects); err != nil { + return nil, fmt.Errorf("error al resolver los miembros: %w", err) + } + members := make([]models.GraphSubject, 0, len(subjects.Value)) + for _, s := range subjects.Value { + members = append(members, s) + } + sort.Slice(members, func(i, j int) bool { return members[i].DisplayName < members[j].DisplayName }) + return members, nil } diff --git a/azdevops/serviceendpoint/endpoints.go b/azdevops/serviceendpoint/endpoints.go new file mode 100644 index 0000000..551ff42 --- /dev/null +++ b/azdevops/serviceendpoint/endpoints.go @@ -0,0 +1,87 @@ +package serviceendpoint + +import ( + "azuredevops/azdevops" + "azuredevops/models" + "fmt" + "net/url" + "sort" + "strconv" +) + +const apiVersion = "7.1" + +// ListEndpoints lista las conexiones de servicio del proyecto. endpointType (p. ej. "github") +// es opcional. +func ListEndpoints(client *azdevops.Client, endpointType string) ([]models.ServiceEndpoint, error) { + q := url.Values{"api-version": {apiVersion}} + if endpointType != "" { + q.Set("type", endpointType) + } + var result struct { + Value []models.ServiceEndpoint `json:"value"` + } + if err := client.Do("GET", client.ProjectURL("serviceendpoint/endpoints", q), nil, &result); err != nil { + return nil, fmt.Errorf("error al listar las conexiones de servicio: %w", err) + } + sort.Slice(result.Value, func(i, j int) bool { return result.Value[i].Name < result.Value[j].Name }) + return result.Value, nil +} + +// GetEndpoint obtiene una conexión de servicio por su ID. +func GetEndpoint(client *azdevops.Client, id string) (*models.ServiceEndpoint, error) { + var ep models.ServiceEndpoint + u := client.ProjectURL("serviceendpoint/endpoints/"+url.PathEscape(id), url.Values{"api-version": {apiVersion}}) + if err := client.Do("GET", u, nil, &ep); err != nil { + return nil, err + } + if ep.ID == "" { + return nil, fmt.Errorf("no se encontró la conexión de servicio '%s'", id) + } + return &ep, nil +} + +// GetEndpointByName busca una conexión de servicio por su nombre exacto. +func GetEndpointByName(client *azdevops.Client, name string) (*models.ServiceEndpoint, error) { + var result struct { + Value []models.ServiceEndpoint `json:"value"` + } + q := url.Values{"api-version": {apiVersion}, "endpointNames": {name}} + if err := client.Do("GET", client.ProjectURL("serviceendpoint/endpoints", q), nil, &result); err != nil { + return nil, err + } + for i := range result.Value { + if result.Value[i].Name == name { + return &result.Value[i], nil + } + } + return nil, fmt.Errorf("no se encontró la conexión de servicio '%s'", name) +} + +// ExecutionHistory devuelve las ejecuciones de pipelines que usaron la conexión. +func ExecutionHistory(client *azdevops.Client, id string, top int) ([]models.ServiceEndpointExecution, error) { + var result struct { + Value []struct { + Data models.ServiceEndpointExecution `json:"data"` + } `json:"value"` + } + q := url.Values{"api-version": {apiVersion}, "top": {strconv.Itoa(top)}} + u := client.ProjectURL("serviceendpoint/"+url.PathEscape(id)+"/executionhistory", q) + if err := client.Do("GET", u, nil, &result); err != nil { + return nil, fmt.Errorf("error al obtener el historial: %w", err) + } + out := make([]models.ServiceEndpointExecution, len(result.Value)) + for i, v := range result.Value { + out[i] = v.Data + } + return out, nil +} + +// ShareEndpoint comparte una conexión de servicio con otros proyectos de la organización. +func ShareEndpoint(client *azdevops.Client, id string, refs []models.ServiceEndpointProjectReference) error { + u := client.OrgURL("serviceendpoint/endpoints/"+url.PathEscape(id), url.Values{"api-version": {apiVersion}}) + if err := client.Do("PATCH", u, refs, nil); err != nil { + return fmt.Errorf("error al compartir la conexión: %w", err) + } + return nil +} diff --git a/azdevops/variable_group/create.go b/azdevops/variable_group/create.go index 9319b59..97f109a 100644 --- a/azdevops/variable_group/create.go +++ b/azdevops/variable_group/create.go @@ -3,15 +3,15 @@ package variable_group import ( "azuredevops/azdevops" "azuredevops/models" - "bytes" - "encoding/json" "fmt" - "io" - "net/http" + "net/url" ) -func CreateVariableGroup(client *azdevops.Client, name string, variables map[string]models.VariableVal, description string) (bool, error) { - url := fmt.Sprintf("https://dev.azure.com/%s/%s/_apis/distributedtask/variablegroups?projectIds={projectIds}&api-version=7.1-preview.2", client.Org, client.Project) +// CreateVariableGroup crea un Variable Group nuevo en el proyecto del cliente. +func CreateVariableGroup(client *azdevops.Client, name string, variables map[string]models.VariableVal, description string) (*models.VariableGroup, error) { + if variables == nil { + variables = map[string]models.VariableVal{} + } group := models.VariableGroupById{ Name: name, Type: "Vsts", @@ -20,32 +20,10 @@ func CreateVariableGroup(client *azdevops.Client, name string, variables map[str VariableGroupProjectReferences: models.ConstructVariableGroupProjectReferences(client.Project, name, description), } - payload, err := json.Marshal(group) - if err != nil { - return false, fmt.Errorf("failed to marshal payload: %w", err) + var created models.VariableGroup + u := client.ProjectURL("distributedtask/variablegroups", url.Values{"api-version": {apiVersion}}) + if err := client.Do("POST", u, group, &created); err != nil { + return nil, fmt.Errorf("error al crear el Variable Group '%s': %w", name, err) } - - req, _ := http.NewRequest("POST", url, bytes.NewBuffer(payload)) - req.Header.Set("Authorization", client.AuthHeader()) - req.Header.Set("Content-Type", "application/json") - - resp, err := client.HTTP.Do(req) - if err != nil { - return false, fmt.Errorf("request failed: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - return false, fmt.Errorf("CreateVariableGroup error: %s", string(body)) - } - - var respJSON models.VariableGroup - if err := json.NewDecoder(resp.Body).Decode(&respJSON); err != nil { - return false, fmt.Errorf("failed to decode response: %w", err) - } - - fmt.Printf("✔ Grupo de variables creado (ID: %d)\n", respJSON.Id) - fmt.Printf("🔗 https://dev.azure.com/%s/%s/_library?itemType=variableGroups&view=VariableGroupView&variableGroupId=%d\n", client.Org, client.Project, respJSON.Id) - return true, nil + return &created, nil } diff --git a/azdevops/variable_group/delete.go b/azdevops/variable_group/delete.go index d80e58d..d0c5ab8 100644 --- a/azdevops/variable_group/delete.go +++ b/azdevops/variable_group/delete.go @@ -2,105 +2,15 @@ package variable_group import ( "azuredevops/azdevops" - org "azuredevops/azdevops/organization" - "azuredevops/models" - "bytes" - "encoding/json" "fmt" - "io" - "net/http" + "net/url" ) -func DeleteVariableGroupByName(client *azdevops.Client, names []string) (bool, error) { - if len(names) == 0 { - return false, fmt.Errorf("debes proporcionar al menos un nombre de Variable Group") +// DeleteVariableGroup elimina un Variable Group del proyecto indicado (projectID es el GUID). +func DeleteVariableGroup(client *azdevops.Client, id int, projectID string) error { + q := url.Values{"api-version": {apiVersion}, "projectIds": {projectID}} + if err := client.Do("DELETE", client.OrgURL(fmt.Sprintf("distributedtask/variablegroups/%d", id), q), nil, nil); err != nil { + return fmt.Errorf("error al eliminar el Variable Group %d: %w", id, err) } - orgInfo, err := org.GetOrganizationInfo(client) - if err != nil { - return false, fmt.Errorf("error al obtener la información del projecto: %w", err) - } - for _, name := range names { - groups, err := GetVariableGroupByName(client, name) - if err != nil { - return false, fmt.Errorf("error al obtener el grupo de variables: %w", err) - } - if len(groups) == 0 { - fmt.Printf("No se encontró el Variable Group con nombre '%s'\n", name) - continue - } - for _, group := range groups { - url := fmt.Sprintf("https://dev.azure.com/%s/_apis/distributedtask/variablegroups/%d?api-version=7.1-preview.2&projectIds=%s", client.Org, group.Id, orgInfo.ID) - req, _ := http.NewRequest("DELETE", url, nil) - req.Header.Add("Authorization", client.AuthHeader()) - - resp, err := client.HTTP.Do(req) - if err != nil { - fmt.Printf("error al eliminar el grupo de variables: %s\n", err) - return false, err - } - - if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent { - body, _ := io.ReadAll(resp.Body) - resp.Body.Close() - fmt.Printf("DeleteVariableGroupByName error: %s", string(body)) - continue - } - - resp.Body.Close() - fmt.Printf("✔ Variable Group '%s' eliminado (ID: %d)\n", name, group.Id) - } - } - return true, nil -} - -func DeleteVariableInGroupById(client *azdevops.Client, names []string, variables []string) (bool, error) { - for _, name := range names { - groups, err := GetVariableGroupByName(client, name) - if err != nil { - return false, fmt.Errorf("error al obtener el grupo de variables: %w", err) - } - if len(groups) == 0 { - fmt.Printf("No se encontró el Variable Group con nombre '%s'\n", name) - continue - } - for _, group := range groups { - url := fmt.Sprintf("https://dev.azure.com/%s/%s/_apis/distributedtask/variablegroups/%d?api-version=7.1-preview.2", client.Org, client.Project, group.Id) - for _, variable := range variables { - if _, exists := group.Variables[variable]; !exists { - fmt.Printf("Variable '%s' no encontrada en el Variable Group '%s'\n", variable, group.Name) - continue - } - delete(group.Variables, variable) - } - groupPayload := models.VariableGroupById{ - ID: group.Id, - Name: group.Name, - Type: group.Type, - Project: client.Project, - Variables: group.Variables, - VariableGroupProjectReferences: models.ConstructVariableGroupProjectReferences(client.Project, group.Name, group.Description), - } - payload, _ := json.Marshal(groupPayload) - - req, _ := http.NewRequest("PUT", url, bytes.NewBuffer(payload)) - req.Header.Add("Authorization", client.AuthHeader()) - req.Header.Add("Content-Type", "application/json") - - resp, err := client.HTTP.Do(req) - - if err != nil { - return false, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - return false, fmt.Errorf("AddVariablesToGroup error: %s", string(body)) - } - - fmt.Printf("✔ Variables eliminadas satisfactoriamente al grupo: %d\n", group.Id) - fmt.Printf("🔗 https://dev.azure.com/%s/%s/_library?itemType=variableGroups&view=VariableGroupView&variableGroupId=%d\n", client.Org, client.Project, group.Id) - } - } - return true, nil + return nil } diff --git a/azdevops/variable_group/diff.go b/azdevops/variable_group/diff.go new file mode 100644 index 0000000..ccf9e14 --- /dev/null +++ b/azdevops/variable_group/diff.go @@ -0,0 +1,81 @@ +package variable_group + +import ( + "azuredevops/models" + "sort" +) + +const ( + DiffOnlyA = "solo-a" + DiffOnlyB = "solo-b" + DiffChanged = "distinto" + DiffEqual = "igual" + DiffSecret = "secreta" + secretDisplay = "********" +) + +// VariableDiff describe la comparación de una variable entre dos grupos. +type VariableDiff struct { + Variable string `json:"variable"` + A string `json:"a"` + B string `json:"b"` + Status string `json:"status"` +} + +// Diff compara las variables de dos grupos. Los valores secretos no se pueden +// leer, así que cuando ambas son secretas el estado es "secreta" (no comparable). +func Diff(a, b map[string]models.VariableVal) []VariableDiff { + keys := map[string]bool{} + for k := range a { + keys[k] = true + } + for k := range b { + keys[k] = true + } + sorted := make([]string, 0, len(keys)) + for k := range keys { + sorted = append(sorted, k) + } + sort.Strings(sorted) + + out := make([]VariableDiff, 0, len(sorted)) + for _, k := range sorted { + va, inA := a[k] + vb, inB := b[k] + d := VariableDiff{Variable: k, A: display(va, inA), B: display(vb, inB)} + switch { + case !inB: + d.Status = DiffOnlyA + case !inA: + d.Status = DiffOnlyB + case va.IsSecret && vb.IsSecret: + d.Status = DiffSecret + case va.IsSecret != vb.IsSecret || va.Value != vb.Value: + d.Status = DiffChanged + default: + d.Status = DiffEqual + } + out = append(out, d) + } + return out +} + +// HasDifferences indica si alguna variable difiere (las secretas no cuentan). +func HasDifferences(diffs []VariableDiff) bool { + for _, d := range diffs { + if d.Status != DiffEqual && d.Status != DiffSecret { + return true + } + } + return false +} + +func display(v models.VariableVal, ok bool) string { + switch { + case !ok: + return "" + case v.IsSecret: + return secretDisplay + } + return v.Value +} diff --git a/azdevops/variable_group/diff_test.go b/azdevops/variable_group/diff_test.go new file mode 100644 index 0000000..8aac3b8 --- /dev/null +++ b/azdevops/variable_group/diff_test.go @@ -0,0 +1,40 @@ +package variable_group + +import ( + "testing" + + "azuredevops/models" +) + +func TestDiff(t *testing.T) { + a := map[string]models.VariableVal{ + "same": {Value: "1"}, "changed": {Value: "1"}, "onlyA": {Value: "x"}, + "secret": {IsSecret: true}, "toSecret": {Value: "v"}, + } + b := map[string]models.VariableVal{ + "same": {Value: "1"}, "changed": {Value: "2"}, "onlyB": {Value: "y"}, + "secret": {IsSecret: true}, "toSecret": {IsSecret: true}, + } + want := map[string]string{ + "same": DiffEqual, "changed": DiffChanged, "onlyA": DiffOnlyA, "onlyB": DiffOnlyB, + "secret": DiffSecret, "toSecret": DiffChanged, + } + diffs := Diff(a, b) + if len(diffs) != len(want) { + t.Fatalf("got %d diffs: %+v", len(diffs), diffs) + } + for _, d := range diffs { + if want[d.Variable] != d.Status { + t.Errorf("%s: status %s, want %s", d.Variable, d.Status, want[d.Variable]) + } + } + if diffs[0].Variable != "changed" { + t.Errorf("no está ordenado: %+v", diffs) + } + if !HasDifferences(diffs) { + t.Error("HasDifferences debería ser true") + } + if HasDifferences(Diff(map[string]models.VariableVal{"s": {IsSecret: true}}, map[string]models.VariableVal{"s": {IsSecret: true}})) { + t.Error("dos secretas no cuentan como diferencia") + } +} diff --git a/azdevops/variable_group/get.go b/azdevops/variable_group/get.go index 4572f70..aee40b5 100644 --- a/azdevops/variable_group/get.go +++ b/azdevops/variable_group/get.go @@ -3,45 +3,43 @@ package variable_group import ( "azuredevops/azdevops" "azuredevops/models" - "encoding/json" "fmt" - "net/http" + "net/url" + "sort" ) -func GetVariableGroupByName(client *azdevops.Client, name string) ([]models.VariableGroup, error) { - url := fmt.Sprintf("https://dev.azure.com/%s/%s/_apis/distributedtask/variablegroups?api-version=7.1-preview.2&groupName=%s", client.Org, client.Project, name) - req, _ := http.NewRequest("GET", url, nil) - req.Header.Add("Authorization", client.AuthHeader()) +const apiVersion = "7.1-preview.2" - resp, err := client.HTTP.Do(req) - if err != nil { - return nil, err +// ListVariableGroups lista los Variable Groups del proyecto. filter admite comodines +// (p. ej. "app-*"); vacío devuelve todos. +func ListVariableGroups(client *azdevops.Client, filter string) ([]models.VariableGroup, error) { + q := url.Values{"api-version": {apiVersion}} + if filter != "" { + q.Set("groupName", filter) } - defer resp.Body.Close() - var result struct { Value []models.VariableGroup `json:"value"` } - - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return nil, err + if err := client.Do("GET", client.ProjectURL("distributedtask/variablegroups", q), nil, &result); err != nil { + return nil, fmt.Errorf("error al listar los Variable Groups: %w", err) } + sort.Slice(result.Value, func(i, j int) bool { return result.Value[i].Name < result.Value[j].Name }) return result.Value, nil } -func GetVariableGroupById(client *azdevops.Client, id int) (*models.VariableGroup, error) { - url := fmt.Sprintf("https://dev.azure.com/%s/%s/_apis/distributedtask/variablegroups/%d?api-version=7.1-preview.2", client.Org, client.Project, id) - req, _ := http.NewRequest("GET", url, nil) - req.Header.Add("Authorization", client.AuthHeader()) - - resp, err := client.HTTP.Do(req) - if err != nil { - return nil, err +// GetVariableGroupByName busca Variable Groups por nombre (admite comodines). +func GetVariableGroupByName(client *azdevops.Client, name string) ([]models.VariableGroup, error) { + if name == "" { + return nil, fmt.Errorf("el nombre del Variable Group no puede estar vacío") } - defer resp.Body.Close() + return ListVariableGroups(client, name) +} +// GetVariableGroupById obtiene un Variable Group por su ID. +func GetVariableGroupById(client *azdevops.Client, id int) (*models.VariableGroup, error) { var result models.VariableGroup - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + u := client.ProjectURL(fmt.Sprintf("distributedtask/variablegroups/%d", id), url.Values{"api-version": {apiVersion}}) + if err := client.Do("GET", u, nil, &result); err != nil { return nil, err } return &result, nil diff --git a/azdevops/variable_group/permissions.go b/azdevops/variable_group/permissions.go index c8bfb24..e1bc23d 100644 --- a/azdevops/variable_group/permissions.go +++ b/azdevops/variable_group/permissions.go @@ -2,63 +2,43 @@ package variable_group import ( "azuredevops/azdevops" - "azuredevops/azdevops/organization" "azuredevops/models" - "bytes" - "encoding/json" "fmt" - "io" - "net/http" + "net/url" "strings" ) -func SetPermissionsOptimized(client *azdevops.Client, variableGroup *models.VariableGroup, identityIDs []string, role string) error { - validRoles := map[string]bool{"reader": true, "user": true, "administrator": true} - normalizedRole := strings.ToLower(role) - if !validRoles[normalizedRole] { - return fmt.Errorf("el rol '%s' no es válido. Roles válidos: Reader, User, Administrator", role) - } - role = strings.Title(normalizedRole) +// ValidRoles son los roles que se pueden asignar en un Variable Group. +var ValidRoles = []string{"Reader", "User", "Administrator"} - assignments := make([]models.SecurityRoleAssignment, len(identityIDs)) - for i, id := range identityIDs { - assignments[i] = models.SecurityRoleAssignment{ - RoleName: role, - UserID: id, +// NormalizeRole valida un rol sin distinguir mayúsculas y lo devuelve con el formato de la API. +func NormalizeRole(role string) (string, error) { + for _, r := range ValidRoles { + if strings.EqualFold(r, role) { + return r, nil } } + return "", fmt.Errorf("el rol '%s' no es válido. Roles válidos: %s", role, strings.Join(ValidRoles, ", ")) +} - if len(assignments) == 0 { - return fmt.Errorf("no se proporcionaron identidades para asignar permisos") - } - - project, err := organization.GetOrganizationInfo(client) +// SetPermissions asigna un rol a las identidades indicadas en un Variable Group. +func SetPermissions(client *azdevops.Client, projectID string, groupID int, identityIDs []string, role string) error { + role, err := NormalizeRole(role) if err != nil { - return fmt.Errorf("no se pudo obtener la información del proyecto: %w", err) + return err } - - payload, err := json.Marshal(assignments) - if err != nil { - return fmt.Errorf("error al serializar el payload: %w", err) + if len(identityIDs) == 0 { + return fmt.Errorf("no se proporcionaron identidades para asignar permisos") } - url := fmt.Sprintf("https://dev.azure.com/%s/_apis/securityroles/scopes/distributedtask.variablegroup/roleassignments/resources/%s$%d?api-version=7.1-preview.1", - client.Org, project.ID, variableGroup.Id) - - req, _ := http.NewRequest("PUT", url, bytes.NewBuffer(payload)) - req.Header.Set("Authorization", client.AuthHeader()) - req.Header.Set("Content-Type", "application/json") - - resp, err := client.HTTP.Do(req) - if err != nil { - return err + assignments := make([]models.SecurityRoleAssignment, len(identityIDs)) + for i, id := range identityIDs { + assignments[i] = models.SecurityRoleAssignment{RoleName: role, UserID: id} } - defer resp.Body.Close() - if resp.StatusCode >= 300 { - body, _ := io.ReadAll(resp.Body) - return fmt.Errorf("error al asignar permisos (código %d): %s", resp.StatusCode, string(body)) + path := fmt.Sprintf("securityroles/scopes/distributedtask.variablegroup/roleassignments/resources/%s$%d", projectID, groupID) + if err := client.Do("PUT", client.OrgURL(path, url.Values{"api-version": {"7.1-preview.1"}}), assignments, nil); err != nil { + return fmt.Errorf("error al asignar permisos: %w", err) } - return nil } diff --git a/azdevops/variable_group/update.go b/azdevops/variable_group/update.go index 362e93d..18f1e3d 100644 --- a/azdevops/variable_group/update.go +++ b/azdevops/variable_group/update.go @@ -3,61 +3,59 @@ package variable_group import ( "azuredevops/azdevops" "azuredevops/models" - "bytes" - "encoding/json" "fmt" - "io" "maps" - "net/http" + "net/url" ) -func AddVariablesToGroup(client *azdevops.Client, names []string, variables map[string]models.VariableVal, description string) (bool, error) { - for _, name := range names { - groups, err := GetVariableGroupByName(client, name) - if err != nil { - return false, fmt.Errorf("error al obtener el grupo de variables: %w", err) - } - if len(groups) == 0 { - fmt.Printf("No se encontró el Variable Group con nombre '%s'\n", name) - continue - } - for _, group := range groups { - url := fmt.Sprintf("https://dev.azure.com/%s/%s/_apis/distributedtask/variablegroups/%d?api-version=7.1-preview.2", client.Org, client.Project, group.Id) - if description != "" { - group.Description = description - } - mergedVars := make(map[string]models.VariableVal) - maps.Copy(mergedVars, group.Variables) - maps.Copy(mergedVars, variables) - groupPayload := models.VariableGroupById{ - ID: group.Id, - Name: group.Name, - Type: group.Type, - Project: client.Project, - Variables: mergedVars, - VariableGroupProjectReferences: models.ConstructVariableGroupProjectReferences(client.Project, group.Name, group.Description), - } - payload, _ := json.Marshal(groupPayload) - - req, _ := http.NewRequest("PUT", url, bytes.NewBuffer(payload)) - req.Header.Add("Authorization", client.AuthHeader()) - req.Header.Add("Content-Type", "application/json") - - resp, err := client.HTTP.Do(req) - - if err != nil { - return false, err - } - defer resp.Body.Close() +// UpdateVariableGroup reemplaza el contenido de un Variable Group con el recibido. +// Las variables secretas sin valor conservan el valor que ya tenían en Azure DevOps. +func UpdateVariableGroup(client *azdevops.Client, group models.VariableGroup) (*models.VariableGroup, error) { + payload := models.VariableGroupById{ + ID: group.Id, + Name: group.Name, + Type: group.Type, + Project: client.Project, + Variables: group.Variables, + VariableGroupProjectReferences: models.ConstructVariableGroupProjectReferences(client.Project, group.Name, group.Description), + } + var updated models.VariableGroup + u := client.ProjectURL(fmt.Sprintf("distributedtask/variablegroups/%d", group.Id), url.Values{"api-version": {apiVersion}}) + if err := client.Do("PUT", u, payload, &updated); err != nil { + return nil, fmt.Errorf("error al actualizar el Variable Group '%s': %w", group.Name, err) + } + return &updated, nil +} - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - return false, fmt.Errorf("AddVariablesToGroup error: %s", string(body)) - } +// AddVariablesToGroup agrega o sobrescribe variables (y opcionalmente la descripción) de un grupo. +func AddVariablesToGroup(client *azdevops.Client, group models.VariableGroup, variables map[string]models.VariableVal, description string) (*models.VariableGroup, error) { + if description != "" { + group.Description = description + } + merged := make(map[string]models.VariableVal, len(group.Variables)+len(variables)) + maps.Copy(merged, group.Variables) + maps.Copy(merged, variables) + group.Variables = merged + return UpdateVariableGroup(client, group) +} - fmt.Printf("✔ Variables agregadas satisfactoriamente al grupo: %d\n", group.Id) - fmt.Printf("🔗 https://dev.azure.com/%s/%s/_library?itemType=variableGroups&view=VariableGroupView&variableGroupId=%d\n", client.Org, client.Project, group.Id) +// RemoveVariablesFromGroup elimina las variables indicadas de un grupo. Devuelve las +// variables que no existían en el grupo. +func RemoveVariablesFromGroup(client *azdevops.Client, group models.VariableGroup, keys []string) (missing []string, err error) { + remaining := maps.Clone(group.Variables) + removed := 0 + for _, key := range keys { + if _, ok := remaining[key]; !ok { + missing = append(missing, key) + continue } + delete(remaining, key) + removed++ + } + if removed == 0 { + return missing, nil } - return true, nil + group.Variables = remaining + _, err = UpdateVariableGroup(client, group) + return missing, err } diff --git a/azdevops/workitem/workitem.go b/azdevops/workitem/workitem.go new file mode 100644 index 0000000..1ecd30c --- /dev/null +++ b/azdevops/workitem/workitem.go @@ -0,0 +1,173 @@ +package workitem + +import ( + "azuredevops/azdevops" + "azuredevops/models" + "fmt" + "net/url" + "strconv" + "strings" +) + +const patchContentType = "application/json-patch+json" + +// DefaultFields son los campos que se piden al listar. +var DefaultFields = []string{ + "System.Id", "System.WorkItemType", "System.Title", "System.State", + "System.AssignedTo", "System.ChangedDate", "System.IterationPath", "System.Tags", +} + +// QuoteWIQL escapa un texto para usarlo entre comillas simples en WIQL. +func QuoteWIQL(s string) string { + return "'" + strings.ReplaceAll(s, "'", "''") + "'" +} + +// Query ejecuta una consulta WIQL y devuelve los IDs encontrados. +func Query(client *azdevops.Client, wiql string, top int) ([]int, error) { + q := url.Values{"api-version": {"7.1"}} + if top > 0 { + q.Set("$top", strconv.Itoa(top)) + } + var result struct { + WorkItems []struct { + ID int `json:"id"` + } `json:"workItems"` + } + if err := client.Do("POST", client.ProjectURL("wit/wiql", q), map[string]string{"query": wiql}, &result); err != nil { + return nil, fmt.Errorf("error en la consulta WIQL: %w", err) + } + ids := make([]int, len(result.WorkItems)) + for i, w := range result.WorkItems { + ids[i] = w.ID + } + return ids, nil +} + +// GetBatch obtiene varios work items (en lotes de 200) conservando el orden de ids. +func GetBatch(client *azdevops.Client, ids []int, fields []string) ([]models.WorkItem, error) { + byID := map[int]models.WorkItem{} + for start := 0; start < len(ids); start += 200 { + end := min(start+200, len(ids)) + body := map[string]any{"ids": ids[start:end], "fields": fields} + var result struct { + Value []models.WorkItem `json:"value"` + } + if err := client.Do("POST", client.ProjectURL("wit/workitemsbatch", url.Values{"api-version": {"7.1"}}), body, &result); err != nil { + return nil, fmt.Errorf("error al obtener los work items: %w", err) + } + for _, w := range result.Value { + byID[w.ID] = w + } + } + out := make([]models.WorkItem, 0, len(ids)) + for _, id := range ids { + if w, ok := byID[id]; ok { + out = append(out, w) + } + } + return out, nil +} + +// Get obtiene un work item con todos sus campos y relaciones. +func Get(client *azdevops.Client, id int) (*models.WorkItem, error) { + var w models.WorkItem + u := client.ProjectURL(fmt.Sprintf("wit/workitems/%d", id), url.Values{"api-version": {"7.1"}, "$expand": {"relations"}}) + if err := client.Do("GET", u, nil, &w); err != nil { + return nil, fmt.Errorf("error al obtener el work item %d: %w", id, err) + } + return &w, nil +} + +type patchOp struct { + Op string `json:"op"` + Path string `json:"path"` + Value any `json:"value"` +} + +func fieldOps(fields map[string]any) []patchOp { + ops := make([]patchOp, 0, len(fields)) + for k, v := range fields { + ops = append(ops, patchOp{Op: "add", Path: "/fields/" + k, Value: v}) + } + return ops +} + +func parentOp(client *azdevops.Client, parentID int) patchOp { + return patchOp{Op: "add", Path: "/relations/-", Value: map[string]any{ + "rel": "System.LinkTypes.Hierarchy-Reverse", + "url": client.OrgURL(fmt.Sprintf("wit/workItems/%d", parentID), nil), + }} +} + +// Create crea un work item del tipo indicado. parentID (opcional) lo enlaza como hijo. +func Create(client *azdevops.Client, itemType string, fields map[string]any, parentID int) (*models.WorkItem, error) { + ops := fieldOps(fields) + if parentID > 0 { + ops = append(ops, parentOp(client, parentID)) + } + var w models.WorkItem + u := client.ProjectURL("wit/workitems/$"+url.PathEscape(itemType), url.Values{"api-version": {"7.1"}}) + if err := client.DoContentType("POST", u, patchContentType, ops, &w); err != nil { + return nil, fmt.Errorf("error al crear el work item: %w", err) + } + return &w, nil +} + +// Update modifica campos de un work item. Un comentario se agrega con el campo System.History. +func Update(client *azdevops.Client, id int, fields map[string]any) (*models.WorkItem, error) { + var w models.WorkItem + u := client.ProjectURL(fmt.Sprintf("wit/workitems/%d", id), url.Values{"api-version": {"7.1"}}) + if err := client.DoContentType("PATCH", u, patchContentType, fieldOps(fields), &w); err != nil { + return nil, fmt.Errorf("error al actualizar el work item %d: %w", id, err) + } + return &w, nil +} + +// Delete envía un work item a la papelera de reciclaje. +func Delete(client *azdevops.Client, id int) error { + u := client.ProjectURL(fmt.Sprintf("wit/workitems/%d", id), url.Values{"api-version": {"7.1"}}) + if err := client.Do("DELETE", u, nil, nil); err != nil { + return fmt.Errorf("error al eliminar el work item %d: %w", id, err) + } + return nil +} + +// Types lista los tipos de work item habilitados del proyecto. +func Types(client *azdevops.Client) ([]models.WorkItemType, error) { + var result struct { + Value []models.WorkItemType `json:"value"` + } + if err := client.Do("GET", client.ProjectURL("wit/workitemtypes", url.Values{"api-version": {"7.1"}}), nil, &result); err != nil { + return nil, err + } + out := result.Value[:0] + for _, t := range result.Value { + if !t.IsDisabled { + out = append(out, t) + } + } + return out, nil +} + +// States lista los estados posibles de un tipo de work item. +func States(client *azdevops.Client, itemType string) ([]string, error) { + var result struct { + Value []struct { + Name string `json:"name"` + } `json:"value"` + } + u := client.ProjectURL("wit/workitemtypes/"+url.PathEscape(itemType)+"/states", url.Values{"api-version": {"7.1"}}) + if err := client.Do("GET", u, nil, &result); err != nil { + return nil, err + } + states := make([]string, len(result.Value)) + for i, s := range result.Value { + states[i] = s.Name + } + return states, nil +} + +// WebURL devuelve el enlace del portal a un work item. +func WebURL(client *azdevops.Client, id int) string { + return client.WebURL(fmt.Sprintf("_workitems/edit/%d", id)) +} diff --git a/cmd/client.go b/cmd/client.go new file mode 100644 index 0000000..fae00e4 --- /dev/null +++ b/cmd/client.go @@ -0,0 +1,95 @@ +package cmd + +import ( + "fmt" + "os" + "strings" + + "azuredevops/azdevops" + "azuredevops/internal/config" + "azuredevops/internal/ui" +) + +var cachedClient *azdevops.Client + +// ResolveSettings combina flags, variables de entorno y el perfil guardado. +// Precedencia: flags > (--profile explícito > env) > perfil actual. +func ResolveSettings() (config.Profile, string, error) { + cfg, err := config.Load() + if err != nil { + return config.Profile{}, "", err + } + profileName := globalFlags.profile + if profileName == "" { + profileName = os.Getenv("AZDEVOPS_PROFILE") + } + profile, name, found := cfg.Profile(profileName) + if profileName != "" && !found { + return config.Profile{}, "", fmt.Errorf("el perfil '%s' no existe (perfiles: %s)", profileName, strings.Join(cfg.Names(), ", ")) + } + + pick := func(flag, env, prof string) string { + if flag != "" { + return flag + } + if profileName != "" && prof != "" { + return prof + } + if v := os.Getenv(env); v != "" { + return v + } + return prof + } + resolved := config.Profile{ + Org: pick(globalFlags.org, "AZURE_ORG", profile.Org), + Project: pick(globalFlags.project, "AZURE_PROJECT", profile.Project), + } + + // El PAT del perfil puede estar en el llavero: solo se lee si hace falta. + switch { + case globalFlags.pat != "": + resolved.PAT = globalFlags.pat + case os.Getenv("AZURE_PAT") != "" && !(profileName != "" && found): + resolved.PAT = os.Getenv("AZURE_PAT") + case found: + if resolved.PAT, err = profile.GetPAT(name); err != nil { + return resolved, name, err + } + } + return resolved, name, nil +} + +// NewClient construye el cliente de Azure DevOps. Si falta configuración y la +// sesión es interactiva, ofrece ejecutar el asistente de login. +func NewClient() (*azdevops.Client, error) { + if cachedClient != nil { + return cachedClient, nil + } + settings, _, err := ResolveSettings() + if err != nil { + return nil, err + } + if settings.Org == "" || settings.Project == "" || settings.PAT == "" { + if !ui.Interactive() { + return nil, fmt.Errorf("falta configuración: define AZURE_ORG, AZURE_PROJECT y AZURE_PAT, usa --org/--project/--pat o ejecuta 'azdevops login'") + } + ui.Warn("No hay credenciales configuradas. Vamos a configurarlas.") + if settings, err = runLoginWizard(config.DefaultProfile, settings); err != nil { + return nil, err + } + } + client := azdevops.NewClient(settings.Org, settings.Project, settings.PAT) + client.Debug = globalFlags.debug || os.Getenv("AZDEVOPS_DEBUG") != "" + // Permite apuntar a otro host (pruebas o Azure DevOps Server). + if u := os.Getenv("AZDEVOPS_BASE_URL"); u != "" { + client.BaseURL = strings.TrimRight(u, "/") + } + if u := os.Getenv("AZDEVOPS_VSSPS_URL"); u != "" { + client.VSSPSBaseURL = strings.TrimRight(u, "/") + } + cachedClient = client + return client, nil +} + +// ResetClient descarta el cliente en caché (p. ej. al cambiar de perfil en el modo interactivo). +func ResetClient() { cachedClient = nil } diff --git a/cmd/config.go b/cmd/config.go new file mode 100644 index 0000000..038cdde --- /dev/null +++ b/cmd/config.go @@ -0,0 +1,187 @@ +package cmd + +import ( + "fmt" + "os" + + "azuredevops/internal/config" + "azuredevops/internal/ui" + + "github.com/spf13/cobra" +) + +var configCmd = &cobra.Command{ + Use: "config", + Short: "Gestiona los perfiles guardados (organización/proyecto/PAT)", +} + +var configViewCmd = &cobra.Command{ + Use: "view", + Short: "Muestra los perfiles y la configuración efectiva", + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := config.Load() + if err != nil { + return err + } + out, err := Output("table") + if err != nil { + return err + } + settings, active, err := ResolveSettings() + if err != nil { + return err + } + if out != "table" { + type view struct { + Name string `json:"name"` + Current bool `json:"current"` + Org string `json:"org"` + Project string `json:"project"` + PAT string `json:"pat"` + } + var views []view + for _, n := range cfg.Names() { + p := cfg.Profiles[n] + views = append(views, view{n, n == cfg.Current, p.Org, p.Project, profilePAT(p)}) + } + return Print(views) + } + + var rows [][]string + for _, n := range cfg.Names() { + p := cfg.Profiles[n] + marker := "" + if n == cfg.Current { + marker = "*" + } + rows = append(rows, []string{marker, n, p.Org, p.Project, profilePAT(p)}) + } + if len(rows) > 0 { + ui.Table([]string{"", "perfil", "organización", "proyecto", "pat"}, rows) + } else { + ui.Info("No hay perfiles guardados. Ejecuta 'azdevops login'.") + } + fmt.Fprintf(ui.Out, "\nConfiguración efectiva (perfil '%s' + variables de entorno + flags):\n org=%s project=%s pat=%s\n", + active, settings.Org, settings.Project, maskPAT(settings.PAT)) + return nil + }, +} + +var configUseCmd = &cobra.Command{ + Use: "use [perfil]", + Short: "Cambia el perfil por defecto", + Args: cobra.MaximumNArgs(1), + ValidArgsFunction: func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { + cfg, _ := config.Load() + return cfg.Names(), cobra.ShellCompDirectiveNoFileComp + }, + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := config.Load() + if err != nil { + return err + } + var name string + if len(args) == 1 { + name = args[0] + } else if ui.Interactive() { + opts := []ui.Option[string]{} + for _, n := range cfg.Names() { + p := cfg.Profiles[n] + opts = append(opts, ui.Option[string]{Label: fmt.Sprintf("%s (%s/%s)", n, p.Org, p.Project), Value: n}) + } + if name, err = ui.Select("Perfil a usar", opts); err != nil { + return err + } + } else { + return fmt.Errorf("indica el nombre del perfil") + } + if _, ok := cfg.Profiles[name]; !ok { + return fmt.Errorf("el perfil '%s' no existe", name) + } + cfg.Current = name + if err := cfg.Save(); err != nil { + return err + } + ResetClient() + ui.Success("Ahora se usa el perfil '%s'", name) + return nil + }, +} + +var configDeleteCmd = &cobra.Command{ + Use: "delete [perfil]", + Short: "Elimina un perfil guardado", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := config.Load() + if err != nil { + return err + } + var name string + if len(args) == 1 { + name = args[0] + } else if ui.Interactive() { + opts := []ui.Option[string]{} + for _, n := range cfg.Names() { + opts = append(opts, ui.Option[string]{Label: n, Value: n}) + } + if name, err = ui.Select("Perfil a eliminar", opts); err != nil { + return err + } + if err := Confirm(fmt.Sprintf("¿Eliminar el perfil '%s'?", name), false); err != nil { + return err + } + } else { + return fmt.Errorf("indica el nombre del perfil") + } + if _, ok := cfg.Profiles[name]; !ok { + return fmt.Errorf("el perfil '%s' no existe", name) + } + delete(cfg.Profiles, name) + config.DeletePAT(name) + if cfg.Current == name { + cfg.Current = "" + } + if err := cfg.Save(); err != nil { + return err + } + ResetClient() + ui.Success("Perfil '%s' eliminado", name) + return nil + }, +} + +var configPathCmd = &cobra.Command{ + Use: "path", + Short: "Muestra la ruta del archivo de configuración", + RunE: func(cmd *cobra.Command, args []string) error { + path, err := config.Path() + if err != nil { + return err + } + fmt.Fprintln(os.Stdout, path) + return nil + }, +} + +func profilePAT(p config.Profile) string { + if p.InKeyring() { + return "(llavero del sistema)" + } + return maskPAT(p.PAT) +} + +func maskPAT(pat string) string { + if pat == "" { + return "(no definido)" + } + if len(pat) <= 4 { + return "****" + } + return "****" + pat[len(pat)-4:] +} + +func init() { + configCmd.AddCommand(configViewCmd, configUseCmd, configDeleteCmd, configPathCmd) + RootCmd.AddCommand(configCmd) +} diff --git a/cmd/environments/approvals.go b/cmd/environments/approvals.go new file mode 100644 index 0000000..15385da --- /dev/null +++ b/cmd/environments/approvals.go @@ -0,0 +1,180 @@ +package environments + +import ( + "fmt" + "strings" + + "azuredevops/azdevops" + "azuredevops/azdevops/environment" + "azuredevops/cmd" + "azuredevops/internal/ui" + "azuredevops/models" + + "github.com/spf13/cobra" +) + +var approvalsCmd = &cobra.Command{ + Use: "approvals", + Aliases: []string{"approval"}, + Short: "Lista, aprueba o rechaza aprobaciones de despliegue pendientes", +} + +var listApprovalsCmd = &cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + Short: "Lista las aprobaciones (pendientes por defecto)", + Example: ` azdevops approvals list + azdevops approvals list --state all -o json`, + RunE: func(c *cobra.Command, args []string) error { + out, err := cmd.Output("table") + if err != nil { + return err + } + client, err := cmd.NewClient() + if err != nil { + return err + } + state, _ := c.Flags().GetString("state") + if state == "all" { + state = "" + } + approvals, err := environment.ListApprovals(client, state) + if err != nil { + return err + } + if out != "table" { + return cmd.Print(nonNil(approvals)) + } + if len(approvals) == 0 { + if state == "pending" { + ui.Info("No hay aprobaciones pendientes.") + } else { + ui.Info("No hay aprobaciones.") + } + return nil + } + rows := make([][]string, len(approvals)) + for i, a := range approvals { + rows[i] = []string{a.ID, approvalPipeline(a), ui.Status(a.Status), approvers(a), ui.FormatTime(a.CreatedOn), a.Instructions} + } + ui.Table([]string{"id", "pipeline / ejecución", "estado", "aprobadores", "creada", "instrucciones"}, rows) + return nil + }, +} + +var approveCmd = &cobra.Command{ + Use: "approve", + Short: "Aprueba una o más aprobaciones pendientes", + Example: ` azdevops approvals approve --id --comment "OK para producción" + azdevops approvals approve # elegir de la lista`, + RunE: func(c *cobra.Command, args []string) error { + return decide(c, "approved") + }, +} + +var rejectCmd = &cobra.Command{ + Use: "reject", + Short: "Rechaza una o más aprobaciones pendientes", + RunE: func(c *cobra.Command, args []string) error { + return decide(c, "rejected") + }, +} + +func decide(c *cobra.Command, status string) error { + client, err := cmd.NewClient() + if err != nil { + return err + } + verb := map[string]string{"approved": "aprobar", "rejected": "rechazar"}[status] + ids, err := cmd.ResolveSliceFlag(c, "id", func() ([]string, error) { + return pickPending(client, "Aprobaciones a "+verb) + }) + if err != nil { + return err + } + comment, _ := c.Flags().GetString("comment") + if comment == "" && ui.Interactive() { + if comment, err = ui.Input("Comentario (opcional)", "", "", nil); err != nil { + return err + } + } + yes, _ := c.Flags().GetBool("yes") + if status == "rejected" || ui.Interactive() { + if err := cmd.Confirm(fmt.Sprintf("¿%s %d aprobación(es)?", strings.ToUpper(verb[:1])+verb[1:], len(ids)), yes); err != nil { + return err + } + } + + updated, err := environment.UpdateApprovals(client, ids, status, comment) + if err != nil { + return err + } + for _, a := range updated { + ui.Success("%s → %s", a.ID, ui.Status(a.Status)) + } + if cmd.WantsData() { + return cmd.Print(updated) + } + return nil +} + +func pickPending(client *azdevops.Client, title string) ([]string, error) { + var pending []models.Approval + if err := ui.Spinner("Cargando aprobaciones pendientes...", func() (err error) { + pending, err = environment.ListApprovals(client, "pending") + return err + }); err != nil { + return nil, err + } + if len(pending) == 0 { + return nil, fmt.Errorf("no hay aprobaciones pendientes") + } + opts := make([]ui.Option[string], len(pending)) + for i, a := range pending { + label := approvalPipeline(a) + if a.Instructions != "" { + label += " · " + a.Instructions + } + opts[i] = ui.Option[string]{Label: label, Value: a.ID} + } + return ui.MultiSelect(title, opts) +} + +func approvalPipeline(a models.Approval) string { + if a.Pipeline == nil { + return "-" + } + if a.Pipeline.Owner.Name != "" { + return a.Pipeline.Name + " / " + a.Pipeline.Owner.Name + } + return a.Pipeline.Name +} + +func approvers(a models.Approval) string { + names := make([]string, 0, len(a.Steps)) + for _, s := range a.Steps { + n := s.AssignedApprover.DisplayName + if s.ActualApprover != nil && s.ActualApprover.DisplayName != "" { + n = s.ActualApprover.DisplayName + } + if n == "" { + n = "-" + } + if s.Status != "" && s.Status != "pending" && s.Status != "undefined" { + n += " (" + s.Status + ")" + } + names = append(names, n) + } + return strings.Join(names, ", ") +} + +func init() { + listApprovalsCmd.Flags().String("state", "pending", "Estado: pending, approved, rejected, all…") + for _, c := range []*cobra.Command{approveCmd, rejectCmd} { + c.Flags().StringSlice("id", nil, "ID de la aprobación (se puede repetir)") + c.Flags().StringP("comment", "m", "", "Comentario") + c.Flags().BoolP("yes", "y", false, "Confirmar sin preguntar") + } + approvalsCmd.AddCommand(listApprovalsCmd, approveCmd, rejectCmd) + cmd.RootCmd.AddCommand(approvalsCmd) +} diff --git a/cmd/environments/environments.go b/cmd/environments/environments.go new file mode 100644 index 0000000..5cb0667 --- /dev/null +++ b/cmd/environments/environments.go @@ -0,0 +1,136 @@ +package environments + +import ( + "fmt" + "strconv" + + "azuredevops/azdevops" + "azuredevops/azdevops/environment" + "azuredevops/cmd" + "azuredevops/internal/ui" + "azuredevops/models" + + "github.com/spf13/cobra" +) + +var environmentsCmd = &cobra.Command{ + Use: "environments", + Aliases: []string{"env", "envs"}, + Short: "Consulta environments y sus despliegues", +} + +var listEnvironmentsCmd = &cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + Short: "Lista los environments del proyecto", + RunE: func(c *cobra.Command, args []string) error { + out, err := cmd.Output("table") + if err != nil { + return err + } + client, err := cmd.NewClient() + if err != nil { + return err + } + envs, err := environment.ListEnvironments(client) + if err != nil { + return err + } + if out != "table" { + return cmd.Print(nonNil(envs)) + } + if len(envs) == 0 { + ui.Info("El proyecto no tiene environments.") + return nil + } + rows := make([][]string, len(envs)) + for i, e := range envs { + rows[i] = []string{strconv.Itoa(e.ID), e.Name, ui.FormatTime(e.LastModifiedOn), e.Description} + } + ui.Table([]string{"id", "nombre", "modificado", "descripción"}, rows) + return nil + }, +} + +var deploymentsCmd = &cobra.Command{ + Use: "deployments", + Short: "Muestra los despliegues recientes de un environment", + Example: ` azdevops environments deployments --name produccion --top 10`, + RunE: func(c *cobra.Command, args []string) error { + out, err := cmd.Output("table") + if err != nil { + return err + } + client, err := cmd.NewClient() + if err != nil { + return err + } + env, err := resolveEnvironment(c, client) + if err != nil { + return err + } + top, _ := c.Flags().GetInt("top") + deps, err := environment.ListDeployments(client, env.ID, top) + if err != nil { + return err + } + if out != "table" { + return cmd.Print(nonNil(deps)) + } + if len(deps) == 0 { + ui.Info("'%s' no tiene despliegues.", env.Name) + return nil + } + rows := make([][]string, len(deps)) + for i, d := range deps { + rows[i] = []string{ + d.Definition.Name, d.Owner.Name, d.StageName, d.JobName, ui.Status(d.Result), + ui.FormatTime(d.StartTime), ui.FormatDuration(d.StartTime, d.FinishTime), + } + } + ui.Table([]string{"pipeline", "ejecución", "etapa", "job", "resultado", "inicio", "duración"}, rows) + return nil + }, +} + +func resolveEnvironment(c *cobra.Command, client *azdevops.Client) (*models.Environment, error) { + envs, err := environment.ListEnvironments(client) + if err != nil { + return nil, err + } + id, _ := c.Flags().GetInt("id") + name, _ := c.Flags().GetString("name") + if id == 0 && name == "" { + if !ui.Interactive() { + return nil, fmt.Errorf("debes indicar el environment con --id o --name") + } + opts := make([]ui.Option[int], len(envs)) + for i, e := range envs { + opts[i] = ui.Option[int]{Label: e.Name, Value: e.ID} + } + if id, err = ui.Select("Environment", opts); err != nil { + return nil, err + } + } + for i, e := range envs { + if (id != 0 && e.ID == id) || (id == 0 && e.Name == name) { + return &envs[i], nil + } + } + return nil, fmt.Errorf("no se encontró el environment") +} + +func nonNil[T any](s []T) []T { + if s == nil { + return []T{} + } + return s +} + +func init() { + deploymentsCmd.Flags().IntP("id", "i", 0, "ID del environment") + deploymentsCmd.Flags().StringP("name", "n", "", "Nombre del environment") + deploymentsCmd.Flags().Int("top", 20, "Número máximo de despliegues") + environmentsCmd.AddCommand(listEnvironmentsCmd, deploymentsCmd) + cmd.RootCmd.AddCommand(environmentsCmd) +} diff --git a/cmd/extension/create.go b/cmd/extension/create.go new file mode 100644 index 0000000..eb0134b --- /dev/null +++ b/cmd/extension/create.go @@ -0,0 +1,430 @@ +package extension + +import ( + "fmt" + "os/exec" + "path/filepath" + "strings" + + "azuredevops/internal/scaffold" + "azuredevops/internal/ui" + + "github.com/spf13/cobra" +) + +var initCmd = &cobra.Command{ + Use: "init [carpeta]", + Short: "Crea una extensión nueva lista para compilar, probar y empaquetar", + Long: `Crea una extensión con una o más tareas, ids (UUID) nuevos, configuraciones +dev/release, iconos de ejemplo, tests con jest, empaquetado con ncc y, opcionalmente, +un tipo de conexión de servicio propio. + +En una terminal se te preguntará todo lo que no indiques con flags.`, + Example: ` azdevops ext init + azdevops ext init mi-extension --name "Mi Extensión" --publisher hendamm --task hola-mundo --yes + azdevops ext init deploy-tools --publisher hendamm --task deploy --task rollback --endpoint --install --git`, + Args: cobra.MaximumNArgs(1), + RunE: func(c *cobra.Command, args []string) error { + f := c.Flags() + yes, _ := f.GetBool("yes") + guided := ui.Interactive() && !yes + + p := scaffold.Project{} + p.Name, _ = f.GetString("name") + p.ID, _ = f.GetString("id") + p.Description, _ = f.GetString("description") + p.Publisher, _ = f.GetString("publisher") + p.DevPublisher, _ = f.GetString("dev-publisher") + p.Author, _ = f.GetString("author") + p.Repository, _ = f.GetString("repo") + p.Category, _ = f.GetString("category") + p.Public, _ = f.GetBool("public") + p.Version, _ = f.GetString("version") + license, _ := f.GetString("license") + taskSlugs, _ := f.GetStringSlice("task") + taskCategory, _ := f.GetString("task-category") + withEndpoint, _ := f.GetBool("endpoint") + endpointName, _ := f.GetString("endpoint-name") + endpointURL, _ := f.GetString("endpoint-url") + install, _ := f.GetBool("install") + gitInit, _ := f.GetBool("git") + + dir := "" + if len(args) == 1 { + dir = args[0] + } + if p.Name == "" && dir != "" { + p.Name = scaffold.Title(scaffold.Slugify(filepath.Base(dir))) + } + if p.Author == "" { + p.Author = gitConfig("user.name") + } + + var err error + ask := func(dst *string, title, desc string, validate func(string) error) error { + if !guided { + return nil + } + v, err := ui.Input(title, desc, *dst, validate) + if err == nil { + *dst = v + } + return err + } + + if err = ask(&p.Name, "Nombre de la extensión", "Como se verá en el Marketplace", ui.Required); err != nil { + return err + } + if p.Name == "" { + return ui.MissingError("name") + } + if p.ID == "" { + p.ID = scaffold.Slugify(p.Name) + } + if err = ask(&p.ID, "ID de la extensión", "kebab-case, único para tu publisher", validSlug); err != nil { + return err + } + if err = ask(&p.Description, "Descripción", "", nil); err != nil { + return err + } + if err = ask(&p.Publisher, "Publisher (release)", "ID de tu publisher en el Marketplace", validSlug); err != nil { + return err + } + if p.Publisher == "" { + return ui.MissingError("publisher") + } + if p.DevPublisher == "" { + p.DevPublisher = "dev-" + p.Publisher + } + if err = ask(&p.DevPublisher, "Publisher (dev)", "Publisher para la versión privada de pruebas", validSlug); err != nil { + return err + } + if err = ask(&p.Author, "Autor", "", nil); err != nil { + return err + } + if p.Repository == "" && guided { + p.Repository = repoGuess(p.ID) + } + if err = ask(&p.Repository, "Repositorio (opcional)", "URL del repositorio git", nil); err != nil { + return err + } + if guided { + if !f.Changed("category") { + if p.Category, err = ui.Select("Categoría del Marketplace", options(scaffold.ExtensionCategories)); err != nil { + return err + } + } + if !f.Changed("public") { + if p.Public, err = ui.Confirm("¿La versión release será pública en el Marketplace?", true); err != nil { + return err + } + } + if err = ask(&p.Version, "Versión inicial", "", validSemver); err != nil { + return err + } + if !f.Changed("license") { + if license, err = ui.Select("Licencia", []ui.Option[string]{{Label: "MIT", Value: "MIT"}, {Label: "Sin licencia (privada)", Value: "UNLICENSED"}}); err != nil { + return err + } + } + } + if p.Description == "" { + p.Description = p.Name + } + if err := validSlug(p.ID); err != nil { + return fmt.Errorf("--id: %w", err) + } + if err := validSemver(p.Version); err != nil { + return fmt.Errorf("--version: %w", err) + } + p.LicenseID = strings.ToUpper(license) + if p.LicenseID != "MIT" { + p.LicenseID = "UNLICENSED" + } + + var endpoint *scaffold.Endpoint + if guided && !f.Changed("endpoint") { + if withEndpoint, err = ui.Confirm("¿Incluir un tipo de conexión de servicio propio (URL + token)?", false); err != nil { + return err + } + } + if withEndpoint { + if endpointName == "" { + endpointName = p.Name + } + if err = ask(&endpointName, "Nombre visible de la conexión de servicio", "", ui.Required); err != nil { + return err + } + if err = ask(&endpointURL, "URL por defecto del servicio", "", nil); err != nil { + return err + } + endpoint = scaffold.NewEndpoint(p.ID, endpointName, endpointURL) + } + + var tasks []scaffold.Task + for _, slug := range taskSlugs { + if err := validSlug(slug); err != nil { + return fmt.Errorf("--task %s: %w", slug, err) + } + tasks = append(tasks, scaffold.NewTask(p.Publisher, slug, "", "", taskCategory, endpoint)) + } + if guided && len(tasks) == 0 { + for { + t, err := promptTask(p, endpoint, len(tasks) == 0, defaultTaskSlug(p.ID, len(tasks))) + if err != nil { + return err + } + tasks = append(tasks, t) + more, err := ui.Confirm("¿Agregar otra tarea?", false) + if err != nil { + return err + } + if !more { + break + } + } + } + if len(tasks) == 0 { + tasks = append(tasks, scaffold.NewTask(p.Publisher, defaultTaskSlug(p.ID, 0), "", "", taskCategory, endpoint)) + } + + if dir == "" { + dir = p.ID + } + if guided { + if !f.Changed("install") { + if install, err = ui.Confirm("¿Ejecutar npm install al terminar?", true); err != nil { + return err + } + } + if !f.Changed("git") { + if gitInit, err = ui.Confirm("¿Inicializar un repositorio git?", true); err != nil { + return err + } + } + } + + var endpoints []scaffold.Endpoint + if endpoint != nil { + endpoints = []scaffold.Endpoint{*endpoint} + } + created, err := scaffold.Generate(dir, p, tasks, endpoints) + if err != nil { + return err + } + ui.Success("Extensión '%s' creada en %s (%d archivos)", p.Name, dir, len(created)) + rows := make([][]string, len(tasks)) + for i, t := range tasks { + rows[i] = []string{t.Slug, t.Name, t.UUID} + } + ui.Table([]string{"tarea", "name (task.json)", "id"}, rows) + if endpoint != nil { + ui.Info("Conexión de servicio: %s (connectedService:%s)", endpoint.DisplayName, endpoint.Name) + } + + if gitInit { + if err := run(dir, "git", "init", "-q"); err != nil { + ui.Warn("%v", err) + } + } + if install { + if err := run(dir, "npm", "install"); err != nil { + return err + } + } + printNextSteps(dir, install) + return nil + }, +} + +var addTaskCmd = &cobra.Command{ + Use: "add-task [nombre]", + Short: "Agrega una tarea nueva a una extensión existente", + Example: ` azdevops ext add-task deploy-app + azdevops ext add-task rollback --friendly-name "Rollback App" --category Deploy --endpoint`, + Args: cobra.MaximumNArgs(1), + RunE: func(c *cobra.Command, args []string) error { + root, err := projectRoot(c) + if err != nil { + return err + } + p, endpoints, err := scaffold.ProjectInfo(root) + if err != nil { + return err + } + f := c.Flags() + friendly, _ := f.GetString("friendly-name") + description, _ := f.GetString("description") + category, _ := f.GetString("category") + useEndpoint, _ := f.GetBool("endpoint") + + slug := "" + if len(args) == 1 { + slug = args[0] + } + var t scaffold.Task + if slug == "" { + if !ui.Interactive() { + return fmt.Errorf("indica el nombre de la tarea") + } + var ep *scaffold.Endpoint + if len(endpoints) > 0 { + use, err := ui.Confirm(fmt.Sprintf("¿La tarea usará la conexión de servicio '%s'?", endpoints[0].DisplayName), false) + if err != nil { + return err + } + if use { + ep = &endpoints[0] + } + } + if t, err = promptTask(p, ep, false, ""); err != nil { + return err + } + } else { + if err := validSlug(slug); err != nil { + return err + } + var ep *scaffold.Endpoint + if useEndpoint { + if len(endpoints) == 0 { + return fmt.Errorf("la extensión no define ninguna conexión de servicio") + } + ep = &endpoints[0] + } + t = scaffold.NewTask(p.Publisher, slug, friendly, description, category, ep) + } + + created, err := scaffold.AddTask(root, p, t) + if err != nil { + return err + } + for _, f := range created { + fmt.Fprintf(ui.Err, " + %s\n", f) + } + ui.Success("Tarea '%s' creada (name: %s, id: %s)", t.Slug, t.Name, t.UUID) + ui.Info("Instala sus dependencias con: npm install (en %s)", root) + return nil + }, +} + +func promptTask(p scaffold.Project, endpoint *scaffold.Endpoint, first bool, def string) (scaffold.Task, error) { + title := "Nombre de la tarea" + if first { + title = "Nombre de la primera tarea" + } + slug, err := ui.Input(title, "kebab-case; será la carpeta en src/tasks", def, validSlug) + if err != nil { + return scaffold.Task{}, err + } + friendly, err := ui.Input("Nombre visible de la tarea", "Como aparece en el catálogo de tareas", scaffold.Title(slug), ui.Required) + if err != nil { + return scaffold.Task{}, err + } + description, err := ui.Input("Descripción de la tarea", "", friendly, nil) + if err != nil { + return scaffold.Task{}, err + } + category, err := ui.Select("Categoría de la tarea", options(scaffold.TaskCategories)) + if err != nil { + return scaffold.Task{}, err + } + return scaffold.NewTask(p.Publisher, slug, friendly, description, category, endpoint), nil +} + +func printNextSteps(dir string, installed bool) { + fmt.Fprintf(ui.Err, "\nSiguientes pasos:\n cd %s\n", dir) + if !installed { + fmt.Fprintln(ui.Err, " npm install") + } + fmt.Fprintln(ui.Err, " npm test # tests de todas las tareas") + fmt.Fprintln(ui.Err, " azdevops ext pack # genera el .vsix de dev") + fmt.Fprintln(ui.Err, " azdevops ext publish # publica y comparte con tu organización") + fmt.Fprintln(ui.Err, " azdevops ext bump # antes de cada publicación") + fmt.Fprintln(ui.Err, "Reemplaza los iconos de images/ y de cada tarea (icon.png) por los definitivos.") +} + +func defaultTaskSlug(id string, n int) string { + if n == 0 { + return id + } + return fmt.Sprintf("%s-%d", id, n+1) +} + +func options(values []string) []ui.Option[string] { + out := make([]ui.Option[string], len(values)) + for i, v := range values { + out[i] = ui.Option[string]{Label: v, Value: v} + } + return out +} + +func validSlug(s string) error { + if !scaffold.IsSlug(s) { + if suggestion := scaffold.Slugify(s); suggestion != "" { + return fmt.Errorf("usa solo minúsculas, números y guiones (p. ej. %s)", suggestion) + } + return fmt.Errorf("usa solo minúsculas, números y guiones") + } + return nil +} + +func validSemver(s string) error { + if !scaffold.IsSemver(s) { + return fmt.Errorf("usa el formato X.Y.Z") + } + return nil +} + +func gitConfig(key string) string { + out, err := exec.Command("git", "config", "--get", key).Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(out)) +} + +func repoGuess(id string) string { + user := gitConfig("github.user") + if user == "" { + return "" + } + return "https://github.com/" + user + "/" + id +} + +func init() { + f := initCmd.Flags() + f.String("name", "", "Nombre de la extensión") + f.String("id", "", "ID de la extensión (kebab-case; por defecto derivado del nombre)") + f.String("description", "", "Descripción") + f.String("publisher", "", "Publisher del Marketplace (release)") + f.String("dev-publisher", "", "Publisher para la versión dev (por defecto dev-)") + f.String("author", "", "Autor (por defecto git config user.name)") + f.String("repo", "", "URL del repositorio") + f.String("category", "Azure Pipelines", "Categoría del Marketplace") + f.Bool("public", true, "La versión release es pública") + f.String("version", "0.1.0", "Versión inicial de la extensión") + f.String("license", "MIT", "Licencia: MIT o none") + f.StringSlice("task", nil, "Tarea a crear (kebab-case; se puede repetir)") + f.String("task-category", "Utility", "Categoría de las tareas creadas con --task") + f.Bool("endpoint", false, "Incluir un tipo de conexión de servicio propio") + f.String("endpoint-name", "", "Nombre visible de la conexión de servicio") + f.String("endpoint-url", "", "URL por defecto de la conexión de servicio") + f.Bool("install", false, "Ejecutar npm install al terminar") + f.Bool("git", false, "Ejecutar git init") + f.BoolP("yes", "y", false, "No preguntar: usar flags y valores por defecto") + _ = initCmd.RegisterFlagCompletionFunc("category", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { + return scaffold.ExtensionCategories, cobra.ShellCompDirectiveNoFileComp + }) + + af := addTaskCmd.Flags() + af.String("friendly-name", "", "Nombre visible de la tarea") + af.String("description", "", "Descripción de la tarea") + af.String("category", "Utility", "Categoría de la tarea") + af.Bool("endpoint", false, "Usar la conexión de servicio de la extensión") + af.String("dir", "", "Carpeta de la extensión (por defecto se busca desde la actual)") + _ = addTaskCmd.RegisterFlagCompletionFunc("category", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { + return scaffold.TaskCategories, cobra.ShellCompDirectiveNoFileComp + }) + + extensionCmd.AddCommand(initCmd, addTaskCmd) +} diff --git a/cmd/extension/extension.go b/cmd/extension/extension.go new file mode 100644 index 0000000..27565c9 --- /dev/null +++ b/cmd/extension/extension.go @@ -0,0 +1,348 @@ +// Package extension contiene los comandos para crear y mantener extensiones de Azure DevOps. +package extension + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "azuredevops/cmd" + "azuredevops/internal/scaffold" + "azuredevops/internal/ui" + + "github.com/spf13/cobra" +) + +var extensionCmd = &cobra.Command{ + Use: "extension", + Aliases: []string{"ext"}, + Short: "Crea y mantiene extensiones de Azure DevOps (tareas de pipeline)", + Long: `Crea y mantiene extensiones de Azure DevOps con la estructura: + + src/tasks// código TypeScript de cada tarea (azure-pipelines-task-lib, jest) + tasks// salida empaquetada con ncc en un único index.js + config/dev.json overrides para el paquete privado de desarrollo + config/release.json overrides para el paquete público + images/ iconos de la extensión y de las conexiones de servicio + +azde-scripts instala y compila todas las tareas y tfx-cli genera el .vsix.`, +} + +var uuidCmd = &cobra.Command{ + Use: "uuid", + Short: "Genera UUIDs (por ejemplo para el id de una tarea)", + RunE: func(c *cobra.Command, args []string) error { + n, _ := c.Flags().GetInt("count") + for i := 0; i < n; i++ { + fmt.Fprintln(ui.Out, scaffold.NewUUID()) + } + return nil + }, +} + +var resetIDsCmd = &cobra.Command{ + Use: "reset-ids", + Short: "Genera UUIDs nuevos para todas las tareas (al partir de otra extensión)", + Long: `Asigna un id nuevo a cada task.json. Úsalo cuando copias una extensión existente +como base: dos tareas con el mismo id en la organización se pisan entre sí.`, + RunE: func(c *cobra.Command, args []string) error { + root, err := projectRoot(c) + if err != nil { + return err + } + yes, _ := c.Flags().GetBool("yes") + if err := cmd.Confirm("¿Generar ids nuevos para todas las tareas? Las tareas instaladas con el id anterior dejarán de actualizarse", yes); err != nil { + return err + } + changes, err := scaffold.ResetIDs(root) + if err != nil { + return err + } + for _, ch := range changes { + ui.Success("%s: %s → %s", ch.File, ch.From, ch.To) + } + return nil + }, +} + +var bumpCmd = &cobra.Command{ + Use: "bump", + Short: "Incrementa la versión de las tareas y de la extensión", + Long: `Incrementa la versión de las tareas (task.json) y de la extensión (config/*.json). + +Azure DevOps solo actualiza una tarea en los agentes si su versión cambia, así que +cada publicación necesita subir la versión de las tareas modificadas y la de la +extensión. Por defecto sube el patch de todas las tareas y de config/dev.json.`, + Example: ` azdevops ext bump # patch: todas las tareas + dev + azdevops ext bump --level minor --release # minor: tareas + release + azdevops ext bump --task mi-tarea --no-config + azdevops ext bump --dry-run`, + RunE: func(c *cobra.Command, args []string) error { + root, err := projectRoot(c) + if err != nil { + return err + } + f := c.Flags() + level, _ := f.GetString("level") + if level != scaffold.LevelMajor && level != scaffold.LevelMinor && level != scaffold.LevelPatch { + return fmt.Errorf("--level debe ser major, minor o patch") + } + only, _ := f.GetStringSlice("task") + dry, _ := f.GetBool("dry-run") + noTasks, _ := f.GetBool("no-tasks") + noConfig, _ := f.GetBool("no-config") + release, _ := f.GetBool("release") + all, _ := f.GetBool("all-configs") + configs := []string{"dev"} + switch { + case all: + configs = []string{"dev", "release"} + case release: + configs = []string{"release"} + } + + var changes []scaffold.Change + if !noTasks { + ch, err := scaffold.BumpTasks(root, level, only, dry) + if err != nil { + return err + } + changes = append(changes, ch...) + } + if !noConfig { + ch, err := scaffold.BumpConfigs(root, level, configs, dry) + if err != nil { + return err + } + changes = append(changes, ch...) + } + if cmd.WantsData() { + return cmd.Print(changes) + } + rows := make([][]string, len(changes)) + for i, ch := range changes { + rows[i] = []string{ch.File, ch.From, ch.To} + } + ui.Table([]string{"archivo", "antes", "después"}, rows) + if dry { + ui.Info("--dry-run: no se modificó ningún archivo") + } + return nil + }, +} + +var validateCmd = &cobra.Command{ + Use: "validate", + Short: "Revisa la extensión antes de empaquetarla (ids, contribuciones, iconos, versiones)", + RunE: func(c *cobra.Command, args []string) error { + root, err := projectRoot(c) + if err != nil { + return err + } + issues := scaffold.Validate(root) + if cmd.WantsData() { + if issues == nil { + issues = []scaffold.Issue{} + } + return cmd.Print(issues) + } + errorsFound := 0 + for _, i := range issues { + if i.Severity == "error" { + errorsFound++ + ui.Errorf("%s: %s", i.File, i.Message) + } else { + ui.Warn("%s: %s", i.File, i.Message) + } + } + if errorsFound > 0 { + return fmt.Errorf("%d error(es) en la extensión", errorsFound) + } + ui.Success("La extensión en %s es válida", root) + return nil + }, +} + +var packCmd = &cobra.Command{ + Use: "pack", + Short: "Empaqueta las tareas con ncc y genera el .vsix (dev por defecto)", + Example: ` azdevops ext pack + azdevops ext pack --release --rev-version`, + RunE: func(c *cobra.Command, args []string) error { + root, err := projectRoot(c) + if err != nil { + return err + } + if err := checkReady(root); err != nil { + return err + } + release, _ := c.Flags().GetBool("release") + rev, _ := c.Flags().GetBool("rev-version") + script := "pack" + if rev { + script = "packupversion" + } + if !release { + script += ":dev" + } + return run(root, "npm", "run", script) + }, +} + +var publishCmd = &cobra.Command{ + Use: "publish", + Short: "Publica la extensión en el Marketplace (dev por defecto) y la comparte", + Long: `Empaqueta las tareas y publica con tfx-cli usando config/dev.json (o +config/release.json con --release). + +Necesita un PAT con el scope "Marketplace (Publish)" y "All accessible +organizations": --token, la variable AZURE_MARKETPLACE_TOKEN o se pedirá. +La versión de desarrollo se comparte por defecto con la organización de tu perfil.`, + Example: ` azdevops ext publish --rev-version + azdevops ext publish --share-with otra-org + azdevops ext publish --release`, + RunE: func(c *cobra.Command, args []string) error { + root, err := projectRoot(c) + if err != nil { + return err + } + if err := checkReady(root); err != nil { + return err + } + if issues := scaffold.Validate(root); hasErrors(issues) { + return fmt.Errorf("la extensión tiene errores; ejecuta 'azdevops ext validate'") + } + f := c.Flags() + release, _ := f.GetBool("release") + rev, _ := f.GetBool("rev-version") + shareWith, _ := f.GetStringSlice("share-with") + token, _ := f.GetString("token") + if token == "" { + token = os.Getenv("AZURE_MARKETPLACE_TOKEN") + } + if token == "" { + if !ui.Interactive() { + return fmt.Errorf("falta el token del Marketplace: usa --token o AZURE_MARKETPLACE_TOKEN") + } + if token, err = ui.Password("PAT del Marketplace", "Scope: Marketplace (Publish), All accessible organizations"); err != nil { + return err + } + } + if len(shareWith) == 0 && !release { + if settings, _, err := cmd.ResolveSettings(); err == nil && settings.Org != "" { + shareWith = []string{settings.Org} + } + } + config := "config/dev.json" + if release { + config = "config/release.json" + } + + if err := run(root, "npm", "run", "package:tasks"); err != nil { + return err + } + tfxArgs := []string{"--no-install", "tfx", "extension", "publish", "--manifest-globs", "vss-extension.json", + "--overrides-file", config, "--token", token, "--no-prompt"} + if rev { + tfxArgs = append(tfxArgs, "--rev-version") + } + if len(shareWith) > 0 { + tfxArgs = append(tfxArgs, "--share-with") + tfxArgs = append(tfxArgs, shareWith...) + } + ui.Info("Publicando con %s%s", config, sharedSuffix(shareWith)) + return run(root, "npx", tfxArgs...) + }, +} + +func sharedSuffix(orgs []string) string { + if len(orgs) == 0 { + return "" + } + return " y compartiendo con " + strings.Join(orgs, ", ") +} + +func hasErrors(issues []scaffold.Issue) bool { + for _, i := range issues { + if i.Severity == "error" { + return true + } + } + return false +} + +// projectRoot localiza la raíz de la extensión desde --dir o el directorio actual. +func projectRoot(c *cobra.Command) (string, error) { + dir, _ := c.Flags().GetString("dir") + if dir == "" { + dir = "." + } + return scaffold.FindRoot(dir) +} + +func checkReady(root string) error { + if _, err := exec.LookPath("npm"); err != nil { + return fmt.Errorf("se necesita Node.js y npm en el PATH") + } + if _, err := os.Stat(filepath.Join(root, "node_modules")); err != nil { + return fmt.Errorf("faltan dependencias: ejecuta 'npm install' en %s", root) + } + return nil +} + +// run ejecuta un comando en dir mostrando su salida. +func run(dir, name string, args ...string) error { + shown := make([]string, len(args)) + for i, a := range args { + shown[i] = a + if i > 0 && args[i-1] == "--token" { + shown[i] = "****" + } + } + ui.Info("%s %s", name, strings.Join(shown, " ")) + c := exec.Command(name, args...) + c.Dir = dir + c.Stdout, c.Stderr, c.Stdin = os.Stderr, os.Stderr, os.Stdin + if err := c.Run(); err != nil { + label := name + for _, a := range shown { + if !strings.HasPrefix(a, "-") { + label += " " + a + if len(strings.Fields(label)) >= 3 { + break + } + } + } + return fmt.Errorf("'%s' falló: %w", label, err) + } + return nil +} + +func init() { + uuidCmd.Flags().IntP("count", "n", 1, "Cantidad de UUIDs") + resetIDsCmd.Flags().BoolP("yes", "y", false, "Confirmar sin preguntar") + + bf := bumpCmd.Flags() + bf.String("level", scaffold.LevelPatch, "Nivel: major, minor o patch") + bf.StringSlice("task", nil, "Solo estas tareas (carpeta en src/tasks; se puede repetir)") + bf.Bool("release", false, "Subir config/release.json en lugar de config/dev.json") + bf.Bool("all-configs", false, "Subir config/dev.json y config/release.json") + bf.Bool("no-tasks", false, "No tocar las tareas") + bf.Bool("no-config", false, "No tocar config/*.json") + bf.Bool("dry-run", false, "Mostrar los cambios sin aplicarlos") + + packCmd.Flags().Bool("release", false, "Usar config/release.json") + packCmd.Flags().Bool("rev-version", false, "Incrementar la versión de la extensión al empaquetar") + publishCmd.Flags().Bool("release", false, "Usar config/release.json") + publishCmd.Flags().Bool("rev-version", false, "Incrementar la versión de la extensión al publicar") + publishCmd.Flags().StringSlice("share-with", nil, "Organizaciones con las que compartir (por defecto la de tu perfil en dev)") + publishCmd.Flags().String("token", "", "PAT del Marketplace (env: AZURE_MARKETPLACE_TOKEN)") + + for _, c := range []*cobra.Command{resetIDsCmd, bumpCmd, validateCmd, packCmd, publishCmd} { + c.Flags().String("dir", "", "Carpeta de la extensión (por defecto se busca desde la actual)") + } + extensionCmd.AddCommand(uuidCmd, resetIDsCmd, bumpCmd, validateCmd, packCmd, publishCmd) + cmd.RootCmd.AddCommand(extensionCmd) +} diff --git a/cmd/helpers.go b/cmd/helpers.go new file mode 100644 index 0000000..ab326da --- /dev/null +++ b/cmd/helpers.go @@ -0,0 +1,165 @@ +package cmd + +import ( + "fmt" + "slices" + "strconv" + "strings" + + "azuredevops/internal/ui" + "azuredevops/models" + + "github.com/spf13/cobra" +) + +// Output devuelve el formato de salida pedido con -o, o def si no se indicó. +// Con --query y sin -o explícito se usa json en lugar de table. +func Output(def string) (string, error) { + out := strings.ToLower(globalFlags.output) + if out == "" { + out = def + if out == "table" && globalFlags.query != "" { + out = "json" + } + } + if out != "table" && !slices.Contains(ui.DataFormats, out) { + return "", fmt.Errorf("formato de salida '%s' no soportado (usa table, json, yaml o tsv)", out) + } + return out, nil +} + +// Print imprime datos en el formato pedido (json por defecto), aplicando --query. +func Print(v any) error { + out, err := Output("json") + if err != nil { + return err + } + if out == "table" { + out = "json" + } + return ui.Render(ui.Out, v, out, globalFlags.query) +} + +// WantsData indica si el usuario pidió explícitamente datos (-o json/yaml/tsv o --query), +// útil en comandos de acción que normalmente solo muestran mensajes. +func WantsData() bool { + o := strings.ToLower(globalFlags.output) + return globalFlags.query != "" || (o != "" && o != "table") +} + +// ParseVariables interpreta entradas "clave=valor" o "secret:clave=valor". +func ParseVariables(entries []string) (map[string]models.VariableVal, error) { + vars := make(map[string]models.VariableVal, len(entries)) + for _, entry := range entries { + key, value, secret, err := parseEntry(entry) + if err != nil { + return nil, err + } + vars[key] = models.VariableVal{Value: value, IsSecret: secret} + } + return vars, nil +} + +// ParseBuildVariables es como ParseVariables pero para variables de ejecución de pipelines. +func ParseBuildVariables(entries []string) (map[string]models.BuildVariable, error) { + vars := make(map[string]models.BuildVariable, len(entries)) + for _, entry := range entries { + key, value, secret, err := parseEntry(entry) + if err != nil { + return nil, err + } + vars[key] = models.BuildVariable{Value: value, IsSecret: secret} + } + return vars, nil +} + +// ParseParams interpreta entradas "clave=valor". +func ParseParams(entries []string) (map[string]string, error) { + params := make(map[string]string, len(entries)) + for _, entry := range entries { + key, value, ok := strings.Cut(entry, "=") + key = strings.TrimSpace(key) + if !ok || key == "" { + return nil, fmt.Errorf("parámetro inválido '%s': usa clave=valor", entry) + } + params[key] = value + } + return params, nil +} + +func parseEntry(entry string) (key, value string, secret bool, err error) { + raw := entry + if rest, ok := strings.CutPrefix(raw, "secret:"); ok { + secret = true + raw = rest + } + key, value, ok := strings.Cut(raw, "=") + key = strings.TrimSpace(key) + if !ok { + return "", "", false, fmt.Errorf("formato de variable inválido '%s': usa clave=valor o secret:clave=valor", entry) + } + if key == "" { + return "", "", false, fmt.Errorf("la clave no puede estar vacía en '%s'", entry) + } + return key, strings.TrimSpace(value), secret, nil +} + +// Confirm pide confirmación salvo que yes sea true. Sin terminal exige --yes. +func Confirm(message string, yes bool) error { + if yes { + return nil + } + if !ui.Interactive() { + return fmt.Errorf("%s: usa --yes para confirmar en modo no interactivo", message) + } + ok, err := ui.Confirm(message, false) + if err != nil { + return err + } + if !ok { + return ui.ErrAborted + } + return nil +} + +// PromptVariables pide variables una a una en modo interactivo. +func PromptVariables() (map[string]models.VariableVal, error) { + vars := map[string]models.VariableVal{} + for { + key, err := ui.Input("Nombre de la variable", "Déjalo vacío para terminar", "", nil) + if err != nil { + return nil, err + } + if key == "" { + return vars, nil + } + secret, err := ui.Confirm(fmt.Sprintf("¿'%s' es secreta?", key), false) + if err != nil { + return nil, err + } + var value string + if secret { + value, err = ui.Password("Valor de "+key, "No se mostrará en pantalla") + } else { + value, err = ui.Input("Valor de "+key, "", "", nil) + } + if err != nil { + return nil, err + } + vars[key] = models.VariableVal{Value: value, IsSecret: secret} + } +} + +// PositiveInt valida que un texto sea un entero positivo (para ui.Input). +func PositiveInt(s string) error { + n, err := strconv.Atoi(strings.TrimSpace(s)) + if err != nil || n <= 0 { + return fmt.Errorf("debe ser un número entero positivo") + } + return nil +} + +// NoFileComp desactiva el autocompletado de archivos para un flag. +func NoFileComp(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { + return nil, cobra.ShellCompDirectiveNoFileComp +} diff --git a/cmd/helpers_test.go b/cmd/helpers_test.go new file mode 100644 index 0000000..13d34cf --- /dev/null +++ b/cmd/helpers_test.go @@ -0,0 +1,57 @@ +package cmd + +import "testing" + +func TestParseVariables(t *testing.T) { + vars, err := ParseVariables([]string{"a=1", "secret:token=x=y", " b = con espacio "}) + if err != nil { + t.Fatal(err) + } + if vars["a"].Value != "1" || vars["a"].IsSecret { + t.Errorf("a = %+v", vars["a"]) + } + if vars["token"].Value != "x=y" || !vars["token"].IsSecret { + t.Errorf("token = %+v", vars["token"]) + } + if vars["b"].Value != "con espacio" { + t.Errorf("b = %+v", vars["b"]) + } + + for _, bad := range []string{"sinigual", "=valor", "secret:=x"} { + if _, err := ParseVariables([]string{bad}); err == nil { + t.Errorf("se esperaba error para %q", bad) + } + } +} + +func TestParseParams(t *testing.T) { + p, err := ParseParams([]string{"tag=1.2.3", "empty="}) + if err != nil { + t.Fatal(err) + } + if p["tag"] != "1.2.3" || p["empty"] != "" { + t.Errorf("params = %v", p) + } + if _, err := ParseParams([]string{"x"}); err == nil { + t.Error("se esperaba error") + } +} + +func TestResolveSettingsPrecedence(t *testing.T) { + t.Setenv("AZDEVOPS_CONFIG", t.TempDir()+"/c.json") + t.Setenv("AZURE_ORG", "env-org") + t.Setenv("AZURE_PROJECT", "env-proj") + t.Setenv("AZURE_PAT", "env-pat") + t.Setenv("AZDEVOPS_PROFILE", "") + + globalFlags.project = "flag-proj" + defer func() { globalFlags.project = "" }() + + s, _, err := ResolveSettings() + if err != nil { + t.Fatal(err) + } + if s.Org != "env-org" || s.Project != "flag-proj" || s.PAT != "env-pat" { + t.Errorf("settings = %+v", s) + } +} diff --git a/cmd/infra/agents.go b/cmd/infra/agents.go new file mode 100644 index 0000000..c658609 --- /dev/null +++ b/cmd/infra/agents.go @@ -0,0 +1,208 @@ +package infra + +import ( + "fmt" + "strconv" + + "azuredevops/azdevops" + "azuredevops/azdevops/agent" + "azuredevops/cmd" + "azuredevops/internal/ui" + "azuredevops/models" + + "github.com/spf13/cobra" +) + +var agentsCmd = &cobra.Command{ + Use: "agents", + Aliases: []string{"agent", "pools"}, + Short: "Consulta agent pools y agentes", +} + +var poolsCmd = &cobra.Command{ + Use: "pools", + Short: "Lista los agent pools de la organización", + RunE: func(c *cobra.Command, args []string) error { + out, err := cmd.Output("table") + if err != nil { + return err + } + client, err := cmd.NewClient() + if err != nil { + return err + } + pools, err := agent.ListPools(client) + if err != nil { + return err + } + if out != "table" { + return cmd.Print(nonNil(pools)) + } + rows := make([][]string, len(pools)) + for i, p := range pools { + kind := "self-hosted" + if p.IsHosted { + kind = "Microsoft" + } + rows[i] = []string{strconv.Itoa(p.ID), p.Name, kind, strconv.Itoa(p.Size)} + } + ui.Table([]string{"id", "nombre", "tipo", "agentes"}, rows) + return nil + }, +} + +var listAgentsCmd = &cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + Short: "Lista los agentes de un pool con su estado y trabajo actual", + Example: ` azdevops agents list --pool Default`, + RunE: func(c *cobra.Command, args []string) error { + out, err := cmd.Output("table") + if err != nil { + return err + } + client, err := cmd.NewClient() + if err != nil { + return err + } + pool, err := resolvePool(c, client) + if err != nil { + return err + } + agents, err := agent.ListAgents(client, pool.ID) + if err != nil { + return err + } + if out != "table" { + return cmd.Print(nonNil(agents)) + } + if len(agents) == 0 { + ui.Info("El pool '%s' no tiene agentes.", pool.Name) + return nil + } + rows := make([][]string, len(agents)) + online := 0 + for i, a := range agents { + if a.Status == "online" { + online++ + } + enabled := "sí" + if !a.Enabled { + enabled = "no" + } + current := "-" + if r := a.AssignedRequest; r != nil { + current = r.Definition.Name + " / " + r.Owner.Name + } + last := "-" + if r := a.LastCompletedRequest; r != nil { + last = fmt.Sprintf("%s %s", ui.Status(r.Result), ui.FormatTime(r.FinishTime)) + } + rows[i] = []string{strconv.Itoa(a.ID), a.Name, ui.Status(a.Status), enabled, a.Version, current, last} + } + ui.Table([]string{"id", "nombre", "estado", "habilitado", "versión", "trabajo actual", "último trabajo"}, rows) + fmt.Fprintf(ui.Err, "%d de %d agentes en línea\n", online, len(agents)) + return nil + }, +} + +var enableAgentCmd = &cobra.Command{ + Use: "enable", + Short: "Habilita un agente para que reciba trabajos", + RunE: func(c *cobra.Command, args []string) error { return setAgentEnabled(c, true) }, +} + +var disableAgentCmd = &cobra.Command{ + Use: "disable", + Short: "Deshabilita un agente (deja de recibir trabajos, p. ej. para mantenimiento)", + RunE: func(c *cobra.Command, args []string) error { return setAgentEnabled(c, false) }, +} + +func setAgentEnabled(c *cobra.Command, enabled bool) error { + client, err := cmd.NewClient() + if err != nil { + return err + } + pool, err := resolvePool(c, client) + if err != nil { + return err + } + agents, err := agent.ListAgents(client, pool.ID) + if err != nil { + return err + } + name, _ := c.Flags().GetString("agent") + var target *models.Agent + if name == "" { + if !ui.Interactive() { + return ui.MissingError("agent") + } + opts := make([]ui.Option[int], len(agents)) + for i, a := range agents { + opts[i] = ui.Option[int]{Label: fmt.Sprintf("%s (%s, habilitado: %v)", a.Name, a.Status, a.Enabled), Value: i} + } + idx, err := ui.Select("Agente", opts) + if err != nil { + return err + } + target = &agents[idx] + } else { + for i, a := range agents { + if a.Name == name || strconv.Itoa(a.ID) == name { + target = &agents[i] + } + } + if target == nil { + return fmt.Errorf("no se encontró el agente '%s' en el pool '%s'", name, pool.Name) + } + } + if !enabled { + yes, _ := c.Flags().GetBool("yes") + if err := cmd.Confirm(fmt.Sprintf("¿Deshabilitar el agente '%s'?", target.Name), yes); err != nil { + return err + } + } + if _, err := agent.SetAgentEnabled(client, pool.ID, target.ID, enabled); err != nil { + return err + } + if enabled { + ui.Success("Agente '%s' habilitado", target.Name) + } else { + ui.Success("Agente '%s' deshabilitado", target.Name) + } + return nil +} + +func resolvePool(c *cobra.Command, client *azdevops.Client) (*models.AgentPool, error) { + if name, _ := c.Flags().GetString("pool"); name != "" { + return agent.FindPool(client, name) + } + if !ui.Interactive() { + return nil, ui.MissingError("pool") + } + pools, err := agent.ListPools(client) + if err != nil { + return nil, err + } + opts := make([]ui.Option[int], len(pools)) + for i, p := range pools { + opts[i] = ui.Option[int]{Label: fmt.Sprintf("%s (%d agentes)", p.Name, p.Size), Value: i} + } + idx, err := ui.Select("Agent pool", opts) + if err != nil { + return nil, err + } + return &pools[idx], nil +} + +func init() { + for _, c := range []*cobra.Command{listAgentsCmd, enableAgentCmd, disableAgentCmd} { + c.Flags().StringP("pool", "p", "", "Nombre o ID del agent pool") + } + for _, c := range []*cobra.Command{enableAgentCmd, disableAgentCmd} { + c.Flags().StringP("agent", "a", "", "Nombre o ID del agente") + } + disableAgentCmd.Flags().BoolP("yes", "y", false, "Confirmar sin preguntar") + agentsCmd.AddCommand(poolsCmd, listAgentsCmd, enableAgentCmd, disableAgentCmd) + cmd.RootCmd.AddCommand(agentsCmd) +} diff --git a/cmd/infra/serviceconnections.go b/cmd/infra/serviceconnections.go new file mode 100644 index 0000000..264f54b --- /dev/null +++ b/cmd/infra/serviceconnections.go @@ -0,0 +1,238 @@ +// Package infra contiene los comandos de conexiones de servicio y agentes. +package infra + +import ( + "fmt" + "strconv" + "strings" + + "azuredevops/azdevops" + "azuredevops/azdevops/organization" + "azuredevops/azdevops/serviceendpoint" + "azuredevops/cmd" + "azuredevops/internal/ui" + "azuredevops/models" + + "github.com/spf13/cobra" +) + +var serviceConnectionsCmd = &cobra.Command{ + Use: "service-connections", + Aliases: []string{"sc", "endpoints"}, + Short: "Consulta y comparte conexiones de servicio", +} + +var listSCCmd = &cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + Short: "Lista las conexiones de servicio del proyecto", + Example: ` azdevops service-connections list + azdevops sc list --type azurerm -o json`, + RunE: func(c *cobra.Command, args []string) error { + out, err := cmd.Output("table") + if err != nil { + return err + } + client, err := cmd.NewClient() + if err != nil { + return err + } + typ, _ := c.Flags().GetString("type") + eps, err := serviceendpoint.ListEndpoints(client, typ) + if err != nil { + return err + } + if out != "table" { + return cmd.Print(nonNil(eps)) + } + if len(eps) == 0 { + ui.Info("No hay conexiones de servicio.") + return nil + } + rows := make([][]string, len(eps)) + for i, e := range eps { + ready := "sí" + if !e.IsReady { + ready = ui.Status("failed") + } + scheme := "" + if e.Authorization != nil { + scheme = e.Authorization.Scheme + } + rows[i] = []string{e.Name, e.Type, scheme, ready, strconv.Itoa(len(e.ServiceEndpointProjectReferences)), e.ID} + } + ui.Table([]string{"nombre", "tipo", "autenticación", "lista", "proyectos", "id"}, rows) + return nil + }, +} + +var getSCCmd = &cobra.Command{ + Use: "get", + Short: "Muestra el detalle de una conexión de servicio", + RunE: func(c *cobra.Command, args []string) error { + client, err := cmd.NewClient() + if err != nil { + return err + } + ep, err := resolveEndpoint(c, client) + if err != nil { + return err + } + return cmd.Print(ep) + }, +} + +var historySCCmd = &cobra.Command{ + Use: "history", + Short: "Muestra qué pipelines usaron una conexión de servicio", + Example: ` azdevops sc history --name DockerHub --top 10`, + RunE: func(c *cobra.Command, args []string) error { + out, err := cmd.Output("table") + if err != nil { + return err + } + client, err := cmd.NewClient() + if err != nil { + return err + } + ep, err := resolveEndpoint(c, client) + if err != nil { + return err + } + top, _ := c.Flags().GetInt("top") + history, err := serviceendpoint.ExecutionHistory(client, ep.ID, top) + if err != nil { + return err + } + if out != "table" { + return cmd.Print(nonNil(history)) + } + if len(history) == 0 { + ui.Info("'%s' no se ha usado en ninguna ejecución.", ep.Name) + return nil + } + rows := make([][]string, len(history)) + for i, h := range history { + rows[i] = []string{h.Definition.Name, h.Owner.Name, h.PlanType, ui.Status(h.Result), ui.FormatTime(h.StartTime), ui.FormatDuration(h.StartTime, h.FinishTime)} + } + ui.Table([]string{"pipeline", "ejecución", "tipo", "resultado", "inicio", "duración"}, rows) + return nil + }, +} + +var shareSCCmd = &cobra.Command{ + Use: "share", + Short: "Comparte una conexión de servicio con otros proyectos", + Example: ` azdevops sc share --name DockerHub --with-project OtroProyecto --with-project Tercero`, + RunE: func(c *cobra.Command, args []string) error { + client, err := cmd.NewClient() + if err != nil { + return err + } + ep, err := resolveEndpoint(c, client) + if err != nil { + return err + } + already := map[string]bool{} + for _, r := range ep.ServiceEndpointProjectReferences { + already[strings.ToLower(r.ProjectReference.Name)] = true + } + + projects, err := organization.ListProjects(client) + if err != nil { + return err + } + targets, err := cmd.ResolveSliceFlag(c, "with-project", func() ([]string, error) { + var opts []ui.Option[string] + for _, p := range projects { + if !already[strings.ToLower(p.Name)] { + opts = append(opts, ui.Option[string]{Label: p.Name, Value: p.Name}) + } + } + return ui.MultiSelect("Proyectos con los que compartir '"+ep.Name+"'", opts) + }) + if err != nil { + return err + } + + var refs []models.ServiceEndpointProjectReference + for _, t := range targets { + if already[strings.ToLower(t)] { + ui.Info("'%s' ya está compartida con %s", ep.Name, t) + continue + } + var found *models.Project + for i := range projects { + if strings.EqualFold(projects[i].Name, t) { + found = &projects[i] + } + } + if found == nil { + return fmt.Errorf("no existe el proyecto '%s'", t) + } + refs = append(refs, models.ServiceEndpointProjectReference{ + Name: ep.Name, Description: ep.Description, + ProjectReference: models.ProjectReference{ID: found.ID, Name: found.Name}, + }) + } + if len(refs) == 0 { + return nil + } + yes, _ := c.Flags().GetBool("yes") + if err := cmd.Confirm(fmt.Sprintf("¿Compartir '%s' con %d proyecto(s)? Podrán usarla sus pipelines", ep.Name, len(refs)), yes); err != nil { + return err + } + if err := serviceendpoint.ShareEndpoint(client, ep.ID, refs); err != nil { + return err + } + for _, r := range refs { + ui.Success("'%s' compartida con %s", ep.Name, r.ProjectReference.Name) + } + return nil + }, +} + +func resolveEndpoint(c *cobra.Command, client *azdevops.Client) (*models.ServiceEndpoint, error) { + if id, _ := c.Flags().GetString("id"); id != "" { + return serviceendpoint.GetEndpoint(client, id) + } + if name, _ := c.Flags().GetString("name"); name != "" { + return serviceendpoint.GetEndpointByName(client, name) + } + if !ui.Interactive() { + return nil, fmt.Errorf("debes indicar la conexión con --name o --id") + } + eps, err := serviceendpoint.ListEndpoints(client, "") + if err != nil { + return nil, err + } + opts := make([]ui.Option[int], len(eps)) + for i, e := range eps { + opts[i] = ui.Option[int]{Label: fmt.Sprintf("%s (%s)", e.Name, e.Type), Value: i} + } + idx, err := ui.Select("Conexión de servicio", opts) + if err != nil { + return nil, err + } + return &eps[idx], nil +} + +func nonNil[T any](s []T) []T { + if s == nil { + return []T{} + } + return s +} + +func init() { + listSCCmd.Flags().StringP("type", "t", "", "Filtrar por tipo (azurerm, github, dockerregistry, …)") + for _, c := range []*cobra.Command{getSCCmd, historySCCmd, shareSCCmd} { + c.Flags().StringP("name", "n", "", "Nombre de la conexión") + c.Flags().String("id", "", "ID de la conexión") + } + historySCCmd.Flags().Int("top", 20, "Número máximo de ejecuciones") + shareSCCmd.Flags().StringSlice("with-project", nil, "Proyecto con el que compartir (se puede repetir)") + shareSCCmd.Flags().BoolP("yes", "y", false, "Confirmar sin preguntar") + serviceConnectionsCmd.AddCommand(listSCCmd, getSCCmd, historySCCmd, shareSCCmd) + cmd.RootCmd.AddCommand(serviceConnectionsCmd) +} diff --git a/cmd/interactive/interactive.go b/cmd/interactive/interactive.go new file mode 100644 index 0000000..841951c --- /dev/null +++ b/cmd/interactive/interactive.go @@ -0,0 +1,157 @@ +// Package interactive implementa el menú navegable de la CLI. El menú se construye +// a partir del árbol de comandos de cobra, por lo que cualquier comando nuevo +// aparece automáticamente. Al elegir un comando se ejecuta sin flags y este +// pregunta lo que necesite. +package interactive + +import ( + "errors" + "fmt" + "strings" + + "azuredevops/cmd" + "azuredevops/internal/ui" + + "github.com/spf13/cobra" +) + +var interactiveCmd = &cobra.Command{ + Use: "interactive", + Aliases: []string{"i", "menu"}, + Short: "Abre el modo interactivo (menú navegable)", + RunE: func(c *cobra.Command, args []string) error { + if !ui.Interactive() { + return fmt.Errorf("el modo interactivo requiere una terminal (y no usar --no-input)") + } + return Run(cmd.RootCmd) + }, +} + +const ( + actionBack = "__back" + actionExit = "__exit" +) + +// Run muestra el menú principal hasta que el usuario sale. +func Run(root *cobra.Command) error { + printHeader() + current := root + for { + choice, err := ui.Select(title(current), menuOptions(current, root)) + if errors.Is(err, ui.ErrAborted) { + if current == root { + return nil + } + current = current.Parent() + continue + } + if err != nil { + return err + } + + switch choice { + case actionExit: + return nil + case actionBack: + current = current.Parent() + continue + } + + next := findChild(current, choice) + if next == nil { + continue + } + if visibleChildren(next) != nil && next.RunE == nil && next.Run == nil { + current = next + continue + } + execute(next) + if next.Name() == "login" || next.Parent().Name() == "config" { + printHeader() + } + } +} + +func execute(c *cobra.Command) { + fmt.Fprintln(ui.Err) + ui.Info("%s", ui.Bold(c.CommandPath())) + var err error + switch { + case c.RunE != nil: + err = c.RunE(c, nil) + case c.Run != nil: + c.Run(c, nil) + } + switch { + case errors.Is(err, ui.ErrAborted): + ui.Warn("Cancelado") + case err != nil: + ui.Errorf("%v", err) + } + fmt.Fprintln(ui.Err) +} + +func printHeader() { + settings, profile, err := cmd.ResolveSettings() + if err != nil { + ui.Warn("%v", err) + return + } + if settings.Org == "" { + ui.Info("Sin credenciales configuradas: elige 'login' para empezar.") + return + } + ui.Info("Organización %s · Proyecto %s · Perfil %s", ui.Bold(settings.Org), ui.Bold(settings.Project), profile) +} + +func title(c *cobra.Command) string { + if !c.HasParent() { + return "¿Qué quieres hacer?" + } + return strings.TrimPrefix(c.CommandPath(), c.Root().Name()+" ") +} + +func menuOptions(c, root *cobra.Command) []ui.Option[string] { + var opts []ui.Option[string] + for _, child := range visibleChildren(c) { + label := fmt.Sprintf("%-16s %s", child.Name(), child.Short) + if child.HasAvailableSubCommands() { + label = fmt.Sprintf("%-16s %s ›", child.Name(), child.Short) + } + opts = append(opts, ui.Option[string]{Label: label, Value: child.Name()}) + } + if c != root { + opts = append(opts, ui.Option[string]{Label: "← Volver", Value: actionBack}) + } + opts = append(opts, ui.Option[string]{Label: "Salir", Value: actionExit}) + return opts +} + +func visibleChildren(c *cobra.Command) []*cobra.Command { + var out []*cobra.Command + for _, child := range c.Commands() { + if !child.IsAvailableCommand() || child.Deprecated != "" { + continue + } + switch child.Name() { + case "help", "completion", "interactive": + continue + } + out = append(out, child) + } + return out +} + +func findChild(c *cobra.Command, name string) *cobra.Command { + for _, child := range c.Commands() { + if child.Name() == name { + return child + } + } + return nil +} + +func init() { + cmd.RootCmd.AddCommand(interactiveCmd) + cmd.InteractiveRun = func(*cobra.Command) error { return Run(cmd.RootCmd) } +} diff --git a/cmd/login.go b/cmd/login.go new file mode 100644 index 0000000..78b727f --- /dev/null +++ b/cmd/login.go @@ -0,0 +1,125 @@ +package cmd + +import ( + "fmt" + + "azuredevops/azdevops" + "azuredevops/azdevops/organization" + "azuredevops/internal/config" + "azuredevops/internal/ui" + + "github.com/spf13/cobra" +) + +var loginCmd = &cobra.Command{ + Use: "login", + Short: "Guarda organización, proyecto y PAT en un perfil", + Long: `Valida las credenciales contra Azure DevOps y las guarda en un perfil +(~/.config/azdevops/config.json, permisos 0600). El PAT se guarda en el llavero +del sistema (Keychain, Credential Manager o Secret Service) cuando está disponible. + +En una terminal se te preguntará lo que falte; para scripts usa: + azdevops login --org mi-org --project mi-proyecto --pat $AZURE_PAT [--profile trabajo]`, + RunE: func(cmd *cobra.Command, args []string) error { + name := globalFlags.profile + if name == "" { + name = config.DefaultProfile + } + defaults := config.Profile{Org: globalFlags.org, Project: globalFlags.project, PAT: globalFlags.pat} + if defaults.Org != "" && defaults.Project != "" && defaults.PAT != "" { + return saveProfile(name, defaults, loginStore) + } + if !ui.Interactive() { + return fmt.Errorf("en modo no interactivo debes indicar --org, --project y --pat") + } + _, err := runLoginWizard(name, defaults) + return err + }, +} + +func runLoginWizard(name string, defaults config.Profile) (config.Profile, error) { + p := defaults + var err error + if p.Org, err = ui.Input("Organización", "El nombre que aparece en https://dev.azure.com/", p.Org, ui.Required); err != nil { + return p, err + } + if p.PAT == "" { + if p.PAT, err = ui.Password("Personal Access Token (PAT)", "Necesita permisos sobre Build, Variable Groups y Graph según lo que vayas a usar"); err != nil { + return p, err + } + } + + if p.Project == "" { + var projects []string + _ = ui.Spinner("Consultando proyectos...", func() error { + list, err := organization.ListProjects(azdevops.NewClient(p.Org, "", p.PAT)) + for _, pr := range list { + projects = append(projects, pr.Name) + } + return err + }) + if len(projects) > 0 { + opts := make([]ui.Option[string], len(projects)) + for i, pr := range projects { + opts[i] = ui.Option[string]{Label: pr, Value: pr} + } + if p.Project, err = ui.Select("Proyecto", opts); err != nil { + return p, err + } + } else if p.Project, err = ui.Input("Proyecto", "", "", ui.Required); err != nil { + return p, err + } + } + return p, saveProfile(name, p, loginStore) +} + +func saveProfile(name string, p config.Profile, store string) error { + client := azdevops.NewClient(p.Org, p.Project, p.PAT) + client.Debug = globalFlags.debug + var user *organization.User + if err := ui.Spinner("Validando credenciales...", func() (err error) { + if user, err = organization.CurrentUser(client); err != nil { + return err + } + _, err = organization.GetProject(client) + return err + }); err != nil { + return fmt.Errorf("no se pudieron validar las credenciales: %w", err) + } + + cfg, err := config.Load() + if err != nil { + return err + } + pat := p.PAT + where, err := config.SetPAT(name, &p, pat, store) + if err != nil { + return err + } + cfg.Profiles[name] = p + if cfg.Current == "" || len(cfg.Profiles) == 1 { + cfg.Current = name + } + if err := cfg.Save(); err != nil { + return fmt.Errorf("no se pudo guardar la configuración: %w", err) + } + path, _ := config.Path() + ui.Success("Perfil '%s' guardado (%s/%s) en %s — sesión de %s", name, p.Org, p.Project, path, user.DisplayName) + if where == config.StoreKeyring { + ui.Info("El PAT se guardó en el llavero del sistema") + } else { + ui.Warn("El PAT se guardó en el archivo de configuración (permisos 0600); usa --store keyring si tu sistema tiene llavero") + } + if cfg.Current != name { + ui.Info("Para usarlo por defecto: azdevops config use %s", name) + } + ResetClient() + return nil +} + +var loginStore string + +func init() { + loginCmd.Flags().StringVar(&loginStore, "store", config.StoreAuto, "Dónde guardar el PAT: auto (llavero si está disponible), keyring o file") + RootCmd.AddCommand(loginCmd) +} diff --git a/cmd/open.go b/cmd/open.go new file mode 100644 index 0000000..2c8eba8 --- /dev/null +++ b/cmd/open.go @@ -0,0 +1,168 @@ +package cmd + +import ( + "fmt" + "net/url" + "strconv" + + "azuredevops/azdevops" + "azuredevops/azdevops/pipeline" + vg "azuredevops/azdevops/variable_group" + "azuredevops/internal/ui" + + "github.com/spf13/cobra" +) + +var openTargets = []string{"project", "pipeline", "run", "variables", "workitem", "repo", "approvals", "environments", "service-connections", "securefiles", "boards"} + +var openCmd = &cobra.Command{ + Use: "open [recurso] [id|nombre]", + Short: "Abre en el navegador el proyecto o un recurso", + Long: `Abre en el navegador el portal de Azure DevOps. + +Recursos: project (por defecto), pipeline [id|nombre], run , +variables [nombre], workitem , repo [nombre], approvals, environments, +service-connections, securefiles y boards.`, + Example: ` azdevops open + azdevops open pipeline 123 + azdevops open run 4567 + azdevops open variables app-dev + azdevops open workitem 42 --print`, + Args: cobra.MaximumNArgs(2), + ValidArgs: openTargets, + RunE: func(c *cobra.Command, args []string) error { + client, err := NewClient() + if err != nil { + return err + } + target := "project" + if len(args) > 0 { + target = args[0] + } else if ui.Interactive() { + opts := make([]ui.Option[string], len(openTargets)) + for i, t := range openTargets { + opts[i] = ui.Option[string]{Label: t, Value: t} + } + if target, err = ui.Select("¿Qué quieres abrir?", opts); err != nil { + return err + } + } + ref := "" + if len(args) > 1 { + ref = args[1] + } + + link, err := resourceURL(client, target, ref) + if err != nil { + return err + } + if printOnly, _ := c.Flags().GetBool("print"); printOnly { + fmt.Fprintln(ui.Out, link) + return nil + } + if err := ui.OpenBrowser(link); err != nil { + ui.Warn("No se pudo abrir el navegador (%v); abre este enlace:", err) + fmt.Fprintln(ui.Out, link) + return nil + } + ui.Success("Abriendo %s", link) + return nil + }, +} + +func resourceURL(client *azdevops.Client, target, ref string) (string, error) { + needID := func(title string, pick func() (int, error)) (int, error) { + if ref != "" { + id, err := strconv.Atoi(ref) + if err != nil { + return 0, fmt.Errorf("%s: '%s' no es un ID válido", title, ref) + } + return id, nil + } + if pick == nil || !ui.Interactive() { + return 0, fmt.Errorf("indica el ID: azdevops open %s ", target) + } + return pick() + } + + switch target { + case "project": + return client.WebURL(""), nil + case "boards": + return client.WebURL("_boards"), nil + case "approvals", "environments": + return client.WebURL("_environments"), nil + case "securefiles", "sf": + return client.WebURL("_library?itemType=SecureFiles"), nil + case "service-connections", "sc": + return client.WebURL("_settings/adminservices"), nil + case "pipeline", "pipelines": + if ref == "" && !ui.Interactive() { + return client.WebURL("_build"), nil + } + id, err := strconv.Atoi(ref) + if ref != "" && err != nil { + defs, err := pipeline.GetBuildDefinitionByName(client, ref) + if err != nil { + return "", err + } + if len(defs) == 0 || defs[0].ID == nil { + return "", fmt.Errorf("no se encontró el pipeline '%s'", ref) + } + id = int(*defs[0].ID) + } else if ref == "" { + if id, err = PickPipeline(client, "Pipeline"); err != nil { + return "", err + } + } + return client.WebURL(fmt.Sprintf("_build?definitionId=%d", id)), nil + case "run", "build": + id, err := needID("run", func() (int, error) { return PickBuild(client, 0, "Ejecución") }) + if err != nil { + return "", err + } + return client.WebURL(fmt.Sprintf("_build/results?buildId=%d", id)), nil + case "variables", "vg": + if ref == "" && !ui.Interactive() { + return client.WebURL("_library?itemType=VariableGroups"), nil + } + name := ref + if name == "" { + var err error + if name, err = PickVariableGroupName(client, "Variable Group"); err != nil { + return "", err + } + } + groups, err := vg.GetVariableGroupByName(client, name) + if err != nil { + return "", err + } + if len(groups) == 0 { + return "", fmt.Errorf("no se encontró el Variable Group '%s'", name) + } + return client.VariableGroupWebURL(groups[0].Id), nil + case "workitem", "wi": + id, err := needID("workitem", func() (int, error) { + v, err := ui.Input("ID del work item", "", "", PositiveInt) + if err != nil { + return 0, err + } + return strconv.Atoi(v) + }) + if err != nil { + return "", err + } + return client.WebURL(fmt.Sprintf("_workitems/edit/%d", id)), nil + case "repo", "repos": + if ref == "" { + return client.WebURL("_git/" + url.PathEscape(client.Project)), nil + } + return client.WebURL("_git/" + url.PathEscape(ref)), nil + } + return "", fmt.Errorf("recurso '%s' desconocido (opciones: %v)", target, openTargets) +} + +func init() { + openCmd.Flags().Bool("print", false, "Solo imprimir la URL, sin abrir el navegador") + RootCmd.AddCommand(openCmd) +} diff --git a/cmd/pickers.go b/cmd/pickers.go new file mode 100644 index 0000000..b439c03 --- /dev/null +++ b/cmd/pickers.go @@ -0,0 +1,190 @@ +package cmd + +import ( + "fmt" + "strconv" + + "azuredevops/azdevops" + "azuredevops/azdevops/pipeline" + vg "azuredevops/azdevops/variable_group" + "azuredevops/internal/ui" + "azuredevops/models" + + "github.com/spf13/cobra" +) + +func variableGroupOptions(client *azdevops.Client) ([]ui.Option[string], error) { + var groups []models.VariableGroup + err := ui.Spinner("Cargando Variable Groups...", func() (err error) { + groups, err = vg.ListVariableGroups(client, "") + return err + }) + if err != nil { + return nil, err + } + opts := make([]ui.Option[string], len(groups)) + for i, g := range groups { + opts[i] = ui.Option[string]{Label: fmt.Sprintf("%s (%d variables)", g.Name, len(g.Variables)), Value: g.Name} + } + return opts, nil +} + +// PickVariableGroupName permite elegir un Variable Group de la lista. +func PickVariableGroupName(client *azdevops.Client, title string) (string, error) { + opts, err := variableGroupOptions(client) + if err != nil { + return "", err + } + return ui.Select(title, opts) +} + +// PickVariableGroupNames permite elegir varios Variable Groups. +func PickVariableGroupNames(client *azdevops.Client, title string) ([]string, error) { + opts, err := variableGroupOptions(client) + if err != nil { + return nil, err + } + return ui.MultiSelect(title, opts) +} + +// PickPipeline permite elegir un pipeline y devuelve su ID. +func PickPipeline(client *azdevops.Client, title string) (int, error) { + var defs []models.BuildDefinition + err := ui.Spinner("Cargando pipelines...", func() (err error) { + defs, err = pipeline.ListDefinitions(client, "", "") + return err + }) + if err != nil { + return 0, err + } + opts := make([]ui.Option[int], 0, len(defs)) + for _, d := range defs { + if d.ID == nil { + continue + } + label := fmt.Sprintf("%s%s #%d", FolderPrefix(d.Path), Str(d.Name), *d.ID) + if d.LatestCompletedBuild != nil { + label += " · " + d.LatestCompletedBuild.Result + } + opts = append(opts, ui.Option[int]{Label: label, Value: int(*d.ID)}) + } + return ui.Select(title, opts) +} + +// PickBuild permite elegir una ejecución reciente (de un pipeline o de todo el proyecto). +func PickBuild(client *azdevops.Client, definitionID int, title string) (int, error) { + var builds []models.Build + err := ui.Spinner("Cargando ejecuciones...", func() (err error) { + builds, err = pipeline.ListBuilds(client, definitionID, 30, "") + return err + }) + if err != nil { + return 0, err + } + opts := make([]ui.Option[int], len(builds)) + for i, b := range builds { + state := b.Result + if state == "" { + state = b.Status + } + opts[i] = ui.Option[int]{ + Label: fmt.Sprintf("#%d %s %s %s %s", b.ID, b.Definition.Name, b.BuildNumber, ShortBranch(b.SourceBranch), state), + Value: b.ID, + } + } + return ui.Select(title, opts) +} + +// ResolveIntFlag lee un flag entero; si es 0 y hay terminal, usa pick. +func ResolveIntFlag(cmd *cobra.Command, flag string, pick func() (int, error)) (int, error) { + v, _ := cmd.Flags().GetInt(flag) + if v != 0 { + return v, nil + } + if !ui.Interactive() { + return 0, ui.MissingError(flag) + } + return pick() +} + +// ResolveStringFlag lee un flag de texto; si está vacío y hay terminal, usa pick. +func ResolveStringFlag(cmd *cobra.Command, flag string, pick func() (string, error)) (string, error) { + v, _ := cmd.Flags().GetString(flag) + if v != "" { + return v, nil + } + if !ui.Interactive() { + return "", ui.MissingError(flag) + } + return pick() +} + +// ResolveSliceFlag lee un flag de lista; si está vacío y hay terminal, usa pick. +func ResolveSliceFlag(cmd *cobra.Command, flag string, pick func() ([]string, error)) ([]string, error) { + v, _ := cmd.Flags().GetStringSlice(flag) + if len(v) > 0 { + return v, nil + } + if !ui.Interactive() { + return nil, ui.MissingError(flag) + } + return pick() +} + +// CompleteVariableGroups autocompleta nombres de Variable Groups en la shell. +func CompleteVariableGroups(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + client, err := NewClient() + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + groups, err := vg.ListVariableGroups(client, toComplete+"*") + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + names := make([]string, len(groups)) + for i, g := range groups { + names[i] = g.Name + } + return names, cobra.ShellCompDirectiveNoFileComp +} + +// CompletePipelineIDs autocompleta IDs de pipelines mostrando su nombre. +func CompletePipelineIDs(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + client, err := NewClient() + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + defs, err := pipeline.ListDefinitions(client, "", "") + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + var out []string + for _, d := range defs { + if d.ID != nil { + out = append(out, strconv.FormatInt(*d.ID, 10)+"\t"+Str(d.Name)) + } + } + return out, cobra.ShellCompDirectiveNoFileComp +} + +func Str(s *string) string { + if s == nil { + return "" + } + return *s +} + +func FolderPrefix(path *string) string { + p := Str(path) + if p == "" || p == "\\" { + return "" + } + return p[1:] + "\\" +} + +func ShortBranch(ref string) string { + if len(ref) > len("refs/heads/") && ref[:len("refs/heads/")] == "refs/heads/" { + return ref[len("refs/heads/"):] + } + return ref +} diff --git a/cmd/pipelines/common.go b/cmd/pipelines/common.go new file mode 100644 index 0000000..d6a5c8b --- /dev/null +++ b/cmd/pipelines/common.go @@ -0,0 +1,173 @@ +package pipeline + +import ( + "fmt" + "strconv" + + "azuredevops/azdevops" + "azuredevops/azdevops/pipeline" + "azuredevops/cmd" + "azuredevops/internal/ui" + "azuredevops/models" + + "github.com/spf13/cobra" +) + +// addPipelineFlags registra --id y --name para identificar un pipeline. +func addPipelineFlags(c *cobra.Command, verb string) { + c.Flags().IntP("id", "i", 0, "ID del pipeline "+verb) + c.Flags().StringP("name", "n", "", "Nombre exacto del pipeline "+verb+" (alternativa a --id)") + _ = c.RegisterFlagCompletionFunc("id", cmd.CompletePipelineIDs) +} + +// resolvePipelineID usa --id, luego --name y por último un selector interactivo. +func resolvePipelineID(c *cobra.Command, client *azdevops.Client) (int, error) { + if id, _ := c.Flags().GetInt("id"); id != 0 { + return id, nil + } + if name, _ := c.Flags().GetString("name"); name != "" { + defs, err := pipeline.GetBuildDefinitionByName(client, name) + if err != nil { + return 0, err + } + var matches []models.BuildDefinition + for _, d := range defs { + if d.Name != nil && *d.Name == name && d.ID != nil { + matches = append(matches, d) + } + } + switch len(matches) { + case 0: + return 0, fmt.Errorf("no se encontró el pipeline '%s'", name) + case 1: + return int(*matches[0].ID), nil + default: + return 0, fmt.Errorf("hay %d pipelines llamados '%s' (en distintas carpetas); usa --id", len(matches), name) + } + } + if !ui.Interactive() { + return 0, fmt.Errorf("debes indicar el pipeline con --id o --name") + } + return cmd.PickPipeline(client, "Pipeline") +} + +// resolveBuildID usa --build-id o un selector de ejecuciones recientes. +func resolveBuildID(c *cobra.Command, client *azdevops.Client) (int, error) { + return cmd.ResolveIntFlag(c, "build-id", func() (int, error) { + return cmd.PickBuild(client, 0, "Ejecución") + }) +} + +func buildState(b *models.Build) string { + if b.Status == "completed" { + return ui.Status(b.Result) + } + return ui.Status(b.Status) +} + +func printBuildsTable(builds []models.Build) { + rows := make([][]string, len(builds)) + for i, b := range builds { + rows[i] = []string{ + strconv.Itoa(b.ID), b.Definition.Name, b.BuildNumber, cmd.ShortBranch(b.SourceBranch), + buildState(&b), b.RequestedFor.DisplayName, ui.FormatTime(b.QueueTime), ui.FormatDuration(b.StartTime, b.FinishTime), + } + } + ui.Table([]string{"id", "pipeline", "número", "rama", "estado", "solicitado por", "encolado", "duración"}, rows) +} + +// printTimeline muestra etapas, jobs y tareas fallidas de una ejecución. +func printTimeline(timeline *models.Timeline, onlyFailed bool) { + var rows [][]string + for _, r := range sortedRecords(timeline) { + if r.Type != "Stage" && r.Type != "Job" && r.Type != "Task" { + continue + } + if onlyFailed && r.Result != "failed" { + continue + } + if r.Type == "Task" && !onlyFailed && r.Result != "failed" { + continue + } + state := r.Result + if state == "" { + state = r.State + } + logID := "-" + if r.Log != nil { + logID = strconv.Itoa(r.Log.ID) + } + indent := map[string]string{"Stage": "", "Job": " ", "Task": " "}[r.Type] + rows = append(rows, []string{indent + r.Name, r.Type, ui.Status(state), logID}) + } + if len(rows) == 0 { + return + } + ui.Table([]string{"nombre", "tipo", "estado", "log"}, rows) +} + +func sortedRecords(t *models.Timeline) []models.TimelineRecord { + // Ordena jerárquicamente: etapa -> job -> tarea, respetando "order". + children := map[string][]models.TimelineRecord{} + for _, r := range t.Records { + children[r.ParentID] = append(children[r.ParentID], r) + } + var out []models.TimelineRecord + var walk func(parent string) + walk = func(parent string) { + list := children[parent] + for i := 1; i < len(list); i++ { + for j := i; j > 0 && list[j].Order < list[j-1].Order; j-- { + list[j], list[j-1] = list[j-1], list[j] + } + } + for _, r := range list { + out = append(out, r) + walk(r.ID) + } + } + walk("") + return out +} + +// printFailures muestra los errores reportados por las tareas fallidas. +func printFailures(timeline *models.Timeline) { + for _, r := range sortedRecords(timeline) { + if r.Type != "Task" || r.Result != "failed" { + continue + } + logInfo := "" + if r.Log != nil { + logInfo = fmt.Sprintf(" (log %d)", r.Log.ID) + } + ui.Errorf("Tarea fallida: %s%s", ui.Bold(r.Name), logInfo) + for _, issue := range r.Issues { + if issue.Type == "error" { + fmt.Fprintf(ui.Err, " %s\n", issue.Message) + } + } + } +} + +// currentActivity resume qué etapas/jobs están en ejecución. +func currentActivity(timeline *models.Timeline) string { + var stage, job string + for _, r := range sortedRecords(timeline) { + if r.State != "inProgress" { + continue + } + switch r.Type { + case "Stage": + stage = r.Name + case "Job": + job = r.Name + } + } + switch { + case stage != "" && job != "": + return stage + " › " + job + case stage != "": + return stage + } + return job +} diff --git a/cmd/pipelines/create.go b/cmd/pipelines/create.go index dd0f2db..63fbf30 100644 --- a/cmd/pipelines/create.go +++ b/cmd/pipelines/create.go @@ -1,45 +1,145 @@ package pipeline import ( - "log" - "azuredevops/azdevops" + "azuredevops/azdevops/git" "azuredevops/azdevops/pipeline" + "azuredevops/azdevops/serviceendpoint" "azuredevops/cmd" + "azuredevops/internal/ui" "github.com/spf13/cobra" ) var createPipelineCmd = &cobra.Command{ Use: "create", - Short: "Crea un nuevo pipeline a partir de un archivo YAML en un repositorio.", - Run: func(cmd *cobra.Command, args []string) { - name, _ := cmd.Flags().GetString("name") - repoType, _ := cmd.Flags().GetString("repo-type") - repoName, _ := cmd.Flags().GetString("repo-name") - branch, _ := cmd.Flags().GetString("branch") - yamlPath, _ := cmd.Flags().GetString("yaml-path") - folder, _ := cmd.Flags().GetString("folder") - serviceConnection, _ := cmd.Flags().GetString("service-connection") + Short: "Crea un pipeline a partir de un archivo YAML de un repositorio", + Example: ` azdevops pipelines create --name MiPipeline --repo-name mi-repo --yaml-path azure-pipelines.yml + azdevops pipelines create -n MiPipeline -t gitHub -r org/repo -p .azure/pipeline.yml -s + azdevops pipelines create # modo guiado`, + RunE: func(c *cobra.Command, args []string) error { + client, err := cmd.NewClient() + if err != nil { + return err + } + f := c.Flags() + opts := pipeline.CreateOptions{} + opts.Name, _ = f.GetString("name") + opts.RepoType, _ = f.GetString("repo-type") + opts.RepoName, _ = f.GetString("repo-name") + opts.YAMLPath, _ = f.GetString("yaml-path") + opts.Folder, _ = f.GetString("folder") + opts.ServiceConnectionID, _ = f.GetString("service-connection") + if f.Changed("branch") { + opts.Branch, _ = f.GetString("branch") + } - if name == "" || repoType == "" || repoName == "" || branch == "" || yamlPath == "" { - log.Fatal("Los parámetros --name, --repo-type, --repo-name, --branch y --yaml-path son obligatorios.") + if opts.Name == "" || opts.RepoName == "" || opts.YAMLPath == "" { + if !ui.Interactive() { + return ui.MissingError("name, --repo-name y --yaml-path") + } + if err := promptCreate(client, &opts, f.Changed("repo-type")); err != nil { + return err + } } - client := azdevops.GetClientFromEnv() - if _, err := pipeline.CreatePipeline(client, name, repoType, repoName, branch, yamlPath, folder, serviceConnection); err != nil { - log.Fatalf("Error al crear el pipeline: %v", err) + created, err := pipeline.CreatePipeline(client, opts) + if err != nil { + if created == nil { + return err + } + ui.Warn("%v", err) + } + ui.Success("Pipeline '%s' creado (ID: %d)", created.Name, created.ID) + ui.Link(created.Links.Web.Href) + if cmd.WantsData() { + return cmd.Print(created) } + return nil }, } +func promptCreate(client *azdevops.Client, opts *pipeline.CreateOptions, repoTypeSet bool) error { + var err error + if opts.Name == "" { + if opts.Name, err = ui.Input("Nombre del pipeline", "", "", ui.Required); err != nil { + return err + } + } + if !repoTypeSet { + if opts.RepoType, err = ui.Select("Tipo de repositorio", []ui.Option[string]{ + {Label: "Azure Repos Git", Value: "azureReposGit"}, + {Label: "GitHub", Value: "gitHub"}, + }); err != nil { + return err + } + } + defaultBranch := "main" + if opts.RepoName == "" { + if opts.RepoType == "azureReposGit" { + repos, _ := git.ListRepositories(client) + if len(repos) > 0 { + choices := make([]ui.Option[int], len(repos)) + for i, r := range repos { + choices[i] = ui.Option[int]{Label: r.Name, Value: i} + } + idx, err := ui.Select("Repositorio", choices) + if err != nil { + return err + } + opts.RepoName = repos[idx].Name + if b := cmd.ShortBranch(repos[idx].DefaultBranch); b != "" { + defaultBranch = b + } + } + } + if opts.RepoName == "" { + if opts.RepoName, err = ui.Input("Repositorio", "Para GitHub usa organización/repositorio", "", ui.Required); err != nil { + return err + } + } + } + if opts.Branch == "" { + if opts.Branch, err = ui.Input("Rama por defecto", "", defaultBranch, ui.Required); err != nil { + return err + } + } + if opts.YAMLPath == "" { + if opts.YAMLPath, err = ui.Input("Ruta del archivo YAML", "Relativa a la raíz del repositorio", "azure-pipelines.yml", ui.Required); err != nil { + return err + } + } + if opts.Folder == "" || opts.Folder == "\\" { + if opts.Folder, err = ui.Input("Carpeta", "Carpeta donde se organizará el pipeline", "\\", nil); err != nil { + return err + } + } + if opts.ServiceConnectionID == "" && opts.RepoType == "gitHub" { + endpoints, _ := serviceendpoint.ListEndpoints(client, "github") + if len(endpoints) > 0 { + choices := []ui.Option[string]{{Label: "(ninguna)", Value: ""}} + for _, e := range endpoints { + choices = append(choices, ui.Option[string]{Label: e.Name, Value: e.ID}) + } + if opts.ServiceConnectionID, err = ui.Select("Conexión de servicio de GitHub", choices); err != nil { + return err + } + } + } + return nil +} + func init() { - createPipelineCmd.Flags().StringP("name", "n", "", "Nombre del pipeline (obligatorio)") - createPipelineCmd.Flags().StringP("repo-type", "t", "azureReposGit", "Tipo de repositorio ('azureReposGit' o 'gitHub')") - createPipelineCmd.Flags().StringP("repo-name", "r", "", "Nombre del repositorio. Para GitHub, usar 'organizacion/repositorio' (obligatorio)") - createPipelineCmd.Flags().StringP("branch", "b", "main", "Rama del repositorio") - createPipelineCmd.Flags().StringP("yaml-path", "p", "", "Ruta al archivo YAML del pipeline (obligatorio)") - createPipelineCmd.Flags().String("folder", "\\", "Carpeta para organizar el pipeline (opcional)") - createPipelineCmd.Flags().StringP("service-connection", "s", "", "ID de la Conexión de Servicio para repositorios privados (opcional)") + f := createPipelineCmd.Flags() + f.StringP("name", "n", "", "Nombre del pipeline (obligatorio)") + f.StringP("repo-type", "t", "azureReposGit", "Tipo de repositorio ('azureReposGit' o 'gitHub')") + f.StringP("repo-name", "r", "", "Nombre del repositorio. Para GitHub, 'organizacion/repositorio' (obligatorio)") + f.StringP("branch", "b", "main", "Rama por defecto del pipeline") + f.StringP("yaml-path", "p", "", "Ruta al archivo YAML del pipeline (obligatorio)") + f.String("folder", "\\", "Carpeta para organizar el pipeline") + f.StringP("service-connection", "s", "", "ID de la conexión de servicio (repos de GitHub)") + _ = createPipelineCmd.RegisterFlagCompletionFunc("repo-type", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { + return []string{"azureReposGit", "gitHub"}, cobra.ShellCompDirectiveNoFileComp + }) cmd.Pipelines.AddCommand(createPipelineCmd) } diff --git a/cmd/pipelines/delete.go b/cmd/pipelines/delete.go index 206fb37..1e5b508 100644 --- a/cmd/pipelines/delete.go +++ b/cmd/pipelines/delete.go @@ -2,45 +2,49 @@ package pipeline import ( "fmt" - "log" - "azuredevops/azdevops" "azuredevops/azdevops/pipeline" "azuredevops/cmd" + "azuredevops/internal/ui" "github.com/spf13/cobra" ) var deletePipelineCmd = &cobra.Command{ - Use: "delete", - Short: "Elimina un pipeline y sus retenciones.", - Run: func(cmd *cobra.Command, args []string) { - pipelineID, err := cmd.Flags().GetInt("id") - if err != nil || pipelineID == 0 { - log.Fatal("Debes proporcionar un ID de pipeline válido con --id.") + Use: "delete", + Short: "Elimina un pipeline y sus retenciones", + Example: ` azdevops pipelines delete --id 123 --yes`, + RunE: func(c *cobra.Command, args []string) error { + client, err := cmd.NewClient() + if err != nil { + return err } - - autoconfirmation, _ := cmd.Flags().GetBool("yes") - if !autoconfirmation { - fmt.Printf("Estás a punto de eliminar el pipeline con ID %d y todas sus retenciones.\n", pipelineID) - fmt.Print("¿Estás seguro de que deseas continuar? (s/n): ") - var confirmation string - fmt.Scanln(&confirmation) - if confirmation != "s" && confirmation != "S" { - fmt.Println("Operación cancelada.") - return - } + id, err := resolvePipelineID(c, client) + if err != nil { + return err } - - client := azdevops.GetClientFromEnv() - if err := pipeline.DeletePipeline(client, pipelineID, autoconfirmation); err != nil { - log.Fatalf("Error al eliminar el pipeline: %v", err) + def, err := pipeline.GetBuildDefinitionByID(client, id) + if err != nil { + return err + } + yes, _ := c.Flags().GetBool("yes") + if err := cmd.Confirm(fmt.Sprintf("¿Eliminar el pipeline '%s' (ID %d) y todas sus retenciones?", cmd.Str(def.Name), id), yes); err != nil { + return err + } + leases, err := pipeline.DeletePipeline(client, id) + if leases > 0 { + ui.Success("%d retención(es) eliminada(s)", leases) + } + if err != nil { + return err } + ui.Success("Pipeline '%s' (ID %d) eliminado", cmd.Str(def.Name), id) + return nil }, } func init() { - deletePipelineCmd.Flags().IntP("id", "i", 0, "ID del pipeline a eliminar (obligatorio)") - deletePipelineCmd.Flags().BoolP("yes", "y", false, "Confirmar eliminación sin preguntar") + addPipelineFlags(deletePipelineCmd, "a eliminar") + deletePipelineCmd.Flags().BoolP("yes", "y", false, "Confirmar sin preguntar") cmd.Pipelines.AddCommand(deletePipelineCmd) } diff --git a/cmd/pipelines/get.go b/cmd/pipelines/get.go index effa742..0b9bbbd 100644 --- a/cmd/pipelines/get.go +++ b/cmd/pipelines/get.go @@ -1,74 +1,68 @@ package pipeline import ( - "encoding/json" "fmt" - "log" - "azuredevops/azdevops" "azuredevops/azdevops/pipeline" "azuredevops/cmd" + "azuredevops/internal/ui" "github.com/spf13/cobra" ) var getPipelineCmd = &cobra.Command{ Use: "get", - Short: "Obtiene uno o más pipelines por su nombre.", - Run: func(cmd *cobra.Command, args []string) { - name, _ := cmd.Flags().GetString("name") - id, _ := cmd.Flags().GetInt("id") - - if name == "" && id == 0 { - log.Fatal("Debes proporcionar el nombre del pipeline con --name o el ID con --id.") + Short: "Obtiene la definición de uno o más pipelines (por nombre o ID)", + Example: ` azdevops pipelines get --name "MiPipeline*" + azdevops pipelines get --id 123`, + RunE: func(c *cobra.Command, args []string) error { + client, err := cmd.NewClient() + if err != nil { + return err + } + id, _ := c.Flags().GetInt("id") + name, _ := c.Flags().GetString("name") + if id == 0 && name == "" { + if !ui.Interactive() { + return fmt.Errorf("debes proporcionar --name o --id") + } + if id, err = cmd.PickPipeline(client, "Pipeline"); err != nil { + return err + } } - - client := azdevops.GetClientFromEnv() - var pipelineData interface{} - var err error if id != 0 { - pipelineData, err = pipeline.GetBuildDefinitionByID(client, id) - } else { - pipelineData, err = pipeline.GetBuildDefinitionByName(client, name) + def, err := pipeline.GetBuildDefinitionByID(client, id) + if err != nil { + return err + } + return cmd.Print(def) } + defs, err := pipeline.GetBuildDefinitionByName(client, name) if err != nil { - log.Fatalf("Error al obtener el pipeline: %v", err) + return err } - jsonData, err := json.MarshalIndent(pipelineData, "", " ") - if err != nil { - log.Fatalf("Error al convertir a JSON: %v", err) + if len(defs) == 0 { + return fmt.Errorf("no se encontró ningún pipeline con el nombre '%s'", name) } - fmt.Println(string(jsonData)) + return cmd.Print(defs) }, } var getPipelineByIDCmd = &cobra.Command{ - Use: "get-by-id", - Short: "Obtiene un pipeline por su ID.", - Run: func(cmd *cobra.Command, args []string) { - id, _ := cmd.Flags().GetInt("id") - if id == 0 { - log.Fatal("Debes proporcionar el ID del pipeline con --id.") - } - - client := azdevops.GetClientFromEnv() - pipeline, err := pipeline.GetBuildDefinitionByID(client, id) - if err != nil { - log.Fatalf("Error al obtener el pipeline: %v", err) - } - - jsonData, err := json.MarshalIndent(pipeline, "", " ") - if err != nil { - log.Fatalf("Error al convertir a JSON: %v", err) - } - fmt.Println(string(jsonData)) + Use: "get-by-id", + Short: "Obtiene un pipeline por su ID", + Deprecated: "usa 'pipelines get --id'", + RunE: func(c *cobra.Command, args []string) error { + return getPipelineCmd.RunE(c, args) }, } func init() { - getPipelineCmd.Flags().StringP("name", "n", "", "Nombre del pipeline a buscar (obligatorio)") - getPipelineCmd.Flags().IntP("id", "i", 0, "ID del pipeline a buscar (opcional)") - cmd.Pipelines.AddCommand(getPipelineCmd) - getPipelineByIDCmd.Flags().IntP("id", "i", 0, "ID del pipeline a buscar (obligatorio)") - cmd.Pipelines.AddCommand(getPipelineByIDCmd) + getPipelineCmd.Flags().StringP("name", "n", "", "Nombre del pipeline (admite comodines)") + getPipelineCmd.Flags().IntP("id", "i", 0, "ID del pipeline") + _ = getPipelineCmd.RegisterFlagCompletionFunc("id", cmd.CompletePipelineIDs) + getPipelineByIDCmd.Flags().IntP("id", "i", 0, "ID del pipeline") + getPipelineByIDCmd.Flags().StringP("name", "n", "", "") + _ = getPipelineByIDCmd.Flags().MarkHidden("name") + cmd.Pipelines.AddCommand(getPipelineCmd, getPipelineByIDCmd) } diff --git a/cmd/pipelines/list.go b/cmd/pipelines/list.go new file mode 100644 index 0000000..146c2c4 --- /dev/null +++ b/cmd/pipelines/list.go @@ -0,0 +1,69 @@ +package pipeline + +import ( + "strconv" + + "azuredevops/azdevops/pipeline" + "azuredevops/cmd" + "azuredevops/internal/ui" + "azuredevops/models" + + "github.com/spf13/cobra" +) + +var listPipelinesCmd = &cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + Short: "Lista los pipelines del proyecto con su último resultado", + Example: ` azdevops pipelines list + azdevops pipelines list --filter "deploy-*" --folder "\\infra"`, + RunE: func(c *cobra.Command, args []string) error { + out, err := cmd.Output("table") + if err != nil { + return err + } + filter, _ := c.Flags().GetString("filter") + folder, _ := c.Flags().GetString("folder") + client, err := cmd.NewClient() + if err != nil { + return err + } + var defs []models.BuildDefinition + if err := ui.Spinner("Cargando pipelines...", func() (err error) { + defs, err = pipeline.ListDefinitions(client, filter, folder) + return err + }); err != nil { + return err + } + if out != "table" { + if defs == nil { + defs = []models.BuildDefinition{} + } + return cmd.Print(defs) + } + if len(defs) == 0 { + ui.Info("No se encontraron pipelines.") + return nil + } + rows := make([][]string, 0, len(defs)) + for _, d := range defs { + id := "" + if d.ID != nil { + id = strconv.FormatInt(*d.ID, 10) + } + last, when, branch := "-", "-", "-" + if b := d.LatestBuild; b != nil { + last, when, branch = buildState(b), ui.FormatTime(b.QueueTime), cmd.ShortBranch(b.SourceBranch) + } + rows = append(rows, []string{id, cmd.Str(d.Name), cmd.Str(d.Path), last, branch, when}) + } + ui.Table([]string{"id", "nombre", "carpeta", "última ejecución", "rama", "fecha"}, rows) + return nil + }, +} + +func init() { + listPipelinesCmd.Flags().StringP("filter", "f", "", "Filtro por nombre, admite comodines") + listPipelinesCmd.Flags().String("folder", "", "Carpeta (p. ej. \\infra)") + cmd.Pipelines.AddCommand(listPipelinesCmd) +} diff --git a/cmd/pipelines/run.go b/cmd/pipelines/run.go index 8b54153..49f17f5 100644 --- a/cmd/pipelines/run.go +++ b/cmd/pipelines/run.go @@ -1,67 +1,154 @@ package pipeline import ( - "log" + "fmt" + "os" "strings" + "time" "azuredevops/azdevops" "azuredevops/azdevops/pipeline" "azuredevops/cmd" + "azuredevops/internal/ui" "azuredevops/models" + "github.com/spf13/cobra" ) var runPipelineCmd = &cobra.Command{ Use: "run", - Short: "Ejecuta un pipeline y opcionalmente espera a que finalice.", - Run: func(cmd *cobra.Command, args []string) { - pipelineID, _ := cmd.Flags().GetInt("id") - wait, _ := cmd.Flags().GetBool("wait") - paramsSlice, _ := cmd.Flags().GetStringSlice("param") - varsSlice, _ := cmd.Flags().GetStringSlice("var") + Short: "Ejecuta un pipeline y opcionalmente espera a que finalice", + Long: `Ejecuta un pipeline. Con --wait muestra el progreso en vivo y, al terminar, +devuelve un código de salida distinto de 0 si la ejecución falló o fue cancelada +(útil en scripts y CI). Si falla, se listan las tareas con error.`, + Example: ` azdevops pipelines run --id 123 --wait + azdevops pipelines run --name MiPipeline --branch feature/x --wait --timeout 30m + azdevops pipelines run --id 123 --param imageTag=1.2.3 --var deployEnv=staging --var secret:apiKey=xxx + azdevops pipelines run # modo guiado`, + RunE: func(c *cobra.Command, args []string) error { + client, err := cmd.NewClient() + if err != nil { + return err + } + f := c.Flags() + guided := !f.Changed("id") && !f.Changed("name") && ui.Interactive() - if pipelineID == 0 { - log.Fatal("Debes proporcionar el ID del pipeline con --id.") + id, err := resolvePipelineID(c, client) + if err != nil { + return err } + wait, _ := f.GetBool("wait") + branch, _ := f.GetString("branch") + paramEntries, _ := f.GetStringSlice("param") + varEntries, _ := f.GetStringSlice("var") + timeout, _ := f.GetDuration("timeout") + interval, _ := f.GetDuration("interval") - paramsMap := make(map[string]string) - for _, p := range paramsSlice { - parts := strings.SplitN(p, "=", 2) - if len(parts) == 2 { - paramsMap[parts[0]] = parts[1] + if guided { + if branch, err = ui.Input("Rama", "Vacío para usar la rama por defecto del pipeline", branch, nil); err != nil { + return err + } + p, err := ui.Input("Parámetros (opcional)", "Formato clave=valor separados por coma", "", nil) + if err != nil { + return err + } + for _, entry := range strings.Split(p, ",") { + if entry = strings.TrimSpace(entry); entry != "" { + paramEntries = append(paramEntries, entry) + } + } + if !f.Changed("wait") { + if wait, err = ui.Confirm("¿Esperar a que termine y ver el progreso?", true); err != nil { + return err + } } } - varsMap := make(map[string]models.BuildVariable) - for _, v := range varsSlice { - parts := strings.SplitN(v, "=", 2) - if len(parts) == 2 { - key := parts[0] - value := parts[1] - isSecret := false + params, err := cmd.ParseParams(paramEntries) + if err != nil { + return err + } + vars, err := cmd.ParseBuildVariables(varEntries) + if err != nil { + return err + } - if strings.HasPrefix(key, "secret:") { - isSecret = true - key = strings.TrimPrefix(key, "secret:") - } + run, err := pipeline.RunPipeline(client, id, pipeline.RunOptions{Branch: branch, Params: params, Vars: vars}) + if err != nil { + return err + } + ui.Success("Pipeline iniciado (ejecución %d: %s)", run.ID, run.Name) + ui.Link(run.Links.Web.Href) - varsMap[key] = models.BuildVariable{ - Value: value, - IsSecret: isSecret, - } + if !wait { + if cmd.WantsData() { + return cmd.Print(run) } + return nil } - client := azdevops.GetClientFromEnv() - if _, err := pipeline.RunPipeline(client, pipelineID, wait, paramsMap, varsMap); err != nil { - log.Fatalf("Error al ejecutar el pipeline: %v", err) - } + return waitAndReport(client, run.ID, interval, timeout) }, } +// waitAndReport espera a que termine el build mostrando el progreso y devuelve error +// si terminó en fallo o cancelación. +func waitAndReport(client *azdevops.Client, buildID int, interval, timeout time.Duration) error { + progress := ui.StartProgress("Esperando a que el pipeline inicie...") + start := time.Now() + plain := !ui.IsTerminal(os.Stderr) + last := "" + build, err := pipeline.WaitForBuild(client, buildID, interval, timeout, func(b *models.Build) { + activity := "" + if b.Status == "inProgress" { + if tl, err := pipeline.GetTimeline(client, buildID); err == nil { + activity = currentActivity(tl) + } + } + title := fmt.Sprintf("%s · %s", b.Status, time.Since(start).Round(time.Second)) + if activity != "" { + title += " · " + activity + } + progress.SetTitle(title) + // Sin terminal (p. ej. en CI) se imprime una línea por cada cambio de estado. + if plain && b.Status+activity != last { + last = b.Status + activity + ui.Info("%s", title) + } + }) + progress.Stop() + if err != nil { + return err + } + + if cmd.WantsData() { + _ = cmd.Print(build) + } + duration := ui.FormatDuration(build.StartTime, build.FinishTime) + switch build.Result { + case "succeeded": + ui.Success("Ejecución %d finalizada: %s (%s)", build.ID, ui.Status(build.Result), duration) + return nil + case "partiallySucceeded": + ui.Warn("Ejecución %d finalizada: %s (%s)", build.ID, ui.Status(build.Result), duration) + return nil + } + + ui.Errorf("Ejecución %d finalizada: %s (%s)", build.ID, ui.Status(build.Result), duration) + if tl, err := pipeline.GetTimeline(client, buildID); err == nil { + printFailures(tl) + ui.Info("Ver logs: azdevops pipelines logs --build-id %d --failed", build.ID) + } + return fmt.Errorf("el pipeline terminó con resultado '%s'", build.Result) +} + func init() { - runPipelineCmd.Flags().IntP("id", "i", 0, "ID del pipeline a ejecutar (obligatorio)") - runPipelineCmd.Flags().BoolP("wait", "w", false, "Esperar a que el pipeline finalice (opcional)") - runPipelineCmd.Flags().StringSliceP("param", "p", nil, "Parámetros del pipeline en formato: clave=valor (se puede repetir)") - runPipelineCmd.Flags().StringSliceP("var", "v", nil, "Variables de ejecución en formato: clave=valor o secret:clave=valor") + addPipelineFlags(runPipelineCmd, "a ejecutar") + f := runPipelineCmd.Flags() + f.BoolP("wait", "w", false, "Esperar a que el pipeline finalice mostrando el progreso") + f.StringP("branch", "b", "", "Rama a ejecutar (por defecto la del pipeline)") + f.StringSliceP("param", "p", nil, "Parámetros del template YAML: clave=valor (se puede repetir)") + f.StringSliceP("var", "v", nil, "Variables de ejecución: clave=valor o secret:clave=valor") + f.Duration("timeout", 0, "Tiempo máximo de espera con --wait (p. ej. 30m). 0 = sin límite") + f.Duration("interval", 10*time.Second, "Intervalo entre consultas de estado con --wait") cmd.Pipelines.AddCommand(runPipelineCmd) } diff --git a/cmd/pipelines/runs.go b/cmd/pipelines/runs.go new file mode 100644 index 0000000..34f28a2 --- /dev/null +++ b/cmd/pipelines/runs.go @@ -0,0 +1,246 @@ +package pipeline + +import ( + "fmt" + "strconv" + "time" + + "azuredevops/azdevops" + + "azuredevops/azdevops/pipeline" + "azuredevops/cmd" + "azuredevops/internal/ui" + "azuredevops/models" + + "github.com/spf13/cobra" +) + +var runsCmd = &cobra.Command{ + Use: "runs", + Short: "Lista las ejecuciones recientes (de un pipeline o de todo el proyecto)", + Example: ` azdevops pipelines runs + azdevops pipelines runs --id 123 --branch main --top 5`, + RunE: func(c *cobra.Command, args []string) error { + out, err := cmd.Output("table") + if err != nil { + return err + } + client, err := cmd.NewClient() + if err != nil { + return err + } + id, _ := c.Flags().GetInt("id") + if name, _ := c.Flags().GetString("name"); name != "" { + if id, err = resolvePipelineID(c, client); err != nil { + return err + } + } + top, _ := c.Flags().GetInt("top") + branch, _ := c.Flags().GetString("branch") + + var builds []models.Build + if err := ui.Spinner("Cargando ejecuciones...", func() (err error) { + builds, err = pipeline.ListBuilds(client, id, top, branch) + return err + }); err != nil { + return err + } + if out != "table" { + if builds == nil { + builds = []models.Build{} + } + return cmd.Print(builds) + } + if len(builds) == 0 { + ui.Info("No hay ejecuciones.") + return nil + } + printBuildsTable(builds) + return nil + }, +} + +var statusCmd = &cobra.Command{ + Use: "status", + Short: "Muestra el estado y las etapas de una ejecución", + Example: ` azdevops pipelines status --build-id 4567 + azdevops pipelines status --build-id 4567 --watch`, + RunE: func(c *cobra.Command, args []string) error { + client, err := cmd.NewClient() + if err != nil { + return err + } + buildID, err := resolveBuildID(c, client) + if err != nil { + return err + } + build, err := pipeline.GetBuildByID(client, buildID) + if err != nil { + return err + } + if watch, _ := c.Flags().GetBool("watch"); watch && build.Status != "completed" { + ui.Link(build.Links.Web.Href) + return waitAndReport(client, buildID, 10*time.Second, 0) + } + if cmd.WantsData() { + return cmd.Print(build) + } + printBuildsTable([]models.Build{*build}) + fmt.Fprintln(ui.Out) + if tl, err := pipeline.GetTimeline(client, buildID); err == nil { + printTimeline(tl, false) + if build.Result == "failed" { + printFailures(tl) + } + } + ui.Link(build.Links.Web.Href) + return nil + }, +} + +var cancelCmd = &cobra.Command{ + Use: "cancel", + Short: "Cancela una ejecución en curso", + RunE: func(c *cobra.Command, args []string) error { + client, err := cmd.NewClient() + if err != nil { + return err + } + buildID, err := cmd.ResolveIntFlag(c, "build-id", func() (int, error) { + builds, err := pipeline.ListBuilds(client, 0, 50, "") + if err != nil { + return 0, err + } + var opts []ui.Option[int] + for _, b := range builds { + if b.Status != "completed" { + opts = append(opts, ui.Option[int]{ + Label: fmt.Sprintf("#%d %s %s %s", b.ID, b.Definition.Name, cmd.ShortBranch(b.SourceBranch), b.Status), + Value: b.ID, + }) + } + } + if len(opts) == 0 { + return 0, fmt.Errorf("no hay ejecuciones en curso") + } + return ui.Select("Ejecución a cancelar", opts) + }) + if err != nil { + return err + } + yes, _ := c.Flags().GetBool("yes") + if err := cmd.Confirm(fmt.Sprintf("¿Cancelar la ejecución %d?", buildID), yes); err != nil { + return err + } + if err := pipeline.CancelBuild(client, buildID); err != nil { + return err + } + ui.Success("Cancelación solicitada para la ejecución %d", buildID) + return nil + }, +} + +var logsCmd = &cobra.Command{ + Use: "logs", + Short: "Muestra los logs de una ejecución", + Long: `Sin --log-id ni --failed muestra las etapas/tareas con su ID de log +(en una terminal te deja elegir cuál ver).`, + Example: ` azdevops pipelines logs --build-id 4567 --failed + azdevops pipelines logs --build-id 4567 --log-id 12`, + RunE: func(c *cobra.Command, args []string) error { + client, err := cmd.NewClient() + if err != nil { + return err + } + buildID, err := resolveBuildID(c, client) + if err != nil { + return err + } + if logID, _ := c.Flags().GetInt("log-id"); logID != 0 { + return printLog(client, buildID, logID, "") + } + tl, err := pipeline.GetTimeline(client, buildID) + if err != nil { + return err + } + if failed, _ := c.Flags().GetBool("failed"); failed { + found := false + for _, r := range sortedRecords(tl) { + if r.Type == "Task" && r.Result == "failed" && r.Log != nil { + found = true + if err := printLog(client, buildID, r.Log.ID, r.Name); err != nil { + return err + } + } + } + if !found { + ui.Info("No hay tareas fallidas en la ejecución %d", buildID) + } + return nil + } + + if !ui.Interactive() { + printTimelineWithTasks(tl) + return nil + } + var opts []ui.Option[int] + for _, r := range sortedRecords(tl) { + if r.Log == nil { + continue + } + state := r.Result + if state == "" { + state = r.State + } + opts = append(opts, ui.Option[int]{Label: fmt.Sprintf("[%s] %s · %s", r.Type, r.Name, state), Value: r.Log.ID}) + } + logID, err := ui.Select("Log a mostrar", opts) + if err != nil { + return err + } + return printLog(client, buildID, logID, "") + }, +} + +func printTimelineWithTasks(tl *models.Timeline) { + var rows [][]string + for _, r := range sortedRecords(tl) { + if r.Log == nil { + continue + } + state := r.Result + if state == "" { + state = r.State + } + rows = append(rows, []string{strconv.Itoa(r.Log.ID), r.Type, r.Name, ui.Status(state)}) + } + ui.Table([]string{"log", "tipo", "nombre", "estado"}, rows) +} + +func printLog(client *azdevops.Client, buildID, logID int, title string) error { + text, err := pipeline.GetLog(client, buildID, logID) + if err != nil { + return err + } + if title != "" { + fmt.Fprintf(ui.Out, "\n===== %s (log %d) =====\n", title, logID) + } + fmt.Fprintln(ui.Out, text) + return nil +} + +func init() { + addPipelineFlags(runsCmd, "") + runsCmd.Flags().Int("top", 20, "Número máximo de ejecuciones") + runsCmd.Flags().StringP("branch", "b", "", "Filtrar por rama") + + for _, c := range []*cobra.Command{statusCmd, cancelCmd, logsCmd} { + c.Flags().Int("build-id", 0, "ID de la ejecución (build)") + } + statusCmd.Flags().BoolP("watch", "w", false, "Seguir la ejecución hasta que termine") + cancelCmd.Flags().BoolP("yes", "y", false, "Confirmar sin preguntar") + logsCmd.Flags().Int("log-id", 0, "ID del log a mostrar") + logsCmd.Flags().Bool("failed", false, "Mostrar solo los logs de las tareas fallidas") + + cmd.Pipelines.AddCommand(runsCmd, statusCmd, cancelCmd, logsCmd) +} diff --git a/cmd/pipelines/update.go b/cmd/pipelines/update.go index a0eaa76..f694f36 100644 --- a/cmd/pipelines/update.go +++ b/cmd/pipelines/update.go @@ -1,44 +1,93 @@ package pipeline import ( - "log" + "fmt" - "azuredevops/azdevops" "azuredevops/azdevops/pipeline" "azuredevops/cmd" + "azuredevops/internal/ui" "github.com/spf13/cobra" ) var updatePipelineCmd = &cobra.Command{ Use: "update", - Short: "Actualiza un pipeline existente.", - Run: func(cmd *cobra.Command, args []string) { - pipelineID, _ := cmd.Flags().GetInt("id") - newName, _ := cmd.Flags().GetString("new-name") - yamlPath, _ := cmd.Flags().GetString("yaml-path") - repoName, _ := cmd.Flags().GetString("repo-name") - serviceConnection, _ := cmd.Flags().GetString("service-connection") - - if pipelineID == 0 { - log.Fatal("Debes proporcionar el ID del pipeline con --id.") + Short: "Actualiza nombre, YAML, repositorio, rama o conexión de un pipeline", + Example: ` azdevops pipelines update --id 123 --new-name NuevoNombre --yaml-path ci.yml + azdevops pipelines update # modo guiado`, + RunE: func(c *cobra.Command, args []string) error { + client, err := cmd.NewClient() + if err != nil { + return err + } + id, err := resolvePipelineID(c, client) + if err != nil { + return err } - if newName == "" && yamlPath == "" && repoName == "" && serviceConnection == "" { - log.Fatal("Debes proporcionar al menos un valor para actualizar.") + f := c.Flags() + opts := pipeline.UpdateOptions{} + opts.NewName, _ = f.GetString("new-name") + opts.YAMLPath, _ = f.GetString("yaml-path") + opts.RepoName, _ = f.GetString("repo-name") + opts.Branch, _ = f.GetString("branch") + opts.ServiceConnectionID, _ = f.GetString("service-connection") + + if opts.IsEmpty() { + if !ui.Interactive() { + return fmt.Errorf("debes proporcionar al menos un valor para actualizar") + } + current, err := pipeline.GetBuildDefinitionByID(client, id) + if err != nil { + return err + } + curName, curYAML, curBranch := cmd.Str(current.Name), "", "" + if current.Process != nil { + curYAML = cmd.Str(current.Process.YAMLFilename) + } + if current.Repository != nil { + curBranch = cmd.ShortBranch(cmd.Str(current.Repository.DefaultBranch)) + } + ui.Info("Deja el valor actual para no modificarlo") + if v, err := ui.Input("Nombre", "", curName, ui.Required); err != nil { + return err + } else if v != curName { + opts.NewName = v + } + if v, err := ui.Input("Ruta del YAML", "", curYAML, ui.Required); err != nil { + return err + } else if v != curYAML { + opts.YAMLPath = v + } + if v, err := ui.Input("Rama por defecto", "", curBranch, ui.Required); err != nil { + return err + } else if v != curBranch { + opts.Branch = v + } + if opts.IsEmpty() { + ui.Info("No hay cambios.") + return nil + } } - client := azdevops.GetClientFromEnv() - if _, err := pipeline.UpdateBuildDefinition(client, pipelineID, newName, yamlPath, repoName, serviceConnection); err != nil { - log.Fatalf("Error al actualizar el pipeline: %v", err) + updated, err := pipeline.UpdateBuildDefinition(client, id, opts) + if err != nil { + return err + } + ui.Success("Pipeline '%s' (ID: %d) actualizado", cmd.Str(updated.Name), id) + if cmd.WantsData() { + return cmd.Print(updated) } + return nil }, } func init() { - updatePipelineCmd.Flags().IntP("id", "i", 0, "ID del pipeline a actualizar (obligatorio)") - updatePipelineCmd.Flags().String("new-name", "", "El nuevo nombre para el pipeline (opcional)") - updatePipelineCmd.Flags().String("yaml-path", "", "La nueva ruta al archivo YAML (opcional)") - updatePipelineCmd.Flags().String("repo-name", "", "El nuevo nombre del repositorio en formato 'org/repo' (opcional)") - updatePipelineCmd.Flags().String("service-connection", "", "El nuevo ID de la conexión de servicio (opcional)") + addPipelineFlags(updatePipelineCmd, "a actualizar") + f := updatePipelineCmd.Flags() + f.String("new-name", "", "Nuevo nombre del pipeline") + f.String("yaml-path", "", "Nueva ruta al archivo YAML") + f.String("repo-name", "", "Nuevo repositorio en formato 'org/repo'") + f.String("branch", "", "Nueva rama por defecto") + f.String("service-connection", "", "Nuevo ID de la conexión de servicio") cmd.Pipelines.AddCommand(updatePipelineCmd) } diff --git a/cmd/resources.go b/cmd/resources.go index dc3fade..a9a4d65 100644 --- a/cmd/resources.go +++ b/cmd/resources.go @@ -5,20 +5,23 @@ import ( ) var Variables = &cobra.Command{ - Use: "variables", - Short: "Comandos para trabajar con variables", - Long: "Comandos para trabajar con variables en Azure DevOps, como Variable Groups.", + Use: "variables", + Aliases: []string{"vg", "variable-groups"}, + Short: "Gestiona Variable Groups", + Long: "Comandos para trabajar con variables en Azure DevOps, como Variable Groups.", } var Pipelines = &cobra.Command{ - Use: "pipelines", - Short: "Comandos para trabajar con pipelines", - Long: "Comandos para trabajar con pipelines en Azure DevOps, incluyendo la creación, actualización y eliminación de pipelines.", + Use: "pipelines", + Aliases: []string{"pipeline", "pl"}, + Short: "Gestiona y ejecuta pipelines", + Long: "Comandos para trabajar con pipelines en Azure DevOps: crear, actualizar, eliminar, ejecutar y seguir sus ejecuciones.", } var Security = &cobra.Command{ - Use: "security", - Short: "Comandos para gestionar grupos y permisos de seguridad", + Use: "security", + Aliases: []string{"sec"}, + Short: "Gestiona grupos de seguridad y membresías", } func init() { diff --git a/cmd/root.go b/cmd/root.go index bf1c2b6..da925af 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -1,18 +1,88 @@ package cmd import ( + "errors" + "fmt" "os" + "azuredevops/azdevops" + "azuredevops/internal/ui" + "github.com/spf13/cobra" ) +var globalFlags struct { + org string + project string + pat string + profile string + output string + query string + noInput bool + debug bool +} + +// InteractiveRun lo registra el paquete cmd/interactive para abrir el menú +// cuando se ejecuta "azdevops" sin argumentos en una terminal. +var InteractiveRun func(cmd *cobra.Command) error + var RootCmd = &cobra.Command{ - Use: "azuredevops", + Use: "azdevops", Short: "CLI para Azure DevOps", + Long: `CLI no oficial para Azure DevOps: Variable Groups, Pipelines y Seguridad. + +Ejecuta "azdevops" sin argumentos en una terminal para abrir el modo interactivo, +o "azdevops login" para guardar tu organización, proyecto y PAT.`, + SilenceUsage: true, + SilenceErrors: true, + PersistentPreRun: func(cmd *cobra.Command, args []string) { + allowed := !globalFlags.noInput && os.Getenv("AZDEVOPS_NO_INPUT") == "" && os.Getenv("CI") == "" + ui.SetInteractive(allowed) + }, + RunE: func(cmd *cobra.Command, args []string) error { + if ui.Interactive() && InteractiveRun != nil { + return InteractiveRun(cmd) + } + return cmd.Help() + }, } +func init() { + RootCmd.Version = azdevops.Version + pf := RootCmd.PersistentFlags() + pf.StringVar(&globalFlags.org, "org", "", "Organización de Azure DevOps (env: AZURE_ORG)") + pf.StringVar(&globalFlags.project, "project", "", "Proyecto de Azure DevOps (env: AZURE_PROJECT)") + pf.StringVar(&globalFlags.pat, "pat", "", "Personal Access Token (env: AZURE_PAT). Preferible usar la variable de entorno o 'azdevops login'") + pf.StringVar(&globalFlags.profile, "profile", "", "Perfil guardado a usar (env: AZDEVOPS_PROFILE)") + pf.StringVarP(&globalFlags.output, "output", "o", "", "Formato de salida: table, json, yaml o tsv") + pf.StringVarP(&globalFlags.query, "query", "q", "", "Consulta JMESPath para filtrar la salida (p. ej. \"[].{id:id, nombre:name}\")") + pf.BoolVar(&globalFlags.noInput, "no-input", false, "Nunca preguntar de forma interactiva (env: AZDEVOPS_NO_INPUT)") + pf.BoolVar(&globalFlags.debug, "debug", false, "Muestra las peticiones HTTP realizadas") + + _ = RootCmd.RegisterFlagCompletionFunc("output", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { + return []string{"table", "json", "yaml", "tsv"}, cobra.ShellCompDirectiveNoFileComp + }) + RootCmd.SetVersionTemplate("azdevops {{.Version}}\n") +} + +// ExitError termina el proceso con Code sin imprimir un mensaje de error +// (p. ej. "variables diff --exit-code" cuando hay diferencias). +type ExitError struct{ Code int } + +func (e ExitError) Error() string { return fmt.Sprintf("código de salida %d", e.Code) } + +// Execute ejecuta la CLI y termina el proceso con el código adecuado. func Execute() { if err := RootCmd.Execute(); err != nil { + var exitErr ExitError + if errors.As(err, &exitErr) { + os.Exit(exitErr.Code) + } + if errors.Is(err, ui.ErrAborted) { + ui.Warn("%v", err) + os.Exit(130) + } + ui.Errorf("%v", err) os.Exit(1) } } diff --git a/cmd/securefiles/replace.go b/cmd/securefiles/replace.go new file mode 100644 index 0000000..936d0cb --- /dev/null +++ b/cmd/securefiles/replace.go @@ -0,0 +1,163 @@ +package securefiles + +import ( + "fmt" + "os" + "strings" + + "azuredevops/azdevops/organization" + "azuredevops/azdevops/securefile" + "azuredevops/cmd" + "azuredevops/internal/ui" + + "github.com/spf13/cobra" +) + +var replaceCmd = &cobra.Command{ + Use: "replace ", + Short: "Reemplaza el contenido de un archivo seguro conservando sus permisos", + Long: `Reemplaza el contenido de un archivo seguro existente conservando: + + · el nombre y sus propiedades, + · los pipelines autorizados (o el acceso abierto a todos los pipelines), + · los roles asignados (quién puede usarlo, verlo o administrarlo) y si hereda los del proyecto, + · las aprobaciones y checks configurados. + +Azure DevOps no permite cambiar el contenido de un archivo seguro ni tener dos con el +mismo nombre, así que el proceso es: renombrar el actual, subir el nuevo con el nombre +original, copiar toda la configuración y eliminar el anterior. Si algo falla antes de +eliminarlo, los cambios se revierten. + +Nota: el archivo nuevo tiene otro ID. Los pipelines YAML lo referencian por nombre, así +que siguen funcionando; si algún release clásico lo referencia por ID habría que revisarlo.`, + Example: ` azdevops securefiles replace ./cert-2026.pfx --name cert.pfx + azdevops sf replace ./kubeconfig --name kubeconfig-prod --dry-run + azdevops sf replace ./cert.pfx --name cert.pfx --keep-old --yes`, + Args: cobra.MaximumNArgs(1), + RunE: func(c *cobra.Command, args []string) error { + client, err := cmd.NewClient() + if err != nil { + return err + } + file, err := resolveFile(c, client, "Archivo seguro a reemplazar") + if err != nil { + return err + } + path, err := localPath(args, "Ruta del nuevo archivo") + if err != nil { + return err + } + data, err := os.ReadFile(strings.TrimSpace(path)) + if err != nil { + return err + } + if len(data) == 0 { + return fmt.Errorf("%s está vacío", path) + } + + project, err := organization.GetProject(client) + if err != nil { + return err + } + var snap *securefile.Snapshot + if err := ui.Spinner("Leyendo la configuración actual...", func() (err error) { + snap, err = securefile.TakeSnapshot(client, project.ID, *file) + return err + }); err != nil { + return err + } + + printPlan(snap, path, len(data)) + if dry, _ := c.Flags().GetBool("dry-run"); dry { + ui.Info("--dry-run: no se hizo ningún cambio") + return nil + } + yes, _ := c.Flags().GetBool("yes") + if err := cmd.Confirm(fmt.Sprintf("¿Reemplazar el contenido de '%s'?", file.Name), yes); err != nil { + return err + } + + keepOld, _ := c.Flags().GetBool("keep-old") + me, err := organization.CurrentUser(client) + if err != nil { + return err + } + progress := ui.StartProgress("Reemplazando...") + res, err := securefile.Replace(client, project.ID, snap, data, securefile.ReplaceOptions{ + KeepOld: keepOld, + SelfID: me.ID, + Progress: progress.SetTitle, + }) + progress.Stop() + if err != nil { + return err + } + + ui.Success("'%s' reemplazado (nuevo ID %s)", res.New.Name, res.New.ID) + if len(res.Pipelines) > 0 || res.AllPipelines { + ui.Success("Pipelines autorizados conservados: %d%s", len(res.Pipelines), map[bool]string{true: " + acceso abierto", false: ""}[res.AllPipelines]) + } + if res.Roles > 0 { + ui.Success("Roles asignados conservados: %d", res.Roles) + } + if !res.Inherits { + ui.Success("Herencia de permisos desactivada, igual que el original") + } + if res.Checks > 0 { + ui.Success("Aprobaciones/checks conservados: %d", res.Checks) + } + for _, w := range res.Warnings { + ui.Warn("%s", w) + } + switch { + case res.DeleteOldError != "": + ui.Warn("El nuevo archivo está listo, pero no se pudo eliminar el anterior ('%s'): %s", res.OldRenamedTo, res.DeleteOldError) + case keepOld: + ui.Info("El archivo anterior se conservó como '%s'", res.OldRenamedTo) + } + if cmd.WantsData() { + return cmd.Print(res) + } + return nil + }, +} + +func printPlan(snap *securefile.Snapshot, path string, size int) { + fmt.Fprintf(ui.Err, "Se reemplazará %s con %s (%d bytes) conservando:\n", ui.Bold(snap.File.Name), path, size) + ids := snap.Permissions.AuthorizedPipelineIDs() + pipelines := fmt.Sprintf("%d pipeline(s) autorizados", len(ids)) + if len(ids) > 0 { + strs := make([]string, len(ids)) + for i, id := range ids { + strs[i] = fmt.Sprintf("#%d", id) + } + pipelines += " (" + strings.Join(strs, ", ") + ")" + } + if snap.Permissions.OpenToAllPipelines() { + pipelines += " + acceso abierto a todos los pipelines" + } + fmt.Fprintf(ui.Err, " · %s\n", pipelines) + assigned := snap.AssignedRoles() + roleNames := make([]string, len(assigned)) + for i, r := range assigned { + roleNames[i] = fmt.Sprintf("%s (%s)", identity(&r.Identity), r.Role.Name) + } + fmt.Fprintf(ui.Err, " · %d rol(es) asignado(s): %s\n", len(assigned), strings.Join(roleNames, ", ")) + if snap.Inherits() { + fmt.Fprintln(ui.Err, " · herencia de permisos del proyecto: activada") + } else { + fmt.Fprintln(ui.Err, " · herencia de permisos del proyecto: desactivada") + } + fmt.Fprintf(ui.Err, " · %d aprobación(es)/check(s)\n", len(snap.Checks)) + if len(snap.File.Properties) > 0 { + fmt.Fprintf(ui.Err, " · %d propiedad(es)\n", len(snap.File.Properties)) + } +} + +func init() { + replaceCmd.Flags().StringP("name", "n", "", "Nombre (o ID) del archivo seguro a reemplazar") + replaceCmd.Flags().Bool("keep-old", false, "Conservar el archivo anterior renombrado en lugar de eliminarlo") + replaceCmd.Flags().Bool("dry-run", false, "Mostrar qué se conservaría sin hacer cambios") + replaceCmd.Flags().BoolP("yes", "y", false, "Confirmar sin preguntar") + secureFilesCmd.AddCommand(replaceCmd) +} diff --git a/cmd/securefiles/securefiles.go b/cmd/securefiles/securefiles.go new file mode 100644 index 0000000..eaeaf52 --- /dev/null +++ b/cmd/securefiles/securefiles.go @@ -0,0 +1,447 @@ +// Package securefiles contiene los comandos de Library > Secure files. +package securefiles + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "azuredevops/azdevops" + "azuredevops/azdevops/organization" + "azuredevops/azdevops/pipeline" + "azuredevops/azdevops/securefile" + "azuredevops/azdevops/security" + "azuredevops/cmd" + "azuredevops/internal/ui" + "azuredevops/models" + + "github.com/spf13/cobra" +) + +var secureFilesCmd = &cobra.Command{ + Use: "securefiles", + Aliases: []string{"sf", "secure-files"}, + Short: "Gestiona archivos seguros (certificados, keystores, kubeconfig…)", + Long: `Gestiona los archivos seguros de Library > Secure files. + +Los archivos seguros se guardan cifrados y solo los pipelines autorizados pueden +descargarlos (tarea DownloadSecureFile). La API no permite descargarlos ni cambiar +su contenido; para actualizar uno usa 'securefiles replace', que conserva sus +pipelines autorizados, roles y aprobaciones.`, +} + +var listCmd = &cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + Short: "Lista los archivos seguros del proyecto", + RunE: func(c *cobra.Command, args []string) error { + out, err := cmd.Output("table") + if err != nil { + return err + } + client, err := cmd.NewClient() + if err != nil { + return err + } + files, err := securefile.List(client) + if err != nil { + return err + } + if out != "table" { + if files == nil { + files = []models.SecureFile{} + } + return cmd.Print(files) + } + if len(files) == 0 { + ui.Info("El proyecto no tiene archivos seguros.") + return nil + } + rows := make([][]string, len(files)) + for i, f := range files { + rows[i] = []string{f.Name, identity(f.ModifiedBy), ui.FormatTime(f.ModifiedOn), f.ID} + } + ui.Table([]string{"nombre", "modificado por", "modificado", "id"}, rows) + return nil + }, +} + +var getCmd = &cobra.Command{ + Use: "get", + Short: "Muestra un archivo seguro: pipelines autorizados, roles y aprobaciones", + RunE: func(c *cobra.Command, args []string) error { + out, err := cmd.Output("table") + if err != nil { + return err + } + client, err := cmd.NewClient() + if err != nil { + return err + } + file, err := resolveFile(c, client, "Archivo seguro") + if err != nil { + return err + } + project, err := organization.GetProject(client) + if err != nil { + return err + } + snap, err := securefile.TakeSnapshot(client, project.ID, *file) + if err != nil { + return err + } + if out != "table" { + return cmd.Print(snap) + } + printSnapshot(client, snap) + return nil + }, +} + +func printSnapshot(client *azdevops.Client, snap *securefile.Snapshot) { + f := snap.File + fmt.Fprintf(ui.Out, "%s (%s)\n", ui.Bold(f.Name), f.ID) + fmt.Fprintf(ui.Out, " Creado: %s por %s\n", ui.FormatTime(f.CreatedOn), identity(f.CreatedBy)) + fmt.Fprintf(ui.Out, " Modificado: %s por %s\n", ui.FormatTime(f.ModifiedOn), identity(f.ModifiedBy)) + for k, v := range f.Properties { + fmt.Fprintf(ui.Out, " Propiedad: %s=%s\n", k, v) + } + + fmt.Fprintf(ui.Out, "\n%s\n", ui.Bold("Pipelines autorizados")) + if snap.Permissions.OpenToAllPipelines() { + fmt.Fprintln(ui.Out, " Todos los pipelines (acceso abierto)") + } + ids := snap.Permissions.AuthorizedPipelineIDs() + names := pipelineNames(client) + for _, id := range ids { + fmt.Fprintf(ui.Out, " #%d %s\n", id, names[id]) + } + if len(ids) == 0 && !snap.Permissions.OpenToAllPipelines() { + fmt.Fprintln(ui.Out, " (ninguno)") + } + + fmt.Fprintf(ui.Out, "\n%s", ui.Bold("Roles")) + if !snap.Inherits() { + fmt.Fprint(ui.Out, " (herencia del proyecto desactivada)") + } + fmt.Fprintln(ui.Out) + rows := make([][]string, len(snap.Roles)) + for i, r := range snap.Roles { + access := "asignado" + if r.Access == "inherited" { + access = "heredado" + } + rows[i] = []string{identity(&r.Identity), r.Role.Name, access} + } + ui.Table([]string{"identidad", "rol", "origen"}, rows) + + if len(snap.Checks) > 0 { + fmt.Fprintf(ui.Out, "\n%s\n", ui.Bold("Aprobaciones y checks")) + for _, ch := range snap.Checks { + fmt.Fprintf(ui.Out, " %s\n", ch.Type.Name) + } + } + ui.Link(client.WebURL("_library?itemType=SecureFiles")) +} + +var uploadCmd = &cobra.Command{ + Use: "upload ", + Short: "Sube un archivo seguro nuevo", + Example: ` azdevops securefiles upload ./cert.pfx + azdevops sf upload ./kubeconfig --name kubeconfig-prod --pipeline 12 --pipeline 15 + azdevops sf upload ./npmrc --authorize-all-pipelines`, + Args: cobra.MaximumNArgs(1), + RunE: func(c *cobra.Command, args []string) error { + client, err := cmd.NewClient() + if err != nil { + return err + } + path, err := localPath(args, "Ruta del archivo a subir") + if err != nil { + return err + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + name, _ := c.Flags().GetString("name") + if name == "" { + name = filepath.Base(path) + } + if existing, _ := securefile.Find(client, name); existing != nil { + return fmt.Errorf("ya existe un archivo seguro llamado '%s'; usa 'securefiles replace --name %q %s' para reemplazarlo", name, name, path) + } + all, _ := c.Flags().GetBool("authorize-all-pipelines") + pipelines, _ := c.Flags().GetIntSlice("pipeline") + + var created *models.SecureFile + if err := ui.Spinner("Subiendo...", func() (err error) { + created, err = securefile.Upload(client, name, data, all) + return err + }); err != nil { + return err + } + ui.Success("Archivo seguro '%s' subido (%s, %d bytes)", created.Name, created.ID, len(data)) + if len(pipelines) > 0 { + if err := securefile.SetPipelinePermissions(client, created.ID, pipelines, true, nil); err != nil { + return err + } + ui.Success("Autorizado para %d pipeline(s)", len(pipelines)) + } + if cmd.WantsData() { + return cmd.Print(created) + } + return nil + }, +} + +var deleteCmd = &cobra.Command{ + Use: "delete", + Short: "Elimina un archivo seguro", + RunE: func(c *cobra.Command, args []string) error { + client, err := cmd.NewClient() + if err != nil { + return err + } + file, err := resolveFile(c, client, "Archivo seguro a eliminar") + if err != nil { + return err + } + yes, _ := c.Flags().GetBool("yes") + if err := cmd.Confirm(fmt.Sprintf("¿Eliminar el archivo seguro '%s'? Los pipelines que lo usan fallarán", file.Name), yes); err != nil { + return err + } + if err := securefile.Delete(client, file.ID); err != nil { + return err + } + ui.Success("Archivo seguro '%s' eliminado", file.Name) + return nil + }, +} + +var authorizeCmd = &cobra.Command{ + Use: "authorize", + Short: "Autoriza o revoca el uso de un archivo seguro por pipelines", + Example: ` azdevops sf authorize --name cert.pfx --pipeline 12 --pipeline 15 + azdevops sf authorize --name cert.pfx --pipeline 12 --revoke + azdevops sf authorize --name npmrc --all-pipelines=true`, + RunE: func(c *cobra.Command, args []string) error { + client, err := cmd.NewClient() + if err != nil { + return err + } + file, err := resolveFile(c, client, "Archivo seguro") + if err != nil { + return err + } + ids, _ := c.Flags().GetIntSlice("pipeline") + revoke, _ := c.Flags().GetBool("revoke") + var all *bool + if c.Flags().Changed("all-pipelines") { + v, _ := c.Flags().GetBool("all-pipelines") + all = &v + } + if len(ids) == 0 && all == nil { + if !ui.Interactive() { + return fmt.Errorf("indica --pipeline o --all-pipelines") + } + id, err := cmd.PickPipeline(client, "Pipeline a autorizar") + if err != nil { + return err + } + ids = []int{id} + } + if err := securefile.SetPipelinePermissions(client, file.ID, ids, !revoke, all); err != nil { + return err + } + verb := "autorizado(s)" + if revoke { + verb = "revocado(s)" + } + if len(ids) > 0 { + ui.Success("%d pipeline(s) %s en '%s'", len(ids), verb, file.Name) + } + if all != nil { + ui.Success("Acceso para todos los pipelines: %v", *all) + } + return nil + }, +} + +var setRoleCmd = &cobra.Command{ + Use: "set-role", + Short: "Asigna un rol (Reader, User, Administrator) a usuarios o grupos", + Example: ` azdevops sf set-role --name cert.pfx --user ana@empresa.com --role Administrator + azdevops sf set-role --name cert.pfx --group Devs --role User`, + RunE: func(c *cobra.Command, args []string) error { + client, err := cmd.NewClient() + if err != nil { + return err + } + file, err := resolveFile(c, client, "Archivo seguro") + if err != nil { + return err + } + users, _ := c.Flags().GetStringSlice("user") + groups, _ := c.Flags().GetStringSlice("group") + if len(users) == 0 && len(groups) == 0 { + if !ui.Interactive() { + return fmt.Errorf("indica al menos un --user o --group") + } + u, err := ui.Input("Usuarios (emails separados por coma)", "Opcional", "", nil) + if err != nil { + return err + } + g, err := ui.Input("Grupos del proyecto (separados por coma)", "Sin el prefijo [Proyecto]\\ · Opcional", "", nil) + if err != nil { + return err + } + users, groups = splitList(u), splitList(g) + } + role, err := cmd.ResolveStringFlag(c, "role", func() (string, error) { + opts := make([]ui.Option[string], len(securefile.ValidRoles)) + for i, r := range securefile.ValidRoles { + opts[i] = ui.Option[string]{Label: r, Value: r} + } + return ui.Select("Rol", opts) + }) + if err != nil { + return err + } + role, err = normalizeRole(role) + if err != nil { + return err + } + + var assignments []models.SecurityRoleAssignment + for _, u := range users { + id, err := security.GetUserByPrincipalName(client, u) + if err != nil { + return err + } + assignments = append(assignments, models.SecurityRoleAssignment{RoleName: role, UserID: id.ID}) + } + for _, g := range groups { + id, err := security.GetGroupByPrincipalName(client, g) + if err != nil { + return err + } + assignments = append(assignments, models.SecurityRoleAssignment{RoleName: role, UserID: id.ID}) + } + if len(assignments) == 0 { + return fmt.Errorf("no se indicaron usuarios ni grupos") + } + project, err := organization.GetProject(client) + if err != nil { + return err + } + if err := securefile.SetRoles(client, project.ID, file.ID, assignments); err != nil { + return err + } + ui.Success("Rol %s asignado a %d identidad(es) en '%s'", role, len(assignments), file.Name) + return nil + }, +} + +func resolveFile(c *cobra.Command, client *azdevops.Client, title string) (*models.SecureFile, error) { + if name, _ := c.Flags().GetString("name"); name != "" { + return securefile.Find(client, name) + } + if !ui.Interactive() { + return nil, ui.MissingError("name") + } + files, err := securefile.List(client) + if err != nil { + return nil, err + } + if len(files) == 0 { + return nil, fmt.Errorf("el proyecto no tiene archivos seguros") + } + opts := make([]ui.Option[int], len(files)) + for i, f := range files { + opts[i] = ui.Option[int]{Label: fmt.Sprintf("%s · %s", f.Name, ui.FormatTime(f.ModifiedOn)), Value: i} + } + idx, err := ui.Select(title, opts) + if err != nil { + return nil, err + } + return &files[idx], nil +} + +func localPath(args []string, title string) (string, error) { + if len(args) == 1 { + return args[0], nil + } + if !ui.Interactive() { + return "", fmt.Errorf("indica la ruta del archivo local") + } + return ui.Input(title, "", "", func(s string) error { + if st, err := os.Stat(strings.TrimSpace(s)); err != nil || st.IsDir() { + return fmt.Errorf("no existe el archivo") + } + return nil + }) +} + +func pipelineNames(client *azdevops.Client) map[int]string { + names := map[int]string{} + defs, err := pipeline.ListDefinitions(client, "", "") + if err != nil { + return names + } + for _, d := range defs { + if d.ID != nil { + names[int(*d.ID)] = cmd.Str(d.Name) + } + } + return names +} + +func identity(id *models.IdentityRef) string { + if id == nil { + return "-" + } + if id.DisplayName != "" { + return id.DisplayName + } + return id.UniqueName +} + +func normalizeRole(role string) (string, error) { + for _, r := range securefile.ValidRoles { + if strings.EqualFold(r, role) { + return r, nil + } + } + return "", fmt.Errorf("rol '%s' no válido (usa %s)", role, strings.Join(securefile.ValidRoles, ", ")) +} + +func splitList(s string) []string { + var out []string + for _, p := range strings.Split(s, ",") { + if p = strings.TrimSpace(p); p != "" { + out = append(out, p) + } + } + return out +} + +func init() { + for _, c := range []*cobra.Command{getCmd, deleteCmd, authorizeCmd, setRoleCmd} { + c.Flags().StringP("name", "n", "", "Nombre (o ID) del archivo seguro") + } + uploadCmd.Flags().StringP("name", "n", "", "Nombre en Azure DevOps (por defecto el del archivo)") + uploadCmd.Flags().Bool("authorize-all-pipelines", false, "Permitir que todos los pipelines lo usen") + uploadCmd.Flags().IntSlice("pipeline", nil, "ID de pipeline a autorizar (se puede repetir)") + deleteCmd.Flags().BoolP("yes", "y", false, "Confirmar sin preguntar") + authorizeCmd.Flags().IntSlice("pipeline", nil, "ID de pipeline (se puede repetir)") + authorizeCmd.Flags().Bool("revoke", false, "Revocar en lugar de autorizar") + authorizeCmd.Flags().Bool("all-pipelines", false, "Abrir (true) o cerrar (false) el acceso a todos los pipelines") + setRoleCmd.Flags().StringSliceP("user", "u", nil, "Email del usuario (se puede repetir)") + setRoleCmd.Flags().StringSliceP("group", "g", nil, "Grupo del proyecto sin el prefijo [Proyecto]\\ (se puede repetir)") + setRoleCmd.Flags().StringP("role", "r", "", "Rol: Reader, User o Administrator") + + secureFilesCmd.AddCommand(listCmd, getCmd, uploadCmd, deleteCmd, authorizeCmd, setRoleCmd) + cmd.RootCmd.AddCommand(secureFilesCmd) +} diff --git a/cmd/security/add_member.go b/cmd/security/add_member.go deleted file mode 100644 index 9207139..0000000 --- a/cmd/security/add_member.go +++ /dev/null @@ -1,77 +0,0 @@ -package security - -import ( - "fmt" - "log" - - "azuredevops/azdevops" - "azuredevops/azdevops/security" - "azuredevops/cmd" - - "github.com/spf13/cobra" -) - -var addMemberCmd = &cobra.Command{ - Use: "add-member", - Short: "Agrega uno o más usuarios a uno o más grupos de seguridad.", - Run: func(cmd *cobra.Command, args []string) { - targetGroups, _ := cmd.Flags().GetStringSlice("target-group") - groupNames, _ := cmd.Flags().GetStringSlice("group") - users, _ := cmd.Flags().GetStringSlice("user") - - if len(groupNames) == 0 || len(users) == 0 || len(targetGroups) == 0 { - log.Fatal("Debes proporcionar al menos un valor para --target-group, --group y --user.") - } - - client := azdevops.GetClientFromEnv() - fmt.Println("Buscando descriptores de grupos destino...") - var targetGroupDescriptors []struct{ Name, Descriptor string } - for _, targetGroup := range targetGroups { - group, err := security.GetGroupByPrincipalName(client, targetGroup) - if err != nil { - continue - } - targetGroupDescriptors = append(targetGroupDescriptors, struct{ Name, Descriptor string }{targetGroup, group.SubjectDescriptor}) - } - - fmt.Println("\nBuscando descriptores de grupos...") - var groupDescriptors []struct{ Name, Descriptor string } - for _, groupName := range groupNames { - group, err := security.GetGroupByPrincipalName(client, groupName) - if err != nil { - continue - } - groupDescriptors = append(groupDescriptors, struct{ Name, Descriptor string }{groupName, group.SubjectDescriptor}) - } - - fmt.Println("\nBuscando descriptores de usuarios...") - var userDescriptors []struct{ Name, Descriptor string } - for _, userPrincipalName := range users { - user, err := security.GetUserByPrincipalName(client, userPrincipalName) - if err != nil { - continue - } - userDescriptors = append(userDescriptors, struct{ Name, Descriptor string }{userPrincipalName, user.SubjectDescriptor}) - } - fmt.Println("\nAsignando membresías...") - allGroupDescriptors := append(groupDescriptors, userDescriptors...) - - for _, group := range targetGroupDescriptors { - fmt.Printf("Procesando grupo: '%s'\n", group.Name) - for _, user := range allGroupDescriptors { - if _, err := security.AddUserToGroupOptimized(client, group.Descriptor, user.Descriptor); err != nil { - log.Printf(" └─ ❌ Error al agregar a '%s': %v\n", user.Name, err) - } else { - fmt.Printf(" └─ ✔️ Usuario/Grupo '%s' agregado con éxito.\n", user.Name) - } - } - } - }, -} - -func init() { - addMemberCmd.Flags().StringSliceP("target-group", "t", nil, "Nombre del grupo al que se agregara (se puede repetir para múltiples grupos)") - addMemberCmd.Flags().StringSliceP("user", "u", nil, "Email o UPN del usuario (se puede repetir para múltiples usuarios)") - addMemberCmd.Flags().StringSliceP("group", "g", nil, "Nombre del grupo a agregar (se puede repetir para múltiples grupos)") - cmd.Security.AddCommand(addMemberCmd) -} diff --git a/cmd/security/common.go b/cmd/security/common.go new file mode 100644 index 0000000..f3fdc62 --- /dev/null +++ b/cmd/security/common.go @@ -0,0 +1,107 @@ +package security + +import ( + "fmt" + "strings" + + "azuredevops/azdevops" + "azuredevops/azdevops/organization" + "azuredevops/azdevops/security" + "azuredevops/internal/ui" + "azuredevops/models" +) + +type subject struct{ Name, Descriptor string } + +// projectGroups lista los grupos de seguridad del proyecto actual. +func projectGroups(client *azdevops.Client) ([]models.GraphGroup, error) { + var groups []models.GraphGroup + err := ui.Spinner("Cargando grupos del proyecto...", func() error { + scope, err := organization.GetProjectDescriptor(client) + if err != nil { + return err + } + groups, err = security.ListGroups(client, scope, "") + return err + }) + return groups, err +} + +// pickProjectGroups permite elegir grupos del proyecto; devuelve sus nombres sin prefijo. +func pickProjectGroups(client *azdevops.Client, title string, multi bool) ([]string, error) { + groups, err := projectGroups(client) + if err != nil { + return nil, err + } + prefix := "[" + client.Project + "]\\" + opts := make([]ui.Option[string], len(groups)) + for i, g := range groups { + name := strings.TrimPrefix(g.PrincipalName, prefix) + opts[i] = ui.Option[string]{Label: name, Value: name} + } + if multi { + return ui.MultiSelect(title, opts) + } + v, err := ui.Select(title, opts) + return []string{v}, err +} + +// resolveGroups busca los descriptores de grupos del proyecto. Avisa de los que no existen. +func resolveGroups(client *azdevops.Client, names []string) []subject { + var out []subject + for _, n := range names { + g, err := security.GetGroupByPrincipalName(client, n) + if err != nil { + ui.Warn("%v", err) + continue + } + out = append(out, subject{n, g.SubjectDescriptor}) + } + return out +} + +// resolveUsers busca los descriptores de usuarios. Avisa de los que no existen. +func resolveUsers(client *azdevops.Client, upns []string) []subject { + var out []subject + for _, u := range upns { + id, err := security.GetUserByPrincipalName(client, u) + if err != nil { + ui.Warn("%v", err) + continue + } + out = append(out, subject{u, id.SubjectDescriptor}) + } + return out +} + +func splitList(s string) []string { + var out []string + for _, p := range strings.Split(s, ",") { + if p = strings.TrimSpace(p); p != "" { + out = append(out, p) + } + } + return out +} + +// promptMembers pide usuarios y grupos a agregar/quitar. +func promptMembers(client *azdevops.Client) (users, groups []string, err error) { + u, err := ui.Input("Usuarios (emails separados por coma)", "Opcional", "", nil) + if err != nil { + return nil, nil, err + } + users = splitList(u) + addGroups, err := ui.Confirm("¿Incluir grupos como miembros?", false) + if err != nil { + return nil, nil, err + } + if addGroups { + if groups, err = pickProjectGroups(client, "Grupos miembro", true); err != nil { + return nil, nil, err + } + } + if len(users) == 0 && len(groups) == 0 { + return nil, nil, fmt.Errorf("debes indicar al menos un usuario o grupo") + } + return users, groups, nil +} diff --git a/cmd/security/groups.go b/cmd/security/groups.go new file mode 100644 index 0000000..0ad615d --- /dev/null +++ b/cmd/security/groups.go @@ -0,0 +1,146 @@ +package security + +import ( + "fmt" + + "azuredevops/azdevops/organization" + "azuredevops/azdevops/security" + "azuredevops/cmd" + "azuredevops/internal/ui" + "azuredevops/models" + + "github.com/spf13/cobra" +) + +var listGroupsCmd = &cobra.Command{ + Use: "list-groups", + Short: "Lista los grupos de seguridad (de la organización o del proyecto)", + Example: ` azdevops security list-groups --search devs -o table + azdevops security list-groups --project-only`, + RunE: func(c *cobra.Command, args []string) error { + out, err := cmd.Output("json") + if err != nil { + return err + } + client, err := cmd.NewClient() + if err != nil { + return err + } + search, _ := c.Flags().GetString("search") + projectOnly, _ := c.Flags().GetBool("project-only") + + var groups []models.GraphGroup + if err := ui.Spinner("Cargando grupos...", func() error { + scope := "" + if projectOnly { + if scope, err = organization.GetProjectDescriptor(client); err != nil { + return err + } + } + groups, err = security.ListGroups(client, scope, search) + return err + }); err != nil { + return err + } + if out != "table" { + if groups == nil { + groups = []models.GraphGroup{} + } + return cmd.Print(groups) + } + rows := make([][]string, len(groups)) + for i, g := range groups { + rows[i] = []string{g.PrincipalName, g.Description} + } + ui.Table([]string{"grupo", "descripción"}, rows) + return nil + }, +} + +var searchGroupCmd = &cobra.Command{ + Use: "search-group", + Short: "Busca un grupo de seguridad del proyecto por su nombre", + RunE: func(c *cobra.Command, args []string) error { + client, err := cmd.NewClient() + if err != nil { + return err + } + name, err := cmd.ResolveStringFlag(c, "name", func() (string, error) { + names, err := pickProjectGroups(client, "Grupo", false) + if err != nil { + return "", err + } + return names[0], nil + }) + if err != nil { + return err + } + group, err := security.GetGroupByPrincipalName(client, name) + if err != nil { + return err + } + return cmd.Print(group) + }, +} + +var listMembersCmd = &cobra.Command{ + Use: "list-members", + Short: "Lista los miembros directos de un grupo del proyecto", + RunE: func(c *cobra.Command, args []string) error { + out, err := cmd.Output("table") + if err != nil { + return err + } + client, err := cmd.NewClient() + if err != nil { + return err + } + name, err := cmd.ResolveStringFlag(c, "group", func() (string, error) { + names, err := pickProjectGroups(client, "Grupo", false) + if err != nil { + return "", err + } + return names[0], nil + }) + if err != nil { + return err + } + group, err := security.GetGroupByPrincipalName(client, name) + if err != nil { + return err + } + members, err := security.ListMembers(client, group.SubjectDescriptor) + if err != nil { + return err + } + if out != "table" { + if members == nil { + members = []models.GraphSubject{} + } + return cmd.Print(members) + } + if len(members) == 0 { + ui.Info("El grupo '%s' no tiene miembros directos", name) + return nil + } + rows := make([][]string, len(members)) + for i, m := range members { + id := m.MailAddress + if id == "" { + id = m.PrincipalName + } + rows[i] = []string{m.DisplayName, id, m.SubjectKind} + } + ui.Table([]string{"nombre", "identificador", "tipo"}, rows) + fmt.Fprintf(ui.Err, "%d miembro(s)\n", len(members)) + return nil + }, +} + +func init() { + listGroupsCmd.Flags().StringP("search", "s", "", "Filtra por nombre (sin distinguir mayúsculas)") + listGroupsCmd.Flags().Bool("project-only", false, "Solo grupos del proyecto actual") + searchGroupCmd.Flags().StringP("name", "n", "", "Nombre del grupo, sin el prefijo [Proyecto]\\") + listMembersCmd.Flags().StringP("group", "g", "", "Nombre del grupo, sin el prefijo [Proyecto]\\") + cmd.Security.AddCommand(listGroupsCmd, searchGroupCmd, listMembersCmd) +} diff --git a/cmd/security/list_groups.go b/cmd/security/list_groups.go deleted file mode 100644 index fe23e1b..0000000 --- a/cmd/security/list_groups.go +++ /dev/null @@ -1,35 +0,0 @@ -package security - -import ( - "encoding/json" - "fmt" - "log" - - "azuredevops/azdevops" - "azuredevops/azdevops/security" - "azuredevops/cmd" - - "github.com/spf13/cobra" -) - -var listGroupsCmd = &cobra.Command{ - Use: "list-groups", - Short: "Lista todos los grupos de seguridad del proyecto.", - Run: func(cmd *cobra.Command, args []string) { - client := azdevops.GetClientFromEnv() - groups, err := security.ListGroups(client) - if err != nil { - log.Fatalf("Error al listar los grupos: %v", err) - } - - jsonData, err := json.MarshalIndent(groups, "", " ") - if err != nil { - log.Fatalf("Error al convertir a JSON: %v", err) - } - fmt.Println(string(jsonData)) - }, -} - -func init() { - cmd.Security.AddCommand(listGroupsCmd) -} diff --git a/cmd/security/members.go b/cmd/security/members.go new file mode 100644 index 0000000..062c1ef --- /dev/null +++ b/cmd/security/members.go @@ -0,0 +1,103 @@ +package security + +import ( + "fmt" + + "azuredevops/azdevops/security" + "azuredevops/cmd" + "azuredevops/internal/ui" + + "github.com/spf13/cobra" +) + +var addMemberCmd = &cobra.Command{ + Use: "add-member", + Short: "Agrega usuarios y/o grupos a uno o más grupos de seguridad", + Example: ` azdevops security add-member --target-group Destino --user ana@empresa.com --group Devs + azdevops security add-member # modo guiado`, + RunE: func(c *cobra.Command, args []string) error { + return changeMembership(c, true) + }, +} + +var removeMemberCmd = &cobra.Command{ + Use: "remove-member", + Short: "Quita usuarios y/o grupos de uno o más grupos de seguridad", + Example: ` azdevops security remove-member --target-group Destino --user ana@empresa.com`, + RunE: func(c *cobra.Command, args []string) error { + return changeMembership(c, false) + }, +} + +func changeMembership(c *cobra.Command, add bool) error { + client, err := cmd.NewClient() + if err != nil { + return err + } + targets, err := cmd.ResolveSliceFlag(c, "target-group", func() ([]string, error) { + return pickProjectGroups(client, "Grupos destino", true) + }) + if err != nil { + return err + } + users, _ := c.Flags().GetStringSlice("user") + groups, _ := c.Flags().GetStringSlice("group") + if len(users) == 0 && len(groups) == 0 { + if !ui.Interactive() { + return fmt.Errorf("debes proporcionar al menos un --user o --group") + } + if users, groups, err = promptMembers(client); err != nil { + return err + } + } + + targetSubjects := resolveGroups(client, targets) + members := append(resolveGroups(client, groups), resolveUsers(client, users)...) + if len(targetSubjects) == 0 || len(members) == 0 { + return fmt.Errorf("no se encontraron grupos destino o miembros válidos") + } + + if !add { + yes, _ := c.Flags().GetBool("yes") + if err := cmd.Confirm(fmt.Sprintf("¿Quitar %d miembro(s) de %d grupo(s)?", len(members), len(targetSubjects)), yes); err != nil { + return err + } + } + + failed := 0 + for _, target := range targetSubjects { + ui.Info("Grupo '%s'", target.Name) + for _, m := range members { + var err error + if add { + err = security.AddMembership(client, target.Descriptor, m.Descriptor) + } else { + err = security.RemoveMembership(client, target.Descriptor, m.Descriptor) + } + if err != nil { + ui.Errorf(" %s: %v", m.Name, err) + failed++ + continue + } + if add { + ui.Success(" '%s' agregado", m.Name) + } else { + ui.Success(" '%s' quitado", m.Name) + } + } + } + if failed > 0 { + return fmt.Errorf("%d operación(es) fallaron", failed) + } + return nil +} + +func init() { + for _, c := range []*cobra.Command{addMemberCmd, removeMemberCmd} { + c.Flags().StringSliceP("target-group", "t", nil, "Grupo destino, sin el prefijo [Proyecto]\\ (se puede repetir)") + c.Flags().StringSliceP("user", "u", nil, "Email o UPN del usuario (se puede repetir)") + c.Flags().StringSliceP("group", "g", nil, "Grupo miembro (se puede repetir)") + } + removeMemberCmd.Flags().BoolP("yes", "y", false, "Confirmar sin preguntar") + cmd.Security.AddCommand(addMemberCmd, removeMemberCmd) +} diff --git a/cmd/security/search_group.go b/cmd/security/search_group.go deleted file mode 100644 index 1099629..0000000 --- a/cmd/security/search_group.go +++ /dev/null @@ -1,44 +0,0 @@ -package security - -import ( - "encoding/json" - "fmt" - "log" - - "azuredevops/azdevops" - "azuredevops/azdevops/security" - "azuredevops/cmd" - - "github.com/spf13/cobra" -) - -var searchGroupCmd = &cobra.Command{ - Use: "search-group", - Short: "Busca un grupo de seguridad específico por su nombre.", - Long: "Busca un grupo de seguridad por su nombre exacto o parcial y devuelve un error si no se encuentra o si hay múltiples coincidencias.", - Run: func(cmd *cobra.Command, args []string) { - groupName, _ := cmd.Flags().GetString("name") - if groupName == "" { - log.Fatal("El flag --name es obligatorio.") - } - - client := azdevops.GetClientFromEnv() - - group, err := security.GetGroupByPrincipalName(client, groupName) - if err != nil { - log.Fatalf("Error al buscar el grupo: %v", err) - } - - jsonData, err := json.MarshalIndent(group, "", " ") - if err != nil { - log.Fatalf("Error al convertir el resultado a JSON: %v", err) - } - - fmt.Println(string(jsonData)) - }, -} - -func init() { - searchGroupCmd.Flags().StringP("name", "n", "", "Nombre del grupo a buscar (obligatorio)") - cmd.Security.AddCommand(searchGroupCmd) -} diff --git a/cmd/variable_group/copy_diff.go b/cmd/variable_group/copy_diff.go new file mode 100644 index 0000000..236547a --- /dev/null +++ b/cmd/variable_group/copy_diff.go @@ -0,0 +1,259 @@ +package variable_group + +import ( + "fmt" + "strings" + + "azuredevops/azdevops" + vg "azuredevops/azdevops/variable_group" + "azuredevops/cmd" + "azuredevops/internal/ui" + "azuredevops/models" + + "github.com/spf13/cobra" +) + +// findExact busca un Variable Group por su nombre exacto. +func findExact(client *azdevops.Client, name string) (*models.VariableGroup, error) { + groups, err := vg.GetVariableGroupByName(client, name) + if err != nil { + return nil, err + } + for i := range groups { + if groups[i].Name == name { + return &groups[i], nil + } + } + return nil, nil +} + +var copyCmd = &cobra.Command{ + Use: "copy", + Short: "Copia un Variable Group (también a otro proyecto)", + Long: `Copia las variables y la descripción de un Variable Group a uno nuevo o existente. + +La API no devuelve el valor de las variables secretas: en una terminal se te pedirá +cada valor (puedes dejarlo vacío) y en scripts puedes pasarlos con --secret-value. +Si el destino ya existe, usa --merge para fusionar: las variables comunes se +sobrescriben y las secretas sin valor conservan el que ya tenían en el destino.`, + Example: ` azdevops variables copy --from app-dev --to app-qa + azdevops variables copy --from app-dev --to app-dev --to-project OtroProyecto + azdevops variables copy --from app-dev --to app-prod --merge --secret-value DB_PASS=xxx`, + RunE: func(c *cobra.Command, args []string) error { + client, err := cmd.NewClient() + if err != nil { + return err + } + from, err := cmd.ResolveStringFlag(c, "from", func() (string, error) { + return cmd.PickVariableGroupName(client, "Variable Group origen") + }) + if err != nil { + return err + } + toProject, _ := c.Flags().GetString("to-project") + to, err := cmd.ResolveStringFlag(c, "to", func() (string, error) { + def := from + if toProject == "" { + def = from + "-copia" + } + return ui.Input("Nombre del grupo destino", "", def, ui.Required) + }) + if err != nil { + return err + } + target := client + if toProject != "" { + target = client.WithProject(toProject) + } + if toProject == "" && to == from { + return fmt.Errorf("el origen y el destino son el mismo grupo; usa otro --to o --to-project") + } + + source, err := findExact(client, from) + if err != nil { + return err + } + if source == nil { + return fmt.Errorf("no se encontró el Variable Group '%s'", from) + } + + secretValues, _ := c.Flags().GetStringSlice("secret-value") + provided, err := cmd.ParseParams(secretValues) + if err != nil { + return err + } + vars := make(map[string]models.VariableVal, len(source.Variables)) + var missingSecrets []string + for k, v := range source.Variables { + if v.IsSecret { + v.Value = "" + if val, ok := provided[k]; ok { + v.Value = val + } else if ui.Interactive() { + if v.Value, err = ui.Input("Valor de la variable secreta "+k, "Vacío para copiarla sin valor", "", nil); err != nil { + return err + } + } + if v.Value == "" { + missingSecrets = append(missingSecrets, k) + } + } + vars[k] = v + } + + existing, err := findExact(target, to) + if err != nil { + return err + } + dest := to + if toProject != "" { + dest = toProject + "/" + to + } + var result *models.VariableGroup + if existing == nil { + if result, err = vg.CreateVariableGroup(target, to, vars, source.Description); err != nil { + return err + } + ui.Success("'%s' copiado a '%s' (ID %d, %d variables)", from, dest, result.Id, len(vars)) + } else { + merge, _ := c.Flags().GetBool("merge") + if !merge { + if !ui.Interactive() { + return fmt.Errorf("el grupo destino '%s' ya existe; usa --merge para fusionar", dest) + } + if err := cmd.Confirm(fmt.Sprintf("'%s' ya existe. ¿Fusionar las variables (las comunes se sobrescriben)?", dest), false); err != nil { + return err + } + } + if result, err = vg.AddVariablesToGroup(target, *existing, vars, ""); err != nil { + return err + } + ui.Success("'%s' fusionado en '%s' (ID %d)", from, dest, result.Id) + } + if len(missingSecrets) > 0 { + if existing == nil { + ui.Warn("Variables secretas copiadas sin valor: %s", strings.Join(missingSecrets, ", ")) + } else { + ui.Warn("Variables secretas sin valor nuevo (conservan el del destino si existían): %s", strings.Join(missingSecrets, ", ")) + } + } + ui.Link(target.VariableGroupWebURL(result.Id)) + if cmd.WantsData() { + return cmd.Print(result) + } + return nil + }, +} + +var diffCmd = &cobra.Command{ + Use: "diff [grupo-a] [grupo-b]", + Short: "Compara las variables de dos Variable Groups", + Long: `Compara dos Variable Groups (por ejemplo dev contra prod), incluso de proyectos +distintos con --project-b. Los valores secretos no se pueden leer, así que solo se +compara si existen y si son secretas en ambos lados.`, + Example: ` azdevops variables diff app-dev app-prod + azdevops variables diff app-prod app-prod --project-b OtroProyecto --all + azdevops variables diff app-dev app-prod --exit-code # código 1 si hay diferencias`, + Args: cobra.MaximumNArgs(2), + RunE: func(c *cobra.Command, args []string) error { + out, err := cmd.Output("table") + if err != nil { + return err + } + client, err := cmd.NewClient() + if err != nil { + return err + } + projectB, _ := c.Flags().GetString("project-b") + clientB := client + if projectB != "" { + clientB = client.WithProject(projectB) + } + names := append([]string{}, args...) + if len(names) < 2 { + if !ui.Interactive() { + return fmt.Errorf("indica los dos grupos a comparar") + } + if len(names) == 0 { + n, err := cmd.PickVariableGroupName(client, "Grupo A") + if err != nil { + return err + } + names = append(names, n) + } + n, err := cmd.PickVariableGroupName(clientB, "Grupo B") + if err != nil { + return err + } + names = append(names, n) + } + + a, err := findExact(client, names[0]) + if err != nil { + return err + } + b, err := findExact(clientB, names[1]) + if err != nil { + return err + } + if a == nil || b == nil { + missing := names[0] + if a != nil { + missing = names[1] + } + return fmt.Errorf("no se encontró el Variable Group '%s'", missing) + } + + diffs := vg.Diff(a.Variables, b.Variables) + showAll, _ := c.Flags().GetBool("all") + if !showAll { + filtered := diffs[:0:0] + for _, d := range diffs { + if d.Status != vg.DiffEqual { + filtered = append(filtered, d) + } + } + diffs = filtered + } + + if out != "table" { + if err := cmd.Print(diffs); err != nil { + return err + } + } else if len(diffs) == 0 { + ui.Success("'%s' y '%s' tienen las mismas variables y valores", names[0], names[1]) + } else { + labels := map[string]string{ + vg.DiffOnlyA: "solo en A", vg.DiffOnlyB: "solo en B", vg.DiffChanged: "distinto", + vg.DiffEqual: "igual", vg.DiffSecret: "secreta (no comparable)", + } + rows := make([][]string, len(diffs)) + for i, d := range diffs { + rows[i] = []string{d.Variable, truncate(d.A, 40), truncate(d.B, 40), labels[d.Status]} + } + fmt.Fprintf(ui.Err, "A = %s · B = %s\n", ui.Bold(names[0]), ui.Bold(names[1])) + ui.Table([]string{"variable", "A", "B", "estado"}, rows) + } + + if exit, _ := c.Flags().GetBool("exit-code"); exit && vg.HasDifferences(diffs) { + return cmd.ExitError{Code: 1} + } + return nil + }, +} + +func init() { + copyCmd.Flags().String("from", "", "Variable Group origen") + copyCmd.Flags().String("to", "", "Nombre del Variable Group destino") + copyCmd.Flags().String("to-project", "", "Proyecto destino (por defecto el mismo)") + copyCmd.Flags().Bool("merge", false, "Si el destino existe, fusionar las variables") + copyCmd.Flags().StringSlice("secret-value", nil, "Valor de una variable secreta: CLAVE=VALOR (se puede repetir)") + _ = copyCmd.RegisterFlagCompletionFunc("from", cmd.CompleteVariableGroups) + + diffCmd.Flags().String("project-b", "", "Proyecto del grupo B (por defecto el mismo)") + diffCmd.Flags().Bool("all", false, "Mostrar también las variables iguales") + diffCmd.Flags().Bool("exit-code", false, "Terminar con código 1 si hay diferencias") + diffCmd.ValidArgsFunction = cmd.CompleteVariableGroups + + cmd.Variables.AddCommand(copyCmd, diffCmd) +} diff --git a/cmd/variable_group/create.go b/cmd/variable_group/create.go index d27f728..df5bcb1 100644 --- a/cmd/variable_group/create.go +++ b/cmd/variable_group/create.go @@ -1,80 +1,75 @@ package variable_group import ( - "log" - "strings" + "fmt" - "azuredevops/azdevops" vg "azuredevops/azdevops/variable_group" "azuredevops/cmd" + "azuredevops/internal/ui" "azuredevops/models" + "github.com/spf13/cobra" ) var createVariableGroupCmd = &cobra.Command{ Use: "create", Short: "Crea un nuevo Variable Group", - Run: func(cmd *cobra.Command, args []string) { - groupName, err := cmd.Flags().GetString("name") - description, _ := cmd.Flags().GetString("description") + Example: ` azdevops variables create --name MiGrupo -d "Descripción" -v clave1=valor1 -v secret:token=abc + azdevops variables create # modo guiado`, + RunE: func(c *cobra.Command, args []string) error { + name, err := cmd.ResolveStringFlag(c, "name", func() (string, error) { + return ui.Input("Nombre del Variable Group", "", "", ui.Required) + }) if err != nil { - log.Fatalf("Error al obtener el nombre del grupo: %v", err) - } - - newVariables, err := cmd.Flags().GetStringSlice("variables") - if err != nil { - log.Fatalf("Error al obtener las variables: %v", err) - } - - if groupName == "" { - log.Fatal("Debes proporcionar el nombre del grupo con --name") + return err } + description, _ := c.Flags().GetString("description") + entries, _ := c.Flags().GetStringSlice("variables") - client := azdevops.GetClientFromEnv() - newVariablesMap := make(map[string]models.VariableVal) - - for _, v := range newVariables { - isSecret := false - variable := v - - if strings.HasPrefix(v, "secret:") { - isSecret = true - variable = strings.TrimPrefix(v, "secret:") + var vars map[string]models.VariableVal + if len(entries) == 0 && ui.Interactive() { + if description == "" { + if description, err = ui.Input("Descripción (opcional)", "", "", nil); err != nil { + return err + } } - - parts := strings.SplitN(variable, "=", 2) - if len(parts) != 2 { - log.Fatalf("Formato de variable inválido: %s. Usa clave=valor o secret:clave=valor", v) - } - - key := strings.TrimSpace(parts[0]) - value := strings.TrimSpace(parts[1]) - - if key == "" { - log.Fatalf("La clave no puede estar vacía en: %s", v) + if vars, err = cmd.PromptVariables(); err != nil { + return err } + } else if vars, err = cmd.ParseVariables(entries); err != nil { + return err + } - newVariablesMap[key] = models.VariableVal{ - Value: value, - IsSecret: isSecret, + client, err := cmd.NewClient() + if err != nil { + return err + } + existing, err := vg.GetVariableGroupByName(client, name) + if err != nil { + return err + } + for _, g := range existing { + if g.Name == name { + return fmt.Errorf("el Variable Group '%s' ya existe (ID %d). Usa 'variables update' para modificarlo", name, g.Id) } } - getVariableGroup, err := vg.GetVariableGroupByName(client, groupName) - if err == nil && len(getVariableGroup) > 0 { - log.Fatalf("El Variable Group '%s' ya existe. Usa otro nombre.", groupName) - } else if err != nil && !strings.Contains(err.Error(), "Variable group not found") { - log.Fatalf("Error al obtener el Variable Group: %v", err) + + created, err := vg.CreateVariableGroup(client, name, vars, description) + if err != nil { + return err } - _, outputErr := vg.CreateVariableGroup(client, groupName, newVariablesMap, description) - if outputErr != nil { - log.Fatalf("Error al agregar variables al grupo: %v", outputErr) + ui.Success("Variable Group '%s' creado (ID: %d) con %d variables", created.Name, created.Id, len(vars)) + ui.Link(client.VariableGroupWebURL(created.Id)) + if cmd.WantsData() { + return cmd.Print(created) } + return nil }, } func init() { - createVariableGroupCmd.Flags().StringP("name", "n", "", "Name of the variable group") - createVariableGroupCmd.Flags().StringSliceP("variables", "v", []string{}, "Variables in the format key=value or secret:key=value") - createVariableGroupCmd.Flags().StringP("description", "d", "", "Description of the variable group (optional)") + createVariableGroupCmd.Flags().StringP("name", "n", "", "Nombre del Variable Group") + createVariableGroupCmd.Flags().StringSliceP("variables", "v", nil, "Variables en formato clave=valor o secret:clave=valor (se puede repetir)") + createVariableGroupCmd.Flags().StringP("description", "d", "", "Descripción del Variable Group (opcional)") cmd.Variables.AddCommand(createVariableGroupCmd) } diff --git a/cmd/variable_group/delete.go b/cmd/variable_group/delete.go index c692dae..81f837b 100644 --- a/cmd/variable_group/delete.go +++ b/cmd/variable_group/delete.go @@ -2,67 +2,148 @@ package variable_group import ( "fmt" - "log" + "sort" + "strings" - "azuredevops/azdevops" + "azuredevops/azdevops/organization" vg "azuredevops/azdevops/variable_group" "azuredevops/cmd" + "azuredevops/internal/ui" + "azuredevops/models" "github.com/spf13/cobra" ) var deleteVariableGroupCmd = &cobra.Command{ Use: "delete", - Short: "Elimina un Variable Group completo o variables específicas de uno", - Run: func(cmd *cobra.Command, args []string) { - groupNames, err := cmd.Flags().GetStringSlice("name") + Short: "Elimina Variable Groups completos o variables específicas", + Example: ` azdevops variables delete --name MiGrupo --variables clave1,clave2 + azdevops variables delete --name MiGrupo --yes + azdevops variables delete # modo guiado`, + RunE: func(c *cobra.Command, args []string) error { + client, err := cmd.NewClient() if err != nil { - log.Fatalf("Error al obtener los nombres del grupo: %v", err) + return err } - if len(groupNames) == 0 { - log.Fatal("Debes proporcionar al menos un nombre de grupo con --name") - } - - variablesToDelete, err := cmd.Flags().GetStringSlice("variables") + names, err := cmd.ResolveSliceFlag(c, "name", func() ([]string, error) { + return cmd.PickVariableGroupNames(client, "Variable Groups") + }) if err != nil { - log.Fatalf("Error al obtener las variables: %v", err) + return err } + yes, _ := c.Flags().GetBool("yes") + variables, _ := c.Flags().GetStringSlice("variables") - client := azdevops.GetClientFromEnv() + var groups []models.VariableGroup + for _, name := range names { + found, err := vg.GetVariableGroupByName(client, name) + if err != nil { + return err + } + if len(found) == 0 { + ui.Warn("No se encontró el Variable Group '%s'", name) + } + groups = append(groups, found...) + } + if len(groups) == 0 { + return fmt.Errorf("no hay Variable Groups que eliminar") + } - if len(variablesToDelete) > 0 { - if _, err := vg.DeleteVariableInGroupById(client, groupNames, variablesToDelete); err != nil { - log.Fatalf("Error al eliminar variables del grupo: %v", err) + if len(variables) == 0 && ui.Interactive() && !yes { + mode, err := ui.Select("¿Qué quieres eliminar?", []ui.Option[string]{ + {Label: "Algunas variables del grupo", Value: "vars"}, + {Label: "El Variable Group completo", Value: "group"}, + }) + if err != nil { + return err + } + if mode == "vars" { + if variables, err = pickVariables(groups); err != nil { + return err + } } - return } - autoconfirmation, _ := cmd.Flags().GetBool("yes") - if !autoconfirmation { - fmt.Printf("Estás a punto de eliminar los siguientes Variable Groups: %v\n", groupNames) - fmt.Print("¿Estás seguro de que deseas continuar? (s/n): ") - var confirmation string - fmt.Scanln(&confirmation) - if confirmation != "s" && confirmation != "S" { - fmt.Println("Operación cancelada.") - return + if len(variables) > 0 { + if ui.Interactive() { + if err := cmd.Confirm(fmt.Sprintf("¿Eliminar %s de %d grupo(s)?", strings.Join(variables, ", "), len(groups)), yes); err != nil { + return err + } + } + failed := 0 + for _, g := range groups { + missing, err := vg.RemoveVariablesFromGroup(client, g, variables) + if err != nil { + ui.Errorf("%s: %v", g.Name, err) + failed++ + continue + } + for _, m := range missing { + ui.Warn("La variable '%s' no existe en '%s'", m, g.Name) + } + if len(missing) < len(variables) { + ui.Success("Variables eliminadas de '%s' (ID %d)", g.Name, g.Id) + ui.Link(client.VariableGroupWebURL(g.Id)) + } + } + if failed > 0 { + return fmt.Errorf("%d grupo(s) no se pudieron actualizar", failed) } + return nil } - success, err := vg.DeleteVariableGroupByName(client, groupNames) + list := make([]string, len(groups)) + for i, g := range groups { + list[i] = fmt.Sprintf("%s (ID %d)", g.Name, g.Id) + } + ui.Warn("Se eliminarán: %s", strings.Join(list, ", ")) + if err := cmd.Confirm(fmt.Sprintf("¿Eliminar %d Variable Group(s) de forma permanente?", len(groups)), yes); err != nil { + return err + } + + project, err := organization.GetProject(client) if err != nil { - log.Fatalf("Error: %v", err) + return err + } + failed := 0 + for _, g := range groups { + if err := vg.DeleteVariableGroup(client, g.Id, project.ID); err != nil { + ui.Errorf("%v", err) + failed++ + continue + } + ui.Success("Variable Group '%s' eliminado (ID: %d)", g.Name, g.Id) } - if !success { - fmt.Printf("No se encontró el Variable Group con nombre '%s'\n", groupNames) - return + if failed > 0 { + return fmt.Errorf("%d Variable Group(s) no se pudieron eliminar", failed) } + return nil }, } +func pickVariables(groups []models.VariableGroup) ([]string, error) { + seen := map[string]bool{} + for _, g := range groups { + for k := range g.Variables { + seen[k] = true + } + } + keys := make([]string, 0, len(seen)) + for k := range seen { + keys = append(keys, k) + } + sort.Strings(keys) + opts := make([]ui.Option[string], len(keys)) + for i, k := range keys { + opts[i] = ui.Option[string]{Label: k, Value: k} + } + return ui.MultiSelect("Variables a eliminar", opts) +} + func init() { - deleteVariableGroupCmd.Flags().StringSliceP("name", "n", nil, "Nombre del variable group o grupos a eliminar") - deleteVariableGroupCmd.Flags().BoolP("yes", "y", false, "Confirmar eliminación sin preguntar") - deleteVariableGroupCmd.Flags().StringSliceP("variables", "v", nil, "Variables específicas a eliminar del variable group") + deleteVariableGroupCmd.Flags().StringSliceP("name", "n", nil, "Nombre del Variable Group (se puede repetir, admite comodines)") + deleteVariableGroupCmd.Flags().BoolP("yes", "y", false, "Confirmar sin preguntar") + deleteVariableGroupCmd.Flags().StringSliceP("variables", "v", nil, "Variables específicas a eliminar (si se omite se elimina el grupo completo)") + _ = deleteVariableGroupCmd.RegisterFlagCompletionFunc("name", cmd.CompleteVariableGroups) cmd.Variables.AddCommand(deleteVariableGroupCmd) } diff --git a/cmd/variable_group/export_import.go b/cmd/variable_group/export_import.go new file mode 100644 index 0000000..49728ed --- /dev/null +++ b/cmd/variable_group/export_import.go @@ -0,0 +1,223 @@ +package variable_group + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + vg "azuredevops/azdevops/variable_group" + "azuredevops/cmd" + "azuredevops/internal/ui" + "azuredevops/models" + + "github.com/spf13/cobra" +) + +var exportCmd = &cobra.Command{ + Use: "export", + Short: "Exporta las variables de un grupo a .env o JSON", + Long: `Exporta las variables de un Variable Group. Los valores secretos no se pueden +leer desde la API, por lo que se exportan sin valor.`, + Example: ` azdevops variables export --name MiGrupo > .env + azdevops variables export --name MiGrupo --format json --file vars.json`, + RunE: func(c *cobra.Command, args []string) error { + client, err := cmd.NewClient() + if err != nil { + return err + } + name, err := cmd.ResolveStringFlag(c, "name", func() (string, error) { + return cmd.PickVariableGroupName(client, "Variable Group a exportar") + }) + if err != nil { + return err + } + format, _ := c.Flags().GetString("format") + file, _ := c.Flags().GetString("file") + if format == "" { + format = "env" + if strings.EqualFold(filepath.Ext(file), ".json") { + format = "json" + } + } + + groups, err := vg.GetVariableGroupByName(client, name) + if err != nil { + return err + } + var group *models.VariableGroup + for i := range groups { + if groups[i].Name == name { + group = &groups[i] + } + } + if group == nil { + return fmt.Errorf("no se encontró el Variable Group '%s'", name) + } + + var data []byte + switch format { + case "env": + data = formatEnv(group.Variables) + case "json": + if data, err = json.MarshalIndent(group.Variables, "", " "); err != nil { + return err + } + data = append(data, '\n') + default: + return fmt.Errorf("formato '%s' no soportado (usa env o json)", format) + } + + secrets := 0 + for _, v := range group.Variables { + if v.IsSecret { + secrets++ + } + } + if secrets > 0 { + ui.Warn("%d variable(s) secreta(s) se exportaron sin valor", secrets) + } + if file == "" { + _, err = ui.Out.Write(data) + return err + } + if err := os.WriteFile(file, data, 0o600); err != nil { + return err + } + ui.Success("%d variables exportadas a %s", len(group.Variables), file) + return nil + }, +} + +var importCmd = &cobra.Command{ + Use: "import", + Short: "Importa variables desde un archivo .env o JSON a un grupo", + Example: ` azdevops variables import --name MiGrupo --file .env --secret DB_PASSWORD --secret API_KEY + azdevops variables import --name NuevoGrupo --file vars.json --create`, + RunE: func(c *cobra.Command, args []string) error { + client, err := cmd.NewClient() + if err != nil { + return err + } + file, err := cmd.ResolveStringFlag(c, "file", func() (string, error) { + return ui.Input("Archivo a importar (.env o .json)", "", ".env", ui.Required) + }) + if err != nil { + return err + } + data, err := os.ReadFile(file) + if err != nil { + return err + } + var vars map[string]models.VariableVal + if strings.EqualFold(filepath.Ext(file), ".json") { + vars, err = parseJSONFile(data) + } else { + vars, err = parseEnvFile(data) + } + if err != nil { + return fmt.Errorf("%s: %w", file, err) + } + if len(vars) == 0 { + return fmt.Errorf("%s no contiene variables", file) + } + + secretKeys, _ := c.Flags().GetStringSlice("secret") + if len(secretKeys) == 0 && ui.Interactive() { + opts := make([]ui.Option[string], 0, len(vars)) + for _, k := range sortedKeys(vars) { + opts = append(opts, ui.Option[string]{Label: k, Value: k}) + } + mark, err := ui.Confirm("¿Marcar alguna variable como secreta?", false) + if err != nil { + return err + } + if mark { + if secretKeys, err = ui.MultiSelect("Variables secretas", opts); err != nil { + return err + } + } + } + for _, k := range secretKeys { + v, ok := vars[k] + if !ok { + return fmt.Errorf("--secret %s: la variable no está en el archivo", k) + } + v.IsSecret = true + vars[k] = v + } + + name, err := cmd.ResolveStringFlag(c, "name", func() (string, error) { + return cmd.PickVariableGroupName(client, "Variable Group destino") + }) + if err != nil { + return err + } + groups, err := vg.GetVariableGroupByName(client, name) + if err != nil { + return err + } + var target *models.VariableGroup + for i := range groups { + if groups[i].Name == name { + target = &groups[i] + } + } + + if target == nil { + create, _ := c.Flags().GetBool("create") + if !create { + if !ui.Interactive() { + return fmt.Errorf("el Variable Group '%s' no existe (usa --create para crearlo)", name) + } + if create, err = ui.Confirm(fmt.Sprintf("'%s' no existe. ¿Crearlo?", name), true); err != nil || !create { + return ui.ErrAborted + } + } + created, err := vg.CreateVariableGroup(client, name, vars, "") + if err != nil { + return err + } + ui.Success("Variable Group '%s' creado con %d variables (ID %d)", name, len(vars), created.Id) + ui.Link(client.VariableGroupWebURL(created.Id)) + return nil + } + + overwritten := 0 + for k := range vars { + if _, ok := target.Variables[k]; ok { + overwritten++ + } + } + if overwritten > 0 && ui.Interactive() { + yes, _ := c.Flags().GetBool("yes") + if err := cmd.Confirm(fmt.Sprintf("Se sobrescribirán %d variable(s) existentes en '%s'. ¿Continuar?", overwritten, name), yes); err != nil { + return err + } + } + updated, err := vg.AddVariablesToGroup(client, *target, vars, "") + if err != nil { + return err + } + ui.Success("%d variables importadas en '%s' (%d nuevas, %d sobrescritas)", len(vars), updated.Name, len(vars)-overwritten, overwritten) + ui.Link(client.VariableGroupWebURL(updated.Id)) + return nil + }, +} + +func init() { + exportCmd.Flags().StringP("name", "n", "", "Nombre del Variable Group") + exportCmd.Flags().StringP("format", "f", "", "Formato: env o json (por defecto según la extensión de --file, o env)") + exportCmd.Flags().String("file", "", "Archivo de salida (por defecto stdout)") + _ = exportCmd.RegisterFlagCompletionFunc("name", cmd.CompleteVariableGroups) + + importCmd.Flags().StringP("name", "n", "", "Nombre del Variable Group destino") + importCmd.Flags().String("file", "", "Archivo .env o .json a importar") + importCmd.Flags().StringSlice("secret", nil, "Claves a marcar como secretas (se puede repetir)") + importCmd.Flags().Bool("create", false, "Crear el Variable Group si no existe") + importCmd.Flags().BoolP("yes", "y", false, "No pedir confirmación al sobrescribir") + _ = importCmd.RegisterFlagCompletionFunc("name", cmd.CompleteVariableGroups) + + cmd.Variables.AddCommand(exportCmd, importCmd) +} diff --git a/cmd/variable_group/file.go b/cmd/variable_group/file.go new file mode 100644 index 0000000..7ea3229 --- /dev/null +++ b/cmd/variable_group/file.go @@ -0,0 +1,100 @@ +package variable_group + +import ( + "bufio" + "bytes" + "encoding/json" + "fmt" + "sort" + "strconv" + "strings" + + "azuredevops/models" +) + +// parseEnvFile interpreta un archivo .env: KEY=VALUE, comentarios con '#', +// prefijo opcional "export " y valores entre comillas simples o dobles. +func parseEnvFile(data []byte) (map[string]models.VariableVal, error) { + vars := map[string]models.VariableVal{} + scanner := bufio.NewScanner(bytes.NewReader(data)) + scanner.Buffer(make([]byte, 1024*1024), 1024*1024) + line := 0 + for scanner.Scan() { + line++ + text := strings.TrimSpace(scanner.Text()) + if text == "" || strings.HasPrefix(text, "#") { + continue + } + text = strings.TrimPrefix(text, "export ") + key, value, ok := strings.Cut(text, "=") + key = strings.TrimSpace(key) + if !ok || key == "" { + return nil, fmt.Errorf("línea %d inválida: se esperaba CLAVE=VALOR", line) + } + value = strings.TrimSpace(value) + if len(value) >= 2 { + switch { + case value[0] == '"' && value[len(value)-1] == '"': + if unq, err := strconv.Unquote(value); err == nil { + value = unq + } else { + value = value[1 : len(value)-1] + } + case value[0] == '\'' && value[len(value)-1] == '\'': + value = value[1 : len(value)-1] + } + } + vars[key] = models.VariableVal{Value: value} + } + return vars, scanner.Err() +} + +// parseJSONFile acepta {"k": "v"} o {"k": {"value": "v", "isSecret": true}}. +func parseJSONFile(data []byte) (map[string]models.VariableVal, error) { + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("JSON inválido: %w", err) + } + vars := make(map[string]models.VariableVal, len(raw)) + for k, v := range raw { + var s string + if err := json.Unmarshal(v, &s); err == nil { + vars[k] = models.VariableVal{Value: s} + continue + } + var vv models.VariableVal + if err := json.Unmarshal(v, &vv); err != nil { + return nil, fmt.Errorf("valor inválido para '%s': se esperaba texto u objeto {value, isSecret}", k) + } + vars[k] = vv + } + return vars, nil +} + +// formatEnv genera un archivo .env. Las variables secretas no tienen valor +// legible desde la API y se emiten comentadas. +func formatEnv(vars map[string]models.VariableVal) []byte { + var b strings.Builder + for _, k := range sortedKeys(vars) { + v := vars[k] + if v.IsSecret { + fmt.Fprintf(&b, "# %s es secreta: la API no devuelve su valor\n# %s=\n", k, k) + continue + } + value := v.Value + if strings.ContainsAny(value, " \t#\"'\n") { + value = strconv.Quote(value) + } + fmt.Fprintf(&b, "%s=%s\n", k, value) + } + return []byte(b.String()) +} + +func sortedKeys(vars map[string]models.VariableVal) []string { + keys := make([]string, 0, len(vars)) + for k := range vars { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} diff --git a/cmd/variable_group/file_test.go b/cmd/variable_group/file_test.go new file mode 100644 index 0000000..d6d412d --- /dev/null +++ b/cmd/variable_group/file_test.go @@ -0,0 +1,67 @@ +package variable_group + +import ( + "testing" + + "azuredevops/models" +) + +func TestParseEnvFile(t *testing.T) { + data := []byte(` +# comentario +export A=1 +B = "con espacios" +C='literal $x' +D=a=b +EMPTY= +`) + vars, err := parseEnvFile(data) + if err != nil { + t.Fatal(err) + } + want := map[string]string{"A": "1", "B": "con espacios", "C": "literal $x", "D": "a=b", "EMPTY": ""} + if len(vars) != len(want) { + t.Fatalf("got %d vars, want %d: %v", len(vars), len(want), vars) + } + for k, v := range want { + if vars[k].Value != v { + t.Errorf("%s = %q, want %q", k, vars[k].Value, v) + } + } + + if _, err := parseEnvFile([]byte("SIN_IGUAL")); err == nil { + t.Error("se esperaba error para línea sin '='") + } +} + +func TestParseJSONFile(t *testing.T) { + vars, err := parseJSONFile([]byte(`{"a":"1","b":{"value":"2","isSecret":true}}`)) + if err != nil { + t.Fatal(err) + } + if vars["a"].Value != "1" || vars["a"].IsSecret { + t.Errorf("a incorrecta: %+v", vars["a"]) + } + if vars["b"].Value != "2" || !vars["b"].IsSecret { + t.Errorf("b incorrecta: %+v", vars["b"]) + } +} + +func TestFormatEnvRoundTrip(t *testing.T) { + in := map[string]models.VariableVal{ + "PLAIN": {Value: "x"}, + "SPACED": {Value: "hola mundo"}, + "TOKEN": {IsSecret: true}, + } + out := formatEnv(in) + parsed, err := parseEnvFile(out) + if err != nil { + t.Fatalf("no se pudo releer:\n%s\n%v", out, err) + } + if parsed["PLAIN"].Value != "x" || parsed["SPACED"].Value != "hola mundo" { + t.Errorf("round trip incorrecto: %v", parsed) + } + if _, ok := parsed["TOKEN"]; ok { + t.Error("las variables secretas no deben exportarse con valor") + } +} diff --git a/cmd/variable_group/get.go b/cmd/variable_group/get.go index 041088f..975ffad 100644 --- a/cmd/variable_group/get.go +++ b/cmd/variable_group/get.go @@ -1,49 +1,66 @@ package variable_group import ( - "encoding/json" "fmt" - "log" - "azuredevops/azdevops" - "azuredevops/cmd" vg "azuredevops/azdevops/variable_group" + "azuredevops/cmd" + "azuredevops/models" + "github.com/spf13/cobra" ) var getVariableGroupCmd = &cobra.Command{ Use: "get", - Short: "Obtiene un Variable Group por nombre", - Run: func(cmd *cobra.Command, args []string) { - groupName, err := cmd.Flags().GetString("name") + Short: "Obtiene uno o más Variable Groups por nombre o ID", + Example: ` azdevops variables get --name "MiGrupo" + azdevops variables get --name "MiGrupo*" + azdevops variables get --id 42 -o table`, + RunE: func(c *cobra.Command, args []string) error { + out, err := cmd.Output("json") if err != nil { - log.Fatalf("Error al obtener el nombre del grupo: %v", err) - } - if groupName == "" { - log.Fatal("Debes proporcionar el nombre del grupo con --name") + return err } - - client := azdevops.GetClientFromEnv() - - group, err := vg.GetVariableGroupByName(client, groupName) + client, err := cmd.NewClient() if err != nil { - log.Fatalf("Error: %v", err) + return err } - if group == nil { - fmt.Printf("No se encontró el Variable Group con nombre '%s'\n", groupName) - return + var groups []models.VariableGroup + if id, _ := c.Flags().GetInt("id"); id != 0 { + g, err := vg.GetVariableGroupById(client, id) + if err != nil { + return err + } + groups = []models.VariableGroup{*g} + } else { + name, err := cmd.ResolveStringFlag(c, "name", func() (string, error) { + return cmd.PickVariableGroupName(client, "Variable Group") + }) + if err != nil { + return err + } + if groups, err = vg.GetVariableGroupByName(client, name); err != nil { + return err + } + if len(groups) == 0 { + return fmt.Errorf("no se encontró el Variable Group '%s'", name) + } } - jsonData, err := json.Marshal(group) - if err != nil { - log.Fatalf("Error al convertir a JSON: %v", err) + if out != "table" { + return cmd.Print(groups) + } + for _, g := range groups { + printGroupVariables(g) } - fmt.Println(string(jsonData)) + return nil }, } func init() { - getVariableGroupCmd.Flags().StringP("name", "n", "", "Nombre del variable group") + getVariableGroupCmd.Flags().StringP("name", "n", "", "Nombre del Variable Group (admite comodines)") + getVariableGroupCmd.Flags().IntP("id", "i", 0, "ID del Variable Group") + _ = getVariableGroupCmd.RegisterFlagCompletionFunc("name", cmd.CompleteVariableGroups) cmd.Variables.AddCommand(getVariableGroupCmd) } diff --git a/cmd/variable_group/list.go b/cmd/variable_group/list.go new file mode 100644 index 0000000..fc206c8 --- /dev/null +++ b/cmd/variable_group/list.go @@ -0,0 +1,100 @@ +package variable_group + +import ( + "fmt" + "strconv" + + vg "azuredevops/azdevops/variable_group" + "azuredevops/cmd" + "azuredevops/internal/ui" + "azuredevops/models" + + "github.com/spf13/cobra" +) + +var listVariableGroupsCmd = &cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + Short: "Lista los Variable Groups del proyecto", + Example: ` azdevops variables list + azdevops variables list --filter "app-*" -o json`, + RunE: func(c *cobra.Command, args []string) error { + out, err := cmd.Output("table") + if err != nil { + return err + } + filter, _ := c.Flags().GetString("filter") + client, err := cmd.NewClient() + if err != nil { + return err + } + var groups []models.VariableGroup + if err := ui.Spinner("Cargando Variable Groups...", func() (err error) { + groups, err = vg.ListVariableGroups(client, filter) + return err + }); err != nil { + return err + } + return printGroups(groups, out) + }, +} + +func printGroups(groups []models.VariableGroup, out string) error { + if out != "table" { + if groups == nil { + groups = []models.VariableGroup{} + } + return cmd.Print(groups) + } + if len(groups) == 0 { + ui.Info("No se encontraron Variable Groups.") + return nil + } + rows := make([][]string, len(groups)) + for i, g := range groups { + secrets := 0 + for _, v := range g.Variables { + if v.IsSecret { + secrets++ + } + } + rows[i] = []string{ + strconv.Itoa(g.Id), g.Name, + fmt.Sprintf("%d (%d secretas)", len(g.Variables), secrets), + g.ModifiedBy.DisplayName, ui.FormatTime(&g.ModifiedOn), truncate(g.Description, 40), + } + } + ui.Table([]string{"id", "nombre", "variables", "modificado por", "modificado", "descripción"}, rows) + return nil +} + +func printGroupVariables(g models.VariableGroup) { + fmt.Fprintf(ui.Out, "%s (ID %d)\n", ui.Bold(g.Name), g.Id) + if g.Description != "" { + fmt.Fprintf(ui.Out, "%s\n", g.Description) + } + rows := make([][]string, 0, len(g.Variables)) + for _, k := range sortedKeys(g.Variables) { + v := g.Variables[k] + value := v.Value + if v.IsSecret { + value = "********" + } + rows = append(rows, []string{k, value}) + } + ui.Table([]string{"variable", "valor"}, rows) + fmt.Fprintln(ui.Out) +} + +func truncate(s string, n int) string { + r := []rune(s) + if len(r) <= n { + return s + } + return string(r[:n-1]) + "…" +} + +func init() { + listVariableGroupsCmd.Flags().StringP("filter", "f", "", "Filtro por nombre, admite comodines (p. ej. 'app-*')") + cmd.Variables.AddCommand(listVariableGroupsCmd) +} diff --git a/cmd/variable_group/set_permissions.go b/cmd/variable_group/set_permissions.go index a3cc8b1..bbf4d8b 100644 --- a/cmd/variable_group/set_permissions.go +++ b/cmd/variable_group/set_permissions.go @@ -2,76 +2,118 @@ package variable_group import ( "fmt" - "log" + "strings" - "azuredevops/azdevops" + "azuredevops/azdevops/organization" "azuredevops/azdevops/security" vg "azuredevops/azdevops/variable_group" "azuredevops/cmd" + "azuredevops/internal/ui" + "azuredevops/models" "github.com/spf13/cobra" ) var setPermissionsCmd = &cobra.Command{ Use: "set-permissions", - Short: "Asigna permisos a uno o más Variable Groups.", - Run: func(cmd *cobra.Command, args []string) { - variableNames, _ := cmd.Flags().GetStringSlice("variable") - users, _ := cmd.Flags().GetStringSlice("user") - groups, _ := cmd.Flags().GetStringSlice("group") - role, _ := cmd.Flags().GetString("role") - - if len(variableNames) == 0 || (len(users) == 0 && len(groups) == 0) || role == "" { - log.Fatal("Los flags --variable y --role son obligatorios. Debes proporcionar al menos un --user o --group.") + Short: "Asigna un rol a usuarios/grupos en uno o más Variable Groups", + Example: ` azdevops variables set-permissions --variable MiGrupo --user ana@empresa.com --group Devs --role Reader + azdevops variables set-permissions # modo guiado`, + RunE: func(c *cobra.Command, args []string) error { + client, err := cmd.NewClient() + if err != nil { + return err + } + names, err := cmd.ResolveSliceFlag(c, "variable", func() ([]string, error) { + return cmd.PickVariableGroupNames(client, "Variable Groups") + }) + if err != nil { + return err + } + users, _ := c.Flags().GetStringSlice("user") + groups, _ := c.Flags().GetStringSlice("group") + if len(users) == 0 && len(groups) == 0 { + if !ui.Interactive() { + return fmt.Errorf("debes proporcionar al menos un --user o --group") + } + u, err := ui.Input("Usuarios (emails separados por coma)", "Opcional", "", nil) + if err != nil { + return err + } + g, err := ui.Input("Grupos de seguridad del proyecto (separados por coma)", "Sin el prefijo [Proyecto]\\ · Opcional", "", nil) + if err != nil { + return err + } + users, groups = splitList(u), splitList(g) + if len(users) == 0 && len(groups) == 0 { + return fmt.Errorf("debes indicar al menos un usuario o grupo") + } + } + role, err := cmd.ResolveStringFlag(c, "role", func() (string, error) { + opts := make([]ui.Option[string], len(vg.ValidRoles)) + for i, r := range vg.ValidRoles { + opts[i] = ui.Option[string]{Label: r, Value: r} + } + return ui.Select("Rol", opts) + }) + if err != nil { + return err + } + if role, err = vg.NormalizeRole(role); err != nil { + return err } - client := azdevops.GetClientFromEnv() - + ui.Info("Buscando identidades...") var identityIDs []string - - fmt.Println("Buscando identidades de usuarios y grupos...") - for _, principalName := range users { - identity, err := security.GetUserByPrincipalName(client, principalName) + for _, u := range users { + id, err := security.GetUserByPrincipalName(client, u) if err != nil { - log.Fatalf(" └─ ❌ Error fatal: No se pudo encontrar a '%s'. La operación se ha cancelado. Detalles: %v", principalName, err) + return fmt.Errorf("operación cancelada: %w", err) } - identityIDs = append(identityIDs, identity.ID) - fmt.Printf(" └─ ✔️ Encontrado: %s\n", principalName) + identityIDs = append(identityIDs, id.ID) + ui.Success("Usuario: %s", u) } - - for _, group := range groups { - identity, err := security.GetGroupByPrincipalName(client, group) + for _, g := range groups { + id, err := security.GetGroupByPrincipalName(client, g) if err != nil { - log.Fatalf(" └─ ❌ Error fatal: No se pudo encontrar al grupo '%s'. La operación se ha cancelado. Detalles: %v", group, err) + return fmt.Errorf("operación cancelada: %w", err) } - identityIDs = append(identityIDs, identity.ID) - fmt.Printf(" └─ ✔️ Encontrado: %s\n", group) + identityIDs = append(identityIDs, id.ID) + ui.Success("Grupo: %s", g) } - fmt.Println("\nAplicando permisos a los Variable Groups...") - for _, variableName := range variableNames { - vgs, err := vg.GetVariableGroupByName(client, variableName) - if err != nil || len(vgs) == 0 { - log.Printf(" └─ ❌ Error al buscar el Variable Group '%s'. Saltando...\n", variableName) - continue + project, err := organization.GetProject(client) + if err != nil { + return err + } + return forEachGroup(client, names, func(g models.VariableGroup) error { + if err := vg.SetPermissions(client, project.ID, g.Id, identityIDs, role); err != nil { + return err } + ui.Success("Rol %s asignado en '%s'", role, g.Name) + return nil + }) + }, +} - variableGroup := vgs[0] - - err = vg.SetPermissionsOptimized(client, &variableGroup, identityIDs, role) - if err != nil { - log.Printf(" └─ ❌ Error al asignar permisos en '%s': %v\n", variableName, err) - } else { - fmt.Printf(" └─ ✔️ Permisos asignados correctamente en '%s'.\n", variableName) - } +func splitList(s string) []string { + var out []string + for _, p := range strings.Split(s, ",") { + if p = strings.TrimSpace(p); p != "" { + out = append(out, p) } - }, + } + return out } func init() { setPermissionsCmd.Flags().StringSliceP("variable", "v", nil, "Nombre del Variable Group (se puede repetir)") - setPermissionsCmd.Flags().StringSliceP("user", "u", nil, "Email del usuario al que se le asignará el permiso (se puede repetir)") - setPermissionsCmd.Flags().StringSliceP("group", "g", nil, "Nombre del grupo al que se le asignará el permiso (se puede repetir)") - setPermissionsCmd.Flags().StringP("role", "r", "", "Rol a asignar: Reader, User, o Administrator") + setPermissionsCmd.Flags().StringSliceP("user", "u", nil, "Email del usuario (se puede repetir)") + setPermissionsCmd.Flags().StringSliceP("group", "g", nil, "Grupo de seguridad del proyecto, sin el prefijo [Proyecto]\\ (se puede repetir)") + setPermissionsCmd.Flags().StringP("role", "r", "", "Rol a asignar: Reader, User o Administrator") + _ = setPermissionsCmd.RegisterFlagCompletionFunc("variable", cmd.CompleteVariableGroups) + _ = setPermissionsCmd.RegisterFlagCompletionFunc("role", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { + return vg.ValidRoles, cobra.ShellCompDirectiveNoFileComp + }) cmd.Variables.AddCommand(setPermissionsCmd) } diff --git a/cmd/variable_group/update.go b/cmd/variable_group/update.go index 4a5931d..c3431ed 100644 --- a/cmd/variable_group/update.go +++ b/cmd/variable_group/update.go @@ -1,74 +1,92 @@ package variable_group import ( - "log" - "strings" + "fmt" "azuredevops/azdevops" vg "azuredevops/azdevops/variable_group" "azuredevops/cmd" + "azuredevops/internal/ui" "azuredevops/models" + "github.com/spf13/cobra" ) var updateVariableGroupCmd = &cobra.Command{ Use: "update", - Short: "Actualiza un Variable Group por nombre", - Run: func(cmd *cobra.Command, args []string) { - groupNames, err := cmd.Flags().GetStringSlice("name") + Short: "Agrega o modifica variables en uno o más Variable Groups", + Example: ` azdevops variables update --name MiGrupo -v clave2=nuevo -v secret:token=xyz + azdevops variables update --name MiGrupo --name OtroGrupo -v "a=1,b=2" + azdevops variables update # modo guiado`, + RunE: func(c *cobra.Command, args []string) error { + client, err := cmd.NewClient() if err != nil { - log.Fatalf("Error al obtener los nombres del grupo: %v", err) - } - if len(groupNames) == 0 { - log.Fatal("Debes proporcionar al menos un nombre de grupo con --name") + return err } - - description, _ := cmd.Flags().GetString("description") - - newVariables, err := cmd.Flags().GetStringSlice("variables") + names, err := cmd.ResolveSliceFlag(c, "name", func() ([]string, error) { + return cmd.PickVariableGroupNames(client, "Variable Groups a actualizar") + }) if err != nil { - log.Fatalf("Error al obtener las variables: %v", err) + return err } + description, _ := c.Flags().GetString("description") + entries, _ := c.Flags().GetStringSlice("variables") - client := azdevops.GetClientFromEnv() - newVariablesMap := make(map[string]models.VariableVal) - - for _, v := range newVariables { - isSecret := false - variable := v - - if strings.HasPrefix(v, "secret:") { - isSecret = true - variable = strings.TrimPrefix(v, "secret:") - } - - parts := strings.SplitN(variable, "=", 2) - if len(parts) != 2 { - log.Fatalf("Formato de variable inválido: %s. Usa clave=valor o secret:clave=valor", v) + var vars map[string]models.VariableVal + if len(entries) == 0 && description == "" && ui.Interactive() { + if vars, err = cmd.PromptVariables(); err != nil { + return err } + } else if vars, err = cmd.ParseVariables(entries); err != nil { + return err + } + if len(vars) == 0 && description == "" { + return fmt.Errorf("no hay nada que actualizar: indica --variables o --description") + } - key := strings.TrimSpace(parts[0]) - value := strings.TrimSpace(parts[1]) - - if key == "" { - log.Fatalf("La clave no puede estar vacía en: %s", v) + return forEachGroup(client, names, func(g models.VariableGroup) error { + updated, err := vg.AddVariablesToGroup(client, g, vars, description) + if err != nil { + return err } + ui.Success("Variable Group '%s' (ID %d) actualizado: %d variables agregadas/modificadas", updated.Name, updated.Id, len(vars)) + ui.Link(client.VariableGroupWebURL(updated.Id)) + return nil + }) + }, +} - newVariablesMap[key] = models.VariableVal{ - Value: value, - IsSecret: isSecret, - } +// forEachGroup resuelve cada nombre (admite comodines) y aplica fn a cada grupo encontrado. +// Continúa con los demás grupos si uno falla y devuelve un error resumen al final. +func forEachGroup(client *azdevops.Client, names []string, fn func(models.VariableGroup) error) error { + failed := 0 + for _, name := range names { + groups, err := vg.GetVariableGroupByName(client, name) + if err != nil { + return err } - - if _, err := vg.AddVariablesToGroup(client, groupNames, newVariablesMap, description); err != nil { - log.Fatalf("Error al agregar variables al grupo: %v", err) + if len(groups) == 0 { + ui.Warn("No se encontró el Variable Group '%s'", name) + failed++ + continue } - }, + for _, g := range groups { + if err := fn(g); err != nil { + ui.Errorf("%s: %v", g.Name, err) + failed++ + } + } + } + if failed > 0 { + return fmt.Errorf("%d operación(es) fallaron", failed) + } + return nil } func init() { - updateVariableGroupCmd.Flags().StringSliceP("name", "n", nil, "Nombre del variable group") - updateVariableGroupCmd.Flags().StringSliceP("variables", "v", nil, "Variables a agregar en formato: clave=valor o secret:clave=valor") - updateVariableGroupCmd.Flags().StringP("description", "d", "", "Descripción del variable group (opcional)") + updateVariableGroupCmd.Flags().StringSliceP("name", "n", nil, "Nombre del Variable Group (se puede repetir, admite comodines)") + updateVariableGroupCmd.Flags().StringSliceP("variables", "v", nil, "Variables en formato clave=valor o secret:clave=valor") + updateVariableGroupCmd.Flags().StringP("description", "d", "", "Nueva descripción (opcional)") + _ = updateVariableGroupCmd.RegisterFlagCompletionFunc("name", cmd.CompleteVariableGroups) cmd.Variables.AddCommand(updateVariableGroupCmd) } diff --git a/cmd/workitems/query.go b/cmd/workitems/query.go new file mode 100644 index 0000000..e8a9745 --- /dev/null +++ b/cmd/workitems/query.go @@ -0,0 +1,92 @@ +package workitems + +import ( + "fmt" + "html" + "regexp" + "strings" + + "azuredevops/azdevops/workitem" +) + +type listOptions struct { + AssignedTo string // "@me", un email/nombre o "" para cualquiera + Types []string + States []string + Search string + Area string + OpenOnly bool +} + +// closedStates son los estados finales de los procesos estándar (Agile, Scrum, CMMI, Basic). +var closedStates = []string{"Closed", "Done", "Removed", "Resolved"} + +func buildWIQL(o listOptions) string { + conds := []string{"[System.TeamProject] = @project"} + switch { + case strings.EqualFold(o.AssignedTo, "@me"): + conds = append(conds, "[System.AssignedTo] = @me") + case o.AssignedTo != "": + conds = append(conds, "[System.AssignedTo] = "+workitem.QuoteWIQL(o.AssignedTo)) + } + if len(o.Types) > 0 { + conds = append(conds, "[System.WorkItemType] IN ("+quoteAll(o.Types)+")") + } + if len(o.States) > 0 { + conds = append(conds, "[System.State] IN ("+quoteAll(o.States)+")") + } else if o.OpenOnly { + conds = append(conds, "[System.State] NOT IN ("+quoteAll(closedStates)+")") + } + if o.Search != "" { + conds = append(conds, "[System.Title] CONTAINS "+workitem.QuoteWIQL(o.Search)) + } + if o.Area != "" { + conds = append(conds, "[System.AreaPath] UNDER "+workitem.QuoteWIQL(o.Area)) + } + return "SELECT [System.Id] FROM WorkItems WHERE " + strings.Join(conds, " AND ") + " ORDER BY [System.ChangedDate] DESC" +} + +func quoteAll(values []string) string { + q := make([]string, len(values)) + for i, v := range values { + q[i] = workitem.QuoteWIQL(v) + } + return strings.Join(q, ", ") +} + +var ( + reBreaks = regexp.MustCompile(`(?i)|

||`) + reTags = regexp.MustCompile(`<[^>]+>`) + reBlank = regexp.MustCompile(`\n{3,}`) +) + +// htmlToText convierte el HTML de descripciones y comentarios en texto plano legible. +func htmlToText(s string) string { + s = reBreaks.ReplaceAllString(s, "\n") + s = reTags.ReplaceAllString(s, "") + s = html.UnescapeString(s) + return strings.TrimSpace(reBlank.ReplaceAllString(s, "\n\n")) +} + +// textToHTML convierte texto plano a HTML simple para la descripción. +func textToHTML(s string) string { + lines := strings.Split(html.EscapeString(s), "\n") + return strings.Join(lines, "
") +} + +// parseFields interpreta --field Nombre=Valor. Los nombres sin punto se asumen System.*. +func parseFields(entries []string) (map[string]any, error) { + fields := map[string]any{} + for _, e := range entries { + k, v, ok := strings.Cut(e, "=") + k = strings.TrimSpace(k) + if !ok || k == "" { + return nil, fmt.Errorf("campo inválido '%s': usa Referencia=Valor (p. ej. Microsoft.VSTS.Common.Priority=1)", e) + } + if !strings.Contains(k, ".") { + k = "System." + k + } + fields[k] = v + } + return fields, nil +} diff --git a/cmd/workitems/query_test.go b/cmd/workitems/query_test.go new file mode 100644 index 0000000..2cfaaf4 --- /dev/null +++ b/cmd/workitems/query_test.go @@ -0,0 +1,46 @@ +package workitems + +import ( + "strings" + "testing" +) + +func TestBuildWIQL(t *testing.T) { + got := buildWIQL(listOptions{AssignedTo: "@me", Types: []string{"Bug", "Task"}, OpenOnly: true, Search: "it's"}) + for _, want := range []string{ + "[System.AssignedTo] = @me", + "[System.WorkItemType] IN ('Bug', 'Task')", + "[System.State] NOT IN ('Closed', 'Done', 'Removed', 'Resolved')", + "[System.Title] CONTAINS 'it''s'", + "ORDER BY [System.ChangedDate] DESC", + } { + if !strings.Contains(got, want) { + t.Errorf("falta %q en:\n%s", want, got) + } + } + got = buildWIQL(listOptions{States: []string{"Active"}, OpenOnly: true}) + if strings.Contains(got, "NOT IN") || strings.Contains(got, "AssignedTo") { + t.Errorf("con --state no debe filtrar abiertos ni asignado: %s", got) + } +} + +func TestHTMLToText(t *testing.T) { + got := htmlToText("
Hola mundo
línea 2
x & y
") + want := "Hola mundo\nlínea 2\nx & y" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestParseFields(t *testing.T) { + f, err := parseFields([]string{"Microsoft.VSTS.Common.Priority=1", "Tags=a; b"}) + if err != nil { + t.Fatal(err) + } + if f["Microsoft.VSTS.Common.Priority"] != "1" || f["System.Tags"] != "a; b" { + t.Errorf("fields = %v", f) + } + if _, err := parseFields([]string{"x"}); err == nil { + t.Error("se esperaba error") + } +} diff --git a/cmd/workitems/workitems.go b/cmd/workitems/workitems.go new file mode 100644 index 0000000..5fb03aa --- /dev/null +++ b/cmd/workitems/workitems.go @@ -0,0 +1,529 @@ +// Package workitems contiene los comandos de Azure Boards. +package workitems + +import ( + "fmt" + "strconv" + "strings" + "time" + + "azuredevops/azdevops" + "azuredevops/azdevops/organization" + "azuredevops/azdevops/workitem" + "azuredevops/cmd" + "azuredevops/internal/ui" + "azuredevops/models" + + "github.com/spf13/cobra" +) + +var workItemsCmd = &cobra.Command{ + Use: "workitems", + Aliases: []string{"wi", "boards"}, + Short: "Consulta, crea y actualiza work items (tareas, bugs, historias…)", +} + +var listCmd = &cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + Short: "Lista work items (por defecto: los tuyos que siguen abiertos)", + Example: ` azdevops wi list + azdevops wi list --type Bug --state Active --assigned-to any + azdevops wi list --search "login" --all-states + azdevops wi list --wiql "SELECT [System.Id] FROM WorkItems WHERE [System.Tags] CONTAINS 'urgente'"`, + RunE: func(c *cobra.Command, args []string) error { + out, err := cmd.Output("table") + if err != nil { + return err + } + client, err := cmd.NewClient() + if err != nil { + return err + } + f := c.Flags() + wiql, _ := f.GetString("wiql") + if wiql == "" { + o := listOptions{} + o.AssignedTo, _ = f.GetString("assigned-to") + if strings.EqualFold(o.AssignedTo, "any") { + o.AssignedTo = "" + } + o.Types, _ = f.GetStringSlice("type") + o.States, _ = f.GetStringSlice("state") + o.Search, _ = f.GetString("search") + o.Area, _ = f.GetString("area") + allStates, _ := f.GetBool("all-states") + o.OpenOnly = !allStates + wiql = buildWIQL(o) + } + top, _ := f.GetInt("top") + + var items []models.WorkItem + if err := ui.Spinner("Consultando work items...", func() error { + ids, err := workitem.Query(client, wiql, top) + if err != nil { + return err + } + items, err = workitem.GetBatch(client, ids, workitem.DefaultFields) + return err + }); err != nil { + return err + } + if out != "table" { + if items == nil { + items = []models.WorkItem{} + } + return cmd.Print(items) + } + if len(items) == 0 { + ui.Info("No se encontraron work items.") + return nil + } + printList(items) + return nil + }, +} + +func printList(items []models.WorkItem) { + rows := make([][]string, len(items)) + for i, w := range items { + rows[i] = []string{ + strconv.Itoa(w.ID), w.Field("System.WorkItemType"), ui.Status(w.Field("System.State")), + truncate(w.Field("System.Title"), 60), w.Field("System.AssignedTo"), formatDate(w.Field("System.ChangedDate")), + } + } + ui.Table([]string{"id", "tipo", "estado", "título", "asignado a", "modificado"}, rows) +} + +var getCmd = &cobra.Command{ + Use: "get [id]", + Aliases: []string{"show"}, + Short: "Muestra un work item", + Args: cobra.MaximumNArgs(1), + RunE: func(c *cobra.Command, args []string) error { + out, err := cmd.Output("table") + if err != nil { + return err + } + client, err := cmd.NewClient() + if err != nil { + return err + } + id, err := resolveID(c, args, client) + if err != nil { + return err + } + w, err := workitem.Get(client, id) + if err != nil { + return err + } + if out != "table" { + return cmd.Print(w) + } + printDetail(client, w) + return nil + }, +} + +func printDetail(client *azdevops.Client, w *models.WorkItem) { + fmt.Fprintf(ui.Out, "%s %s\n", ui.Bold(fmt.Sprintf("%s %d:", w.Field("System.WorkItemType"), w.ID)), w.Field("System.Title")) + rows := [][]string{ + {"Estado", ui.Status(w.Field("System.State")) + reason(w)}, + {"Asignado a", orDash(w.Field("System.AssignedTo"))}, + {"Área", w.Field("System.AreaPath")}, + {"Iteración", w.Field("System.IterationPath")}, + {"Etiquetas", orDash(w.Field("System.Tags"))}, + {"Creado", fmt.Sprintf("%s por %s", formatDate(w.Field("System.CreatedDate")), w.Field("System.CreatedBy"))}, + {"Modificado", fmt.Sprintf("%s por %s", formatDate(w.Field("System.ChangedDate")), w.Field("System.ChangedBy"))}, + } + if p := w.Field("Microsoft.VSTS.Common.Priority"); p != "" { + rows = append(rows, []string{"Prioridad", p}) + } + for _, r := range w.Relations { + name, _ := r.Attributes["name"].(string) + if name == "Parent" || name == "Child" { + parts := strings.Split(r.URL, "/") + rows = append(rows, []string{map[string]string{"Parent": "Padre", "Child": "Hijo"}[name], "#" + parts[len(parts)-1]}) + } + } + for _, r := range rows { + fmt.Fprintf(ui.Out, " %-12s %s\n", r[0]+":", r[1]) + } + desc := w.Field("System.Description") + if desc == "" { + desc = w.Field("Microsoft.VSTS.TCM.ReproSteps") + } + if desc != "" { + fmt.Fprintf(ui.Out, "\n%s\n", htmlToText(desc)) + } + ui.Link(workitem.WebURL(client, w.ID)) +} + +func reason(w *models.WorkItem) string { + if r := w.Field("System.Reason"); r != "" { + return " (" + r + ")" + } + return "" +} + +var createCmd = &cobra.Command{ + Use: "create", + Short: "Crea un work item", + Example: ` azdevops wi create --type Bug --title "Falla el login" --assigned-to @me --tags "frontend; urgente" + azdevops wi create --type Task --title "Escribir tests" --parent 123 + azdevops wi create # modo guiado`, + RunE: func(c *cobra.Command, args []string) error { + client, err := cmd.NewClient() + if err != nil { + return err + } + f := c.Flags() + itemType, _ := f.GetString("type") + title, _ := f.GetString("title") + description, _ := f.GetString("description") + assignedTo, _ := f.GetString("assigned-to") + parent, _ := f.GetInt("parent") + fieldEntries, _ := f.GetStringSlice("field") + fields, err := parseFields(fieldEntries) + if err != nil { + return err + } + + if title == "" { + if !ui.Interactive() { + return ui.MissingError("title") + } + if !f.Changed("type") { + if itemType, err = pickType(client); err != nil { + return err + } + } + if title, err = ui.Input("Título", "", "", ui.Required); err != nil { + return err + } + if description, err = ui.Input("Descripción (opcional)", "", description, nil); err != nil { + return err + } + if assignedTo == "" { + mine, err := ui.Confirm("¿Asignármelo?", true) + if err != nil { + return err + } + if mine { + assignedTo = "@me" + } + } + } + + fields["System.Title"] = title + if description != "" { + fields["System.Description"] = textToHTML(description) + } + if assignedTo != "" { + if fields["System.AssignedTo"], err = resolveAssignee(client, assignedTo); err != nil { + return err + } + } + for flag, field := range map[string]string{"area": "System.AreaPath", "iteration": "System.IterationPath", "tags": "System.Tags"} { + if v, _ := f.GetString(flag); v != "" { + fields[field] = v + } + } + + w, err := workitem.Create(client, itemType, fields, parent) + if err != nil { + return err + } + ui.Success("%s %d creado: %s", w.Field("System.WorkItemType"), w.ID, w.Field("System.Title")) + ui.Link(workitem.WebURL(client, w.ID)) + if cmd.WantsData() { + return cmd.Print(w) + } + return nil + }, +} + +var updateCmd = &cobra.Command{ + Use: "update [id]", + Short: "Actualiza estado, título, asignación o etiquetas, o agrega un comentario", + Example: ` azdevops wi update 123 --state Active --comment "Empiezo con esto" + azdevops wi update 123 --assigned-to ana@empresa.com + azdevops wi update # elegir uno de tus work items y qué cambiar`, + Args: cobra.MaximumNArgs(1), + RunE: func(c *cobra.Command, args []string) error { + client, err := cmd.NewClient() + if err != nil { + return err + } + id, err := resolveID(c, args, client) + if err != nil { + return err + } + f := c.Flags() + fieldEntries, _ := f.GetStringSlice("field") + fields, err := parseFields(fieldEntries) + if err != nil { + return err + } + for flag, field := range map[string]string{"state": "System.State", "title": "System.Title", "tags": "System.Tags", "comment": "System.History"} { + if v, _ := f.GetString(flag); v != "" { + fields[field] = v + } + } + if v, _ := f.GetString("assigned-to"); v != "" { + if fields["System.AssignedTo"], err = resolveAssignee(client, v); err != nil { + return err + } + } + + if len(fields) == 0 { + if !ui.Interactive() { + return fmt.Errorf("no hay nada que actualizar: usa --state, --title, --assigned-to, --tags, --comment o --field") + } + if fields, err = promptUpdate(client, id); err != nil { + return err + } + if len(fields) == 0 { + ui.Info("No hay cambios.") + return nil + } + } + + w, err := workitem.Update(client, id, fields) + if err != nil { + return err + } + ui.Success("Work item %d actualizado (estado: %s)", w.ID, w.Field("System.State")) + ui.Link(workitem.WebURL(client, w.ID)) + if cmd.WantsData() { + return cmd.Print(w) + } + return nil + }, +} + +func promptUpdate(client *azdevops.Client, id int) (map[string]any, error) { + w, err := workitem.Get(client, id) + if err != nil { + return nil, err + } + ui.Info("%s %d: %s (%s)", w.Field("System.WorkItemType"), w.ID, w.Field("System.Title"), w.Field("System.State")) + changes, err := ui.MultiSelect("¿Qué quieres cambiar?", []ui.Option[string]{ + {Label: "Estado", Value: "state"}, + {Label: "Agregar comentario", Value: "comment"}, + {Label: "Asignación", Value: "assign"}, + {Label: "Título", Value: "title"}, + {Label: "Etiquetas", Value: "tags"}, + }) + if err != nil { + return nil, err + } + fields := map[string]any{} + for _, ch := range changes { + switch ch { + case "state": + states, err := workitem.States(client, w.Field("System.WorkItemType")) + if err != nil { + return nil, err + } + opts := make([]ui.Option[string], 0, len(states)) + for _, s := range states { + if s != w.Field("System.State") { + opts = append(opts, ui.Option[string]{Label: s, Value: s}) + } + } + if fields["System.State"], err = ui.Select("Nuevo estado", opts); err != nil { + return nil, err + } + case "comment": + v, err := ui.Input("Comentario", "", "", ui.Required) + if err != nil { + return nil, err + } + fields["System.History"] = textToHTML(v) + case "assign": + v, err := ui.Input("Asignar a", "Email, @me, o vacío para quitar la asignación", "@me", nil) + if err != nil { + return nil, err + } + if v == "" { + fields["System.AssignedTo"] = "" + } else if fields["System.AssignedTo"], err = resolveAssignee(client, v); err != nil { + return nil, err + } + case "title": + if fields["System.Title"], err = ui.Input("Título", "", w.Field("System.Title"), ui.Required); err != nil { + return nil, err + } + case "tags": + if fields["System.Tags"], err = ui.Input("Etiquetas", "Separadas por ';'", w.Field("System.Tags"), nil); err != nil { + return nil, err + } + } + } + return fields, nil +} + +var deleteCmd = &cobra.Command{ + Use: "delete [id]", + Short: "Envía un work item a la papelera de reciclaje", + Args: cobra.MaximumNArgs(1), + RunE: func(c *cobra.Command, args []string) error { + client, err := cmd.NewClient() + if err != nil { + return err + } + id, err := resolveID(c, args, client) + if err != nil { + return err + } + w, err := workitem.Get(client, id) + if err != nil { + return err + } + yes, _ := c.Flags().GetBool("yes") + if err := cmd.Confirm(fmt.Sprintf("¿Enviar a la papelera '%s %d: %s'?", w.Field("System.WorkItemType"), id, w.Field("System.Title")), yes); err != nil { + return err + } + if err := workitem.Delete(client, id); err != nil { + return err + } + ui.Success("Work item %d enviado a la papelera (se puede restaurar desde Boards)", id) + return nil + }, +} + +// resolveID toma el ID del argumento, de --id o de un selector con tus work items abiertos. +func resolveID(c *cobra.Command, args []string, client *azdevops.Client) (int, error) { + if len(args) == 1 { + id, err := strconv.Atoi(strings.TrimPrefix(args[0], "#")) + if err != nil { + return 0, fmt.Errorf("ID de work item inválido: %s", args[0]) + } + return id, nil + } + if id, _ := c.Flags().GetInt("id"); id != 0 { + return id, nil + } + if !ui.Interactive() { + return 0, fmt.Errorf("indica el ID del work item") + } + var items []models.WorkItem + if err := ui.Spinner("Cargando tus work items...", func() error { + ids, err := workitem.Query(client, buildWIQL(listOptions{AssignedTo: "@me", OpenOnly: true}), 100) + if err != nil { + return err + } + items, err = workitem.GetBatch(client, ids, workitem.DefaultFields) + return err + }); err != nil { + return 0, err + } + if len(items) == 0 { + v, err := ui.Input("ID del work item", "No tienes work items abiertos asignados", "", cmd.PositiveInt) + if err != nil { + return 0, err + } + return strconv.Atoi(v) + } + opts := make([]ui.Option[int], len(items)) + for i, w := range items { + opts[i] = ui.Option[int]{Label: fmt.Sprintf("#%d [%s] %s · %s", w.ID, w.Field("System.WorkItemType"), w.Field("System.Title"), w.Field("System.State")), Value: w.ID} + } + return ui.Select("Work item", opts) +} + +// resolveAssignee traduce "@me" al email del dueño del PAT. +func resolveAssignee(client *azdevops.Client, v string) (string, error) { + if !strings.EqualFold(v, "@me") { + return v, nil + } + u, err := organization.CurrentUser(client) + if err != nil { + return "", err + } + if u.Email != "" { + return u.Email, nil + } + return u.DisplayName, nil +} + +func pickType(client *azdevops.Client) (string, error) { + types, err := workitem.Types(client) + if err != nil { + return "", err + } + common := map[string]int{"Task": 0, "Bug": 1, "User Story": 2, "Product Backlog Item": 2, "Issue": 2, "Feature": 3, "Epic": 4} + var first, rest []ui.Option[string] + for _, t := range types { + o := ui.Option[string]{Label: t.Name, Value: t.Name} + if _, ok := common[t.Name]; ok { + first = append(first, o) + } else { + rest = append(rest, o) + } + } + return ui.Select("Tipo de work item", append(first, rest...)) +} + +func formatDate(s string) string { + t, err := time.Parse(time.RFC3339, s) + if err != nil { + return orDash(s) + } + return ui.FormatTime(&t) +} + +func orDash(s string) string { + if s == "" { + return "-" + } + return s +} + +func truncate(s string, n int) string { + r := []rune(s) + if len(r) <= n { + return s + } + return string(r[:n-1]) + "…" +} + +func init() { + lf := listCmd.Flags() + lf.String("assigned-to", "@me", "Asignado a: @me, un email/nombre o 'any'") + lf.StringSliceP("type", "t", nil, "Tipo (Bug, Task, User Story…; se puede repetir)") + lf.StringSliceP("state", "s", nil, "Estado (se puede repetir)") + lf.Bool("all-states", false, "Incluir también los cerrados/terminados") + lf.String("search", "", "Texto contenido en el título") + lf.String("area", "", "Ruta de área (incluye sub-áreas)") + lf.String("wiql", "", "Consulta WIQL completa (ignora los demás filtros)") + lf.Int("top", 50, "Número máximo de resultados") + + cf := createCmd.Flags() + cf.StringP("type", "t", "Task", "Tipo de work item") + cf.String("title", "", "Título") + cf.StringP("description", "d", "", "Descripción (texto plano)") + cf.String("assigned-to", "", "Asignar a: @me o email") + cf.String("area", "", "Ruta de área") + cf.String("iteration", "", "Ruta de iteración") + cf.String("tags", "", "Etiquetas separadas por ';'") + cf.Int("parent", 0, "ID del work item padre") + cf.StringSlice("field", nil, "Otro campo: Referencia=Valor (p. ej. Microsoft.VSTS.Common.Priority=1)") + + uf := updateCmd.Flags() + uf.String("state", "", "Nuevo estado") + uf.String("title", "", "Nuevo título") + uf.String("assigned-to", "", "Asignar a: @me o email") + uf.String("tags", "", "Etiquetas (reemplaza las actuales)") + uf.StringP("comment", "m", "", "Agregar un comentario") + uf.StringSlice("field", nil, "Otro campo: Referencia=Valor") + + for _, c := range []*cobra.Command{getCmd, updateCmd, deleteCmd} { + c.Flags().IntP("id", "i", 0, "ID del work item (también se acepta como argumento)") + } + deleteCmd.Flags().BoolP("yes", "y", false, "Confirmar sin preguntar") + + workItemsCmd.AddCommand(listCmd, getCmd, createCmd, updateCmd, deleteCmd) + cmd.RootCmd.AddCommand(workItemsCmd) +} diff --git a/go.mod b/go.mod index 1b6e35c..aeeebaf 100644 --- a/go.mod +++ b/go.mod @@ -2,9 +2,43 @@ module azuredevops go 1.23.9 -require github.com/spf13/cobra v1.9.1 +require ( + github.com/charmbracelet/huh v1.0.0 + github.com/charmbracelet/lipgloss v1.1.0 + github.com/jmespath/go-jmespath v0.4.0 + github.com/mattn/go-isatty v0.0.20 + github.com/spf13/cobra v1.9.1 + github.com/zalando/go-keyring v0.2.8 + golang.org/x/text v0.23.0 + gopkg.in/yaml.v3 v3.0.1 +) require ( + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/catppuccin/go v0.3.0 // indirect + github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect + github.com/charmbracelet/bubbletea v1.3.6 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/x/ansi v0.9.3 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13 // indirect + github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/danieljoos/wincred v1.2.3 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/godbus/dbus/v5 v5.2.2 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/spf13/pflag v1.0.6 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sync v0.15.0 // indirect + golang.org/x/sys v0.33.0 // indirect ) diff --git a/go.sum b/go.sum index ffae55e..b561494 100644 --- a/go.sum +++ b/go.sum @@ -1,10 +1,109 @@ +github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= +github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= +github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= +github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= +github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws= +github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw= +github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU= +github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/huh v1.0.0 h1:wOnedH8G4qzJbmhftTqrpppyqHakl/zbbNdXIWJyIxw= +github.com/charmbracelet/huh v1.0.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0= +github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= +github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U= +github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI= +github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ= +github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= +github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= +github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= +github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= +github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= +github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs= +github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= +golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..6e21ee1 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,103 @@ +// Package config gestiona los perfiles guardados en ~/.config/azdevops/config.json. +package config + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sort" +) + +const DefaultProfile = "default" + +type Profile struct { + Org string `json:"org"` + Project string `json:"project"` + PAT string `json:"pat,omitempty"` + // Credential vale "keyring" cuando el PAT está en el llavero del sistema. + Credential string `json:"credential,omitempty"` +} + +type Config struct { + Current string `json:"current"` + Profiles map[string]Profile `json:"profiles"` +} + +// Path devuelve la ruta del archivo de configuración. AZDEVOPS_CONFIG permite sobrescribirla. +func Path() (string, error) { + if p := os.Getenv("AZDEVOPS_CONFIG"); p != "" { + return p, nil + } + dir, err := os.UserConfigDir() + if err != nil { + return "", err + } + return filepath.Join(dir, "azdevops", "config.json"), nil +} + +// Load lee la configuración. Si el archivo no existe devuelve una configuración vacía. +func Load() (*Config, error) { + cfg := &Config{Profiles: map[string]Profile{}} + path, err := Path() + if err != nil { + return cfg, err + } + data, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return cfg, nil + } + if err != nil { + return cfg, err + } + if err := json.Unmarshal(data, cfg); err != nil { + return cfg, fmt.Errorf("el archivo de configuración %s es inválido: %w", path, err) + } + if cfg.Profiles == nil { + cfg.Profiles = map[string]Profile{} + } + return cfg, nil +} + +// Save escribe la configuración con permisos 0600 (contiene el PAT). +func (c *Config) Save() error { + path, err := Path() + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return err + } + data, err := json.MarshalIndent(c, "", " ") + if err != nil { + return err + } + tmp := path + ".tmp" + if err := os.WriteFile(tmp, data, 0o600); err != nil { + return err + } + return os.Rename(tmp, path) +} + +// Profile devuelve el perfil indicado, o el actual si name está vacío. +func (c *Config) Profile(name string) (Profile, string, bool) { + if name == "" { + name = c.Current + } + if name == "" { + name = DefaultProfile + } + p, ok := c.Profiles[name] + return p, name, ok +} + +// Names devuelve los nombres de perfiles ordenados. +func (c *Config) Names() []string { + names := make([]string, 0, len(c.Profiles)) + for n := range c.Profiles { + names = append(names, n) + } + sort.Strings(names) + return names +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..236e7c4 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,80 @@ +package config + +import ( + "errors" + "os" + "path/filepath" + "testing" + + "github.com/zalando/go-keyring" +) + +func TestSaveAndLoad(t *testing.T) { + path := filepath.Join(t.TempDir(), "sub", "config.json") + t.Setenv("AZDEVOPS_CONFIG", path) + + cfg, err := Load() + if err != nil { + t.Fatalf("Load sin archivo: %v", err) + } + if len(cfg.Profiles) != 0 { + t.Fatalf("se esperaba config vacía") + } + + cfg.Profiles["work"] = Profile{Org: "o", Project: "p", PAT: "secret"} + cfg.Current = "work" + if err := cfg.Save(); err != nil { + t.Fatalf("Save: %v", err) + } + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0o600 { + t.Errorf("permisos = %v, se esperaba 0600", info.Mode().Perm()) + } + + loaded, err := Load() + if err != nil { + t.Fatal(err) + } + p, name, ok := loaded.Profile("") + if !ok || name != "work" || p.Org != "o" || p.PAT != "secret" { + t.Errorf("perfil cargado incorrecto: %+v %s %v", p, name, ok) + } +} + +func TestKeyringPAT(t *testing.T) { + keyring.MockInit() + var p Profile + where, err := SetPAT("work", &p, "secret", StoreAuto) + if err != nil || where != StoreKeyring { + t.Fatalf("SetPAT = %s, %v", where, err) + } + if p.PAT != "" || !p.InKeyring() { + t.Errorf("el PAT no debe quedar en el archivo: %+v", p) + } + got, err := p.GetPAT("work") + if err != nil || got != "secret" { + t.Errorf("GetPAT = %q, %v", got, err) + } + + if where, _ := SetPAT("work", &p, "otro", StoreFile); where != StoreFile || p.PAT != "otro" || p.InKeyring() { + t.Errorf("store=file incorrecto: %s %+v", where, p) + } + if _, err := (Profile{Credential: "keyring"}).GetPAT("work"); err == nil { + t.Error("se esperaba error: el PAT se eliminó del llavero al pasar a archivo") + } +} + +func TestKeyringUnavailableFallsBack(t *testing.T) { + keyring.MockInitWithError(errors.New("sin dbus")) + var p Profile + where, err := SetPAT("work", &p, "secret", StoreAuto) + if err != nil || where != StoreFile || p.PAT != "secret" { + t.Errorf("auto debería caer a archivo: %s %v %+v", where, err, p) + } + if _, err := SetPAT("work", &p, "secret", StoreKeyring); err == nil { + t.Error("store=keyring debería fallar si no hay llavero") + } +} diff --git a/internal/config/credentials.go b/internal/config/credentials.go new file mode 100644 index 0000000..bd8b840 --- /dev/null +++ b/internal/config/credentials.go @@ -0,0 +1,68 @@ +package config + +import ( + "errors" + "fmt" + + "github.com/zalando/go-keyring" +) + +const ( + keyringService = "azdevops-cli" + + StoreAuto = "auto" + StoreKeyring = "keyring" + StoreFile = "file" + + credentialKeyring = "keyring" +) + +// SetPAT guarda el PAT del perfil según store: +// - keyring: en el llavero del sistema (Keychain, Credential Manager, Secret Service); falla si no está disponible. +// - file: en el archivo de configuración. +// - auto: intenta el llavero y, si no está disponible, usa el archivo. +// +// Devuelve dónde quedó guardado ("keyring" o "file"). +func SetPAT(name string, p *Profile, pat, store string) (string, error) { + switch store { + case StoreFile: + _ = keyring.Delete(keyringService, name) + p.PAT, p.Credential = pat, "" + return StoreFile, nil + case StoreKeyring, StoreAuto, "": + err := keyring.Set(keyringService, name, pat) + if err == nil { + p.PAT, p.Credential = "", credentialKeyring + return StoreKeyring, nil + } + if store == StoreKeyring { + return "", fmt.Errorf("no se pudo guardar el PAT en el llavero del sistema: %w", err) + } + p.PAT, p.Credential = pat, "" + return StoreFile, nil + } + return "", fmt.Errorf("almacenamiento '%s' no válido (usa auto, keyring o file)", store) +} + +// GetPAT devuelve el PAT del perfil, leyéndolo del llavero si corresponde. +func (p Profile) GetPAT(name string) (string, error) { + if p.Credential != credentialKeyring { + return p.PAT, nil + } + pat, err := keyring.Get(keyringService, name) + if errors.Is(err, keyring.ErrNotFound) { + return "", fmt.Errorf("el PAT del perfil '%s' no está en el llavero; ejecuta 'azdevops login --profile %s'", name, name) + } + if err != nil { + return "", fmt.Errorf("no se pudo leer el PAT del llavero: %w", err) + } + return pat, nil +} + +// InKeyring indica si el PAT del perfil está en el llavero del sistema. +func (p Profile) InKeyring() bool { return p.Credential == credentialKeyring } + +// DeletePAT elimina el PAT del perfil del llavero (si existe). +func DeletePAT(name string) { + _ = keyring.Delete(keyringService, name) +} diff --git a/internal/scaffold/generate.go b/internal/scaffold/generate.go new file mode 100644 index 0000000..54fb6cd --- /dev/null +++ b/internal/scaffold/generate.go @@ -0,0 +1,387 @@ +package scaffold + +import ( + "bytes" + "embed" + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" + "text/template" + "time" +) + +//go:embed all:templates +var templatesFS embed.FS + +// Categorías de task.json y del Marketplace. +var ( + TaskCategories = []string{"Utility", "Build", "Deploy", "Test", "Package", "Tool", "Azure Pipelines"} + ExtensionCategories = []string{"Azure Pipelines", "Azure Repos", "Azure Boards", "Azure Test Plans", "Azure Artifacts"} +) + +// Project describe la extensión. +type Project struct { + ID string // id del manifiesto (release); dev usa ID + "-dev" + Name string + Description string + Publisher string + DevPublisher string + Author string + Repository string + Category string + Public bool + Version string + LicenseID string // "MIT" o "UNLICENSED" +} + +// Endpoint describe un tipo de conexión de servicio personalizado. +type Endpoint struct { + ID string // id de la contribución + Name string // nombre del tipo, usado en connectedService: + DisplayName string + Description string + Icon string // archivo dentro de images/ + URL string +} + +// Task describe una tarea de pipeline. +type Task struct { + Slug string // carpeta en src/tasks y tasks/ + Name string // name en task.json (único en la organización) + FriendlyName string + Description string + Category string + UUID string + Major int + Minor int + Patch int + Entry string // archivo principal sin extensión + Endpoint *Endpoint +} + +// NewTask completa los valores derivados de una tarea. +func NewTask(publisher, slug, friendly, description, category string, endpoint *Endpoint) Task { + if friendly == "" { + friendly = Title(slug) + } + if description == "" { + description = friendly + } + if category == "" { + category = "Utility" + } + return Task{ + Slug: slug, Name: publisher + "-" + slug, FriendlyName: friendly, Description: description, + Category: category, UUID: NewUUID(), Major: 0, Minor: 1, Patch: 0, Entry: Snake(slug), Endpoint: endpoint, + } +} + +// NewEndpoint crea la definición de un tipo de conexión de servicio para la extensión. +func NewEndpoint(projectID, displayName, url string) *Endpoint { + slug := Slugify(projectID) + if displayName == "" { + displayName = Title(slug) + } + if url == "" { + url = "https://" + } + return &Endpoint{ + ID: "service-endpoint", Name: Camel(slug) + "Service", DisplayName: displayName, + Description: "Service connection for " + displayName, Icon: "service-endpoint.png", URL: url, + } +} + +type templateData struct { + Project Project + Task Task + Tasks []Task + Endpoints []Endpoint + Year int +} + +var funcs = template.FuncMap{ + // j escapa un texto para usarlo dentro de una cadena JSON. + "j": func(s string) string { + b, _ := json.Marshal(s) + return string(b[1 : len(b)-1]) + }, +} + +func render(name string, data templateData) ([]byte, error) { + src, err := templatesFS.ReadFile("templates/" + name) + if err != nil { + return nil, err + } + tpl, err := template.New(name).Funcs(funcs).Option("missingkey=error").Parse(string(src)) + if err != nil { + return nil, err + } + var buf bytes.Buffer + if err := tpl.Execute(&buf, data); err != nil { + return nil, fmt.Errorf("plantilla %s: %w", name, err) + } + return buf.Bytes(), nil +} + +// fileWriter escribe archivos y registra los creados. Nunca sobrescribe. +type fileWriter struct { + root string + created []string +} + +func (w *fileWriter) write(rel string, data []byte, mode os.FileMode) error { + path := filepath.Join(w.root, rel) + if _, err := os.Stat(path); err == nil { + return fmt.Errorf("%s ya existe", rel) + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + if err := os.WriteFile(path, data, mode); err != nil { + return err + } + w.created = append(w.created, rel) + return nil +} + +func (w *fileWriter) renderTo(tpl, rel string, data templateData) error { + out, err := render(tpl, data) + if err != nil { + return err + } + if strings.HasSuffix(rel, ".json") { + var v any + if err := json.Unmarshal(out, &v); err != nil { + return fmt.Errorf("%s generado no es JSON válido: %w", rel, err) + } + } + return w.write(rel, out, 0o644) +} + +var projectFiles = map[string]string{ + "project/package.json.tmpl": "package.json", + "project/vss-extension.json.tmpl": "vss-extension.json", + "project/config/dev.json.tmpl": "config/dev.json", + "project/config/release.json.tmpl": "config/release.json", + "project/README.md.tmpl": "README.md", + "project/gitignore.tmpl": ".gitignore", + "project/scripts/tasks.js.tmpl": "scripts/tasks.js", +} + +var taskFiles = map[string]string{ + "task/package.json.tmpl": "package.json", + "task/tsconfig.json.tmpl": "tsconfig.json", + "task/tsconfig.build.json.tmpl": "tsconfig.build.json", + "task/jest.config.js.tmpl": "jest.config.js", + "task/testSetup.js.tmpl": "testSetup.js", + "task/env-test.tmpl": ".env-test", + "task/task.json.tmpl": "task.json", + "task/src/utils/inputs.ts.tmpl": "src/utils/inputs.ts", + "task/src/__tests__/inputs.test.ts.tmpl": "src/__tests__/inputs.test.ts", +} + +// Generate crea un proyecto de extensión en dir (que debe estar vacío o no existir). +// Devuelve las rutas relativas de los archivos creados. +func Generate(dir string, p Project, tasks []Task, endpoints []Endpoint) ([]string, error) { + if entries, err := os.ReadDir(dir); err == nil && len(entries) > 0 { + return nil, fmt.Errorf("el directorio %s no está vacío", dir) + } + if len(tasks) == 0 { + return nil, errors.New("la extensión necesita al menos una tarea") + } + data := templateData{Project: p, Tasks: tasks, Endpoints: endpoints, Year: time.Now().Year()} + w := &fileWriter{root: dir} + for tpl, rel := range projectFiles { + if err := w.renderTo(tpl, rel, data); err != nil { + return w.created, err + } + } + if p.LicenseID == "MIT" { + if err := w.renderTo("project/LICENSE.tmpl", "LICENSE", data); err != nil { + return w.created, err + } + } + if err := w.write("images/extension-icon.png", PlaceholderIcon(128, p.ID), 0o644); err != nil { + return w.created, err + } + for _, e := range endpoints { + if err := w.write("images/"+e.Icon, PlaceholderIcon(64, e.Name), 0o644); err != nil { + return w.created, err + } + } + for _, t := range tasks { + if err := writeTask(w, p, t); err != nil { + return w.created, err + } + } + sort.Strings(w.created) + return w.created, nil +} + +func writeTask(w *fileWriter, p Project, t Task) error { + data := templateData{Project: p, Task: t, Year: time.Now().Year()} + base := filepath.Join("src", "tasks", t.Slug) + for tpl, rel := range taskFiles { + if err := w.renderTo(tpl, filepath.Join(base, rel), data); err != nil { + return err + } + } + if err := w.renderTo("task/src/entry.ts.tmpl", filepath.Join(base, "src", t.Entry+".ts"), data); err != nil { + return err + } + return w.write(filepath.Join(base, "icon.png"), PlaceholderIcon(128, t.Name), 0o644) +} + +// FindRoot busca hacia arriba desde dir la carpeta que contiene vss-extension.json. +func FindRoot(dir string) (string, error) { + abs, err := filepath.Abs(dir) + if err != nil { + return "", err + } + for d := abs; ; d = filepath.Dir(d) { + if _, err := os.Stat(filepath.Join(d, "vss-extension.json")); err == nil { + return d, nil + } + if filepath.Dir(d) == d { + return "", fmt.Errorf("no se encontró vss-extension.json en %s ni en sus carpetas superiores", abs) + } + } +} + +// TaskDirs devuelve las carpetas de src/tasks que contienen task.json. +func TaskDirs(root string) ([]string, error) { + entries, err := os.ReadDir(filepath.Join(root, "src", "tasks")) + if err != nil { + return nil, err + } + var dirs []string + for _, e := range entries { + if e.IsDir() { + if _, err := os.Stat(filepath.Join(root, "src", "tasks", e.Name(), "task.json")); err == nil { + dirs = append(dirs, e.Name()) + } + } + } + return dirs, nil +} + +// ProjectInfo lee del proyecto existente los datos necesarios para agregar tareas. +func ProjectInfo(root string) (Project, []Endpoint, error) { + var p Project + manifest, err := ReadObject(filepath.Join(root, "vss-extension.json")) + if err != nil { + return p, nil, err + } + _, _ = manifest.Get("id", &p.ID) + _, _ = manifest.Get("name", &p.Name) + _, _ = manifest.Get("publisher", &p.Publisher) + if release, err := ReadObject(filepath.Join(root, "config", "release.json")); err == nil { + if p.Publisher == "" { + _, _ = release.Get("publisher", &p.Publisher) + } + } + if p.Publisher == "" { + p.Publisher = Slugify(p.ID) + } + var pkg struct { + Author string `json:"author"` + License string `json:"license"` + } + if data, err := os.ReadFile(filepath.Join(root, "package.json")); err == nil { + _ = json.Unmarshal(data, &pkg) + } + p.Author, p.LicenseID = pkg.Author, pkg.License + if p.LicenseID == "" { + p.LicenseID = "UNLICENSED" + } + + var contributions []struct { + ID string `json:"id"` + Type string `json:"type"` + Properties struct { + Name string `json:"name"` + DisplayName string `json:"displayName"` + URL struct { + Value string `json:"value"` + } `json:"url"` + } `json:"properties"` + } + _, _ = manifest.Get("contributions", &contributions) + var endpoints []Endpoint + for _, c := range contributions { + if c.Type == "ms.vss-endpoint.service-endpoint-type" { + endpoints = append(endpoints, Endpoint{ID: c.ID, Name: c.Properties.Name, DisplayName: c.Properties.DisplayName, URL: c.Properties.URL.Value}) + } + } + return p, endpoints, nil +} + +// AddTask crea una tarea nueva en un proyecto existente y la registra en vss-extension.json. +func AddTask(root string, p Project, t Task) ([]string, error) { + if _, err := os.Stat(filepath.Join(root, "src", "tasks", t.Slug)); err == nil { + return nil, fmt.Errorf("ya existe la tarea src/tasks/%s", t.Slug) + } + manifestPath := filepath.Join(root, "vss-extension.json") + manifest, err := ReadObject(manifestPath) + if err != nil { + return nil, err + } + var contributions []json.RawMessage + if _, err := manifest.Get("contributions", &contributions); err != nil { + return nil, fmt.Errorf("vss-extension.json: contributions inválido: %w", err) + } + // Insertar después de la última tarea para mantenerlas agrupadas. + contribution := map[string]any{ + "id": t.Slug + "-task", + "type": "ms.vss-distributed-task.task", + "targets": []string{"ms.vss-distributed-task.tasks"}, + "properties": map[string]string{"name": "tasks/" + t.Slug}, + } + ordered := &Object{vals: map[string]json.RawMessage{}} + for _, k := range []string{"id", "type", "targets", "properties"} { + _ = ordered.Set(k, contribution[k]) + } + raw, err := ordered.Bytes() + if err != nil { + return nil, err + } + insertAt := 0 + for i, c := range contributions { + var head struct{ Type string } + if json.Unmarshal(c, &head) == nil && head.Type == "ms.vss-distributed-task.task" { + insertAt = i + 1 + } + } + contributions = append(contributions[:insertAt], append([]json.RawMessage{json.RawMessage(bytes.TrimSpace(raw))}, contributions[insertAt:]...)...) + + w := &fileWriter{root: root} + if err := writeTask(w, p, t); err != nil { + _ = os.RemoveAll(filepath.Join(root, "src", "tasks", t.Slug)) + return nil, err + } + if err := manifest.Set("contributions", contributions); err != nil { + return nil, err + } + if err := manifest.Write(manifestPath); err != nil { + return nil, err + } + sort.Strings(w.created) + return append(w.created, "vss-extension.json (contribución "+t.Slug+"-task)"), nil +} + +// listTemplates se usa en los tests para comprobar que todas las plantillas están mapeadas. +func listTemplates() ([]string, error) { + var out []string + err := fs.WalkDir(templatesFS, "templates", func(path string, d fs.DirEntry, err error) error { + if err == nil && !d.IsDir() { + out = append(out, strings.TrimPrefix(path, "templates/")) + } + return err + }) + return out, err +} diff --git a/internal/scaffold/icon.go b/internal/scaffold/icon.go new file mode 100644 index 0000000..d11d136 --- /dev/null +++ b/internal/scaffold/icon.go @@ -0,0 +1,67 @@ +package scaffold + +import ( + "bytes" + "hash/fnv" + "image" + "image/color" + "image/png" +) + +// PlaceholderIcon genera un PNG cuadrado con un degradado cuyo color depende de seed, +// para tener iconos válidos hasta reemplazarlos por los definitivos. +func PlaceholderIcon(size int, seed string) []byte { + h := fnv.New32a() + h.Write([]byte(seed)) + sum := h.Sum32() + base := color.RGBA{uint8(40 + sum%140), uint8(40 + (sum>>8)%140), uint8(90 + (sum>>16)%140), 255} + + img := image.NewRGBA(image.Rect(0, 0, size, size)) + radius := size / 6 + for y := 0; y < size; y++ { + for x := 0; x < size; x++ { + if outsideRounded(x, y, size, radius) { + continue + } + f := float64(x+y) / float64(2*size) + c := color.RGBA{shade(base.R, f), shade(base.G, f), shade(base.B, f), 255} + // Marco interior más claro. + inset := size / 4 + if x > inset && x < size-inset && y > inset && y < size-inset && + (x < inset+size/16 || x > size-inset-size/16 || y < inset+size/16 || y > size-inset-size/16) { + c = color.RGBA{255, 255, 255, 220} + } + img.Set(x, y, c) + } + } + var buf bytes.Buffer + _ = png.Encode(&buf, img) + return buf.Bytes() +} + +func shade(v uint8, f float64) uint8 { + out := float64(v) * (1.15 - 0.4*f) + if out > 255 { + return 255 + } + return uint8(out) +} + +func outsideRounded(x, y, size, r int) bool { + cx, cy := -1, -1 + switch { + case x < r && y < r: + cx, cy = r, r + case x >= size-r && y < r: + cx, cy = size-r-1, r + case x < r && y >= size-r: + cx, cy = r, size-r-1 + case x >= size-r && y >= size-r: + cx, cy = size-r-1, size-r-1 + } + if cx < 0 { + return false + } + dx, dy := x-cx, y-cy + return dx*dx+dy*dy > r*r +} diff --git a/internal/scaffold/maintain.go b/internal/scaffold/maintain.go new file mode 100644 index 0000000..d5f758a --- /dev/null +++ b/internal/scaffold/maintain.go @@ -0,0 +1,286 @@ +package scaffold + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" +) + +// Niveles de incremento de versión. +const ( + LevelMajor = "major" + LevelMinor = "minor" + LevelPatch = "patch" +) + +// Change describe una modificación hecha por Bump o ResetIDs. +type Change struct { + File string `json:"file"` + From string `json:"from"` + To string `json:"to"` +} + +type taskVersion struct { + Major int `json:"Major"` + Minor int `json:"Minor"` + Patch int `json:"Patch"` +} + +func (v taskVersion) String() string { return fmt.Sprintf("%d.%d.%d", v.Major, v.Minor, v.Patch) } + +func bumpParts(major, minor, patch int, level string) (int, int, int) { + switch level { + case LevelMajor: + return major + 1, 0, 0 + case LevelMinor: + return major, minor + 1, 0 + } + return major, minor, patch + 1 +} + +// BumpVersion incrementa una versión "X.Y.Z". +func BumpVersion(v, level string) (string, error) { + if !IsSemver(v) { + return "", fmt.Errorf("versión inválida '%s' (se esperaba X.Y.Z)", v) + } + p := strings.Split(v, ".") + a, _ := strconv.Atoi(p[0]) + b, _ := strconv.Atoi(p[1]) + c, _ := strconv.Atoi(p[2]) + a, b, c = bumpParts(a, b, c, level) + return fmt.Sprintf("%d.%d.%d", a, b, c), nil +} + +// BumpTasks incrementa la versión de las tareas indicadas (todas si only está vacío). +func BumpTasks(root, level string, only []string, dryRun bool) ([]Change, error) { + dirs, err := TaskDirs(root) + if err != nil { + return nil, err + } + selected := map[string]bool{} + for _, o := range only { + selected[o] = true + } + var changes []Change + for _, d := range dirs { + if len(only) > 0 && !selected[d] { + continue + } + delete(selected, d) + path := filepath.Join(root, "src", "tasks", d, "task.json") + obj, err := ReadObject(path) + if err != nil { + return changes, err + } + var v taskVersion + if ok, err := obj.Get("version", &v); !ok || err != nil { + return changes, fmt.Errorf("%s: falta 'version' o es inválida", path) + } + nv := v + nv.Major, nv.Minor, nv.Patch = bumpParts(v.Major, v.Minor, v.Patch, level) + if !dryRun { + if err := obj.Set("version", nv); err != nil { + return changes, err + } + if err := obj.Write(path); err != nil { + return changes, err + } + } + changes = append(changes, Change{File: filepath.Join("src", "tasks", d, "task.json"), From: v.String(), To: nv.String()}) + } + for missing := range selected { + return changes, fmt.Errorf("no existe la tarea '%s'", missing) + } + return changes, nil +} + +// BumpConfigs incrementa "version" en los archivos de config indicados (p. ej. dev, release). +func BumpConfigs(root, level string, configs []string, dryRun bool) ([]Change, error) { + var changes []Change + for _, name := range configs { + rel := filepath.Join("config", name+".json") + path := filepath.Join(root, rel) + obj, err := ReadObject(path) + if err != nil { + return changes, err + } + var v string + if ok, err := obj.Get("version", &v); !ok || err != nil { + return changes, fmt.Errorf("%s: falta 'version'", rel) + } + nv, err := BumpVersion(v, level) + if err != nil { + return changes, fmt.Errorf("%s: %w", rel, err) + } + if !dryRun { + if err := obj.Set("version", nv); err != nil { + return changes, err + } + if err := obj.Write(path); err != nil { + return changes, err + } + } + changes = append(changes, Change{File: rel, From: v, To: nv}) + } + return changes, nil +} + +// ResetIDs genera un UUID nuevo para cada tarea (útil al partir de otra extensión). +func ResetIDs(root string) ([]Change, error) { + dirs, err := TaskDirs(root) + if err != nil { + return nil, err + } + var changes []Change + for _, d := range dirs { + path := filepath.Join(root, "src", "tasks", d, "task.json") + obj, err := ReadObject(path) + if err != nil { + return changes, err + } + var old string + _, _ = obj.Get("id", &old) + id := NewUUID() + if err := obj.Set("id", id); err != nil { + return changes, err + } + if err := obj.Write(path); err != nil { + return changes, err + } + changes = append(changes, Change{File: filepath.Join("src", "tasks", d, "task.json"), From: old, To: id}) + } + return changes, nil +} + +// Issue es un problema detectado por Validate. +type Issue struct { + Severity string `json:"severity"` // "error" o "warning" + File string `json:"file"` + Message string `json:"message"` +} + +// Validate revisa la estructura de la extensión antes de empaquetarla. +func Validate(root string) []Issue { + var issues []Issue + add := func(sev, file, format string, a ...any) { + issues = append(issues, Issue{Severity: sev, File: file, Message: fmt.Sprintf(format, a...)}) + } + + manifest, err := ReadObject(filepath.Join(root, "vss-extension.json")) + if err != nil { + add("error", "vss-extension.json", "%v", err) + return issues + } + var contributions []struct { + ID string `json:"id"` + Type string `json:"type"` + Properties struct { + Name string `json:"name"` + Icon string `json:"icon"` + } `json:"properties"` + } + _, _ = manifest.Get("contributions", &contributions) + registered := map[string]bool{} + contribIDs := map[string]bool{} + for _, c := range contributions { + if contribIDs[c.ID] { + add("error", "vss-extension.json", "id de contribución duplicado: %s", c.ID) + } + contribIDs[c.ID] = true + switch c.Type { + case "ms.vss-distributed-task.task": + registered[strings.TrimPrefix(c.Properties.Name, "tasks/")] = true + case "ms.vss-endpoint.service-endpoint-type": + if c.Properties.Icon != "" { + if _, err := os.Stat(filepath.Join(root, c.Properties.Icon)); err != nil { + add("error", "vss-extension.json", "no existe el icono %s de la conexión '%s'", c.Properties.Icon, c.ID) + } + } + } + } + var icons struct { + Default string `json:"default"` + } + if ok, _ := manifest.Get("icons", &icons); ok && icons.Default != "" { + if _, err := os.Stat(filepath.Join(root, icons.Default)); err != nil { + add("error", "vss-extension.json", "no existe el icono de la extensión %s", icons.Default) + } + } + + for _, name := range []string{"dev", "release"} { + rel := filepath.Join("config", name+".json") + obj, err := ReadObject(filepath.Join(root, rel)) + if err != nil { + add("warning", rel, "no se pudo leer: %v", err) + continue + } + var v, pub string + _, _ = obj.Get("version", &v) + _, _ = obj.Get("publisher", &pub) + if !IsSemver(v) { + add("error", rel, "versión inválida '%s'", v) + } + if pub == "" { + add("error", rel, "falta 'publisher'") + } + } + + dirs, err := TaskDirs(root) + if err != nil { + add("error", "src/tasks", "%v", err) + return issues + } + ids := map[string]string{} + names := map[string]string{} + for _, d := range dirs { + rel := filepath.Join("src", "tasks", d, "task.json") + obj, err := ReadObject(filepath.Join(root, rel)) + if err != nil { + add("error", rel, "%v", err) + continue + } + var id, name string + var execution map[string]struct { + Target string `json:"target"` + } + _, _ = obj.Get("id", &id) + _, _ = obj.Get("name", &name) + _, _ = obj.Get("execution", &execution) + if !IsUUID(id) { + add("error", rel, "el id '%s' no es un UUID válido", id) + } else if other, dup := ids[strings.ToLower(id)]; dup { + add("error", rel, "el id %s está repetido en %s (usa 'extension reset-ids')", id, other) + } + ids[strings.ToLower(id)] = d + if other, dup := names[name]; dup { + add("error", rel, "el name '%s' está repetido en %s", name, other) + } + names[name] = d + if !registered[d] { + add("error", "vss-extension.json", "la tarea '%s' no está registrada en contributions", d) + } + delete(registered, d) + if len(execution) == 0 { + add("error", rel, "falta 'execution'") + } + if _, err := os.Stat(filepath.Join(root, "src", "tasks", d, "icon.png")); err != nil { + add("warning", filepath.Join("src", "tasks", d), "falta icon.png") + } + var pkg struct { + Scripts map[string]string `json:"scripts"` + } + if data, err := os.ReadFile(filepath.Join(root, "src", "tasks", d, "package.json")); err == nil { + _ = json.Unmarshal(data, &pkg) + } + if pkg.Scripts["package"] == "" { + add("warning", filepath.Join("src", "tasks", d, "package.json"), "no tiene script 'package'") + } + } + for d := range registered { + add("error", "vss-extension.json", "la contribución apunta a tasks/%s pero no existe src/tasks/%s", d, d) + } + return issues +} diff --git a/internal/scaffold/orderedjson.go b/internal/scaffold/orderedjson.go new file mode 100644 index 0000000..dec8142 --- /dev/null +++ b/internal/scaffold/orderedjson.go @@ -0,0 +1,152 @@ +package scaffold + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "regexp" +) + +// Object es un objeto JSON que conserva el orden de sus claves, para poder editar +// task.json, vss-extension.json y config/*.json sin reordenarlos. +type Object struct { + keys []string + vals map[string]json.RawMessage + dirty map[string]bool // claves modificadas; las demás se escriben tal cual estaban +} + +// ParseObject decodifica un objeto JSON conservando el orden de las claves. +func ParseObject(data []byte) (*Object, error) { + dec := json.NewDecoder(bytes.NewReader(data)) + tok, err := dec.Token() + if err != nil { + return nil, err + } + if d, ok := tok.(json.Delim); !ok || d != '{' { + return nil, fmt.Errorf("se esperaba un objeto JSON") + } + o := &Object{vals: map[string]json.RawMessage{}, dirty: map[string]bool{}} + for dec.More() { + keyTok, err := dec.Token() + if err != nil { + return nil, err + } + key := keyTok.(string) + var raw json.RawMessage + if err := dec.Decode(&raw); err != nil { + return nil, err + } + if _, dup := o.vals[key]; !dup { + o.keys = append(o.keys, key) + } + o.vals[key] = raw + } + return o, nil +} + +// ReadObject lee y decodifica un archivo JSON. +func ReadObject(path string) (*Object, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + o, err := ParseObject(data) + if err != nil { + return nil, fmt.Errorf("%s: %w", path, err) + } + return o, nil +} + +// Get decodifica el valor de key en v. Devuelve false si no existe. +func (o *Object) Get(key string, v any) (bool, error) { + raw, ok := o.vals[key] + if !ok { + return false, nil + } + return true, json.Unmarshal(raw, v) +} + +// Raw devuelve el valor sin decodificar. +func (o *Object) Raw(key string) (json.RawMessage, bool) { + raw, ok := o.vals[key] + return raw, ok +} + +// Set asigna key (al final si es nueva). +func (o *Object) Set(key string, v any) error { + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + if err := enc.Encode(v); err != nil { + return err + } + raw := json.RawMessage(bytes.TrimSpace(buf.Bytes())) + if _, ok := o.vals[key]; !ok { + o.keys = append(o.keys, key) + } + o.vals[key] = raw + if o.dirty == nil { + o.dirty = map[string]bool{} + } + o.dirty[key] = true + return nil +} + +// Bytes serializa el objeto con sangría de 2 espacios. Los valores que no se +// modificaron con Set se escriben exactamente como estaban en el archivo original. +func (o *Object) Bytes() ([]byte, error) { + var buf bytes.Buffer + buf.WriteString("{\n") + for i, k := range o.keys { + kb, _ := json.Marshal(k) + buf.WriteString(" ") + buf.Write(kb) + buf.WriteString(": ") + if o.dirty[k] { + var v bytes.Buffer + if err := json.Indent(&v, o.vals[k], " ", " "); err != nil { + return nil, err + } + buf.Write(compactScalarArrays(v.Bytes())) + } else { + buf.Write(o.vals[k]) + } + if i < len(o.keys)-1 { + buf.WriteByte(',') + } + buf.WriteByte('\n') + } + buf.WriteString("}\n") + if !json.Valid(buf.Bytes()) { + return nil, fmt.Errorf("el JSON resultante no es válido") + } + return buf.Bytes(), nil +} + +// scalarArrayRe encuentra arreglos de valores simples que json.Indent separa en varias líneas. +var scalarArrayRe = regexp.MustCompile(`\[\n\s+((?:"(?:[^"\\\n]|\\.)*"|-?\d[\d.eE+-]*|true|false|null)(?:,\n\s+(?:"(?:[^"\\\n]|\\.)*"|-?\d[\d.eE+-]*|true|false|null))*)\n\s*\]`) + +var arraySepRe = regexp.MustCompile(`,\n\s+`) + +// compactScalarArrays deja en una línea los arreglos cortos de valores simples +// (["Build", "Release"]), como suelen escribirse a mano. +func compactScalarArrays(data []byte) []byte { + return scalarArrayRe.ReplaceAllFunc(data, func(m []byte) []byte { + inner := scalarArrayRe.FindSubmatch(m)[1] + joined := arraySepRe.ReplaceAll(inner, []byte(", ")) + if len(joined) > 70 { + return m + } + return append(append([]byte("["), joined...), ']') + }) +} + +// Write guarda el objeto en path. +func (o *Object) Write(path string) error { + data, err := o.Bytes() + if err != nil { + return err + } + return os.WriteFile(path, data, 0o644) +} diff --git a/internal/scaffold/scaffold_test.go b/internal/scaffold/scaffold_test.go new file mode 100644 index 0000000..e80118a --- /dev/null +++ b/internal/scaffold/scaffold_test.go @@ -0,0 +1,201 @@ +package scaffold + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func sampleProject() (Project, []Task, []Endpoint) { + p := Project{ + ID: "mi-extension", Name: "Mi \"Extensión\"", Description: "Hace ", Publisher: "henda", + DevPublisher: "dev-henda", Author: "Henry", Repository: "https://github.com/x/y", + Category: "Azure Pipelines", Version: "0.1.0", LicenseID: "MIT", + } + ep := NewEndpoint(p.ID, "Mi Servicio", "https://api.example.com") + tasks := []Task{ + NewTask(p.Publisher, "exec-thing", "Exec Thing", "", "Utility", ep), + NewTask(p.Publisher, "otra", "", "", "", nil), + } + return p, tasks, []Endpoint{*ep} +} + +func TestGenerateProducesValidProject(t *testing.T) { + dir := t.TempDir() + p, tasks, eps := sampleProject() + created, err := Generate(dir, p, tasks, eps) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{ + "package.json", "vss-extension.json", "config/dev.json", "config/release.json", "LICENSE", ".gitignore", + "images/extension-icon.png", "images/service-endpoint.png", "scripts/tasks.js", + "src/tasks/exec-thing/task.json", "src/tasks/exec-thing/src/exec_thing.ts", "src/tasks/exec-thing/icon.png", + "src/tasks/exec-thing/.env-test", "src/tasks/otra/src/otra.ts", + } { + if _, err := os.Stat(filepath.Join(dir, want)); err != nil { + t.Errorf("falta %s", want) + } + } + if issues := Validate(dir); len(issues) > 0 { + t.Errorf("el proyecto generado tiene problemas: %+v", issues) + } + + var task struct { + ID string + Name string + Inputs []struct{ Name, Type string } + } + data, _ := os.ReadFile(filepath.Join(dir, "src/tasks/exec-thing/task.json")) + if err := json.Unmarshal(data, &task); err != nil { + t.Fatal(err) + } + if !IsUUID(task.ID) || task.Name != "henda-exec-thing" { + t.Errorf("task.json incorrecto: %+v", task) + } + if task.Inputs[1].Type != "connectedService:miExtensionService" { + t.Errorf("input de conexión incorrecto: %+v", task.Inputs) + } + var other struct{ ID string } + data, _ = os.ReadFile(filepath.Join(dir, "src/tasks/otra/task.json")) + _ = json.Unmarshal(data, &other) + if other.ID == task.ID { + t.Error("cada tarea debe tener su propio UUID") + } + manifest, _ := os.ReadFile(filepath.Join(dir, "vss-extension.json")) + if !strings.Contains(string(manifest), `"name": "Mi \"Extensión\""`) { + t.Errorf("escape JSON incorrecto en el manifiesto:\n%s", manifest) + } + if len(created) < 20 { + t.Errorf("se crearon pocos archivos: %d", len(created)) + } + if _, err := Generate(dir, p, tasks, eps); err == nil { + t.Error("no debe generar en un directorio con contenido") + } +} + +func TestAllTemplatesAreUsed(t *testing.T) { + names, err := listTemplates() + if err != nil { + t.Fatal(err) + } + for _, n := range names { + _, inProject := projectFiles[n] + _, inTask := taskFiles[n] + if !inProject && !inTask && n != "project/LICENSE.tmpl" && n != "task/src/entry.ts.tmpl" { + t.Errorf("plantilla sin usar: %s", n) + } + } +} + +func TestAddTaskBumpAndResetIDs(t *testing.T) { + dir := t.TempDir() + p, tasks, eps := sampleProject() + if _, err := Generate(dir, p, tasks[:1], eps); err != nil { + t.Fatal(err) + } + info, endpoints, err := ProjectInfo(dir) + if err != nil || info.Publisher != "henda" || len(endpoints) != 1 || endpoints[0].Name != "miExtensionService" { + t.Fatalf("ProjectInfo = %+v %+v %v", info, endpoints, err) + } + if _, err := AddTask(dir, info, NewTask(info.Publisher, "nueva", "", "", "", &endpoints[0])); err != nil { + t.Fatal(err) + } + if _, err := AddTask(dir, info, NewTask(info.Publisher, "nueva", "", "", "", nil)); err == nil { + t.Error("no debe permitir una tarea repetida") + } + if issues := Validate(dir); len(issues) > 0 { + t.Errorf("problemas tras add-task: %+v", issues) + } + // La contribución nueva va después de las tareas y antes de la conexión. + var m struct { + Contributions []struct{ ID string } `json:"contributions"` + } + data, _ := os.ReadFile(filepath.Join(dir, "vss-extension.json")) + _ = json.Unmarshal(data, &m) + if len(m.Contributions) != 3 || m.Contributions[1].ID != "nueva-task" || m.Contributions[2].ID != "service-endpoint" { + t.Errorf("orden de contribuciones: %+v", m.Contributions) + } + if !strings.HasPrefix(string(data), "{\n \"manifestVersion\": 1,") { + t.Errorf("el manifiesto perdió el orden de claves:\n%.80s", data) + } + + changes, err := BumpTasks(dir, LevelMinor, []string{"nueva"}, false) + if err != nil || len(changes) != 1 || changes[0].From != "0.1.0" || changes[0].To != "0.2.0" { + t.Errorf("BumpTasks = %+v %v", changes, err) + } + if _, err := BumpTasks(dir, LevelPatch, []string{"no-existe"}, false); err == nil { + t.Error("se esperaba error para una tarea inexistente") + } + changes, err = BumpConfigs(dir, LevelPatch, []string{"dev"}, false) + if err != nil || changes[0].To != "0.1.1" { + t.Errorf("BumpConfigs = %+v %v", changes, err) + } + + before, _ := os.ReadFile(filepath.Join(dir, "src/tasks/nueva/task.json")) + changes, err = ResetIDs(dir) + if err != nil || len(changes) != 2 || changes[0].From == changes[0].To { + t.Errorf("ResetIDs = %+v %v", changes, err) + } + after, _ := os.ReadFile(filepath.Join(dir, "src/tasks/nueva/task.json")) + if strings.Index(string(before), `"name"`) != strings.Index(string(after), `"name"`) { + t.Error("ResetIDs no debe reordenar task.json") + } +} + +func TestValidateDetectsDuplicateIDs(t *testing.T) { + dir := t.TempDir() + p, tasks, _ := sampleProject() + tasks[1].UUID = tasks[0].UUID + tasks[0].Endpoint = nil + if _, err := Generate(dir, p, tasks, nil); err != nil { + t.Fatal(err) + } + issues := Validate(dir) + found := false + for _, i := range issues { + if strings.Contains(i.Message, "repetido") { + found = true + } + } + if !found { + t.Errorf("no detectó el UUID repetido: %+v", issues) + } +} + +func TestHelpers(t *testing.T) { + if got := Slugify(" Ejecutar Pipeline Ñandú! "); got != "ejecutar-pipeline-nandu" { + t.Errorf("Slugify = %q", got) + } + if Camel("exec-pipeline") != "execPipeline" || Snake("exec-pipeline") != "exec_pipeline" || Title("exec-pipeline") != "Exec Pipeline" { + t.Error("conversiones de nombre incorrectas") + } + for i := 0; i < 50; i++ { + if u := NewUUID(); !IsUUID(u) || u[14] != '4' { + t.Fatalf("UUID inválido %s", u) + } + } + if v, _ := BumpVersion("1.2.3", LevelMajor); v != "2.0.0" { + t.Errorf("BumpVersion = %s", v) + } +} + +func TestObjectKeepsFormatting(t *testing.T) { + in := "{\n \"b\": 1,\n \"a\": [\"x\", \"y\"],\n \"c\": {\"d\": [1, 2]},\n \"long\": [\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\", \"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"]\n}\n" + o, err := ParseObject([]byte(in)) + if err != nil { + t.Fatal(err) + } + out, _ := o.Bytes() + if string(out) != in { + t.Errorf("sin cambios debe quedar idéntico:\n%s", out) + } + _ = o.Set("c", map[string]any{"d": []int{1, 2}, "e": []string{"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}}) + out, _ = o.Bytes() + want := "{\n \"b\": 1,\n \"a\": [\"x\", \"y\"],\n \"c\": {\n \"d\": [1, 2],\n \"e\": [\n \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\n \"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"\n ]\n },\n \"long\": [\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\", \"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"]\n}\n" + if string(out) != want { + t.Errorf("got:\n%s\nwant:\n%s", out, want) + } +} diff --git a/internal/scaffold/templates/project/LICENSE.tmpl b/internal/scaffold/templates/project/LICENSE.tmpl new file mode 100644 index 0000000..6b213de --- /dev/null +++ b/internal/scaffold/templates/project/LICENSE.tmpl @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) {{.Year}} {{.Project.Author}} + +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/internal/scaffold/templates/project/README.md.tmpl b/internal/scaffold/templates/project/README.md.tmpl new file mode 100644 index 0000000..f42bdc9 --- /dev/null +++ b/internal/scaffold/templates/project/README.md.tmpl @@ -0,0 +1,44 @@ +# {{.Project.Name}} + +{{.Project.Description}} + +# Usage and Inputs + +{{range .Tasks}}## {{.FriendlyName}} + +{{.Description}} + +```yaml +- task: {{.Name}}@{{.Major}} + inputs: + message: "Hello" +{{- if .Endpoint}} + useSVC: false +{{- end}} +``` + +| Input | Required | Description | +| ----- | -------- | ----------- | +| `message` | yes | Message to print. | +| `verbose` | no | Prints extra debug information. | +{{- if .Endpoint}} +| `useSVC` | yes | Use the "{{.Endpoint.DisplayName}}" service connection. | +| `serviceConnection` | no | Service connection to use when `useSVC` is true. | +{{- end}} + +Output variables: `result`. + +{{end -}} +# Development + +```bash +npm install # installs root and every task (azde-scripts) +npm test # jest in every task +npm run pack:dev # private dev package (config/dev.json) +npm run packupversion:dev # same, increasing the extension version +npm run pack # release package (config/release.json) +``` + +Run a task locally: copy `src/tasks//.env-test` to `.env`, fill it and run `npm run test-local` inside the task folder. + +Remember to increase the task version in `task.json` (or run `azdevops extension bump`) every time you publish, otherwise agents keep the cached version. diff --git a/internal/scaffold/templates/project/config/dev.json.tmpl b/internal/scaffold/templates/project/config/dev.json.tmpl new file mode 100644 index 0000000..eca8bde --- /dev/null +++ b/internal/scaffold/templates/project/config/dev.json.tmpl @@ -0,0 +1,7 @@ +{ + "id": "{{.Project.ID}}-dev", + "name": "{{j .Project.Name}} (dev)", + "public": false, + "publisher": "{{.Project.DevPublisher}}", + "version": "{{.Project.Version}}" +} diff --git a/internal/scaffold/templates/project/config/release.json.tmpl b/internal/scaffold/templates/project/config/release.json.tmpl new file mode 100644 index 0000000..7f5ced5 --- /dev/null +++ b/internal/scaffold/templates/project/config/release.json.tmpl @@ -0,0 +1,6 @@ +{ + "id": "{{.Project.ID}}", + "public": {{.Project.Public}}, + "publisher": "{{.Project.Publisher}}", + "version": "{{.Project.Version}}" +} diff --git a/internal/scaffold/templates/project/gitignore.tmpl b/internal/scaffold/templates/project/gitignore.tmpl new file mode 100644 index 0000000..ceb3824 --- /dev/null +++ b/internal/scaffold/templates/project/gitignore.tmpl @@ -0,0 +1,35 @@ +# Dependencias +node_modules/ +.npm + +# Logs +*.log +npm-debug.log* + +# Cobertura y caches +coverage +**/coverage +*.lcov +*.tsbuildinfo +.eslintcache + +# Variables locales (copiar .env-test a .env en cada tarea) +.env +**/.env +.env.*.local + +# Salida de empaquetado +/tasks/ +dist +**.vsix +**/*.vsix +**/*.zip +*.tgz +**.taskkey + +# Bun +*.bun +**bun.lockb + +# SO +**DS_Store diff --git a/internal/scaffold/templates/project/package.json.tmpl b/internal/scaffold/templates/project/package.json.tmpl new file mode 100644 index 0000000..cb5ce86 --- /dev/null +++ b/internal/scaffold/templates/project/package.json.tmpl @@ -0,0 +1,24 @@ +{ + "name": "{{.Project.ID}}", + "version": "1.0.0", + "description": "{{j .Project.Description}}", + "private": true, + "scripts": { + "postinstall": "azde-scripts install src/tasks", + "build": "azde-scripts build src/tasks", + "test": "node scripts/tasks.js test", + "package:tasks": "node scripts/tasks.js package", + "pack": "npm run package:tasks && tfx extension create --manifest-globs vss-extension.json --overrides-file config/release.json", + "packupversion": "npm run package:tasks && tfx extension create --manifest-globs vss-extension.json --rev-version --overrides-file config/release.json", + "pack:dev": "npm run package:tasks && tfx extension create --manifest-globs vss-extension.json --overrides-file config/dev.json", + "packupversion:dev": "npm run package:tasks && tfx extension create --manifest-globs vss-extension.json --rev-version --overrides-file config/dev.json", + "clean:tasks": "node -e \"require('fs').rmSync('tasks',{recursive:true,force:true})\"" + }, + "author": "{{j .Project.Author}}", + "license": "{{.Project.LicenseID}}", + "dependencies": { + "azde-scripts": "^2.0.2", + "tfx-cli": "^0.24.2", + "typescript": "^5.7.2" + } +} diff --git a/internal/scaffold/templates/project/scripts/tasks.js.tmpl b/internal/scaffold/templates/project/scripts/tasks.js.tmpl new file mode 100644 index 0000000..17b557e --- /dev/null +++ b/internal/scaffold/templates/project/scripts/tasks.js.tmpl @@ -0,0 +1,22 @@ +// Ejecuta un script de npm (package, test, build…) en cada tarea de src/tasks. +// Uso: node scripts/tasks.js