bluesurf-mcp 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bluesurf_mcp-0.1.0/.gitignore +40 -0
- bluesurf_mcp-0.1.0/LICENSE +21 -0
- bluesurf_mcp-0.1.0/PKG-INFO +135 -0
- bluesurf_mcp-0.1.0/README.md +97 -0
- bluesurf_mcp-0.1.0/bluesurf_client/__init__.py +26 -0
- bluesurf_mcp-0.1.0/bluesurf_client/auth/__init__.py +15 -0
- bluesurf_mcp-0.1.0/bluesurf_client/auth/base.py +48 -0
- bluesurf_mcp-0.1.0/bluesurf_client/auth/cookie.py +40 -0
- bluesurf_mcp-0.1.0/bluesurf_client/auth/credentials.py +59 -0
- bluesurf_mcp-0.1.0/bluesurf_client/client.py +145 -0
- bluesurf_mcp-0.1.0/bluesurf_client/constants.py +10 -0
- bluesurf_mcp-0.1.0/bluesurf_client/models.py +129 -0
- bluesurf_mcp-0.1.0/bluesurf_client/session.py +75 -0
- bluesurf_mcp-0.1.0/bluesurf_mcp/__init__.py +1 -0
- bluesurf_mcp-0.1.0/bluesurf_mcp/__main__.py +6 -0
- bluesurf_mcp-0.1.0/bluesurf_mcp/cli.py +85 -0
- bluesurf_mcp-0.1.0/bluesurf_mcp/credentials_store.py +103 -0
- bluesurf_mcp-0.1.0/bluesurf_mcp/server.py +120 -0
- bluesurf_mcp-0.1.0/pyproject.toml +35 -0
- bluesurf_mcp-0.1.0/tests/test_build_auth.py +74 -0
- bluesurf_mcp-0.1.0/tests/test_cli.py +66 -0
- bluesurf_mcp-0.1.0/tests/test_credentials_store.py +74 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
.dev
|
|
2
|
+
|
|
3
|
+
# Secretos / credenciales (crítico: este proyecto maneja auth y cookies de Surf)
|
|
4
|
+
.env
|
|
5
|
+
.env.*
|
|
6
|
+
*.env
|
|
7
|
+
secrets.*
|
|
8
|
+
*.secret
|
|
9
|
+
cookies.txt
|
|
10
|
+
*.cookies
|
|
11
|
+
session.json
|
|
12
|
+
credentials.json
|
|
13
|
+
|
|
14
|
+
# Python
|
|
15
|
+
__pycache__/
|
|
16
|
+
*.py[cod]
|
|
17
|
+
*.egg-info/
|
|
18
|
+
build/
|
|
19
|
+
dist/
|
|
20
|
+
.eggs/
|
|
21
|
+
|
|
22
|
+
# Entornos virtuales
|
|
23
|
+
.venv/
|
|
24
|
+
venv/
|
|
25
|
+
env/
|
|
26
|
+
|
|
27
|
+
# Tests / herramientas
|
|
28
|
+
.pytest_cache/
|
|
29
|
+
.mypy_cache/
|
|
30
|
+
.ruff_cache/
|
|
31
|
+
.coverage
|
|
32
|
+
htmlcov/
|
|
33
|
+
|
|
34
|
+
# IDE
|
|
35
|
+
.vscode/
|
|
36
|
+
.idea/
|
|
37
|
+
|
|
38
|
+
# OS (Windows)
|
|
39
|
+
Thumbs.db
|
|
40
|
+
Desktop.ini
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 BluePeople
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: bluesurf-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Servidor MCP local de solo lectura para leer tickets de BlueSurf
|
|
5
|
+
Author: BluePeople
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2026 BluePeople
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
|
27
|
+
License-File: LICENSE
|
|
28
|
+
Keywords: bluesurf,mcp,tickets
|
|
29
|
+
Requires-Python: >=3.10
|
|
30
|
+
Requires-Dist: httpx>=0.27
|
|
31
|
+
Requires-Dist: keyring>=25
|
|
32
|
+
Requires-Dist: mcp>=2.0
|
|
33
|
+
Provides-Extra: dev
|
|
34
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
35
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
36
|
+
Requires-Dist: python-dotenv>=1.0; extra == 'dev'
|
|
37
|
+
Description-Content-Type: text/markdown
|
|
38
|
+
|
|
39
|
+
# bluesurf-mcp
|
|
40
|
+
|
|
41
|
+
Servidor MCP local de **solo lectura** para que Claude u otros agentes IA lean
|
|
42
|
+
tickets de **BlueSurf**. Un solo paquete PyPI incluye el núcleo
|
|
43
|
+
(`bluesurf_client`) y el adaptador MCP (`bluesurf_mcp`).
|
|
44
|
+
|
|
45
|
+
**Solo lectura por diseño:** no hay operaciones de escritura.
|
|
46
|
+
|
|
47
|
+
## Instalación
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
pip install bluesurf-mcp
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
En este monorepo, modo editable:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
pip install -e ./bluesurf-mcp
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Guardar credenciales (`login` / `logout`)
|
|
60
|
+
|
|
61
|
+
La contraseña no tiene que ir en `mcp.json`. Una vez, en una terminal:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
bluesurf-mcp login
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Pide usuario y contraseña, valida contra BlueSurf y guarda la contraseña en el
|
|
68
|
+
almacén del sistema (Credential Manager en Windows, Keychain en macOS, Secret
|
|
69
|
+
Service en Linux). El username queda en un JSON local, sin la contraseña.
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
bluesurf-mcp logout
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
borra lo guardado.
|
|
76
|
+
|
|
77
|
+
## Configuración (variables de entorno)
|
|
78
|
+
|
|
79
|
+
El entorno **gana** sobre lo guardado con `login`. Útil para CI o para
|
|
80
|
+
sobrescribir sin tocar el almacén.
|
|
81
|
+
|
|
82
|
+
Claves canónicas `BLUESURF_*`; se aceptan `SURF_*` como fallback.
|
|
83
|
+
|
|
84
|
+
- `BLUESURF_BASE_URL` — URL base (opcional; default `https://surf.bluepeople.com`).
|
|
85
|
+
- `BLUESURF_COOKIE` — cookie de sesión ya válida → usa `CookieAuth`.
|
|
86
|
+
- `BLUESURF_USERNAME` + `BLUESURF_PASSWORD` — credenciales → usa `CredentialsAuth`.
|
|
87
|
+
|
|
88
|
+
Precedencia: cookie en env → usuario/contraseña en env → `bluesurf-mcp login`.
|
|
89
|
+
|
|
90
|
+
El núcleo (`bluesurf_client`) no persiste secretos.
|
|
91
|
+
|
|
92
|
+
## Correr el servidor
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
bluesurf-mcp
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Se comunica por **stdio** (el transporte estándar para MCP local). Sin
|
|
99
|
+
argumentos no pide contraseña: usa env o las credenciales de `login`.
|
|
100
|
+
|
|
101
|
+
## Registrar el MCP en un cliente
|
|
102
|
+
|
|
103
|
+
### Claude Code / Cursor (`.mcp.json` o config de usuario)
|
|
104
|
+
|
|
105
|
+
Tras `bluesurf-mcp login`:
|
|
106
|
+
|
|
107
|
+
```json
|
|
108
|
+
{
|
|
109
|
+
"mcpServers": {
|
|
110
|
+
"bluesurf": {
|
|
111
|
+
"command": "bluesurf-mcp"
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Si `bluesurf-mcp` no está en el `PATH`, usa la ruta absoluta al ejecutable del
|
|
118
|
+
venv o `python -m bluesurf_mcp`.
|
|
119
|
+
|
|
120
|
+
Las variables `BLUESURF_*` en `env` siguen siendo válidas como override; no hace
|
|
121
|
+
falta poner la contraseña ahí.
|
|
122
|
+
|
|
123
|
+
## Tools expuestas (solo lectura)
|
|
124
|
+
|
|
125
|
+
- `mis_proyectos` — proyectos del usuario (id, smallCode, code, name).
|
|
126
|
+
- `buscar_mis_tickets(project, include_closed=False)` — tickets asignados al usuario.
|
|
127
|
+
- `obtener_ticket(code)` — detalle de un ticket por su código (p. ej. `PROJ-123`).
|
|
128
|
+
|
|
129
|
+
## Usar el cliente desde Python
|
|
130
|
+
|
|
131
|
+
El paquete de import del núcleo es `bluesurf_client`:
|
|
132
|
+
|
|
133
|
+
```python
|
|
134
|
+
from bluesurf_client import BlueSurfClient, CredentialsAuth # o CookieAuth
|
|
135
|
+
```
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# bluesurf-mcp
|
|
2
|
+
|
|
3
|
+
Servidor MCP local de **solo lectura** para que Claude u otros agentes IA lean
|
|
4
|
+
tickets de **BlueSurf**. Un solo paquete PyPI incluye el núcleo
|
|
5
|
+
(`bluesurf_client`) y el adaptador MCP (`bluesurf_mcp`).
|
|
6
|
+
|
|
7
|
+
**Solo lectura por diseño:** no hay operaciones de escritura.
|
|
8
|
+
|
|
9
|
+
## Instalación
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install bluesurf-mcp
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
En este monorepo, modo editable:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pip install -e ./bluesurf-mcp
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Guardar credenciales (`login` / `logout`)
|
|
22
|
+
|
|
23
|
+
La contraseña no tiene que ir en `mcp.json`. Una vez, en una terminal:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
bluesurf-mcp login
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Pide usuario y contraseña, valida contra BlueSurf y guarda la contraseña en el
|
|
30
|
+
almacén del sistema (Credential Manager en Windows, Keychain en macOS, Secret
|
|
31
|
+
Service en Linux). El username queda en un JSON local, sin la contraseña.
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
bluesurf-mcp logout
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
borra lo guardado.
|
|
38
|
+
|
|
39
|
+
## Configuración (variables de entorno)
|
|
40
|
+
|
|
41
|
+
El entorno **gana** sobre lo guardado con `login`. Útil para CI o para
|
|
42
|
+
sobrescribir sin tocar el almacén.
|
|
43
|
+
|
|
44
|
+
Claves canónicas `BLUESURF_*`; se aceptan `SURF_*` como fallback.
|
|
45
|
+
|
|
46
|
+
- `BLUESURF_BASE_URL` — URL base (opcional; default `https://surf.bluepeople.com`).
|
|
47
|
+
- `BLUESURF_COOKIE` — cookie de sesión ya válida → usa `CookieAuth`.
|
|
48
|
+
- `BLUESURF_USERNAME` + `BLUESURF_PASSWORD` — credenciales → usa `CredentialsAuth`.
|
|
49
|
+
|
|
50
|
+
Precedencia: cookie en env → usuario/contraseña en env → `bluesurf-mcp login`.
|
|
51
|
+
|
|
52
|
+
El núcleo (`bluesurf_client`) no persiste secretos.
|
|
53
|
+
|
|
54
|
+
## Correr el servidor
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
bluesurf-mcp
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Se comunica por **stdio** (el transporte estándar para MCP local). Sin
|
|
61
|
+
argumentos no pide contraseña: usa env o las credenciales de `login`.
|
|
62
|
+
|
|
63
|
+
## Registrar el MCP en un cliente
|
|
64
|
+
|
|
65
|
+
### Claude Code / Cursor (`.mcp.json` o config de usuario)
|
|
66
|
+
|
|
67
|
+
Tras `bluesurf-mcp login`:
|
|
68
|
+
|
|
69
|
+
```json
|
|
70
|
+
{
|
|
71
|
+
"mcpServers": {
|
|
72
|
+
"bluesurf": {
|
|
73
|
+
"command": "bluesurf-mcp"
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Si `bluesurf-mcp` no está en el `PATH`, usa la ruta absoluta al ejecutable del
|
|
80
|
+
venv o `python -m bluesurf_mcp`.
|
|
81
|
+
|
|
82
|
+
Las variables `BLUESURF_*` en `env` siguen siendo válidas como override; no hace
|
|
83
|
+
falta poner la contraseña ahí.
|
|
84
|
+
|
|
85
|
+
## Tools expuestas (solo lectura)
|
|
86
|
+
|
|
87
|
+
- `mis_proyectos` — proyectos del usuario (id, smallCode, code, name).
|
|
88
|
+
- `buscar_mis_tickets(project, include_closed=False)` — tickets asignados al usuario.
|
|
89
|
+
- `obtener_ticket(code)` — detalle de un ticket por su código (p. ej. `PROJ-123`).
|
|
90
|
+
|
|
91
|
+
## Usar el cliente desde Python
|
|
92
|
+
|
|
93
|
+
El paquete de import del núcleo es `bluesurf_client`:
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
from bluesurf_client import BlueSurfClient, CredentialsAuth # o CookieAuth
|
|
97
|
+
```
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Núcleo de solo lectura para hablar con BlueSurf.
|
|
2
|
+
|
|
3
|
+
Este paquete concentra toda la lógica reutilizable: autenticación (pluggable),
|
|
4
|
+
manejo de sesión/cookie, endpoints de lectura y modelos tipados. Los adaptadores
|
|
5
|
+
(el servidor MCP local y una eventual CLI) se construyen encima de esto sin
|
|
6
|
+
duplicar lógica.
|
|
7
|
+
|
|
8
|
+
Diseño de solo lectura: aquí no se expone ninguna operación de escritura.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from bluesurf_client.auth import AuthError, AuthProvider, CookieAuth, CredentialsAuth
|
|
12
|
+
from bluesurf_client.constants import DEFAULT_BASE_URL
|
|
13
|
+
from bluesurf_client.client import BlueSurfClient
|
|
14
|
+
from bluesurf_client.models import Comment, Ticket, User
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"BlueSurfClient",
|
|
18
|
+
"Ticket",
|
|
19
|
+
"Comment",
|
|
20
|
+
"User",
|
|
21
|
+
"AuthProvider",
|
|
22
|
+
"AuthError",
|
|
23
|
+
"CredentialsAuth",
|
|
24
|
+
"CookieAuth",
|
|
25
|
+
"DEFAULT_BASE_URL",
|
|
26
|
+
]
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Autenticación pluggable contra BlueSurf.
|
|
2
|
+
|
|
3
|
+
El consumidor decide el mecanismo y entrega credenciales o cookie:
|
|
4
|
+
- :class:`CredentialsAuth` — usuario/contraseña (hace login).
|
|
5
|
+
- :class:`CookieAuth` — cookie de sesión ya válida.
|
|
6
|
+
|
|
7
|
+
La librería no persiste credenciales ni sesión: la cookie vive solo en memoria
|
|
8
|
+
durante el ciclo de vida de la instancia del cliente.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from bluesurf_client.auth.base import AuthError, AuthProvider
|
|
12
|
+
from bluesurf_client.auth.cookie import CookieAuth
|
|
13
|
+
from bluesurf_client.auth.credentials import CredentialsAuth
|
|
14
|
+
|
|
15
|
+
__all__ = ["AuthProvider", "AuthError", "CredentialsAuth", "CookieAuth"]
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Contrato de autenticación (pluggable).
|
|
2
|
+
|
|
3
|
+
Un ``AuthProvider`` sabe cómo dejar una petición HTTP autenticada contra BlueSurf.
|
|
4
|
+
No asume un mecanismo concreto: una implementación puede hacer login con
|
|
5
|
+
credenciales y capturar la cookie ``.AspNetCore.Identity.Application``, otra
|
|
6
|
+
puede reusar una cookie exportada del navegador. El núcleo solo depende de esta
|
|
7
|
+
interfaz.
|
|
8
|
+
|
|
9
|
+
Notas sobre la sesión de BlueSurf:
|
|
10
|
+
- La sesión viaja por cookie de ASP.NET Core Identity, no por header ``Authorization``.
|
|
11
|
+
- Los flags ``samesite=lax`` y ``httponly`` solo afectan al navegador; un cliente
|
|
12
|
+
backend (httpx) puede almacenar y reenviar la cookie sin problema.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import abc
|
|
18
|
+
|
|
19
|
+
import httpx
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class AuthError(RuntimeError):
|
|
23
|
+
"""La autenticación falló o la sesión no pudo renovarse."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class AuthProvider(abc.ABC):
|
|
27
|
+
"""Interfaz que provee/renueva una sesión autenticada contra BlueSurf.
|
|
28
|
+
|
|
29
|
+
Las implementaciones concretas se agregan después (login programático,
|
|
30
|
+
cookie del navegador, etc.). El contrato es intencionalmente mínimo.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
@abc.abstractmethod
|
|
34
|
+
async def apply(self, client: httpx.AsyncClient) -> None:
|
|
35
|
+
"""Deja ``client`` autenticado.
|
|
36
|
+
|
|
37
|
+
Típicamente instala la cookie de sesión en ``client.cookies``. Se llama
|
|
38
|
+
una vez al construir el cliente y de nuevo tras ``refresh``. Debe lanzar
|
|
39
|
+
:class:`AuthError` si no puede autenticar.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
async def refresh(self, client: httpx.AsyncClient) -> None:
|
|
43
|
+
"""Renueva la sesión tras un 401.
|
|
44
|
+
|
|
45
|
+
Por defecto reintenta ``apply``. Las implementaciones que necesiten
|
|
46
|
+
re-login explícito (p. ej. volver a POSTear credenciales) lo sobrescriben.
|
|
47
|
+
"""
|
|
48
|
+
await self.apply(client)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Autenticación por cookie provista por el consumidor.
|
|
2
|
+
|
|
3
|
+
El consumidor entrega una cookie de sesión ya válida (p. ej. extraída de una
|
|
4
|
+
sesión de navegador autenticada). Esta clase la instala en el cliente httpx. No
|
|
5
|
+
persiste nada y no puede renovar la sesión por sí sola: si expira, es
|
|
6
|
+
responsabilidad del consumidor entregar una cookie fresca.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from bluesurf_client.auth.base import AuthError, AuthProvider
|
|
14
|
+
from bluesurf_client.constants import IDENTITY_COOKIE
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class CookieAuth(AuthProvider):
|
|
18
|
+
"""Usa una cookie de sesión provista por el consumidor.
|
|
19
|
+
|
|
20
|
+
Por defecto asume la cookie ``.AspNetCore.Identity.Application``; se puede
|
|
21
|
+
indicar otro nombre si hiciera falta.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, cookie_value: str, *, cookie_name: str = IDENTITY_COOKIE) -> None:
|
|
25
|
+
if not cookie_value:
|
|
26
|
+
raise ValueError("cookie_value es obligatorio")
|
|
27
|
+
self._cookie_value = cookie_value
|
|
28
|
+
self._cookie_name = cookie_name
|
|
29
|
+
|
|
30
|
+
async def apply(self, client: httpx.AsyncClient) -> None:
|
|
31
|
+
domain = client.base_url.host
|
|
32
|
+
client.cookies.set(
|
|
33
|
+
self._cookie_name, self._cookie_value, domain=domain, path="/"
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
async def refresh(self, client: httpx.AsyncClient) -> None:
|
|
37
|
+
raise AuthError(
|
|
38
|
+
"La cookie provista expiró o fue rechazada. CookieAuth no puede "
|
|
39
|
+
"renovar la sesión: entrega una cookie fresca (o usa CredentialsAuth)."
|
|
40
|
+
)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Autenticación por credenciales (usuario/contraseña).
|
|
2
|
+
|
|
3
|
+
El consumidor entrega usuario y contraseña; esta clase hace el POST de login y
|
|
4
|
+
deja la cookie de sesión en el cliente httpx (en memoria). **No** persiste nada
|
|
5
|
+
en disco: ni credenciales ni cookie. Las credenciales se mantienen en memoria
|
|
6
|
+
solo para poder re-loguear si la sesión expira (401) durante la vida de la
|
|
7
|
+
instancia.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
|
|
14
|
+
from bluesurf_client.auth.base import AuthError, AuthProvider
|
|
15
|
+
from bluesurf_client.constants import IDENTITY_COOKIE, LOGIN_PATH
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class CredentialsAuth(AuthProvider):
|
|
19
|
+
"""Inicia sesión con usuario/contraseña contra ``POST /api/login``.
|
|
20
|
+
|
|
21
|
+
El endpoint espera JSON ``{"email", "password"}`` (el campo es ``email``
|
|
22
|
+
aunque el valor sea un nombre de usuario). No usa CSRF/anti-forgery.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, username: str, password: str) -> None:
|
|
26
|
+
if not username or not password:
|
|
27
|
+
raise ValueError("username y password son obligatorios")
|
|
28
|
+
# Solo en memoria; nunca se escriben a disco.
|
|
29
|
+
self._username = username
|
|
30
|
+
self._password = password
|
|
31
|
+
|
|
32
|
+
async def apply(self, client: httpx.AsyncClient) -> None:
|
|
33
|
+
base = str(client.base_url).rstrip("/")
|
|
34
|
+
try:
|
|
35
|
+
response = await client.post(
|
|
36
|
+
LOGIN_PATH,
|
|
37
|
+
json={"email": self._username, "password": self._password},
|
|
38
|
+
headers={"Origin": base, "Referer": f"{base}/login"},
|
|
39
|
+
)
|
|
40
|
+
except httpx.HTTPError as exc: # pragma: no cover - error de red
|
|
41
|
+
raise AuthError(f"No se pudo contactar el login de BlueSurf: {exc}") from exc
|
|
42
|
+
|
|
43
|
+
if response.status_code in (400, 401, 403):
|
|
44
|
+
raise AuthError(
|
|
45
|
+
f"Login rechazado por BlueSurf (HTTP {response.status_code}): "
|
|
46
|
+
"revisa usuario/contraseña"
|
|
47
|
+
)
|
|
48
|
+
response.raise_for_status()
|
|
49
|
+
|
|
50
|
+
# httpx captura el Set-Cookie automáticamente en client.cookies.
|
|
51
|
+
if IDENTITY_COOKIE not in client.cookies:
|
|
52
|
+
raise AuthError(
|
|
53
|
+
"El login respondió 200 pero no llegó la cookie de sesión "
|
|
54
|
+
f"'{IDENTITY_COOKIE}'"
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
async def refresh(self, client: httpx.AsyncClient) -> None:
|
|
58
|
+
# La cookie expiró: volvemos a loguear con las credenciales en memoria.
|
|
59
|
+
await self.apply(client)
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Cliente de solo lectura de BlueSurf (la fachada de alto nivel).
|
|
2
|
+
|
|
3
|
+
Expone las lecturas centradas en el objetivo: los tickets (work items) asignados
|
|
4
|
+
al usuario. En BlueSurf no hay un endpoint "asignados a mí"; se resuelve consultando
|
|
5
|
+
el kanban de un proyecto con el filtro ``assignedTo`` puesto en el id del usuario
|
|
6
|
+
actual.
|
|
7
|
+
|
|
8
|
+
Ciclo de vida:
|
|
9
|
+
- Construcción sin efectos: ``BlueSurfClient(base_url=...)``.
|
|
10
|
+
- Autenticación explícita: ``await client.login(auth)`` (inyección por método).
|
|
11
|
+
- Limpieza: ``await client.aclose()``, o usa ``async with`` que la hace sola.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from bluesurf_client.auth.base import AuthProvider
|
|
19
|
+
from bluesurf_client.constants import DEFAULT_BASE_URL, IDENTITY_COOKIE
|
|
20
|
+
from bluesurf_client.models import Ticket, User
|
|
21
|
+
from bluesurf_client.session import BlueSurfSession
|
|
22
|
+
|
|
23
|
+
_PATH_CURRENT_USER = "/api/instance/currentUser"
|
|
24
|
+
_PATH_MY_PROJECTS = "/api/user/current/project"
|
|
25
|
+
_PATH_KANBAN = "/api/project/{project}/kanban"
|
|
26
|
+
_PATH_WORKITEM = "/api/workItem/{code}"
|
|
27
|
+
|
|
28
|
+
# Body base del kanban (todos los filtros vacíos). Se rellena ``assignedTo`` con
|
|
29
|
+
# el id del usuario actual para que BlueSurf filtre del lado servidor.
|
|
30
|
+
_KANBAN_FILTER_TEMPLATE: dict[str, Any] = {
|
|
31
|
+
"statuses": [],
|
|
32
|
+
"tags": [],
|
|
33
|
+
"sprints": [],
|
|
34
|
+
"priorities": [],
|
|
35
|
+
"assignedTo": [],
|
|
36
|
+
"reportedBy": [],
|
|
37
|
+
"colors": [],
|
|
38
|
+
"defectsOrigins": [],
|
|
39
|
+
"tagCondition": "and",
|
|
40
|
+
"search": "",
|
|
41
|
+
"quantityPerStatus": [],
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class BlueSurfClient:
|
|
46
|
+
"""Fachada de solo lectura sobre la API de BlueSurf."""
|
|
47
|
+
|
|
48
|
+
def __init__(self, *, base_url: str = DEFAULT_BASE_URL) -> None:
|
|
49
|
+
self._session = BlueSurfSession(base_url=base_url)
|
|
50
|
+
self._current_user: User | None = None
|
|
51
|
+
|
|
52
|
+
async def __aenter__(self) -> "BlueSurfClient":
|
|
53
|
+
return self
|
|
54
|
+
|
|
55
|
+
async def __aexit__(self, *exc: object) -> None:
|
|
56
|
+
await self._session.aclose()
|
|
57
|
+
|
|
58
|
+
async def login(self, auth: AuthProvider) -> "BlueSurfClient":
|
|
59
|
+
"""Autentica la sesión. Devuelve ``self`` para encadenar."""
|
|
60
|
+
await self._session.login(auth)
|
|
61
|
+
return self
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def authenticated(self) -> bool:
|
|
65
|
+
return self._session.authenticated
|
|
66
|
+
|
|
67
|
+
def session_cookie(self, name: str = IDENTITY_COOKIE) -> str | None:
|
|
68
|
+
"""Valor de la cookie de sesión (por defecto la de ASP.NET Identity)."""
|
|
69
|
+
return self._session.cookie(name)
|
|
70
|
+
|
|
71
|
+
async def aclose(self) -> None:
|
|
72
|
+
await self._session.aclose()
|
|
73
|
+
|
|
74
|
+
async def current_user(self) -> User:
|
|
75
|
+
"""El usuario autenticado. Se cachea en la instancia tras la 1ª llamada."""
|
|
76
|
+
if self._current_user is None:
|
|
77
|
+
data = await self._session.get_json(_PATH_CURRENT_USER)
|
|
78
|
+
self._current_user = User.from_current_user(data)
|
|
79
|
+
return self._current_user
|
|
80
|
+
|
|
81
|
+
async def mis_proyectos(self) -> list[dict]:
|
|
82
|
+
"""Proyectos del usuario (crudo). Útil para descubrir el ``smallCode``."""
|
|
83
|
+
data = await self._session.get_json(_PATH_MY_PROJECTS)
|
|
84
|
+
return data if isinstance(data, list) else []
|
|
85
|
+
|
|
86
|
+
async def mis_tickets(
|
|
87
|
+
self, project: str, *, include_closed: bool = False
|
|
88
|
+
) -> list[Ticket]:
|
|
89
|
+
"""Tickets asignados al usuario en un proyecto.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
project: ``smallCode`` del proyecto (p. ej. ``"PROJ"``), que es el
|
|
93
|
+
prefijo del código de los tickets (``PROJ-123``).
|
|
94
|
+
include_closed: si ``False`` (default) omite los tickets en estados
|
|
95
|
+
finales (cerrados/done); si ``True`` los incluye.
|
|
96
|
+
|
|
97
|
+
Nota: el kanban pagina por columna. Con el filtro por asignado el volumen
|
|
98
|
+
es bajo, pero si tuvieras muchísimos tickets abiertos en una sola columna
|
|
99
|
+
podrían no venir todos en una sola respuesta.
|
|
100
|
+
"""
|
|
101
|
+
me = await self.current_user()
|
|
102
|
+
|
|
103
|
+
body = dict(_KANBAN_FILTER_TEMPLATE)
|
|
104
|
+
body["assignedTo"] = [me.id]
|
|
105
|
+
|
|
106
|
+
data = await self._session.query_json(
|
|
107
|
+
_PATH_KANBAN.format(project=project),
|
|
108
|
+
json=body,
|
|
109
|
+
params={"skipWorkItems": "false"},
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
tickets: list[Ticket] = []
|
|
113
|
+
for raw in _iter_work_items(data):
|
|
114
|
+
ticket = Ticket.from_json(raw)
|
|
115
|
+
# Safety: garantizamos que sean míos aunque el filtro del servidor
|
|
116
|
+
# cambiara de semántica.
|
|
117
|
+
if ticket.assignee is None or ticket.assignee.id != me.id:
|
|
118
|
+
continue
|
|
119
|
+
if not include_closed and ticket.status_is_final:
|
|
120
|
+
continue
|
|
121
|
+
tickets.append(ticket)
|
|
122
|
+
return tickets
|
|
123
|
+
|
|
124
|
+
async def obtener_ticket(self, code: str) -> Ticket:
|
|
125
|
+
"""Detalle de un ticket por su código (p. ej. ``"PROJ-123"``)."""
|
|
126
|
+
data = await self._session.get_json(_PATH_WORKITEM.format(code=code))
|
|
127
|
+
return Ticket.from_json(data)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _iter_work_items(kanban: Any):
|
|
131
|
+
"""Aplana la respuesta del kanban → work items.
|
|
132
|
+
|
|
133
|
+
Estructura: lista por tipo → ``statuses[]`` (columnas) → ``workItems[]``.
|
|
134
|
+
"""
|
|
135
|
+
if not isinstance(kanban, list):
|
|
136
|
+
return
|
|
137
|
+
for branch in kanban:
|
|
138
|
+
if not isinstance(branch, dict):
|
|
139
|
+
continue
|
|
140
|
+
for status in branch.get("statuses", []):
|
|
141
|
+
if not isinstance(status, dict):
|
|
142
|
+
continue
|
|
143
|
+
for wi in status.get("workItems", []):
|
|
144
|
+
if isinstance(wi, dict):
|
|
145
|
+
yield wi
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Constantes del núcleo."""
|
|
2
|
+
|
|
3
|
+
# URL base por defecto de BlueSurf. Sobrescribible al construir el cliente/auth.
|
|
4
|
+
DEFAULT_BASE_URL = "https://surf.bluepeople.com"
|
|
5
|
+
|
|
6
|
+
# Cookie de sesión que emite ASP.NET Core Identity al iniciar sesión.
|
|
7
|
+
IDENTITY_COOKIE = ".AspNetCore.Identity.Application"
|
|
8
|
+
|
|
9
|
+
# Ruta del endpoint de login (POST JSON {"email", "password"}).
|
|
10
|
+
LOGIN_PATH = "/api/login"
|