From a96f3d7f6a6378add68c9618c90efddcba5b38b7 Mon Sep 17 00:00:00 2001 From: muddlebee Date: Wed, 22 Jul 2026 16:31:27 +0530 Subject: [PATCH 1/2] feat(integrations): migrate Groundcover, GitLab, Sentry, PostHog and Vercel onto the shared setup flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second batch of #4168, and the first that needed no new spec machinery — everything these five required landed with #4191. Two more wizard configurators were dropping credentials on the floor: _configure_vercel called sync_env_values({}) so neither the token nor the team id reached any tier but the store, and _configure_sentry wrote the URL, org and project slugs but never SENTRY_AUTH_TOKEN to the keyring. That is five such bugs across two batches; hand-written per-vendor persistence was wrong more often than it was right. Both are fixed by removing the persistence code rather than correcting it. GitLab was worse: _setup_gitlab prompted for a URL and a token, called upsert_integration, and stopped — no check for a blank token and no probe at all, so a typo produced a stored integration that failed on first use. Three of the five name their env vars differently from the credential they carry (auth_token as GITLAB_ACCESS_TOKEN, organization_slug as SENTRY_ORG_SLUG, base_url as SENTRY_URL), which is the mistake the round-trip test exists to catch; it now covers all nine migrated integrations. The readers — _catalog_impl, integrations/gitlab and integrations/posthog/config — import the same constants the specs write, so the two sides cannot drift. config/constants/sentry.py and posthog.py already existed but hold OpenSRE's *own* telemetry config: the DSN it reports its crashes to and the write-only key for its analytics project. Those are unrelated to what a user supplies to query their own Sentry or PostHog, so both docstrings now say so and the integration names sit under a separate section. Deletes the four wizard validators, whose files contained nothing else. Three were literally build_X_config + validate_X_config, which is what register_validation_verifier already wires up; Vercel's duplicated client.probe_access(). Vercel's probe failure path turned out to be untested, so tests/integrations/vercel/test_client.py gains that coverage rather than losing it with the validator. --- config/constants/__init__.py | 38 ++++++++ config/constants/gitlab.py | 12 +++ config/constants/groundcover.py | 21 ++++ config/constants/posthog.py | 14 ++- config/constants/sentry.py | 17 +++- config/constants/vercel.py | 11 +++ integrations/_catalog_impl.py | 47 ++++++--- integrations/cli.py | 76 +++------------ integrations/gitlab/__init__.py | 5 +- integrations/gitlab/setup.py | 42 ++++++++ integrations/groundcover/setup.py | 80 +++++++++++++++ integrations/posthog/config.py | 12 ++- integrations/posthog/setup.py | 54 +++++++++++ integrations/sentry/setup.py | 65 +++++++++++++ integrations/vercel/setup.py | 40 ++++++++ surfaces/cli/wizard/configurators/gitlab.py | 43 +------- surfaces/cli/wizard/configurators/posthog.py | 61 ++---------- surfaces/cli/wizard/configurators/sentry.py | 67 +++---------- surfaces/cli/wizard/configurators/vercel.py | 37 +------ surfaces/cli/wizard/integration_health.py | 8 -- .../wizard/integration_validators/gitlab.py | 18 ---- .../wizard/integration_validators/posthog.py | 26 ----- .../wizard/integration_validators/sentry.py | 27 ------ .../wizard/integration_validators/vercel.py | 27 ------ tests/cli/test_integrations.py | 25 ----- tests/cli/wizard/test_flow.py | 31 +++--- tests/cli/wizard/test_integration_health.py | 97 ------------------- tests/integrations/test_cli_spec_setup.py | 40 ++++++++ .../test_setup_spec_env_round_trip.py | 40 +++++++- tests/integrations/vercel/test_client.py | 23 +++++ 30 files changed, 598 insertions(+), 506 deletions(-) create mode 100644 config/constants/gitlab.py create mode 100644 config/constants/groundcover.py create mode 100644 config/constants/vercel.py create mode 100644 integrations/gitlab/setup.py create mode 100644 integrations/groundcover/setup.py create mode 100644 integrations/posthog/setup.py create mode 100644 integrations/sentry/setup.py create mode 100644 integrations/vercel/setup.py delete mode 100644 surfaces/cli/wizard/integration_validators/gitlab.py delete mode 100644 surfaces/cli/wizard/integration_validators/posthog.py delete mode 100644 surfaces/cli/wizard/integration_validators/sentry.py delete mode 100644 surfaces/cli/wizard/integration_validators/vercel.py diff --git a/config/constants/__init__.py b/config/constants/__init__.py index dfc5d77138..cf38c97a96 100644 --- a/config/constants/__init__.py +++ b/config/constants/__init__.py @@ -19,6 +19,15 @@ DATADOG_APP_KEY_ENV, DATADOG_SITE_ENV, ) +from config.constants.gitlab import GITLAB_AUTH_TOKEN_ENV, GITLAB_BASE_URL_ENV +from config.constants.groundcover import ( + GROUNDCOVER_API_KEY_ENV, + GROUNDCOVER_BACKEND_ID_ENV, + GROUNDCOVER_MCP_TOKEN_ENV, + GROUNDCOVER_MCP_URL_ENV, + GROUNDCOVER_TENANT_UUID_ENV, + GROUNDCOVER_TIMEZONE_ENV, +) from config.constants.honeycomb import ( HONEYCOMB_API_KEY_ENV, HONEYCOMB_BASE_URL_ENV, @@ -41,20 +50,30 @@ from config.constants.posthog import ( DEFAULT_POSTHOG_TIMEOUT_SECONDS, DEFAULT_POSTHOG_URL, + POSTHOG_BASE_URL_ENV, POSTHOG_CAPTURE_API_KEY, POSTHOG_HOST, + POSTHOG_PERSONAL_API_KEY_ENV, + POSTHOG_PROJECT_ID_ENV, + POSTHOG_TIMEOUT_SECONDS_ENV, ) from config.constants.sentry import ( + DEFAULT_SENTRY_BASE_URL, + SENTRY_AUTH_TOKEN_ENV, + SENTRY_BASE_URL_ENV, SENTRY_DSN, SENTRY_ERROR_SAMPLE_RATE, SENTRY_IN_APP_INCLUDE, SENTRY_MAX_BREADCRUMBS, + SENTRY_ORGANIZATION_SLUG_ENV, + SENTRY_PROJECT_SLUG_ENV, SENTRY_TRACES_SAMPLE_RATE, ) from config.constants.telegram import ( TELEGRAM_BOT_TOKEN_ENV, TELEGRAM_DEFAULT_CHAT_ID_ENV, ) +from config.constants.vercel import VERCEL_API_TOKEN_ENV, VERCEL_TEAM_ID_ENV __all__ = [ "AZURE_OPENAI_API_KEY_ENV", @@ -70,6 +89,15 @@ "DATADOG_SITE_ENV", "DEFAULT_POSTHOG_TIMEOUT_SECONDS", "DEFAULT_POSTHOG_URL", + "DEFAULT_SENTRY_BASE_URL", + "GITLAB_AUTH_TOKEN_ENV", + "GITLAB_BASE_URL_ENV", + "GROUNDCOVER_API_KEY_ENV", + "GROUNDCOVER_BACKEND_ID_ENV", + "GROUNDCOVER_MCP_TOKEN_ENV", + "GROUNDCOVER_MCP_URL_ENV", + "GROUNDCOVER_TENANT_UUID_ENV", + "GROUNDCOVER_TIMEZONE_ENV", "HONEYCOMB_API_KEY_ENV", "HONEYCOMB_BASE_URL_ENV", "HONEYCOMB_DATASET_ENV", @@ -79,16 +107,26 @@ "OPENSRE_HOME_DIR", "OPENSRE_TMP_DIR", "ORGANIZATION_ID_ENV", + "POSTHOG_BASE_URL_ENV", "POSTHOG_CAPTURE_API_KEY", "POSTHOG_HOST", + "POSTHOG_PERSONAL_API_KEY_ENV", + "POSTHOG_PROJECT_ID_ENV", + "POSTHOG_TIMEOUT_SECONDS_ENV", + "SENTRY_AUTH_TOKEN_ENV", + "SENTRY_BASE_URL_ENV", "SENTRY_DSN", "SENTRY_ERROR_SAMPLE_RATE", "SENTRY_IN_APP_INCLUDE", "SENTRY_MAX_BREADCRUMBS", + "SENTRY_ORGANIZATION_SLUG_ENV", + "SENTRY_PROJECT_SLUG_ENV", "SENTRY_TRACES_SAMPLE_RATE", "TELEGRAM_BOT_TOKEN_ENV", "TELEGRAM_DEFAULT_CHAT_ID_ENV", "USAGE_SECRET_ENV", + "VERCEL_API_TOKEN_ENV", + "VERCEL_TEAM_ID_ENV", "WEBAPP_URL_ENV", "ensure_opensre_tmp_dir", "get_store_path", diff --git a/config/constants/gitlab.py b/config/constants/gitlab.py new file mode 100644 index 0000000000..7cbb6cd036 --- /dev/null +++ b/config/constants/gitlab.py @@ -0,0 +1,12 @@ +"""GitLab environment variable names.""" + +from __future__ import annotations + +GITLAB_BASE_URL_ENV = "GITLAB_BASE_URL" +# Mirrors the ``auth_token`` credential; the names deliberately differ. +GITLAB_AUTH_TOKEN_ENV = "GITLAB_ACCESS_TOKEN" + +__all__ = [ + "GITLAB_AUTH_TOKEN_ENV", + "GITLAB_BASE_URL_ENV", +] diff --git a/config/constants/groundcover.py b/config/constants/groundcover.py new file mode 100644 index 0000000000..81ffecc309 --- /dev/null +++ b/config/constants/groundcover.py @@ -0,0 +1,21 @@ +"""Groundcover environment variable names.""" + +from __future__ import annotations + +GROUNDCOVER_API_KEY_ENV = "GROUNDCOVER_API_KEY" +# Accepted as a fallback when GROUNDCOVER_API_KEY is unset; setup always writes +# the primary name. +GROUNDCOVER_MCP_TOKEN_ENV = "GROUNDCOVER_MCP_TOKEN" +GROUNDCOVER_MCP_URL_ENV = "GROUNDCOVER_MCP_URL" +GROUNDCOVER_TENANT_UUID_ENV = "GROUNDCOVER_TENANT_UUID" +GROUNDCOVER_BACKEND_ID_ENV = "GROUNDCOVER_BACKEND_ID" +GROUNDCOVER_TIMEZONE_ENV = "GROUNDCOVER_TIMEZONE" + +__all__ = [ + "GROUNDCOVER_API_KEY_ENV", + "GROUNDCOVER_BACKEND_ID_ENV", + "GROUNDCOVER_MCP_TOKEN_ENV", + "GROUNDCOVER_MCP_URL_ENV", + "GROUNDCOVER_TENANT_UUID_ENV", + "GROUNDCOVER_TIMEZONE_ENV", +] diff --git a/config/constants/posthog.py b/config/constants/posthog.py index 1d9e0ecea7..7a99643e7b 100644 --- a/config/constants/posthog.py +++ b/config/constants/posthog.py @@ -1,4 +1,10 @@ -"""Shared PostHog constants used across analytics and integrations.""" +"""PostHog constants — OpenSRE's own product analytics, and the PostHog integration. + +Two unrelated things share the vendor name. ``POSTHOG_CAPTURE_API_KEY`` is a +write-only key for *OpenSRE's* analytics project; the ``*_ENV`` names further +down identify the credentials a *user* supplies to let investigations query +*their* PostHog. Nothing is shared between them but the default host. +""" from __future__ import annotations @@ -9,3 +15,9 @@ DEFAULT_POSTHOG_URL: Final[str] = POSTHOG_HOST DEFAULT_POSTHOG_TIMEOUT_SECONDS: Final[float] = 15.0 + +# --- The user's PostHog integration --------------------------------------- +POSTHOG_BASE_URL_ENV = "POSTHOG_BASE_URL" +POSTHOG_PROJECT_ID_ENV = "POSTHOG_PROJECT_ID" +POSTHOG_PERSONAL_API_KEY_ENV = "POSTHOG_PERSONAL_API_KEY" +POSTHOG_TIMEOUT_SECONDS_ENV = "POSTHOG_TIMEOUT_SECONDS" diff --git a/config/constants/sentry.py b/config/constants/sentry.py index 0988a4e221..c55c2636ad 100644 --- a/config/constants/sentry.py +++ b/config/constants/sentry.py @@ -1,4 +1,10 @@ -"""Sentry constants for OpenSRE runtime error monitoring.""" +"""Sentry constants — OpenSRE's own error monitoring, and the Sentry integration. + +Two unrelated things share the vendor name. The values below configure the +Sentry SDK that reports *OpenSRE's* crashes to the project's own account; the +``*_ENV`` names further down identify the credentials a *user* supplies to let +investigations query *their* Sentry. Nothing is shared between them. +""" from __future__ import annotations @@ -12,3 +18,12 @@ SENTRY_TRACES_SAMPLE_RATE: Final[float] = 1.0 SENTRY_MAX_BREADCRUMBS: Final[int] = 100 SENTRY_IN_APP_INCLUDE: Final[tuple[str, ...]] = ("app",) + +# --- The user's Sentry integration --------------------------------------- +# Mirror the ``base_url`` and ``organization_slug`` credentials; the names +# deliberately differ. +SENTRY_BASE_URL_ENV = "SENTRY_URL" +SENTRY_ORGANIZATION_SLUG_ENV = "SENTRY_ORG_SLUG" +SENTRY_AUTH_TOKEN_ENV = "SENTRY_AUTH_TOKEN" +SENTRY_PROJECT_SLUG_ENV = "SENTRY_PROJECT_SLUG" +DEFAULT_SENTRY_BASE_URL: Final[str] = "https://sentry.io" diff --git a/config/constants/vercel.py b/config/constants/vercel.py new file mode 100644 index 0000000000..bc8d88ce91 --- /dev/null +++ b/config/constants/vercel.py @@ -0,0 +1,11 @@ +"""Vercel environment variable names.""" + +from __future__ import annotations + +VERCEL_API_TOKEN_ENV = "VERCEL_API_TOKEN" +VERCEL_TEAM_ID_ENV = "VERCEL_TEAM_ID" + +__all__ = [ + "VERCEL_API_TOKEN_ENV", + "VERCEL_TEAM_ID_ENV", +] diff --git a/integrations/_catalog_impl.py b/integrations/_catalog_impl.py index ddb94928ed..d2065dfb92 100644 --- a/integrations/_catalog_impl.py +++ b/integrations/_catalog_impl.py @@ -20,11 +20,28 @@ DATADOG_APP_KEY_ENV, DATADOG_SITE_ENV, ) +from config.constants.gitlab import GITLAB_AUTH_TOKEN_ENV, GITLAB_BASE_URL_ENV +from config.constants.groundcover import ( + GROUNDCOVER_API_KEY_ENV, + GROUNDCOVER_BACKEND_ID_ENV, + GROUNDCOVER_MCP_TOKEN_ENV, + GROUNDCOVER_MCP_URL_ENV, + GROUNDCOVER_TENANT_UUID_ENV, + GROUNDCOVER_TIMEZONE_ENV, +) from config.constants.honeycomb import ( HONEYCOMB_API_KEY_ENV, HONEYCOMB_BASE_URL_ENV, HONEYCOMB_DATASET_ENV, ) +from config.constants.sentry import ( + DEFAULT_SENTRY_BASE_URL, + SENTRY_AUTH_TOKEN_ENV, + SENTRY_BASE_URL_ENV, + SENTRY_ORGANIZATION_SLUG_ENV, + SENTRY_PROJECT_SLUG_ENV, +) +from config.constants.vercel import VERCEL_API_TOKEN_ENV, VERCEL_TEAM_ID_ENV from config.llm_credentials import resolve_env_credential from integrations.airflow.config import airflow_config_from_env from integrations.airflow.config import classify as _classify_airflow @@ -481,8 +498,8 @@ def load_env_integrations() -> list[dict[str, Any]]: groundcover_api_key = "" else: groundcover_api_key = resolve_env_credential( - "GROUNDCOVER_API_KEY" - ) or resolve_env_credential("GROUNDCOVER_MCP_TOKEN") + GROUNDCOVER_API_KEY_ENV + ) or resolve_env_credential(GROUNDCOVER_MCP_TOKEN_ENV) if groundcover_api_key: # The groundcover config validates the MCP URL (HTTPS-or-loopback), which # can raise on a bad GROUNDCOVER_MCP_URL. Guard it so one malformed value @@ -491,10 +508,10 @@ def load_env_integrations() -> list[dict[str, Any]]: groundcover_config = GroundcoverIntegrationConfig.model_validate( { "api_key": groundcover_api_key, - "mcp_url": os.getenv("GROUNDCOVER_MCP_URL", "").strip(), - "tenant_uuid": os.getenv("GROUNDCOVER_TENANT_UUID", "").strip(), - "backend_id": os.getenv("GROUNDCOVER_BACKEND_ID", "").strip(), - "timezone": os.getenv("GROUNDCOVER_TIMEZONE", "").strip(), + "mcp_url": os.getenv(GROUNDCOVER_MCP_URL_ENV, "").strip(), + "tenant_uuid": os.getenv(GROUNDCOVER_TENANT_UUID_ENV, "").strip(), + "backend_id": os.getenv(GROUNDCOVER_BACKEND_ID_ENV, "").strip(), + "timezone": os.getenv(GROUNDCOVER_TIMEZONE_ENV, "").strip(), } ) except Exception as exc: @@ -647,16 +664,16 @@ def load_env_integrations() -> list[dict[str, Any]]: ) ) - sentry_org_slug = os.getenv("SENTRY_ORG_SLUG", "").strip() - sentry_auth_token = resolve_env_credential("SENTRY_AUTH_TOKEN") + sentry_org_slug = os.getenv(SENTRY_ORGANIZATION_SLUG_ENV, "").strip() + sentry_auth_token = resolve_env_credential(SENTRY_AUTH_TOKEN_ENV) if sentry_org_slug and sentry_auth_token: sentry_config = build_sentry_config( { - "base_url": os.getenv("SENTRY_URL", "https://sentry.io").strip() - or "https://sentry.io", + "base_url": os.getenv(SENTRY_BASE_URL_ENV, DEFAULT_SENTRY_BASE_URL).strip() + or DEFAULT_SENTRY_BASE_URL, "organization_slug": sentry_org_slug, "auth_token": sentry_auth_token, - "project_slug": os.getenv("SENTRY_PROJECT_SLUG", "").strip(), + "project_slug": os.getenv(SENTRY_PROJECT_SLUG_ENV, "").strip(), } ) integrations.append( @@ -666,11 +683,11 @@ def load_env_integrations() -> list[dict[str, Any]]: ) ) - gitlab_access_token = resolve_env_credential("GITLAB_ACCESS_TOKEN") + gitlab_access_token = resolve_env_credential(GITLAB_AUTH_TOKEN_ENV) if gitlab_access_token: gitlab_config = build_gitlab_config( { - "base_url": os.getenv("GITLAB_BASE_URL", DEFAULT_GITLAB_BASE_URL).strip() + "base_url": os.getenv(GITLAB_BASE_URL_ENV, DEFAULT_GITLAB_BASE_URL).strip() or DEFAULT_GITLAB_BASE_URL, "auth_token": gitlab_access_token, } @@ -789,13 +806,13 @@ def load_env_integrations() -> list[dict[str, Any]]: ) ) - vercel_api_token = resolve_env_credential("VERCEL_API_TOKEN") + vercel_api_token = resolve_env_credential(VERCEL_API_TOKEN_ENV) if vercel_api_token: try: vercel_config = VercelConfig.model_validate( { "api_token": vercel_api_token, - "team_id": os.getenv("VERCEL_TEAM_ID", "").strip(), + "team_id": os.getenv(VERCEL_TEAM_ID_ENV, "").strip(), } ) except Exception as exc: diff --git a/integrations/cli.py b/integrations/cli.py index 33a87d2de4..6174ce90dd 100644 --- a/integrations/cli.py +++ b/integrations/cli.py @@ -32,7 +32,6 @@ from integrations.github.mcp import GitHubMcpDisplayDetailLevel from integrations.setup_flow import IntegrationSetupSpec -from integrations.gitlab import DEFAULT_GITLAB_BASE_URL from integrations.openclaw import build_openclaw_config, validate_openclaw_config from integrations.posthog_mcp import ( DEFAULT_POSTHOG_MCP_URL, @@ -199,23 +198,9 @@ def _setup_datadog() -> None: def _setup_groundcover() -> None: - api_key = _p("Service-account API key", secret=True) - mcp_url = _p("MCP URL", default="https://mcp.groundcover.com/api/mcp") - tenant_uuid = _p("Tenant UUID (optional, for multi-workspace accounts)") - backend_id = _p("Backend ID (optional, for multi-backend tenants)") - timezone = _p("Timezone", default="UTC") - if not api_key: - _die("api_key is required.") - credentials: dict[str, str] = { - "api_key": api_key, - "mcp_url": mcp_url, - "timezone": timezone, - } - if tenant_uuid: - credentials["tenant_uuid"] = tenant_uuid - if backend_id: - credentials["backend_id"] = backend_id - upsert_integration("groundcover", {"credentials": credentials}) + from integrations.groundcover.setup import GROUNDCOVER_SETUP + + _run_spec_setup(GROUNDCOVER_SETUP) def _setup_honeycomb() -> None: @@ -428,11 +413,9 @@ def _setup_tracer() -> None: def _setup_vercel() -> None: - api_token = _p("Vercel API token", secret=True) - team_id = _p("Team ID (optional for personal accounts)") - if not api_token: - _die("api_token is required.") - upsert_integration("vercel", {"credentials": {"api_token": api_token, "team_id": team_id}}) + from integrations.vercel.setup import VERCEL_SETUP + + _run_spec_setup(VERCEL_SETUP) def _setup_betterstack() -> None: @@ -680,50 +663,21 @@ def _setup_github() -> str | None: def _setup_gitlab() -> None: - base_url = _p("Gitlab base URL", default=DEFAULT_GITLAB_BASE_URL) - auth_token = _p("Gitlab access token", secret=True) - upsert_integration( - "gitlab", - {"credentials": {"base_url": base_url, "auth_token": auth_token}}, - ) + from integrations.gitlab.setup import GITLAB_SETUP + + _run_spec_setup(GITLAB_SETUP) def _setup_sentry() -> None: - base_url = _p("Sentry URL", default="https://sentry.io") - organization_slug = _p("Organization slug") - auth_token = _p("Auth token", secret=True) - project_slug = _p("Project slug (optional)") - if not organization_slug or not auth_token: - _die("organization_slug and auth_token are required.") - upsert_integration( - "sentry", - { - "credentials": { - "base_url": base_url, - "organization_slug": organization_slug, - "auth_token": auth_token, - "project_slug": project_slug, - } - }, - ) + from integrations.sentry.setup import SENTRY_SETUP + + _run_spec_setup(SENTRY_SETUP) def _setup_posthog() -> None: - base_url = _p("PostHog API base URL", default="https://us.i.posthog.com") - project_id = _p("PostHog project ID") - personal_api_key = _p("PostHog personal API key (phx_...)", secret=True) - if not project_id or not personal_api_key: - _die("project_id and personal_api_key are required.") - upsert_integration( - "posthog", - { - "credentials": { - "base_url": base_url, - "project_id": project_id, - "personal_api_key": personal_api_key, - } - }, - ) + from integrations.posthog.setup import POSTHOG_SETUP + + _run_spec_setup(POSTHOG_SETUP) def _setup_mongodb() -> None: diff --git a/integrations/gitlab/__init__.py b/integrations/gitlab/__init__.py index 1e8fcf199b..d7442af0cd 100644 --- a/integrations/gitlab/__init__.py +++ b/integrations/gitlab/__init__.py @@ -11,6 +11,7 @@ import httpx from pydantic import Field, field_validator +from config.constants.gitlab import GITLAB_AUTH_TOKEN_ENV, GITLAB_BASE_URL_ENV from config.llm_credentials import resolve_env_credential from config.strict_config import StrictConfigModel from integrations._validation_helpers import report_classify_failure, report_validation_failure @@ -70,12 +71,12 @@ def build_gitlab_config(raw: dict[str, Any] | None) -> GitlabConfig: def gitlab_config_from_env() -> GitlabConfig | None: """Load a Gitlab config from env vars.""" - auth_token = resolve_env_credential("GITLAB_ACCESS_TOKEN") + auth_token = resolve_env_credential(GITLAB_AUTH_TOKEN_ENV) if not auth_token: return None return build_gitlab_config( { - "base_url": os.getenv("GITLAB_BASE_URL", DEFAULT_GITLAB_BASE_URL).strip() + "base_url": os.getenv(GITLAB_BASE_URL_ENV, DEFAULT_GITLAB_BASE_URL).strip() or DEFAULT_GITLAB_BASE_URL, "auth_token": auth_token, } diff --git a/integrations/gitlab/setup.py b/integrations/gitlab/setup.py new file mode 100644 index 0000000000..9c94aab103 --- /dev/null +++ b/integrations/gitlab/setup.py @@ -0,0 +1,42 @@ +"""What GitLab needs before it is considered configured. + +``base_url`` defaults to gitlab.com's API root and moves only for self-managed +instances. The token is required — the previous CLI handler accepted a blank one +and stored an integration that could not authenticate. +""" + +from __future__ import annotations + +from config.constants.gitlab import GITLAB_AUTH_TOKEN_ENV, GITLAB_BASE_URL_ENV +from integrations.gitlab import DEFAULT_GITLAB_BASE_URL +from integrations.gitlab.verifier import verify_gitlab +from integrations.setup_flow import IntegrationSetupSpec, SetupField + +BASE_URL_FIELD = "base_url" +AUTH_TOKEN_FIELD = "auth_token" + +GITLAB_SETUP = IntegrationSetupSpec( + service="gitlab", + fields=( + SetupField( + name=BASE_URL_FIELD, + label="GitLab base URL", + prompt="GitLab base URL (e.g. https://gitlab.example.com/api/v4)", + env_var=GITLAB_BASE_URL_ENV, + default=DEFAULT_GITLAB_BASE_URL, + ), + SetupField( + name=AUTH_TOKEN_FIELD, + label="GitLab access token", + env_var=GITLAB_AUTH_TOKEN_ENV, + secret=True, + ), + ), + verify=verify_gitlab, +) + +__all__ = [ + "AUTH_TOKEN_FIELD", + "BASE_URL_FIELD", + "GITLAB_SETUP", +] diff --git a/integrations/groundcover/setup.py b/integrations/groundcover/setup.py new file mode 100644 index 0000000000..d802ba3ac3 --- /dev/null +++ b/integrations/groundcover/setup.py @@ -0,0 +1,80 @@ +"""What Groundcover needs before it is considered configured. + +Only the service-account key is required. The tenant and backend identifiers +narrow which workspace and backend queries address, and are needed only by +multi-workspace or multi-backend accounts. + +The key is written as ``GROUNDCOVER_API_KEY``; credential resolution also +accepts ``GROUNDCOVER_MCP_TOKEN`` as a fallback, but setup writes the primary +name so there is one place to look. +""" + +from __future__ import annotations + +from config.constants.groundcover import ( + GROUNDCOVER_API_KEY_ENV, + GROUNDCOVER_BACKEND_ID_ENV, + GROUNDCOVER_MCP_URL_ENV, + GROUNDCOVER_TENANT_UUID_ENV, + GROUNDCOVER_TIMEZONE_ENV, +) +from integrations.config_models import DEFAULT_GROUNDCOVER_MCP_URL, DEFAULT_GROUNDCOVER_TIMEZONE +from integrations.groundcover.verifier import verify_groundcover +from integrations.setup_flow import IntegrationSetupSpec, SetupField + +API_KEY_FIELD = "api_key" +MCP_URL_FIELD = "mcp_url" +TENANT_UUID_FIELD = "tenant_uuid" +BACKEND_ID_FIELD = "backend_id" +TIMEZONE_FIELD = "timezone" + +GROUNDCOVER_SETUP = IntegrationSetupSpec( + service="groundcover", + fields=( + SetupField( + name=API_KEY_FIELD, + label="Groundcover API key", + prompt="Service-account API key", + env_var=GROUNDCOVER_API_KEY_ENV, + secret=True, + ), + SetupField( + name=MCP_URL_FIELD, + label="Groundcover MCP URL", + prompt="MCP URL", + env_var=GROUNDCOVER_MCP_URL_ENV, + default=DEFAULT_GROUNDCOVER_MCP_URL, + ), + SetupField( + name=TENANT_UUID_FIELD, + label="Groundcover tenant UUID", + prompt="Tenant UUID (optional, for multi-workspace accounts)", + env_var=GROUNDCOVER_TENANT_UUID_ENV, + required=False, + ), + SetupField( + name=BACKEND_ID_FIELD, + label="Groundcover backend ID", + prompt="Backend ID (optional, for multi-backend tenants)", + env_var=GROUNDCOVER_BACKEND_ID_ENV, + required=False, + ), + SetupField( + name=TIMEZONE_FIELD, + label="Groundcover timezone", + prompt="Timezone", + env_var=GROUNDCOVER_TIMEZONE_ENV, + default=DEFAULT_GROUNDCOVER_TIMEZONE, + ), + ), + verify=verify_groundcover, +) + +__all__ = [ + "API_KEY_FIELD", + "BACKEND_ID_FIELD", + "GROUNDCOVER_SETUP", + "MCP_URL_FIELD", + "TENANT_UUID_FIELD", + "TIMEZONE_FIELD", +] diff --git a/integrations/posthog/config.py b/integrations/posthog/config.py index bdb5a2c874..7918da56c1 100644 --- a/integrations/posthog/config.py +++ b/integrations/posthog/config.py @@ -10,6 +10,10 @@ from config.constants.posthog import ( DEFAULT_POSTHOG_TIMEOUT_SECONDS, DEFAULT_POSTHOG_URL, + POSTHOG_BASE_URL_ENV, + POSTHOG_PERSONAL_API_KEY_ENV, + POSTHOG_PROJECT_ID_ENV, + POSTHOG_TIMEOUT_SECONDS_ENV, ) from config.llm_credentials import resolve_env_credential from config.strict_config import StrictConfigModel @@ -57,19 +61,19 @@ def build_posthog_config(raw: dict[str, Any] | None) -> PostHogConfig: def posthog_config_from_env() -> PostHogConfig | None: - project_id = os.getenv("POSTHOG_PROJECT_ID", "").strip() - personal_api_key = resolve_env_credential("POSTHOG_PERSONAL_API_KEY") + project_id = os.getenv(POSTHOG_PROJECT_ID_ENV, "").strip() + personal_api_key = resolve_env_credential(POSTHOG_PERSONAL_API_KEY_ENV) if not project_id or not personal_api_key: return None return build_posthog_config( { - "base_url": os.getenv("POSTHOG_BASE_URL", DEFAULT_POSTHOG_URL), + "base_url": os.getenv(POSTHOG_BASE_URL_ENV, DEFAULT_POSTHOG_URL), "project_id": project_id, "personal_api_key": personal_api_key, "timeout_seconds": os.getenv( - "POSTHOG_TIMEOUT_SECONDS", str(DEFAULT_POSTHOG_TIMEOUT_SECONDS) + POSTHOG_TIMEOUT_SECONDS_ENV, str(DEFAULT_POSTHOG_TIMEOUT_SECONDS) ), } ) diff --git a/integrations/posthog/setup.py b/integrations/posthog/setup.py new file mode 100644 index 0000000000..95f540d8dd --- /dev/null +++ b/integrations/posthog/setup.py @@ -0,0 +1,54 @@ +"""What PostHog needs before it is considered configured. + +The project ID is required alongside the key: every query endpoint is addressed +per project, so a key on its own cannot reach any data. + +``base_url`` defaults to PostHog's US cloud and moves for EU or self-hosted. +""" + +from __future__ import annotations + +from config.constants.posthog import ( + DEFAULT_POSTHOG_URL, + POSTHOG_BASE_URL_ENV, + POSTHOG_PERSONAL_API_KEY_ENV, + POSTHOG_PROJECT_ID_ENV, +) +from integrations.posthog.verifier import verify_posthog +from integrations.setup_flow import IntegrationSetupSpec, SetupField + +BASE_URL_FIELD = "base_url" +PROJECT_ID_FIELD = "project_id" +PERSONAL_API_KEY_FIELD = "personal_api_key" + +POSTHOG_SETUP = IntegrationSetupSpec( + service="posthog", + fields=( + SetupField( + name=BASE_URL_FIELD, + label="PostHog API base URL", + env_var=POSTHOG_BASE_URL_ENV, + default=DEFAULT_POSTHOG_URL, + ), + SetupField( + name=PROJECT_ID_FIELD, + label="PostHog project ID", + env_var=POSTHOG_PROJECT_ID_ENV, + ), + SetupField( + name=PERSONAL_API_KEY_FIELD, + label="PostHog personal API key", + prompt="PostHog personal API key (phx_...)", + env_var=POSTHOG_PERSONAL_API_KEY_ENV, + secret=True, + ), + ), + verify=verify_posthog, +) + +__all__ = [ + "BASE_URL_FIELD", + "PERSONAL_API_KEY_FIELD", + "POSTHOG_SETUP", + "PROJECT_ID_FIELD", +] diff --git a/integrations/sentry/setup.py b/integrations/sentry/setup.py new file mode 100644 index 0000000000..2f2839cc38 --- /dev/null +++ b/integrations/sentry/setup.py @@ -0,0 +1,65 @@ +"""What Sentry needs before it is considered configured. + +The organization slug is required alongside the token: Sentry's API is scoped +per organization, so a token without one cannot address any endpoint. The +project slug is optional — left blank, queries span every project the token can +see. + +``base_url`` moves only for self-hosted Sentry. +""" + +from __future__ import annotations + +from config.constants.sentry import ( + DEFAULT_SENTRY_BASE_URL, + SENTRY_AUTH_TOKEN_ENV, + SENTRY_BASE_URL_ENV, + SENTRY_ORGANIZATION_SLUG_ENV, + SENTRY_PROJECT_SLUG_ENV, +) +from integrations.sentry.verifier import verify_sentry +from integrations.setup_flow import IntegrationSetupSpec, SetupField + +BASE_URL_FIELD = "base_url" +ORGANIZATION_SLUG_FIELD = "organization_slug" +AUTH_TOKEN_FIELD = "auth_token" +PROJECT_SLUG_FIELD = "project_slug" + +SENTRY_SETUP = IntegrationSetupSpec( + service="sentry", + fields=( + SetupField( + name=BASE_URL_FIELD, + label="Sentry URL", + env_var=SENTRY_BASE_URL_ENV, + default=DEFAULT_SENTRY_BASE_URL, + ), + SetupField( + name=ORGANIZATION_SLUG_FIELD, + label="Sentry organization slug", + env_var=SENTRY_ORGANIZATION_SLUG_ENV, + ), + SetupField( + name=AUTH_TOKEN_FIELD, + label="Sentry auth token", + env_var=SENTRY_AUTH_TOKEN_ENV, + secret=True, + ), + SetupField( + name=PROJECT_SLUG_FIELD, + label="Sentry project slug", + prompt="Project slug (optional)", + env_var=SENTRY_PROJECT_SLUG_ENV, + required=False, + ), + ), + verify=verify_sentry, +) + +__all__ = [ + "AUTH_TOKEN_FIELD", + "BASE_URL_FIELD", + "ORGANIZATION_SLUG_FIELD", + "PROJECT_SLUG_FIELD", + "SENTRY_SETUP", +] diff --git a/integrations/vercel/setup.py b/integrations/vercel/setup.py new file mode 100644 index 0000000000..b2dc1100b3 --- /dev/null +++ b/integrations/vercel/setup.py @@ -0,0 +1,40 @@ +"""What Vercel needs before it is considered configured. + +``team_id`` is optional: personal accounts have none, and supplying one scopes +every query to that team. +""" + +from __future__ import annotations + +from config.constants.vercel import VERCEL_API_TOKEN_ENV, VERCEL_TEAM_ID_ENV +from integrations.setup_flow import IntegrationSetupSpec, SetupField +from integrations.vercel.verifier import verify_vercel + +API_TOKEN_FIELD = "api_token" +TEAM_ID_FIELD = "team_id" + +VERCEL_SETUP = IntegrationSetupSpec( + service="vercel", + fields=( + SetupField( + name=API_TOKEN_FIELD, + label="Vercel API token", + env_var=VERCEL_API_TOKEN_ENV, + secret=True, + ), + SetupField( + name=TEAM_ID_FIELD, + label="Vercel team ID", + prompt="Team ID (optional for personal accounts)", + env_var=VERCEL_TEAM_ID_ENV, + required=False, + ), + ), + verify=verify_vercel, +) + +__all__ = [ + "API_TOKEN_FIELD", + "TEAM_ID_FIELD", + "VERCEL_SETUP", +] diff --git a/surfaces/cli/wizard/configurators/gitlab.py b/surfaces/cli/wizard/configurators/gitlab.py index 6168bf62d4..439475ff08 100644 --- a/surfaces/cli/wizard/configurators/gitlab.py +++ b/surfaces/cli/wizard/configurators/gitlab.py @@ -2,46 +2,9 @@ from __future__ import annotations -from config.env_file import sync_env_secret, sync_env_values -from integrations.store import upsert_integration -from platform.terminal.theme import SECONDARY -from surfaces.cli.wizard._ui import ( - _console, - _integration_defaults, - _prompt_value, - _render_integration_result, - _string_value, -) -from surfaces.cli.wizard.integration_health import validate_gitlab_integration - -DEFAULT_GITLAB_BASE_URL = "https://gitlab.com/api/v4" +from integrations.gitlab.setup import GITLAB_SETUP +from surfaces.cli.wizard.configurators.spec_configurator import configure_from_spec def _configure_gitlab() -> tuple[str, str]: - _, credentials = _integration_defaults("gitlab") - - while True: - base_url = _prompt_value( - "Gitlab base URL", - default=_string_value(credentials.get("base_url"), DEFAULT_GITLAB_BASE_URL), - ) - auth_token = _prompt_value( - "Gitlab access token", - default=_string_value(credentials.get("auth_token")), - secret=True, - ) - - with _console.status("Validating Gitlab integration...", spinner="dots"): - result = validate_gitlab_integration(base_url=base_url, auth_token=auth_token) - _render_integration_result("Gitlab", result) - if result.ok: - credentials = {"base_url": base_url, "auth_token": auth_token} - upsert_integration("gitlab", {"credentials": credentials}) - sync_env_secret("GITLAB_ACCESS_TOKEN", auth_token) - env_path = sync_env_values( - { - "GITLAB_BASE_URL": base_url, - } - ) - return "Gitlab", str(env_path) - _console.print(f"[{SECONDARY}]Try again or press Ctrl+C to cancel.[/]") + return configure_from_spec(GITLAB_SETUP, title="GitLab") diff --git a/surfaces/cli/wizard/configurators/posthog.py b/surfaces/cli/wizard/configurators/posthog.py index 5fb2de3dca..711abfaa90 100644 --- a/surfaces/cli/wizard/configurators/posthog.py +++ b/surfaces/cli/wizard/configurators/posthog.py @@ -2,8 +2,8 @@ from __future__ import annotations -from config.constants.posthog import DEFAULT_POSTHOG_URL from config.env_file import sync_env_secret, sync_env_values +from integrations.posthog.setup import POSTHOG_SETUP from integrations.store import upsert_integration from platform.terminal.theme import HIGHLIGHT, SECONDARY from surfaces.cli.wizard._ui import ( @@ -14,64 +14,23 @@ _render_integration_result, _string_value, ) -from surfaces.cli.wizard.integration_health import ( - validate_posthog_integration, - validate_posthog_mcp_integration, -) +from surfaces.cli.wizard.configurators.spec_configurator import configure_from_spec +from surfaces.cli.wizard.integration_health import validate_posthog_mcp_integration DEFAULT_POSTHOG_MCP_URL = "https://mcp.posthog.com/mcp" DEFAULT_POSTHOG_MCP_MODE = "streamable-http" def _configure_posthog() -> tuple[str, str]: - _, credentials = _integration_defaults("posthog") - _console.print( - f"[{SECONDARY}]Create a personal API key (phx_...) with read access — " - "https://posthog.com/docs/api/personal-api-keys[/]" + return configure_from_spec( + POSTHOG_SETUP, + title="PostHog", + intro=( + f"[{SECONDARY}]Create a personal API key (phx_...) with read access — " + "https://posthog.com/docs/api/personal-api-keys[/]" + ), ) - while True: - base_url = _prompt_value( - "PostHog API base URL", - default=_string_value(credentials.get("base_url"), DEFAULT_POSTHOG_URL), - ) - project_id = _prompt_value( - "PostHog project ID", - default=_string_value(credentials.get("project_id")), - ) - personal_api_key = _prompt_value( - "PostHog personal API key", - default=_string_value(credentials.get("personal_api_key")), - secret=True, - ) - - with _console.status("Validating PostHog integration...", spinner="dots"): - result = validate_posthog_integration( - base_url=base_url, - project_id=project_id, - personal_api_key=personal_api_key, - ) - _render_integration_result("PostHog", result) - if result.ok: - credentials = { - "base_url": base_url, - "project_id": project_id, - "personal_api_key": personal_api_key, - } - upsert_integration("posthog", {"credentials": credentials}) - sync_env_secret("POSTHOG_PERSONAL_API_KEY", personal_api_key) - env_path = sync_env_values( - { - "POSTHOG_PROJECT_ID": project_id, - "POSTHOG_BASE_URL": base_url, - } - ) - _console.print( - f"[{SECONDARY}]Verify:[/] [bold]uv run opensre integrations verify posthog[/]" - ) - return "PostHog", str(env_path) - _console.print(f"[{SECONDARY}]Try again or press Ctrl+C to cancel.[/]") - def _configure_posthog_mcp() -> tuple[str, str]: _, credentials = _integration_defaults("posthog_mcp") diff --git a/surfaces/cli/wizard/configurators/sentry.py b/surfaces/cli/wizard/configurators/sentry.py index b0e0353d55..1f766b8abb 100644 --- a/surfaces/cli/wizard/configurators/sentry.py +++ b/surfaces/cli/wizard/configurators/sentry.py @@ -4,6 +4,7 @@ from config.env_file import sync_env_secret, sync_env_values from integrations.sentry import get_sentry_auth_recommendations +from integrations.sentry.setup import SENTRY_SETUP from integrations.store import upsert_integration from platform.terminal.theme import HIGHLIGHT, SECONDARY from surfaces.cli.wizard._ui import ( @@ -14,14 +15,11 @@ _render_integration_result, _string_value, ) -from surfaces.cli.wizard.integration_health import ( - validate_sentry_integration, - validate_sentry_mcp_integration, -) +from surfaces.cli.wizard.configurators.spec_configurator import configure_from_spec +from surfaces.cli.wizard.integration_health import validate_sentry_mcp_integration DEFAULT_SENTRY_MCP_URL = "https://mcp.sentry.dev/mcp" DEFAULT_SENTRY_MCP_MODE = "streamable-http" -DEFAULT_SENTRY_URL = "https://sentry.io" def _configure_sentry_mcp() -> tuple[str, str]: @@ -113,56 +111,13 @@ def _configure_sentry_mcp() -> tuple[str, str]: def _configure_sentry() -> tuple[str, str]: - _, credentials = _integration_defaults("sentry") guidance = get_sentry_auth_recommendations() - _console.print( - f"[{SECONDARY}]Recommended: " - f"{guidance['recommended_token_type']} from {guidance['where_to_create']}. " - f"{guidance['fallback_token_type']} only if you need broader scopes.[/]" + return configure_from_spec( + SENTRY_SETUP, + title="Sentry", + intro=( + f"[{SECONDARY}]Recommended: " + f"{guidance['recommended_token_type']} from {guidance['where_to_create']}. " + f"{guidance['fallback_token_type']} only if you need broader scopes.[/]" + ), ) - - while True: - base_url = _prompt_value( - "Sentry base URL", - default=_string_value(credentials.get("base_url"), DEFAULT_SENTRY_URL), - ) - organization_slug = _prompt_value( - "Sentry organization slug", - default=_string_value(credentials.get("organization_slug")), - ) - project_slug = _prompt_value( - "Sentry project slug (optional)", - default=_string_value(credentials.get("project_slug")), - allow_empty=True, - ) - auth_token = _prompt_value( - "Sentry auth token", - default=_string_value(credentials.get("auth_token")), - secret=True, - ) - - with _console.status("Validating Sentry integration...", spinner="dots"): - result = validate_sentry_integration( - base_url=base_url, - organization_slug=organization_slug, - auth_token=auth_token, - project_slug=project_slug, - ) - _render_integration_result("Sentry", result) - if result.ok: - credentials = { - "base_url": base_url, - "organization_slug": organization_slug, - "auth_token": auth_token, - "project_slug": project_slug, - } - upsert_integration("sentry", {"credentials": credentials}) - env_path = sync_env_values( - { - "SENTRY_URL": base_url, - "SENTRY_ORG_SLUG": organization_slug, - "SENTRY_PROJECT_SLUG": project_slug, - } - ) - return "Sentry", str(env_path) - _console.print(f"[{SECONDARY}]Try again or press Ctrl+C to cancel.[/]") diff --git a/surfaces/cli/wizard/configurators/vercel.py b/surfaces/cli/wizard/configurators/vercel.py index 659e7c6080..4b7a4bddaf 100644 --- a/surfaces/cli/wizard/configurators/vercel.py +++ b/surfaces/cli/wizard/configurators/vercel.py @@ -2,40 +2,9 @@ from __future__ import annotations -from config.env_file import sync_env_values -from integrations.store import upsert_integration -from platform.terminal.theme import SECONDARY -from surfaces.cli.wizard._ui import ( - _console, - _integration_defaults, - _prompt_value, - _render_integration_result, - _string_value, -) -from surfaces.cli.wizard.integration_health import validate_vercel_integration +from integrations.vercel.setup import VERCEL_SETUP +from surfaces.cli.wizard.configurators.spec_configurator import configure_from_spec def _configure_vercel() -> tuple[str, str]: - _, credentials = _integration_defaults("vercel") - while True: - api_token = _prompt_value( - "Vercel API token (Account Settings > Tokens)", - default=_string_value(credentials.get("api_token")), - secret=True, - ) - team_id = _prompt_value( - "Vercel team ID (optional, for team-scoped access)", - default=_string_value(credentials.get("team_id")), - allow_empty=True, - ) - with _console.status("Validating Vercel integration...", spinner="dots"): - result = validate_vercel_integration(api_token=api_token, team_id=team_id) - _render_integration_result("Vercel", result) - if result.ok: - upsert_integration( - "vercel", - {"credentials": {"api_token": api_token, "team_id": team_id}}, - ) - env_path = sync_env_values({}) - return "Vercel", str(env_path) - _console.print(f"[{SECONDARY}]Try again or press Ctrl+C to cancel.[/]") + return configure_from_spec(VERCEL_SETUP, title="Vercel") diff --git a/surfaces/cli/wizard/integration_health.py b/surfaces/cli/wizard/integration_health.py index 2d67c3b8b1..e4244fab69 100644 --- a/surfaces/cli/wizard/integration_health.py +++ b/surfaces/cli/wizard/integration_health.py @@ -11,7 +11,6 @@ ) from surfaces.cli.wizard.integration_validators.aws import validate_aws_integration from surfaces.cli.wizard.integration_validators.dagster import validate_dagster_integration -from surfaces.cli.wizard.integration_validators.gitlab import validate_gitlab_integration from surfaces.cli.wizard.integration_validators.http_probe_validators import ( validate_discord_bot, validate_jira_integration, @@ -34,13 +33,10 @@ validate_splunk_integration, validate_tempo_integration, ) -from surfaces.cli.wizard.integration_validators.posthog import validate_posthog_integration from surfaces.cli.wizard.integration_validators.productivity import ( validate_google_docs_integration, ) -from surfaces.cli.wizard.integration_validators.sentry import validate_sentry_integration from surfaces.cli.wizard.integration_validators.shared import IntegrationHealthResult -from surfaces.cli.wizard.integration_validators.vercel import validate_vercel_integration __all__ = [ "IntegrationHealthResult", @@ -50,7 +46,6 @@ "validate_dagster_integration", "validate_discord_bot", "validate_github_mcp_integration", - "validate_gitlab_integration", "validate_google_docs_integration", "validate_grafana_integration", "validate_incident_io_integration", @@ -62,14 +57,11 @@ "validate_posthog_mcp_integration", "validate_opsgenie_integration", "validate_pagerduty_integration", - "validate_posthog_integration", "validate_rocketchat", "validate_rocketchat_webhook", - "validate_sentry_integration", "validate_sentry_mcp_integration", "validate_servicenow_integration", "validate_slack_webhook", "validate_splunk_integration", "validate_tempo_integration", - "validate_vercel_integration", ] diff --git a/surfaces/cli/wizard/integration_validators/gitlab.py b/surfaces/cli/wizard/integration_validators/gitlab.py deleted file mode 100644 index 6b0846383a..0000000000 --- a/surfaces/cli/wizard/integration_validators/gitlab.py +++ /dev/null @@ -1,18 +0,0 @@ -"""Client-backed validator for the GitLab integration.""" - -from __future__ import annotations - -from integrations.gitlab import build_gitlab_config, validate_gitlab_config - -from .shared import IntegrationHealthResult - - -def validate_gitlab_integration( - *, - base_url: str, - auth_token: str, -) -> IntegrationHealthResult: - """Validate Gitlab connectivity with an users api.""" - config = build_gitlab_config({"base_url": base_url, "auth_token": auth_token}) - result = validate_gitlab_config(config) - return IntegrationHealthResult(ok=result.ok, detail=result.detail) diff --git a/surfaces/cli/wizard/integration_validators/posthog.py b/surfaces/cli/wizard/integration_validators/posthog.py deleted file mode 100644 index bcf5dd3f0d..0000000000 --- a/surfaces/cli/wizard/integration_validators/posthog.py +++ /dev/null @@ -1,26 +0,0 @@ -"""Client-backed validator for the PostHog integration.""" - -from __future__ import annotations - -from integrations.posthog.config import build_posthog_config -from integrations.posthog.verifier import validate_posthog_config - -from .shared import IntegrationHealthResult - - -def validate_posthog_integration( - *, - base_url: str, - project_id: str, - personal_api_key: str, -) -> IntegrationHealthResult: - """Validate PostHog REST connectivity with a project metadata probe.""" - config = build_posthog_config( - { - "base_url": base_url, - "project_id": project_id, - "personal_api_key": personal_api_key, - } - ) - result = validate_posthog_config(config) - return IntegrationHealthResult(ok=result.ok, detail=result.detail) diff --git a/surfaces/cli/wizard/integration_validators/sentry.py b/surfaces/cli/wizard/integration_validators/sentry.py deleted file mode 100644 index 6b37f66d0e..0000000000 --- a/surfaces/cli/wizard/integration_validators/sentry.py +++ /dev/null @@ -1,27 +0,0 @@ -"""Client-backed validator for the Sentry integration.""" - -from __future__ import annotations - -from integrations.sentry import build_sentry_config, validate_sentry_config - -from .shared import IntegrationHealthResult - - -def validate_sentry_integration( - *, - base_url: str, - organization_slug: str, - auth_token: str, - project_slug: str = "", -) -> IntegrationHealthResult: - """Validate Sentry connectivity with an organization issues query.""" - config = build_sentry_config( - { - "base_url": base_url, - "organization_slug": organization_slug, - "auth_token": auth_token, - "project_slug": project_slug, - } - ) - result = validate_sentry_config(config) - return IntegrationHealthResult(ok=result.ok, detail=result.detail) diff --git a/surfaces/cli/wizard/integration_validators/vercel.py b/surfaces/cli/wizard/integration_validators/vercel.py deleted file mode 100644 index e7f39b2cd3..0000000000 --- a/surfaces/cli/wizard/integration_validators/vercel.py +++ /dev/null @@ -1,27 +0,0 @@ -"""Client-backed validator for the Vercel integration.""" - -from __future__ import annotations - -from integrations.vercel.client import VercelClient, VercelConfig - -from .shared import IntegrationHealthResult - - -def validate_vercel_integration(*, api_token: str, team_id: str = "") -> IntegrationHealthResult: - """Validate Vercel credentials by listing accessible projects.""" - if not api_token: - return IntegrationHealthResult(ok=False, detail="Vercel API token is required.") - try: - with VercelClient(VercelConfig(api_token=api_token, team_id=team_id)) as client: - result = client.list_projects() - if result.get("success"): - return IntegrationHealthResult( - ok=True, - detail=f"Vercel validated; listed {result.get('total', 0)} project(s).", - ) - return IntegrationHealthResult( - ok=False, - detail=f"Vercel validation failed: {result.get('error', 'unknown error')}", - ) - except Exception as err: - return IntegrationHealthResult(ok=False, detail=f"Vercel validation failed: {err}") diff --git a/tests/cli/test_integrations.py b/tests/cli/test_integrations.py index 9dc846db84..ae381f9597 100644 --- a/tests/cli/test_integrations.py +++ b/tests/cli/test_integrations.py @@ -10,7 +10,6 @@ _setup_openclaw, _setup_servicenow, _setup_smtp, - _setup_vercel, ) from surfaces.cli.__main__ import cli from surfaces.cli.constants import SETUP_SERVICES, VERIFY_SERVICES @@ -94,30 +93,6 @@ def test_integrations_setup_accepts_openclaw() -> None: mock_capture.assert_not_called() -def test_setup_vercel_saves_credentials(monkeypatch) -> None: - answers = iter(["vcp_test_token", "team_123"]) - - def fake_p(_label: str, default: str = "", secret: bool = False) -> str: - return next(answers) - - saved: list[tuple[str, dict[str, object]]] = [] - monkeypatch.setattr("integrations.cli._p", fake_p) - monkeypatch.setattr( - "integrations.cli.upsert_integration", - lambda service, entry: saved.append((service, entry)), - ) - - _setup_vercel() - - assert _HANDLERS["vercel"] is _setup_vercel - assert saved == [ - ( - "vercel", - {"credentials": {"api_token": "vcp_test_token", "team_id": "team_123"}}, - ) - ] - - def test_setup_openclaw_saves_credentials(monkeypatch) -> None: answers = iter(["openclaw", "mcp serve"]) diff --git a/tests/cli/wizard/test_flow.py b/tests/cli/wizard/test_flow.py index 2705ff418a..4b215ce901 100644 --- a/tests/cli/wizard/test_flow.py +++ b/tests/cli/wizard/test_flow.py @@ -1718,8 +1718,11 @@ def _mock_text(*_args, **_kwargs): monkeypatch.setattr(flow, "probe_local_target", lambda _path: ProbeResult("local", True, "ok")) monkeypatch.setattr( _gitlab_configurator, - "validate_gitlab_integration", - lambda **_kwargs: flow.IntegrationHealthResult(ok=True, detail="GitLab ok"), + "GITLAB_SETUP", + dataclasses.replace( + _gitlab_configurator.GITLAB_SETUP, + verify=lambda _source, _config: {"status": "passed", "detail": "GitLab ok"}, + ), ) monkeypatch.setattr(flow, "save_local_config", lambda **_kwargs: tmp_path / "opensre.json") monkeypatch.setattr(flow, "sync_provider_env", lambda **_kwargs: tmp_path / ".env") @@ -1732,10 +1735,10 @@ def _sync_env_values(values: dict[str, str], **_kwargs): def _sync_env_secret(key: str, value: str) -> None: synced_env_secrets.append((key, value)) - monkeypatch.setattr(_gitlab_configurator, "sync_env_values", _sync_env_values) - monkeypatch.setattr(_gitlab_configurator, "sync_env_secret", _sync_env_secret) + monkeypatch.setattr(_setup_flow, "sync_env_values", _sync_env_values) + monkeypatch.setattr(_setup_flow, "sync_env_secret", _sync_env_secret) monkeypatch.setattr( - _gitlab_configurator, + _setup_flow, "upsert_integration", lambda service, payload: saved_integrations.append((service, payload)), ) @@ -1794,19 +1797,23 @@ def _mock_text(*_args, **_kwargs): m.ask.return_value = next(text_responses) return m - def _validate_gitlab(**_kwargs): + def _verify_gitlab(_source, _config): nonlocal validation_call_count validation_call_count += 1 if validation_call_count == 1: - return flow.IntegrationHealthResult(ok=False, detail="Unauthorized") - return flow.IntegrationHealthResult(ok=True, detail="GitLab ok") + return {"status": "failed", "detail": "Unauthorized"} + return {"status": "passed", "detail": "GitLab ok"} monkeypatch.setattr(_ui, "select_prompt", _mock_select) monkeypatch.setattr(flow.questionary, "password", _mock_password) monkeypatch.setattr(flow.questionary, "text", _mock_text) monkeypatch.setattr(_ui, "get_store_path", lambda: tmp_path / "opensre.json") monkeypatch.setattr(flow, "probe_local_target", lambda _path: ProbeResult("local", True, "ok")) - monkeypatch.setattr(_gitlab_configurator, "validate_gitlab_integration", _validate_gitlab) + monkeypatch.setattr( + _gitlab_configurator, + "GITLAB_SETUP", + dataclasses.replace(_gitlab_configurator.GITLAB_SETUP, verify=_verify_gitlab), + ) monkeypatch.setattr(flow, "save_local_config", lambda **_kwargs: tmp_path / "opensre.json") monkeypatch.setattr(flow, "sync_provider_env", lambda **_kwargs: tmp_path / ".env") monkeypatch.setattr(_ui, "save_keyring_secret", lambda *_args, **_kwargs: None) @@ -1818,10 +1825,10 @@ def _sync_env_values(values: dict[str, str], **_kwargs): def _sync_env_secret(key: str, value: str) -> None: synced_env_secrets.append((key, value)) - monkeypatch.setattr(_gitlab_configurator, "sync_env_values", _sync_env_values) - monkeypatch.setattr(_gitlab_configurator, "sync_env_secret", _sync_env_secret) + monkeypatch.setattr(_setup_flow, "sync_env_values", _sync_env_values) + monkeypatch.setattr(_setup_flow, "sync_env_secret", _sync_env_secret) monkeypatch.setattr( - _gitlab_configurator, + _setup_flow, "upsert_integration", lambda service, payload: saved_integrations.append((service, payload)), ) diff --git a/tests/cli/wizard/test_integration_health.py b/tests/cli/wizard/test_integration_health.py index 48f4843569..e5f4fe6d6b 100644 --- a/tests/cli/wizard/test_integration_health.py +++ b/tests/cli/wizard/test_integration_health.py @@ -17,10 +17,8 @@ validate_github_mcp_integration, validate_grafana_integration, validate_incident_io_integration, - validate_sentry_integration, validate_servicenow_integration, validate_slack_webhook, - validate_vercel_integration, ) @@ -35,7 +33,6 @@ def test_legacy_integration_health_import_surface_still_exports_validators() -> "validate_dagster_integration", "validate_discord_bot", "validate_github_mcp_integration", - "validate_gitlab_integration", "validate_google_docs_integration", "validate_grafana_integration", "validate_incident_io_integration", @@ -45,18 +42,15 @@ def test_legacy_integration_health_import_surface_still_exports_validators() -> "validate_openclaw_integration", "validate_opensearch_integration", "validate_opsgenie_integration", - "validate_posthog_integration", "validate_posthog_mcp_integration", "validate_pagerduty_integration", "validate_rocketchat", "validate_rocketchat_webhook", - "validate_sentry_integration", "validate_sentry_mcp_integration", "validate_servicenow_integration", "validate_slack_webhook", "validate_splunk_integration", "validate_tempo_integration", - "validate_vercel_integration", } assert set(module.__all__) == expected_exports @@ -366,23 +360,6 @@ def test_validate_github_mcp_integration_uses_shared_validator(monkeypatch) -> N assert result.github_mcp.authenticated_user == "ghuser" -def test_validate_sentry_integration_uses_shared_validator(monkeypatch) -> None: - monkeypatch.setattr( - "surfaces.cli.wizard.integration_validators.sentry.validate_sentry_config", - lambda _config: types.SimpleNamespace(ok=True, detail="Sentry ok"), - ) - - result = validate_sentry_integration( - base_url="https://sentry.io", - organization_slug="demo-org", - auth_token="sntrys_test", - project_slug="payments", - ) - - assert result.ok is True - assert result.detail == "Sentry ok" - - def test_validate_dagster_integration_uses_shared_validator(monkeypatch) -> None: monkeypatch.setattr( "surfaces.cli.wizard.integration_validators.dagster.validate_dagster_config", @@ -431,80 +408,6 @@ def list_projects(self) -> dict: return self._result -def test_validate_vercel_integration_succeeds(monkeypatch) -> None: - monkeypatch.setattr( - "surfaces.cli.wizard.integration_validators.vercel.VercelClient", - lambda _config: _FakeVercelClient( - {"success": True, "projects": [{"id": "p1"}], "total": 1} - ), - ) - - result = validate_vercel_integration(api_token="tok_test") - - assert result.ok is True - assert "1 project" in result.detail - - -def test_validate_vercel_integration_succeeds_with_team_id(monkeypatch) -> None: - captured: dict = {} - - class _CapturingClient: - def __init__(self, config) -> None: - captured["team_id"] = config.team_id - - def __enter__(self) -> _CapturingClient: - return self - - def __exit__(self, *_: object) -> None: - pass - - def list_projects(self) -> dict: - return {"success": True, "projects": [], "total": 0} - - monkeypatch.setattr( - "surfaces.cli.wizard.integration_validators.vercel.VercelClient", - _CapturingClient, - ) - - result = validate_vercel_integration(api_token="tok_test", team_id="team_xyz") - - assert result.ok is True - assert captured["team_id"] == "team_xyz" - - -def test_validate_vercel_integration_fails_on_api_error(monkeypatch) -> None: - monkeypatch.setattr( - "surfaces.cli.wizard.integration_validators.vercel.VercelClient", - lambda _config: _FakeVercelClient({"success": False, "error": "HTTP 401: unauthorized"}), - ) - - result = validate_vercel_integration(api_token="bad_token") - - assert result.ok is False - assert "401" in result.detail - - -def test_validate_vercel_integration_fails_with_empty_token() -> None: - result = validate_vercel_integration(api_token="") - - assert result.ok is False - assert "required" in result.detail.lower() - - -def test_validate_vercel_integration_surfaces_exception(monkeypatch) -> None: - def _raise(_config): - raise RuntimeError("network unreachable") - - monkeypatch.setattr( - "surfaces.cli.wizard.integration_validators.vercel.VercelClient", - _raise, - ) - - result = validate_vercel_integration(api_token="tok_test") - - assert result.ok is False - - # --------------------------------------------------------------------------- # validate_discord_bot # --------------------------------------------------------------------------- diff --git a/tests/integrations/test_cli_spec_setup.py b/tests/integrations/test_cli_spec_setup.py index 7537c59529..6ba894d4d4 100644 --- a/tests/integrations/test_cli_spec_setup.py +++ b/tests/integrations/test_cli_spec_setup.py @@ -28,8 +28,13 @@ import integrations.cli as cli import integrations.coralogix.setup as coralogix_setup import integrations.datadog.setup as datadog_setup +import integrations.gitlab.setup as gitlab_setup +import integrations.groundcover.setup as groundcover_setup import integrations.honeycomb.setup as honeycomb_setup +import integrations.posthog.setup as posthog_setup +import integrations.sentry.setup as sentry_setup import integrations.setup_flow as setup_flow +import integrations.vercel.setup as vercel_setup _ANSWERS: dict[str, dict[str, str]] = { "datadog": {"api_key": "dd-api-key", "app_key": "dd-app-key", "site": "datadoghq.eu"}, @@ -44,6 +49,29 @@ "application_name": "checkout", "subsystem_name": "api", }, + "groundcover": { + "api_key": "gc-api-key", + "mcp_url": "https://mcp.eu.groundcover.com/api/mcp", + "tenant_uuid": "11111111-2222-3333-4444-555555555555", + "backend_id": "gc-backend-7", + "timezone": "Europe/Berlin", + }, + "gitlab": { + "base_url": "https://gitlab.example.com/api/v4", + "auth_token": "glpat-gitlab-token", + }, + "sentry": { + "base_url": "https://sentry.example.com", + "organization_slug": "checkout-org", + "auth_token": "sntrys-sentry-token", + "project_slug": "checkout-api", + }, + "posthog": { + "base_url": "https://eu.i.posthog.com", + "project_id": "40182", + "personal_api_key": "phx-posthog-key", + }, + "vercel": {"api_token": "vercel-api-token", "team_id": "team_abc123"}, } # (spec module, spec attribute, CLI handler) — the attribute is patched rather @@ -52,6 +80,11 @@ pytest.param(datadog_setup, "DATADOG_SETUP", cli._setup_datadog, id="datadog"), pytest.param(honeycomb_setup, "HONEYCOMB_SETUP", cli._setup_honeycomb, id="honeycomb"), pytest.param(coralogix_setup, "CORALOGIX_SETUP", cli._setup_coralogix, id="coralogix"), + pytest.param(groundcover_setup, "GROUNDCOVER_SETUP", cli._setup_groundcover, id="groundcover"), + pytest.param(gitlab_setup, "GITLAB_SETUP", cli._setup_gitlab, id="gitlab"), + pytest.param(sentry_setup, "SENTRY_SETUP", cli._setup_sentry, id="sentry"), + pytest.param(posthog_setup, "POSTHOG_SETUP", cli._setup_posthog, id="posthog"), + pytest.param(vercel_setup, "VERCEL_SETUP", cli._setup_vercel, id="vercel"), ] @@ -187,3 +220,10 @@ def test_blank_required_field_exits_before_the_next_prompt( assert len(run.asked) == 1 + [f.name for f in spec.fields].index(first_required.name) assert (run.verified, run.store) == ([], []) + + +@pytest.mark.parametrize(("module", "attr", "handler"), _CASES) +def test_handler_is_registered_for_the_service(module: Any, attr: str, handler: Any) -> None: + """The dispatch entry is what makes `integrations setup ` reachable.""" + spec = getattr(module, attr) + assert cli._HANDLERS[spec.service] is handler diff --git a/tests/integrations/test_setup_spec_env_round_trip.py b/tests/integrations/test_setup_spec_env_round_trip.py index 7b3b66cac5..296ce5b7ce 100644 --- a/tests/integrations/test_setup_spec_env_round_trip.py +++ b/tests/integrations/test_setup_spec_env_round_trip.py @@ -29,8 +29,13 @@ from integrations._catalog_impl import load_env_integrations from integrations.coralogix.setup import CORALOGIX_SETUP from integrations.datadog.setup import DATADOG_SETUP +from integrations.gitlab.setup import GITLAB_SETUP +from integrations.groundcover.setup import GROUNDCOVER_SETUP from integrations.honeycomb.setup import HONEYCOMB_SETUP +from integrations.posthog.setup import POSTHOG_SETUP +from integrations.sentry.setup import SENTRY_SETUP from integrations.telegram.setup import TELEGRAM_SETUP +from integrations.vercel.setup import VERCEL_SETUP # A distinct, recognizable value per field, so two fields of the same # integration swapping places fails instead of coincidentally matching. Values @@ -49,10 +54,43 @@ "application_name": "checkout", "subsystem_name": "api", }, + "groundcover": { + "api_key": "gc-api-key", + "mcp_url": "https://mcp.eu.groundcover.com/api/mcp", + "tenant_uuid": "11111111-2222-3333-4444-555555555555", + "backend_id": "gc-backend-7", + "timezone": "Europe/Berlin", + }, + "gitlab": { + "base_url": "https://gitlab.example.com/api/v4", + "auth_token": "glpat-gitlab-token", + }, + "sentry": { + "base_url": "https://sentry.example.com", + "organization_slug": "checkout-org", + "auth_token": "sntrys-sentry-token", + "project_slug": "checkout-api", + }, + "posthog": { + "base_url": "https://eu.i.posthog.com", + "project_id": "40182", + "personal_api_key": "phx-posthog-key", + }, + "vercel": {"api_token": "vercel-api-token", "team_id": "team_abc123"}, "telegram": {"bot_token": "123456:tg-bot-token", "default_chat_id": "-1001234567890"}, } -_SPECS = [DATADOG_SETUP, HONEYCOMB_SETUP, CORALOGIX_SETUP, TELEGRAM_SETUP] +_SPECS = [ + CORALOGIX_SETUP, + DATADOG_SETUP, + GITLAB_SETUP, + GROUNDCOVER_SETUP, + HONEYCOMB_SETUP, + POSTHOG_SETUP, + SENTRY_SETUP, + TELEGRAM_SETUP, + VERCEL_SETUP, +] @dataclasses.dataclass diff --git a/tests/integrations/vercel/test_client.py b/tests/integrations/vercel/test_client.py index 49590be57c..b1f5806a7e 100644 --- a/tests/integrations/vercel/test_client.py +++ b/tests/integrations/vercel/test_client.py @@ -103,6 +103,29 @@ def test_probe_access_success(monkeypatch: pytest.MonkeyPatch) -> None: assert "1 project" in result.detail +def test_probe_access_reports_the_api_error(monkeypatch: pytest.MonkeyPatch) -> None: + """Setup refuses to persist on a failed probe, so the reason has to survive.""" + monkeypatch.setattr( + VercelClient, + "list_projects", + lambda _self: {"success": False, "error": "HTTP 403: forbidden"}, + ) + + result = _client().probe_access() + + assert result.status == "failed" + assert "403" in result.detail + + +def test_probe_access_without_a_token_reports_missing_not_failed( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A blank token is an unconfigured integration, not a rejected credential.""" + result = VercelClient(VercelConfig(api_token="")).probe_access() + + assert result.status == "missing" + + def test_list_projects_success(monkeypatch: pytest.MonkeyPatch) -> None: payload = { "projects": [ From 2d853b0e88fed39b8fa60917085a3b4fbe0ec5ff Mon Sep 17 00:00:00 2001 From: muddlebee Date: Wed, 22 Jul 2026 16:41:16 +0530 Subject: [PATCH 2/2] style(config): annotate the new env-name constants as Final MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit config/constants/sentry.py and posthog.py annotate every constant they hold, including DEFAULT_SENTRY_BASE_URL added in the same change; the new *_ENV names did not, which reads as though they are variables. The standalone per-vendor env modules (telegram, datadog, gitlab, …) use no Final and stay as they are — they are consistent with each other and with the telegram precedent. Only the two files that mix styles change. --- config/constants/posthog.py | 8 ++++---- config/constants/sentry.py | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/config/constants/posthog.py b/config/constants/posthog.py index 7a99643e7b..154991b2b3 100644 --- a/config/constants/posthog.py +++ b/config/constants/posthog.py @@ -17,7 +17,7 @@ DEFAULT_POSTHOG_TIMEOUT_SECONDS: Final[float] = 15.0 # --- The user's PostHog integration --------------------------------------- -POSTHOG_BASE_URL_ENV = "POSTHOG_BASE_URL" -POSTHOG_PROJECT_ID_ENV = "POSTHOG_PROJECT_ID" -POSTHOG_PERSONAL_API_KEY_ENV = "POSTHOG_PERSONAL_API_KEY" -POSTHOG_TIMEOUT_SECONDS_ENV = "POSTHOG_TIMEOUT_SECONDS" +POSTHOG_BASE_URL_ENV: Final[str] = "POSTHOG_BASE_URL" +POSTHOG_PROJECT_ID_ENV: Final[str] = "POSTHOG_PROJECT_ID" +POSTHOG_PERSONAL_API_KEY_ENV: Final[str] = "POSTHOG_PERSONAL_API_KEY" +POSTHOG_TIMEOUT_SECONDS_ENV: Final[str] = "POSTHOG_TIMEOUT_SECONDS" diff --git a/config/constants/sentry.py b/config/constants/sentry.py index c55c2636ad..77c2cbf288 100644 --- a/config/constants/sentry.py +++ b/config/constants/sentry.py @@ -22,8 +22,8 @@ # --- The user's Sentry integration --------------------------------------- # Mirror the ``base_url`` and ``organization_slug`` credentials; the names # deliberately differ. -SENTRY_BASE_URL_ENV = "SENTRY_URL" -SENTRY_ORGANIZATION_SLUG_ENV = "SENTRY_ORG_SLUG" -SENTRY_AUTH_TOKEN_ENV = "SENTRY_AUTH_TOKEN" -SENTRY_PROJECT_SLUG_ENV = "SENTRY_PROJECT_SLUG" +SENTRY_BASE_URL_ENV: Final[str] = "SENTRY_URL" +SENTRY_ORGANIZATION_SLUG_ENV: Final[str] = "SENTRY_ORG_SLUG" +SENTRY_AUTH_TOKEN_ENV: Final[str] = "SENTRY_AUTH_TOKEN" +SENTRY_PROJECT_SLUG_ENV: Final[str] = "SENTRY_PROJECT_SLUG" DEFAULT_SENTRY_BASE_URL: Final[str] = "https://sentry.io"