airbyte_ops_mcp.cli.local

CLI commands for local Airbyte monorepo operations.

Commands:

airbyte-ops local connector list - List connectors in the monorepo airbyte-ops local connector info - Get metadata for a single connector airbyte-ops local connector get-version - Get connector version (current or next) airbyte-ops local connector bump-version - Bump connector version airbyte-ops local connector qa - Run QA checks on a connector airbyte-ops local connector qa-docs-generate - Generate QA checks documentation airbyte-ops local connector changelog check - Check changelog entries for issues airbyte-ops local connector changelog fix - Fix changelog entry dates airbyte-ops local connector bump-deps - Update Poetry-managed dependencies airbyte-ops local connector marketing-stub check - Validate marketing stub entries airbyte-ops local connector marketing-stub sync - Sync stub from connector metadata airbyte-ops local connector release-block add - Add a connector release block marker airbyte-ops local connector release-block clear - Clear a connector release block marker airbyte-ops local connector release-block list - List connector release block markers

CLI reference

The commands below are regenerated by poe docs-generate via cyclopts's programmatic docs API; see docs/generate_cli.py.

airbyte-ops local COMMAND

Local Airbyte monorepo operations.

Commands:

  • connector: Connector operations in the monorepo.

airbyte-ops local connector

Connector operations in the monorepo.

airbyte-ops local connector list

airbyte-ops local connector list REPO-PATH [ARGS]

List connectors in the Airbyte monorepo with filtering options.

Parameters:

  • REPO-PATH, --repo-path: Absolute path to the Airbyte monorepo. [required]
  • CERTIFIED-ONLY, --certified-only, --no-certified-only: Include only certified connectors. [default: False]
  • MODIFIED-ONLY, --modified-only, --no-modified-only: Include only modified connectors (requires PR context). [default: False]
  • LOCAL-CDK, --local-cdk, --no-local-cdk: Include connectors using local CDK reference. When combined with --modified-only, adds local-CDK connectors to the modified set. [default: False]
  • LANGUAGE, --language, --empty-language: Languages to include (python, java, low-code, manifest-only).
  • EXCLUDE-LANGUAGE, --exclude-language, --empty-exclude-language: Languages to exclude.
  • CONNECTOR-TYPE, --connector-type: Connector types to include (source, destination). Accepts CSV or newline-delimited values.
  • MIN-SUPPORT-LEVEL, --min-support-level: Minimum support level (inclusive). Accepts integer (100, 200, 300) or keyword (archived, community, certified).
  • MAX-SUPPORT-LEVEL, --max-support-level: Maximum support level (inclusive). Accepts integer (100, 200, 300) or keyword (archived, community, certified).
  • PR, --pr: PR number or GitHub URL for modification detection.
  • GH-TOKEN, --gh-token: GitHub API token. When provided together with --pr, the GitHub API is used to detect modified files instead of local git diff (avoids shallow-clone issues).
  • EXCLUDE-CONNECTORS, --exclude-connectors, --empty-exclude-connectors: Connectors to exclude from results. Accepts CSV or newline-delimited values. Can be specified multiple times.
  • FORCE-INCLUDE-CONNECTORS, --force-include-connectors, --empty-force-include-connectors: Connectors to force-include regardless of other filters. Accepts CSV or newline-delimited values. Can be specified multiple times.
  • CONNECTORS-FILTER, --connectors-filter, --empty-connectors-filter: Intersect results with this explicit set of connector names. Only connectors present in both the filtered results and this set are returned. Useful for composing multiple filter passes (e.g. combining separate source and destination lists). Accepts CSV or newline-delimited values. Can be specified multiple times.
  • OUTPUT-FORMAT, --output-format: Output format: "csv" (comma-separated), "lines" (one connector per line), "json-gh-matrix" (GitHub Actions matrix JSON). [choices: csv, lines, json-gh-matrix] [default: lines]
  • UNPUBLISHED, --unpublished, --no-unpublished: Filter to only connectors whose local dockerImageTag has not been published to the GCS registry. Requires --store. [default: False]
  • STORE, --store: Store target for unpublished check (e.g. 'coral:prod', 'coral:dev'). Required when --unpublished is set.
  • ASSERT-NONE, --assert-none, --no-assert-none: Exit with non-zero status if any connectors match the filters. Useful for audit/CI checks (e.g. --unpublished --assert-none). [default: False]
  • SORT-BY, --sort-by: Sort connectors by the given key. Only 'name' is supported for now. [choices: name] [default: name]
  • SORT-DIRECTION, --sort-direction: Sort direction: 'asc' (ascending, default) or 'desc' (descending). [choices: asc, desc] [default: asc]
  • LIMIT, --limit: Maximum number of connectors to return. Applied after sorting. Useful for batched processing.
  • OFFSET, --offset: Number of connectors to skip from the start of the (sorted) list. Applied after sorting, before --limit. Useful for batched processing (e.g. --offset=200 --limit=200 for batch 2). [default: 0]

airbyte-ops local connector info

airbyte-ops local connector info CONNECTOR-NAME [ARGS]

Get metadata for a single connector.

Prints JSON output with connector metadata. When running in GitHub Actions (CI env var set), also writes each field to GitHub step outputs.

Parameters:

  • CONNECTOR-NAME, --connector-name: Name of the connector (e.g., source-github). [required]
  • REPO-PATH, --repo-path: Path to the Airbyte monorepo. Can be inferred from context.
  • DPATH, --dpath: Evaluate this dpath expression against the parsed metadata.yaml object and print only that value (e.g., data/dockerImageTag).

airbyte-ops local connector get-version

airbyte-ops local connector get-version NAME REPO-PATH [ARGS]

Get the current or next version for a connector.

By default, prints the current version from metadata.yaml. This is analogous to local connector info --dpath data/dockerImageTag and uses the same dpath evaluation internally.

With --next, computes and prints the next version. Requires either --bump-type or --prerelease to be specified.

Parameters:

  • NAME, --name: Connector technical name (e.g., source-github). [required]
  • REPO-PATH, --repo-path: Absolute path to the Airbyte monorepo. [required]
  • NEXT, --next, --no-next: Compute the next version instead of the current version. [default: False]
  • BUMP-TYPE, --bump-type: Version bump type (requires --next). Standard: patch, minor, major. RC: patch_rc, minor_rc, major_rc, rc, promote. [choices: patch, minor, major, patch_rc, minor_rc, major_rc, rc, promote]
  • PRERELEASE, --prerelease, --no-prerelease: Compute a prerelease (preview) tag using the repo HEAD SHA (requires --next). [default: False]

airbyte-ops local connector bump-version

airbyte-ops local connector bump-version NAME REPO-PATH [ARGS]

Bump a connector's version across all relevant files.

Updates version in metadata.yaml (always), pyproject.toml (if exists), and documentation changelog (if --changelog-message provided).

Note: --changelog-message is ignored when --no-changelog is set.

Either --bump-type or --new-version must be provided.

Parameters:

  • NAME, --name: Connector technical name (e.g., source-github). [required]
  • REPO-PATH, --repo-path: Absolute path to the Airbyte monorepo. [required]
  • BUMP-TYPE, --bump-type: Version bump type. Standard: patch, minor, major. RC: patch_rc, minor_rc, major_rc, rc, promote. [choices: patch, minor, major, patch_rc, minor_rc, major_rc, rc, promote]
  • NEW-VERSION, --new-version: Explicit new version (overrides --bump-type if provided).
  • CHANGELOG-MESSAGE, --changelog-message: Message to add to changelog. Ignored if --no-changelog is set.
  • PR-NUMBER, --pr-number: PR number for changelog entry.
  • DRY-RUN, --dry-run, --no-dry-run: Show what would be changed without modifying files. [default: False]
  • NO-CHANGELOG, --no-changelog, --no-no-changelog: Skip changelog updates even if --changelog-message is provided. Useful for ephemeral version bumps (e.g. pre-release artifact generation). [default: False]
  • PROGRESSIVE-ROLLOUT-ENABLED, --progressive-rollout-enabled, --no-progressive-rollout-enabled: Explicitly set enableProgressiveRollout in metadata.yaml. Pass false to disable progressive rollout (e.g. for preview builds). When omitted, the automatic behaviour based on --bump-type is used.

airbyte-ops local connector bump-base-image

airbyte-ops local connector bump-base-image NAME REPO-PATH [ARGS]

Update a connector's base image.

Two modes:

  • Default: bump to the latest stable tag within the same major version. Major version changes are treated as breaking-change boundaries.
  • --force-latest: bump to the absolute latest stable tag regardless of semver.

Parameters:

  • NAME, --name: Connector technical name (e.g., source-github). [required]
  • REPO-PATH, --repo-path: Absolute path to the Airbyte monorepo. [required]
  • FORCE-LATEST, --force-latest, --no-force-latest: Bump to the absolute latest stable base image, ignoring major-version boundaries. Without this flag the bump stays within the current major version. [default: False]
  • DRY-RUN, --dry-run, --no-dry-run: Show what would be changed without modifying files. [default: False]

airbyte-ops local connector bump-cdk

airbyte-ops local connector bump-cdk NAME REPO-PATH [ARGS]

Bump a connector's CDK dependency.

Two modes:

  • Default: refresh the lock file so it resolves the newest CDK that satisfies the existing constraint. The constraint is NOT changed.
  • --force-latest: rewrite the constraint to >=LATEST,

For Java connectors, updates build.gradle to the latest CDK version.

Parameters:

  • NAME, --name: Connector technical name (e.g., source-github). [required]
  • REPO-PATH, --repo-path: Absolute path to the Airbyte monorepo. [required]
  • FORCE-LATEST, --force-latest, --no-force-latest: Rewrite the CDK constraint to >=LATEST,[default: False]
  • DRY-RUN, --dry-run, --no-dry-run: Show what would be changed without modifying files. [default: False]

airbyte-ops local connector bump-deps

airbyte-ops local connector bump-deps NAME REPO-PATH [ARGS]

Update a connector's dependencies.

For Python / low-code connectors using Poetry, this runs poetry update --lock to refresh the lock file with the latest versions allowed by existing constraints.

For connectors that do not use Poetry (manifest-only, Java, etc.), this is a no-op.

Parameters:

  • NAME, --name: Connector technical name (e.g., source-github). [required]
  • REPO-PATH, --repo-path: Absolute path to the Airbyte monorepo. [required]
  • DRY-RUN, --dry-run, --no-dry-run: Show what would be changed without modifying files. [default: False]

airbyte-ops local connector qa

airbyte-ops local connector qa [ARGS]

Run QA checks on connector(s).

Validates connector metadata, documentation, packaging, security, and versioning. Exit code is non-zero if any checks fail.

Parameters:

  • NAME, --name, --empty-name: Connector technical name(s) (e.g., source-github). Can be specified multiple times.
  • CONNECTOR-DIRECTORY, --connector-directory: Directory containing connectors to run checks on all connectors in this directory.
  • CHECK, --check, --empty-check: Specific check(s) to run. Can be specified multiple times.
  • REPORT-PATH, --report-path: Path to write the JSON report file.

airbyte-ops local connector qa-docs-generate

airbyte-ops local connector qa-docs-generate OUTPUT-FILE

Generate documentation for QA checks.

Creates a markdown file documenting all available QA checks organized by category.

Parameters:

  • OUTPUT-FILE, --output-file: Path to write the generated documentation file. [required]

airbyte-ops local connector changelog

Changelog operations for connectors.

Commands:

  • add: Add a changelog entry for a connector using its current version.
  • check: Check changelog entries for issues.
  • fix: Fix changelog entry dates to match PR merge dates.
airbyte-ops local connector changelog check
airbyte-ops local connector changelog check [ARGS]

Check changelog entries for issues.

Validates changelog dates match PR merge dates and checks for PR number mismatches.

Parameters:

  • CONNECTOR-NAME, --connector-name: Connector technical name (e.g., source-github).
  • ALL, --all, --no-all: Check all connectors in the repository. [default: False]
  • REPO-PATH, --repo-path: Path to the Airbyte monorepo. Can be inferred from context.
  • LOOKBACK-DAYS, --lookback-days: Only check entries with dates within this many days.
  • STRICT, --strict, --no-strict: Exit with error code if any issues are found. [default: False]
airbyte-ops local connector changelog fix
airbyte-ops local connector changelog fix [ARGS]

Fix changelog entry dates to match PR merge dates.

Looks up the actual merge date for each PR referenced in the changelog and updates the date column to match.

Parameters:

  • CONNECTOR-NAME, --connector-name: Connector technical name (e.g., source-github).
  • ALL, --all, --no-all: Fix all connectors in the repository. [default: False]
  • REPO-PATH, --repo-path: Path to the Airbyte monorepo. Can be inferred from context.
  • LOOKBACK-DAYS, --lookback-days: Only fix entries with dates within this many days.
  • DRY-RUN, --dry-run, --no-dry-run: Print changes without modifying files. [default: False]
airbyte-ops local connector changelog add
airbyte-ops local connector changelog add CONNECTOR-NAME PR-NUMBER MESSAGE [ARGS]

Add a changelog entry for a connector using its current version.

Reads the version from metadata.yaml and writes a single changelog entry to the connector's documentation file. Does not modify any version files.

Parameters:

  • CONNECTOR-NAME, --connector-name: Connector technical name (e.g., source-github). [required]
  • PR-NUMBER, --pr-number: PR number for the changelog entry. [required]
  • MESSAGE, --message: Changelog entry message. [required]
  • REPO-PATH, --repo-path: Path to the Airbyte monorepo. Can be inferred from context.
  • DRY-RUN, --dry-run, --no-dry-run: Print changes without modifying files. [default: False]

airbyte-ops local connector marketing-stub

Marketing connector stub operations (local file validation and updates).

Commands:

  • check: Validate marketing connector stub entries.
  • sync: Sync connector stub(s) from connector metadata.yaml file(s).
airbyte-ops local connector marketing-stub check
airbyte-ops local connector marketing-stub check [ARGS]

Validate marketing connector stub entries.

Checks that stub entries have valid required fields (id, name, url, icon) and optionally validates that the stub matches the connector's metadata.yaml.

Exit codes:

0: All checks passed 1: Validation errors found

Output:

STDOUT: JSON validation result STDERR: Informational messages

Parameters:

  • CONNECTOR, --connector: Connector name to check (e.g., 'source-oracle-enterprise').
  • ALL, --all, --no-all: Check all stubs in the file. [default: False]
  • REPO-ROOT, --repo-root: Path to the airbyte-enterprise repository root. Defaults to current directory.
airbyte-ops local connector marketing-stub sync
airbyte-ops local connector marketing-stub sync [ARGS]

Sync connector stub(s) from connector metadata.yaml file(s).

Reads the connector's metadata.yaml file and updates the corresponding entry in connector_stubs.json with the current values.

Exit codes:

0: Sync successful (or dry-run completed) 1: Error (connector not found, no metadata, etc.)

Output:

STDOUT: JSON representation of the synced stub(s) STDERR: Informational messages

Parameters:

  • CONNECTOR, --connector: Connector name to sync (e.g., 'source-oracle-enterprise').
  • ALL, --all, --no-all: Sync all connectors that have metadata.yaml files. [default: False]
  • REPO-ROOT, --repo-root: Path to the airbyte-enterprise repository root. Defaults to current directory.
  • DRY-RUN, --dry-run, --no-dry-run: Show what would be synced without making changes. [default: False]

airbyte-ops local connector release-block

Manage release block markers for connectors.

Commands:

  • add: Add a block-release.yaml marker to prevent publishing a connector.
  • clear: Remove the block-release.yaml marker to allow publishing a connector.
  • list: List all connectors that have a block-release.yaml marker.
airbyte-ops local connector release-block add
airbyte-ops local connector release-block add NAME REASON [ARGS]

Add a block-release.yaml marker to prevent publishing a connector.

Creates a marker file in the connector's directory that causes the publish pipeline to skip the connector with a warning.

Parameters:

  • NAME, --name: Connector technical name (e.g., source-faker). [required]
  • REASON, --reason: Human-readable reason for blocking the release. [required]
  • REPO-PATH, --repo-path: Path to the Airbyte monorepo. Can be inferred from context.
  • YANKED-VERSION, --yanked-version: Version that was yanked (for reference).
  • BLOCKED-BY, --blocked-by: Email or identifier of the person requesting the block.
airbyte-ops local connector release-block clear
airbyte-ops local connector release-block clear NAME [ARGS]

Remove the block-release.yaml marker to allow publishing a connector.

Parameters:

  • NAME, --name: Connector technical name (e.g., source-faker). [required]
  • REPO-PATH, --repo-path: Path to the Airbyte monorepo. Can be inferred from context.
airbyte-ops local connector release-block list
airbyte-ops local connector release-block list [ARGS]

List all connectors that have a block-release.yaml marker.

Parameters:

  • REPO-PATH, --repo-path: Path to the Airbyte monorepo. Can be inferred from context.
  • OUTPUT-FORMAT, --output-format: Output format: "text" (human-readable), "json" (blocked connectors with block file contents), "csv" (comma-delimited connector names). [choices: text, json, csv] [default: text]
   1# Copyright (c) 2025 Airbyte, Inc., all rights reserved.
   2"""CLI commands for local Airbyte monorepo operations.
   3
   4Commands:
   5    airbyte-ops local connector list - List connectors in the monorepo
   6    airbyte-ops local connector info - Get metadata for a single connector
   7    airbyte-ops local connector get-version - Get connector version (current or next)
   8    airbyte-ops local connector bump-version - Bump connector version
   9    airbyte-ops local connector qa - Run QA checks on a connector
  10    airbyte-ops local connector qa-docs-generate - Generate QA checks documentation
  11    airbyte-ops local connector changelog check - Check changelog entries for issues
  12    airbyte-ops local connector changelog fix - Fix changelog entry dates
  13    airbyte-ops local connector bump-deps - Update Poetry-managed dependencies
  14    airbyte-ops local connector marketing-stub check - Validate marketing stub entries
  15    airbyte-ops local connector marketing-stub sync - Sync stub from connector metadata
  16    airbyte-ops local connector release-block add - Add a connector release block marker
  17    airbyte-ops local connector release-block clear - Clear a connector release block marker
  18    airbyte-ops local connector release-block list - List connector release block markers
  19
  20## CLI reference
  21
  22The commands below are regenerated by `poe docs-generate` via cyclopts's
  23programmatic docs API; see `docs/generate_cli.py`.
  24
  25.. include:: ../../../docs/generated/cli/local.md
  26   :start-line: 2
  27"""
  28
  29from __future__ import annotations
  30
  31# Hide Python-level members from the pdoc page for this module; the rendered
  32# docs for this CLI group come entirely from the grafted `.. include::` in
  33# the module docstring above.
  34__all__: list[str] = []
  35
  36import json
  37import os
  38import subprocess
  39import sys
  40from pathlib import Path
  41from typing import Annotated, Literal
  42
  43import yaml
  44from cyclopts import Parameter
  45from fastmcp_extensions.cli import exit_with_error, print_json
  46from jinja2 import Environment, PackageLoader, select_autoescape
  47from rich.console import Console
  48
  49from airbyte_ops_mcp.airbyte_repo.bump_base_image import (
  50    BaseImageError,
  51    bump_base_image,
  52)
  53from airbyte_ops_mcp.airbyte_repo.bump_cdk import (
  54    CdkBumpError,
  55    bump_cdk,
  56)
  57from airbyte_ops_mcp.airbyte_repo.bump_deps import (
  58    DepsError,
  59    bump_deps,
  60)
  61from airbyte_ops_mcp.airbyte_repo.bump_version import (
  62    ConnectorNotFoundError,
  63    InvalidVersionError,
  64    VersionNotFoundError,
  65    bump_connector_version,
  66    calculate_new_version,
  67    get_connector_doc_path,
  68    get_connector_path,
  69    get_current_version,
  70    update_changelog,
  71)
  72from airbyte_ops_mcp.airbyte_repo.changelog_fix import (
  73    ChangelogCheckResult,
  74    ChangelogFixResult,
  75    check_all_changelogs,
  76    check_changelog,
  77    fix_all_changelog_dates,
  78    fix_changelog_dates,
  79)
  80from airbyte_ops_mcp.airbyte_repo.list_connectors import (
  81    CONNECTOR_PATH_PREFIX,
  82    METADATA_FILE_NAME,
  83    _detect_connector_language,
  84    get_connectors_with_local_cdk,
  85)
  86from airbyte_ops_mcp.airbyte_repo.release_block import (
  87    add_release_block,
  88    clear_release_block,
  89    list_release_blocks,
  90)
  91from airbyte_ops_mcp.cli._base import App, app
  92from airbyte_ops_mcp.connector_metadata import (
  93    ConnectorMetadataDpathError,
  94    ConnectorMetadataDpathNotFoundError,
  95    format_metadata_dpath_value,
  96    get_connector_version_from_metadata,
  97    load_raw_connector_metadata_from_local,
  98)
  99from airbyte_ops_mcp.connector_ops.utils import Connector
 100from airbyte_ops_mcp.connector_qa.checks import ENABLED_CHECKS
 101from airbyte_ops_mcp.connector_qa.consts import CONNECTORS_QA_DOC_TEMPLATE_NAME
 102from airbyte_ops_mcp.connector_qa.models import (
 103    Check,
 104    CheckCategory,
 105    CheckStatus,
 106    Report,
 107)
 108from airbyte_ops_mcp.connector_qa.utils import (
 109    get_all_connectors_in_directory,
 110    remove_strict_encrypt_suffix,
 111)
 112from airbyte_ops_mcp.mcp.github_repo_ops import list_connectors_in_repo
 113from airbyte_ops_mcp.mcp.prerelease import compute_prerelease_docker_image_tag
 114from airbyte_ops_mcp.registry._enums import ConnectorType, SupportLevel
 115from airbyte_ops_mcp.registry.audit import (
 116    AuditResult,
 117    find_unpublished_connectors,
 118    generate_connector_list_summary,
 119)
 120from airbyte_ops_mcp.registry.connector_stubs import (
 121    CONNECTOR_STUBS_FILE,
 122    ConnectorStub,
 123    find_stub_by_connector,
 124    load_local_stubs,
 125    save_local_stubs,
 126)
 127from airbyte_ops_mcp.registry.store import resolve_registry_store
 128from airbyte_ops_mcp.regression_tests.ci_output import write_github_summary
 129
 130console = Console()
 131error_console = Console(stderr=True)
 132
 133OutputFormat = Literal["csv", "lines", "json-gh-matrix"]
 134SortBy = Literal["name"]
 135
 136
 137def _parse_support_level(value: str) -> SupportLevel:
 138    """Parse a support level string into a `SupportLevel` enum member.
 139
 140    Accepts a keyword ("certified", "community", "archived") or a legacy
 141    integer string ("100", "200", "300").
 142    """
 143    return SupportLevel.parse(value.strip().lower())
 144
 145
 146def _get_connector_support_level(connector_dir: Path) -> SupportLevel | None:
 147    """Read support level from connector's metadata.yaml."""
 148    metadata_file = connector_dir / METADATA_FILE_NAME
 149    if not metadata_file.exists():
 150        return None
 151    metadata = yaml.safe_load(metadata_file.read_text())
 152    support_level_str = metadata.get("data", {}).get("supportLevel")
 153    if not support_level_str:
 154        return None
 155    try:
 156        return SupportLevel(support_level_str.lower())
 157    except ValueError:
 158        return None
 159
 160
 161def _parse_connector_types(value: str) -> set[ConnectorType]:
 162    """Parse connector types from CSV or newline-delimited string."""
 163    types: set[ConnectorType] = set()
 164    for item in value.replace(",", "\n").split("\n"):
 165        item = item.strip().lower()
 166        if item:
 167            types.add(ConnectorType.parse(item))
 168    return types
 169
 170
 171def _get_connector_type(connector_name: str) -> str:
 172    """Derive connector type from name prefix."""
 173    if connector_name.startswith("source-"):
 174        return ConnectorType.SOURCE
 175    elif connector_name.startswith("destination-"):
 176        return ConnectorType.DESTINATION
 177    return "unknown"
 178
 179
 180def _parse_connector_names(value: str) -> set[str]:
 181    """Parse connector names from CSV or newline-delimited string."""
 182    names = set()
 183    for item in value.replace(",", "\n").split("\n"):
 184        item = item.strip()
 185        if item:
 186            names.add(item)
 187    return names
 188
 189
 190def _get_connector_version(connector_dir: Path) -> str | None:
 191    """Read connector version (dockerImageTag) from metadata.yaml."""
 192    metadata_file = connector_dir / METADATA_FILE_NAME
 193    if not metadata_file.exists():
 194        return None
 195    metadata = yaml.safe_load(metadata_file.read_text())
 196    return metadata.get("data", {}).get("dockerImageTag")
 197
 198
 199def _support_level_to_int(level: SupportLevel | None) -> int | None:
 200    """Convert a `SupportLevel` to its legacy integer representation for JSON output."""
 201    if level is None:
 202        return None
 203    return level.precedence
 204
 205
 206def _get_connector_info(
 207    connector_name: str, connector_dir: Path
 208) -> dict[str, str | int | None]:
 209    """Get full connector metadata as a dict with connector_ prefixed keys.
 210
 211    This is shared between the `list --output-format json-gh-matrix` and `info` commands.
 212    """
 213    return {
 214        "connector": connector_name,
 215        "connector_type": _get_connector_type(connector_name),
 216        "connector_language": _detect_connector_language(connector_dir, connector_name)
 217        or "unknown",
 218        "connector_support_level": _support_level_to_int(
 219            _get_connector_support_level(connector_dir)
 220        ),
 221        "connector_version": _get_connector_version(connector_dir),
 222        "connector_dir": f"{CONNECTOR_PATH_PREFIX}/{connector_name}",
 223    }
 224
 225
 226# Create the local sub-app
 227local_app = App(name="local", help="Local Airbyte monorepo operations.")
 228app.command(local_app)
 229
 230# Create the connector sub-app under local
 231connector_app = App(name="connector", help="Connector operations in the monorepo.")
 232local_app.command(connector_app)
 233
 234
 235@connector_app.command(name="list")
 236def list_connectors(
 237    repo_path: Annotated[
 238        str,
 239        Parameter(help="Absolute path to the Airbyte monorepo."),
 240    ],
 241    certified_only: Annotated[
 242        bool,
 243        Parameter(help="Include only certified connectors."),
 244    ] = False,
 245    modified_only: Annotated[
 246        bool,
 247        Parameter(help="Include only modified connectors (requires PR context)."),
 248    ] = False,
 249    local_cdk: Annotated[
 250        bool,
 251        Parameter(
 252            help=(
 253                "Include connectors using local CDK reference. "
 254                "When combined with --modified-only, adds local-CDK connectors to the modified set."
 255            )
 256        ),
 257    ] = False,
 258    language: Annotated[
 259        list[str] | None,
 260        Parameter(help="Languages to include (python, java, low-code, manifest-only)."),
 261    ] = None,
 262    exclude_language: Annotated[
 263        list[str] | None,
 264        Parameter(help="Languages to exclude."),
 265    ] = None,
 266    connector_type: Annotated[
 267        str | None,
 268        Parameter(
 269            help=(
 270                "Connector types to include (source, destination). "
 271                "Accepts CSV or newline-delimited values."
 272            )
 273        ),
 274    ] = None,
 275    min_support_level: Annotated[
 276        str | None,
 277        Parameter(
 278            help=(
 279                "Minimum support level (inclusive). "
 280                "Accepts integer (100, 200, 300) or keyword (archived, community, certified)."
 281            )
 282        ),
 283    ] = None,
 284    max_support_level: Annotated[
 285        str | None,
 286        Parameter(
 287            help=(
 288                "Maximum support level (inclusive). "
 289                "Accepts integer (100, 200, 300) or keyword (archived, community, certified)."
 290            )
 291        ),
 292    ] = None,
 293    pr: Annotated[
 294        str | None,
 295        Parameter(help="PR number or GitHub URL for modification detection."),
 296    ] = None,
 297    gh_token: Annotated[
 298        str | None,
 299        Parameter(
 300            help=(
 301                "GitHub API token. When provided together with --pr, the GitHub API "
 302                "is used to detect modified files instead of local git diff "
 303                "(avoids shallow-clone issues)."
 304            )
 305        ),
 306    ] = None,
 307    exclude_connectors: Annotated[
 308        list[str] | None,
 309        Parameter(
 310            help=(
 311                "Connectors to exclude from results. "
 312                "Accepts CSV or newline-delimited values. Can be specified multiple times."
 313            )
 314        ),
 315    ] = None,
 316    force_include_connectors: Annotated[
 317        list[str] | None,
 318        Parameter(
 319            help=(
 320                "Connectors to force-include regardless of other filters. "
 321                "Accepts CSV or newline-delimited values. Can be specified multiple times."
 322            )
 323        ),
 324    ] = None,
 325    connectors_filter: Annotated[
 326        list[str] | None,
 327        Parameter(
 328            help=(
 329                "Intersect results with this explicit set of connector names. "
 330                "Only connectors present in both the filtered results and this set are returned. "
 331                "Useful for composing multiple filter passes (e.g. combining separate source "
 332                "and destination lists). "
 333                "Accepts CSV or newline-delimited values. Can be specified multiple times."
 334            )
 335        ),
 336    ] = None,
 337    output_format: Annotated[
 338        OutputFormat,
 339        Parameter(
 340            help=(
 341                'Output format: "csv" (comma-separated), '
 342                '"lines" (one connector per line), '
 343                '"json-gh-matrix" (GitHub Actions matrix JSON).'
 344            )
 345        ),
 346    ] = "lines",
 347    unpublished: Annotated[
 348        bool,
 349        Parameter(
 350            help=(
 351                "Filter to only connectors whose local dockerImageTag "
 352                "has not been published to the GCS registry. Requires --store."
 353            )
 354        ),
 355    ] = False,
 356    store: Annotated[
 357        str | None,
 358        Parameter(
 359            help=(
 360                "Store target for unpublished check (e.g. 'coral:prod', 'coral:dev'). "
 361                "Required when --unpublished is set."
 362            )
 363        ),
 364    ] = None,
 365    assert_none: Annotated[
 366        bool,
 367        Parameter(
 368            help=(
 369                "Exit with non-zero status if any connectors match the filters. "
 370                "Useful for audit/CI checks (e.g. --unpublished --assert-none)."
 371            )
 372        ),
 373    ] = False,
 374    sort_by: Annotated[
 375        SortBy,
 376        Parameter(
 377            help="Sort connectors by the given key. Only 'name' is supported for now."
 378        ),
 379    ] = "name",
 380    sort_direction: Annotated[
 381        Literal["asc", "desc"],
 382        Parameter(
 383            help="Sort direction: 'asc' (ascending, default) or 'desc' (descending)."
 384        ),
 385    ] = "asc",
 386    limit: Annotated[
 387        int | None,
 388        Parameter(
 389            help=(
 390                "Maximum number of connectors to return. "
 391                "Applied after sorting. Useful for batched processing."
 392            )
 393        ),
 394    ] = None,
 395    offset: Annotated[
 396        int,
 397        Parameter(
 398            help=(
 399                "Number of connectors to skip from the start of the (sorted) list. "
 400                "Applied after sorting, before --limit. "
 401                "Useful for batched processing (e.g. --offset=200 --limit=200 for batch 2)."
 402            )
 403        ),
 404    ] = 0,
 405) -> None:
 406    """List connectors in the Airbyte monorepo with filtering options."""
 407    # Validate mutually exclusive flags
 408    if language and exclude_language:
 409        exit_with_error("Cannot specify both --language and --exclude-language.")
 410    if assert_none and (offset or limit is not None):
 411        exit_with_error(
 412            "Cannot combine --assert-none with --offset or --limit. "
 413            "--assert-none checks all connectors matching the filters, "
 414            "which is incompatible with pagination."
 415        )
 416
 417    # Map CLI flags to MCP tool parameters
 418    certified: bool | None = True if certified_only else None
 419    modified: bool | None = True if modified_only else None
 420
 421    language_filter: set[str] | None = set(language) if language else None
 422    language_exclude: set[str] | None = (
 423        set(exclude_language) if exclude_language else None
 424    )
 425
 426    # Parse connector type filter
 427    connector_type_filter: set[str] | None = None
 428    if connector_type:
 429        try:
 430            connector_type_filter = _parse_connector_types(connector_type)
 431        except ValueError as e:
 432            exit_with_error(str(e))
 433
 434    # Parse support level filters
 435    min_level: SupportLevel | None = None
 436    max_level: SupportLevel | None = None
 437    if min_support_level:
 438        try:
 439            min_level = _parse_support_level(min_support_level)
 440        except ValueError as e:
 441            exit_with_error(str(e))
 442    if max_support_level:
 443        try:
 444            max_level = _parse_support_level(max_support_level)
 445        except ValueError as e:
 446            exit_with_error(str(e))
 447
 448    # Parse exclude/force-include connector lists (merge multiple flag values)
 449    exclude_set: set[str] = set()
 450    if exclude_connectors:
 451        for value in exclude_connectors:
 452            exclude_set.update(_parse_connector_names(value))
 453
 454    force_include_set: set[str] = set()
 455    if force_include_connectors:
 456        for value in force_include_connectors:
 457            force_include_set.update(_parse_connector_names(value))
 458
 459    connectors_filter_set: set[str] | None = None
 460    if connectors_filter:
 461        connectors_filter_set = set()
 462        for value in connectors_filter:
 463            connectors_filter_set.update(_parse_connector_names(value))
 464
 465    result = list_connectors_in_repo(
 466        repo_path=repo_path,
 467        certified=certified,
 468        modified=modified,
 469        language_filter=language_filter,
 470        language_exclude=language_exclude,
 471        pr_num_or_url=pr,
 472        gh_token=gh_token,
 473    )
 474    connectors = list(result.connectors)
 475    repo_path_obj = Path(repo_path)
 476
 477    # Add connectors with local CDK reference if --local-cdk flag is set
 478    if local_cdk:
 479        local_cdk_connectors = get_connectors_with_local_cdk(repo_path)
 480        connectors = sorted(set(connectors) | local_cdk_connectors)
 481
 482    # Apply connector type filter
 483    if connector_type_filter:
 484        connectors = [
 485            name
 486            for name in connectors
 487            if _get_connector_type(name) in connector_type_filter
 488        ]
 489
 490    # Apply support level filters (requires reading metadata)
 491    if min_level is not None or max_level is not None:
 492        filtered_connectors = []
 493        for name in connectors:
 494            connector_dir = repo_path_obj / CONNECTOR_PATH_PREFIX / name
 495            level = _get_connector_support_level(connector_dir)
 496            if level is None:
 497                continue  # Skip connectors without support level
 498            if min_level is not None and level.precedence < min_level.precedence:
 499                continue
 500            if max_level is not None and level.precedence > max_level.precedence:
 501                continue
 502            filtered_connectors.append(name)
 503        connectors = filtered_connectors
 504
 505    # Apply exclude filter
 506    if exclude_set:
 507        connectors = [name for name in connectors if name not in exclude_set]
 508
 509    # Apply connectors filter (intersection, narrows the set)
 510    if connectors_filter_set is not None:
 511        connectors = [name for name in connectors if name in connectors_filter_set]
 512
 513    # Apply force-include (union, overrides all other filters)
 514    if force_include_set:
 515        connectors_set = set(connectors)
 516        connectors_set.update(force_include_set)
 517        connectors = sorted(connectors_set)
 518
 519    # Apply unpublished filter (requires GCS check)
 520    audit_result: AuditResult | None = None
 521    if unpublished:
 522        if not store:
 523            exit_with_error("--store is required when --unpublished is set.")
 524
 525        target = resolve_registry_store(store=store)
 526        audit_result = find_unpublished_connectors(
 527            repo_path=repo_path,
 528            bucket_name=target.bucket,
 529            connector_names=connectors,
 530        )
 531        unpublished_names = {entry.connector_name for entry in audit_result.unpublished}
 532        connectors = [name for name in connectors if name in unpublished_names]
 533
 534        # Log audit summary to stderr
 535        error_console.print(
 536            f"Audit: {audit_result.checked_count} checked, "
 537            f"{len(audit_result.unpublished)} unpublished, "
 538            f"{len(audit_result.skipped_archived)} archived-skipped, "
 539            f"{len(audit_result.skipped_disabled)} disabled-skipped, "
 540            f"{len(audit_result.skipped_rc)} rc-skipped"
 541        )
 542        if audit_result.errors:
 543            for err in audit_result.errors:
 544                error_console.print(f"  Warning: {err}")
 545
 546    # --- Sort, offset, limit (applied after all filters) ---
 547    if offset < 0:
 548        exit_with_error("--offset must be non-negative.")
 549    if limit is not None and limit < 0:
 550        exit_with_error("--limit must be non-negative.")
 551
 552    total_before_slice = len(connectors)
 553    reverse = sort_direction == "desc"
 554    if sort_by == "name":
 555        connectors = sorted(connectors, reverse=reverse)
 556
 557    if offset:
 558        connectors = connectors[offset:]
 559    if limit is not None:
 560        connectors = connectors[:limit]
 561
 562    if offset or limit is not None:
 563        error_console.print(
 564            f"Slice: showing {len(connectors)} of {total_before_slice} "
 565            f"(offset={offset}, limit={limit})"
 566        )
 567
 568    if output_format == "csv":
 569        sys.stdout.write(",".join(connectors) + "\n")
 570    elif output_format == "lines":
 571        for name in connectors:
 572            sys.stdout.write(name + "\n")
 573    elif output_format == "json-gh-matrix":
 574        # Build matrix with full connector metadata
 575        include_list = []
 576        for name in connectors:
 577            connector_dir = repo_path_obj / CONNECTOR_PATH_PREFIX / name
 578            include_list.append(_get_connector_info(name, connector_dir))
 579        matrix = {"include": include_list}
 580        sys.stdout.write(
 581            json.dumps(
 582                matrix,
 583                indent=2,
 584                default=str,
 585            )
 586            + "\n"
 587        )
 588
 589    # Write GitHub Step Summary when GITHUB_STEP_SUMMARY is set
 590    _write_connector_list_summary(
 591        connectors=connectors,
 592        assert_none=assert_none,
 593        is_unpublished=unpublished,
 594        audit_result=audit_result,
 595    )
 596
 597    # Exit non-zero if --assert-none and any connectors matched
 598    if assert_none and connectors:
 599        error_console.print(f"FAIL: {len(connectors)} connector(s) matched.")
 600        sys.exit(1)
 601
 602
 603def _write_github_step_outputs(outputs: dict[str, str | int | None]) -> None:
 604    """Write outputs to GitHub Actions step output file if running in CI."""
 605    github_output = os.getenv("GITHUB_OUTPUT")
 606    if not (os.getenv("CI") and github_output):
 607        return
 608
 609    with open(github_output, "a", encoding="utf-8") as f:
 610        for key, value in outputs.items():
 611            if value is None:
 612                continue
 613            f.write(f"{key}={value}\n")
 614
 615
 616def _write_connector_list_summary(
 617    connectors: list[str],
 618    *,
 619    assert_none: bool,
 620    is_unpublished: bool,
 621    audit_result: AuditResult | None,
 622) -> None:
 623    """Write a GitHub Step Summary for the connector list command.
 624
 625    This is a no-op when `GITHUB_STEP_SUMMARY` is not set.
 626    """
 627    github_summary = os.getenv("GITHUB_STEP_SUMMARY")
 628    if not github_summary:
 629        return
 630
 631    summary = generate_connector_list_summary(
 632        connectors,
 633        assert_none=assert_none,
 634        unpublished=is_unpublished,
 635        audit_result=audit_result,
 636    )
 637    write_github_summary(summary)
 638
 639
 640@connector_app.command(name="info")
 641def connector_info(
 642    connector_name: Annotated[
 643        str,
 644        Parameter(help="Name of the connector (e.g., source-github)."),
 645    ],
 646    repo_path: Annotated[
 647        str | None,
 648        Parameter(help="Path to the Airbyte monorepo. Can be inferred from context."),
 649    ] = None,
 650    dpath_expression: Annotated[
 651        str | None,
 652        Parameter(
 653            name="--dpath",
 654            help=(
 655                "Evaluate this dpath expression against the parsed metadata.yaml "
 656                "object and print only that value (e.g., data/dockerImageTag)."
 657            ),
 658        ),
 659    ] = None,
 660) -> None:
 661    """Get metadata for a single connector.
 662
 663    Prints JSON output with connector metadata. When running in GitHub Actions
 664    (CI env var set), also writes each field to GitHub step outputs.
 665    """
 666    # Infer repo_path from current directory if not provided
 667    if repo_path is None:
 668        # Check if we're in an airbyte repo by looking for the connectors directory
 669        cwd = Path.cwd()
 670        # Walk up to find airbyte-integrations/connectors
 671        for parent in [cwd, *cwd.parents]:
 672            if (parent / CONNECTOR_PATH_PREFIX).exists():
 673                repo_path = str(parent)
 674                break
 675        if repo_path is None:
 676            exit_with_error(
 677                "Could not infer repo path. Please provide --repo-path or run from within the Airbyte monorepo."
 678            )
 679
 680    repo_path_obj = Path(repo_path)
 681    connector_dir = repo_path_obj / CONNECTOR_PATH_PREFIX / connector_name
 682
 683    if not connector_dir.exists():
 684        exit_with_error(f"Connector directory not found: {connector_dir}")
 685
 686    try:
 687        value = load_raw_connector_metadata_from_local(
 688            repo_path_obj,
 689            connector_name,
 690            dpath_expression=dpath_expression,
 691        )
 692    except (
 693        ConnectorMetadataDpathError,
 694        ConnectorMetadataDpathNotFoundError,
 695        FileNotFoundError,
 696        ValueError,
 697    ) as e:
 698        exit_with_error(str(e))
 699
 700    if dpath_expression is None:
 701        print_json(value)
 702        return
 703
 704    sys.stdout.write(format_metadata_dpath_value(value) + "\n")
 705
 706
 707BumpType = Literal[
 708    "patch",
 709    "minor",
 710    "major",
 711    "patch_rc",
 712    "minor_rc",
 713    "major_rc",
 714    "rc",
 715    "promote",
 716]
 717
 718
 719def _get_git_head_sha(repo_path: Path) -> str:
 720    """Get the HEAD commit SHA from a git repository.
 721
 722    Args:
 723        repo_path: Path to the git repository.
 724
 725    Returns:
 726        Full commit SHA string.
 727
 728    Raises:
 729        SystemExit: If the git command fails (e.g., not a git repo).
 730    """
 731    try:
 732        result = subprocess.run(
 733            ["git", "rev-parse", "HEAD"],
 734            cwd=repo_path,
 735            capture_output=True,
 736            text=True,
 737            check=True,
 738        )
 739    except subprocess.CalledProcessError:
 740        exit_with_error(
 741            f"Failed to get HEAD SHA from {repo_path}. Is it a git repository?"
 742        )
 743    except OSError:
 744        exit_with_error("Could not run 'git'. Is git installed and on PATH?")
 745    return result.stdout.strip()
 746
 747
 748@connector_app.command(name="get-version")
 749def get_version(
 750    name: Annotated[
 751        str,
 752        Parameter(help="Connector technical name (e.g., source-github)."),
 753    ],
 754    repo_path: Annotated[
 755        str,
 756        Parameter(help="Absolute path to the Airbyte monorepo."),
 757    ],
 758    compute_next: Annotated[
 759        bool,
 760        Parameter(
 761            name="--next",
 762            help="Compute the next version instead of the current version.",
 763        ),
 764    ] = False,
 765    bump_type: Annotated[
 766        BumpType | None,
 767        Parameter(
 768            help="Version bump type (requires --next). "
 769            "Standard: patch, minor, major. RC: patch_rc, minor_rc, major_rc, rc, promote."
 770        ),
 771    ] = None,
 772    prerelease: Annotated[
 773        bool,
 774        Parameter(
 775            help="Compute a prerelease (preview) tag using the repo HEAD SHA (requires --next)."
 776        ),
 777    ] = False,
 778) -> None:
 779    """Get the current or next version for a connector.
 780
 781    By default, prints the current version from metadata.yaml.
 782    This is analogous to `local connector info --dpath data/dockerImageTag` and
 783    uses the same dpath evaluation internally.
 784
 785    With --next, computes and prints the next version. Requires either
 786    --bump-type or --prerelease to be specified.
 787
 788    Examples:
 789        # Current version
 790        airbyte-ops local connector get-version --name source-github --repo-path /path/to/airbyte
 791
 792        # Next patch version
 793        VERSION=$(airbyte-ops local connector get-version --name source-github --repo-path /path/to/airbyte --next --bump-type patch)
 794
 795        # Next prerelease (preview) tag based on HEAD SHA
 796        TAG=$(airbyte-ops local connector get-version --name source-github --repo-path /path/to/airbyte --next --prerelease)
 797    """
 798    repo = Path(repo_path)
 799
 800    # Validate flag combinations
 801    if bump_type and not compute_next:
 802        exit_with_error("--bump-type requires --next.")
 803    if prerelease and not compute_next:
 804        exit_with_error("--prerelease requires --next.")
 805    if bump_type and prerelease:
 806        exit_with_error("--bump-type and --prerelease are mutually exclusive.")
 807    if compute_next and not bump_type and not prerelease:
 808        exit_with_error(
 809            "--next requires either --bump-type or --prerelease. "
 810            "Automatic detection of bump type is not yet supported."
 811        )
 812
 813    try:
 814        get_connector_path(repo, name)
 815        metadata = load_raw_connector_metadata_from_local(repo, name)
 816        current_version = get_connector_version_from_metadata(metadata)
 817    except (
 818        ConnectorMetadataDpathError,
 819        ConnectorMetadataDpathNotFoundError,
 820        ConnectorNotFoundError,
 821        FileNotFoundError,
 822        ValueError,
 823    ) as e:
 824        exit_with_error(str(e))
 825
 826    if not compute_next:
 827        sys.stdout.write(current_version + "\n")
 828        return
 829
 830    if prerelease:
 831        sha = _get_git_head_sha(repo)
 832        try:
 833            tag = compute_prerelease_docker_image_tag(current_version, sha)
 834        except InvalidVersionError as e:
 835            exit_with_error(str(e))
 836        sys.stdout.write(tag + "\n")
 837        return
 838
 839    # bump_type is set (validated above)
 840    try:
 841        new_version = calculate_new_version(
 842            current_version=current_version,
 843            bump_type=bump_type,
 844        )
 845    except (InvalidVersionError, ValueError) as e:
 846        exit_with_error(str(e))
 847
 848    sys.stdout.write(new_version + "\n")
 849
 850
 851@connector_app.command(name="bump-version")
 852def bump_version(
 853    name: Annotated[
 854        str,
 855        Parameter(help="Connector technical name (e.g., source-github)."),
 856    ],
 857    repo_path: Annotated[
 858        str,
 859        Parameter(help="Absolute path to the Airbyte monorepo."),
 860    ],
 861    bump_type: Annotated[
 862        BumpType | None,
 863        Parameter(
 864            help="Version bump type. Standard: patch, minor, major. RC: patch_rc, minor_rc, major_rc, rc, promote."
 865        ),
 866    ] = None,
 867    new_version: Annotated[
 868        str | None,
 869        Parameter(help="Explicit new version (overrides --bump-type if provided)."),
 870    ] = None,
 871    changelog_message: Annotated[
 872        str | None,
 873        Parameter(
 874            help="Message to add to changelog. Ignored if --no-changelog is set."
 875        ),
 876    ] = None,
 877    pr_number: Annotated[
 878        int | None,
 879        Parameter(help="PR number for changelog entry."),
 880    ] = None,
 881    dry_run: Annotated[
 882        bool,
 883        Parameter(help="Show what would be changed without modifying files."),
 884    ] = False,
 885    no_changelog: Annotated[
 886        bool,
 887        Parameter(
 888            help="Skip changelog updates even if --changelog-message is provided. "
 889            "Useful for ephemeral version bumps (e.g. pre-release artifact generation)."
 890        ),
 891    ] = False,
 892    progressive_rollout_enabled: Annotated[
 893        bool | None,
 894        Parameter(
 895            help="Explicitly set `enableProgressiveRollout` in metadata.yaml. "
 896            "Pass `false` to disable progressive rollout (e.g. for preview builds). "
 897            "When omitted, the automatic behaviour based on --bump-type is used.",
 898        ),
 899    ] = None,
 900) -> None:
 901    """Bump a connector's version across all relevant files.
 902
 903    Updates version in metadata.yaml (always), pyproject.toml (if exists),
 904    and documentation changelog (if --changelog-message provided).
 905
 906    Note: --changelog-message is ignored when --no-changelog is set.
 907
 908    Either --bump-type or --new-version must be provided.
 909
 910    Examples:
 911        airbyte-ops local connector bump-version --name source-github --repo-path /path/to/airbyte --bump-type patch
 912        airbyte-ops local connector bump-version --name source-github --repo-path /path/to/airbyte --new-version 1.2.3-preview.abc1234 --no-changelog
 913        airbyte-ops local connector bump-version --name source-github --repo-path /path/to/airbyte --new-version 1.2.3-preview.abc1234 --no-changelog --progressive-rollout-enabled=false
 914    """
 915    try:
 916        result = bump_connector_version(
 917            repo_path=repo_path,
 918            connector_name=name,
 919            bump_type=bump_type,
 920            new_version=new_version,
 921            changelog_message=changelog_message,
 922            pr_number=pr_number,
 923            dry_run=dry_run,
 924            no_changelog=no_changelog,
 925            progressive_rollout_enabled=progressive_rollout_enabled,
 926        )
 927    except ConnectorNotFoundError as e:
 928        exit_with_error(str(e))
 929    except VersionNotFoundError as e:
 930        exit_with_error(str(e))
 931    except InvalidVersionError as e:
 932        exit_with_error(str(e))
 933    except ValueError as e:
 934        exit_with_error(str(e))
 935
 936    # Build output matching the issue spec
 937    output = {
 938        "connector": result.connector,
 939        "previous_version": result.previous_version,
 940        "new_version": result.new_version,
 941        "files_modified": result.files_modified,
 942        "dry_run": result.dry_run,
 943    }
 944    print_json(output)
 945
 946    # Write to GitHub step outputs if in CI
 947    _write_github_step_outputs(
 948        {
 949            "connector": result.connector,
 950            "previous_version": result.previous_version,
 951            "new_version": result.new_version,
 952        }
 953    )
 954
 955
 956@connector_app.command(name="bump-base-image")
 957def bump_base_image_cmd(
 958    name: Annotated[
 959        str,
 960        Parameter(help="Connector technical name (e.g., source-github)."),
 961    ],
 962    repo_path: Annotated[
 963        str,
 964        Parameter(help="Absolute path to the Airbyte monorepo."),
 965    ],
 966    force_latest: Annotated[
 967        bool,
 968        Parameter(
 969            help=(
 970                "Bump to the absolute latest stable base image, ignoring "
 971                "major-version boundaries.  Without this flag the bump stays "
 972                "within the current major version."
 973            )
 974        ),
 975    ] = False,
 976    dry_run: Annotated[
 977        bool,
 978        Parameter(help="Show what would be changed without modifying files."),
 979    ] = False,
 980) -> None:
 981    """Update a connector's base image.
 982
 983    Two modes:
 984
 985    * Default: bump to the latest stable tag within the same major version.
 986      Major version changes are treated as breaking-change boundaries.
 987    * --force-latest: bump to the absolute latest stable tag regardless of semver.
 988
 989    Examples:
 990        airbyte-ops local connector bump-base-image --name source-github --repo-path /path/to/airbyte
 991        airbyte-ops local connector bump-base-image --name source-github --repo-path /path/to/airbyte --force-latest
 992    """
 993    try:
 994        result = bump_base_image(
 995            repo_path=repo_path,
 996            connector_name=name,
 997            force_latest=force_latest,
 998            dry_run=dry_run,
 999        )
1000    except (ConnectorNotFoundError, BaseImageError) as e:
1001        exit_with_error(str(e))
1002    except ValueError as e:
1003        exit_with_error(str(e))
1004
1005    output = {
1006        "connector": result.connector,
1007        "previous_base_image": result.previous_base_image,
1008        "new_base_image": result.new_base_image,
1009        "updated": result.updated,
1010        "dry_run": result.dry_run,
1011        "files_modified": result.files_modified,
1012        "message": result.message,
1013    }
1014    print_json(output)
1015
1016
1017@connector_app.command(name="bump-cdk")
1018def bump_cdk_cmd(
1019    name: Annotated[
1020        str,
1021        Parameter(help="Connector technical name (e.g., source-github)."),
1022    ],
1023    repo_path: Annotated[
1024        str,
1025        Parameter(help="Absolute path to the Airbyte monorepo."),
1026    ],
1027    force_latest: Annotated[
1028        bool,
1029        Parameter(
1030            help=(
1031                "Rewrite the CDK constraint to >=LATEST,<NEXT_MAJOR and refresh "
1032                "the lock file.  Without this flag only the lock file is refreshed "
1033                "(constraint unchanged)."
1034            )
1035        ),
1036    ] = False,
1037    dry_run: Annotated[
1038        bool,
1039        Parameter(help="Show what would be changed without modifying files."),
1040    ] = False,
1041) -> None:
1042    """Bump a connector's CDK dependency.
1043
1044    Two modes:
1045
1046    * Default: refresh the lock file so it resolves the newest CDK that
1047      satisfies the existing constraint.  The constraint is NOT changed.
1048    * --force-latest: rewrite the constraint to >=LATEST,<NEXT_MAJOR and refresh
1049      the lock file.  Extras (e.g. file-based) are preserved.
1050
1051    For Java connectors, updates `build.gradle` to the latest CDK version.
1052
1053    Examples:
1054        airbyte-ops local connector bump-cdk --name source-github --repo-path /path/to/airbyte
1055        airbyte-ops local connector bump-cdk --name source-github --repo-path /path/to/airbyte --force-latest
1056    """
1057    try:
1058        result = bump_cdk(
1059            repo_path=repo_path,
1060            connector_name=name,
1061            force_latest=force_latest,
1062            dry_run=dry_run,
1063        )
1064    except (ConnectorNotFoundError, CdkBumpError) as e:
1065        exit_with_error(str(e))
1066    except ValueError as e:
1067        exit_with_error(str(e))
1068
1069    output = {
1070        "connector": result.connector,
1071        "language": result.language,
1072        "previous_version": result.previous_version,
1073        "new_version": result.new_version,
1074        "updated": result.updated,
1075        "dry_run": result.dry_run,
1076        "files_modified": result.files_modified,
1077        "message": result.message,
1078    }
1079    print_json(output)
1080
1081
1082@connector_app.command(name="bump-deps")
1083def bump_deps_cmd(
1084    name: Annotated[
1085        str,
1086        Parameter(help="Connector technical name (e.g., source-github)."),
1087    ],
1088    repo_path: Annotated[
1089        str,
1090        Parameter(help="Absolute path to the Airbyte monorepo."),
1091    ],
1092    dry_run: Annotated[
1093        bool,
1094        Parameter(help="Show what would be changed without modifying files."),
1095    ] = False,
1096) -> None:
1097    """Update a connector's dependencies.
1098
1099    For Python / low-code connectors using Poetry, this runs
1100    `poetry update --lock` to refresh the lock file with the latest
1101    versions allowed by existing constraints.
1102
1103    For connectors that do not use Poetry (manifest-only, Java, etc.),
1104    this is a no-op.
1105
1106    Examples:
1107        airbyte-ops local connector bump-deps --name source-github --repo-path /path/to/airbyte
1108        airbyte-ops local connector bump-deps --name source-github --repo-path /path/to/airbyte --dry-run
1109    """
1110    try:
1111        result = bump_deps(
1112            repo_path=repo_path,
1113            connector_name=name,
1114            dry_run=dry_run,
1115        )
1116    except (ConnectorNotFoundError, DepsError) as e:
1117        exit_with_error(str(e))
1118    except ValueError as e:
1119        exit_with_error(str(e))
1120
1121    output = {
1122        "connector": result.connector,
1123        "language": result.language,
1124        "updated": result.updated,
1125        "dry_run": result.dry_run,
1126        "files_modified": result.files_modified,
1127        "outdated_packages": result.outdated_packages,
1128        "message": result.message,
1129    }
1130    print_json(output)
1131
1132
1133@connector_app.command(name="qa")
1134def run_qa_checks(
1135    name: Annotated[
1136        list[str] | None,
1137        Parameter(
1138            help="Connector technical name(s) (e.g., source-github). Can be specified multiple times."
1139        ),
1140    ] = None,
1141    connector_directory: Annotated[
1142        str | None,
1143        Parameter(
1144            help="Directory containing connectors to run checks on all connectors in this directory."
1145        ),
1146    ] = None,
1147    check: Annotated[
1148        list[str] | None,
1149        Parameter(help="Specific check(s) to run. Can be specified multiple times."),
1150    ] = None,
1151    report_path: Annotated[
1152        str | None,
1153        Parameter(help="Path to write the JSON report file."),
1154    ] = None,
1155) -> None:
1156    """Run QA checks on connector(s).
1157
1158    Validates connector metadata, documentation, packaging, security, and versioning.
1159    Exit code is non-zero if any checks fail.
1160    """
1161    # Determine which checks to run
1162    checks_to_run = ENABLED_CHECKS
1163    if check:
1164        check_names = set(check)
1165        checks_to_run = [c for c in ENABLED_CHECKS if type(c).__name__ in check_names]
1166        if not checks_to_run:
1167            exit_with_error(
1168                f"No matching checks found. Available checks: {[type(c).__name__ for c in ENABLED_CHECKS]}"
1169            )
1170
1171    # Collect connectors to check
1172    connectors: list[Connector] = []
1173    if name:
1174        connectors.extend(Connector(remove_strict_encrypt_suffix(n)) for n in name)
1175    if connector_directory:
1176        connectors.extend(get_all_connectors_in_directory(Path(connector_directory)))
1177
1178    if not connectors:
1179        exit_with_error("No connectors specified. Use --name or --connector-directory.")
1180
1181    connectors = sorted(connectors, key=lambda c: c.technical_name)
1182
1183    # Run checks synchronously (simpler than async for CLI)
1184    all_results = []
1185    for connector in connectors:
1186        for qa_check in checks_to_run:
1187            result = qa_check.run(connector)
1188            if result.status == CheckStatus.PASSED:
1189                status_icon = "[green]✅ PASS[/green]"
1190            elif result.status == CheckStatus.SKIPPED:
1191                status_icon = "[yellow]🔶 SKIP[/yellow]"
1192            else:
1193                status_icon = "[red]❌ FAIL[/red]"
1194            console.print(
1195                f"{status_icon} {connector.technical_name}: {result.check.name}"
1196            )
1197            if result.message:
1198                console.print(f"    {result.message}")
1199            all_results.append(result)
1200
1201    # Write report if requested
1202    if report_path:
1203        Report(check_results=all_results).write(Path(report_path))
1204        console.print(f"Report written to {report_path}")
1205
1206    # Exit with error if any checks failed
1207    failed = [r for r in all_results if r.status == CheckStatus.FAILED]
1208    if failed:
1209        exit_with_error(f"{len(failed)} check(s) failed")
1210
1211
1212@connector_app.command(name="qa-docs-generate")
1213def generate_qa_docs(
1214    output_file: Annotated[
1215        str,
1216        Parameter(help="Path to write the generated documentation file."),
1217    ],
1218) -> None:
1219    """Generate documentation for QA checks.
1220
1221    Creates a markdown file documenting all available QA checks organized by category.
1222    """
1223    checks_by_category: dict[CheckCategory, list[Check]] = {}
1224    for qa_check in ENABLED_CHECKS:
1225        checks_by_category.setdefault(qa_check.category, []).append(qa_check)
1226
1227    jinja_env = Environment(
1228        loader=PackageLoader("airbyte_ops_mcp.connector_qa", "templates"),
1229        autoescape=select_autoescape(),
1230        trim_blocks=False,
1231        lstrip_blocks=True,
1232    )
1233    template = jinja_env.get_template(CONNECTORS_QA_DOC_TEMPLATE_NAME)
1234    documentation = template.render(checks_by_category=checks_by_category)
1235
1236    output_path = Path(output_file)
1237    output_path.write_text(documentation)
1238    console.print(f"Documentation written to {output_file}")
1239
1240
1241# Create the changelog sub-app under connector
1242changelog_app = App(name="changelog", help="Changelog operations for connectors.")
1243connector_app.command(changelog_app)
1244
1245
1246@changelog_app.command(name="check")
1247def changelog_check(
1248    connector_name: Annotated[
1249        str | None,
1250        Parameter(help="Connector technical name (e.g., source-github)."),
1251    ] = None,
1252    all_connectors: Annotated[
1253        bool,
1254        Parameter("--all", help="Check all connectors in the repository."),
1255    ] = False,
1256    repo_path: Annotated[
1257        str | None,
1258        Parameter(help="Path to the Airbyte monorepo. Can be inferred from context."),
1259    ] = None,
1260    lookback_days: Annotated[
1261        int | None,
1262        Parameter(help="Only check entries with dates within this many days."),
1263    ] = None,
1264    strict: Annotated[
1265        bool,
1266        Parameter(help="Exit with error code if any issues are found."),
1267    ] = False,
1268) -> None:
1269    """Check changelog entries for issues.
1270
1271    Validates changelog dates match PR merge dates and checks for PR number mismatches.
1272    """
1273    if not connector_name and not all_connectors:
1274        exit_with_error("Either --connector-name or --all must be specified.")
1275
1276    if connector_name and all_connectors:
1277        exit_with_error("Cannot specify both --connector-name and --all.")
1278
1279    if repo_path is None:
1280        cwd = Path.cwd()
1281        for parent in [cwd, *cwd.parents]:
1282            if (parent / CONNECTOR_PATH_PREFIX).exists():
1283                repo_path = str(parent)
1284                break
1285        if repo_path is None:
1286            exit_with_error(
1287                "Could not infer repo path. Please provide --repo-path or run from within the Airbyte monorepo."
1288            )
1289
1290    total_issues = 0
1291
1292    if all_connectors:
1293        results = check_all_changelogs(repo_path=repo_path, lookback_days=lookback_days)
1294        for result in results:
1295            if result.has_issues or result.errors:
1296                _print_check_result(result)
1297                total_issues += result.issue_count
1298    else:
1299        result = check_changelog(
1300            repo_path=repo_path,
1301            connector_name=connector_name,
1302            lookback_days=lookback_days,
1303        )
1304        _print_check_result(result)
1305        total_issues = result.issue_count
1306
1307    if total_issues > 0:
1308        console.print(f"\n[bold]Total issues found: {total_issues}[/bold]")
1309        if strict:
1310            exit_with_error(f"Found {total_issues} issue(s) in changelog(s).")
1311    else:
1312        console.print("[green]No issues found.[/green]")
1313
1314
1315def _print_check_result(result: ChangelogCheckResult) -> None:
1316    """Print a changelog check result."""
1317    if not result.has_issues and not result.errors:
1318        return
1319
1320    console.print(f"\n[bold]{result.connector}[/bold]")
1321
1322    for warning in result.pr_mismatch_warnings:
1323        console.print(
1324            f"  [yellow]WARNING[/yellow] Line {warning.line_number} (v{warning.version}): {warning.message}"
1325        )
1326
1327    for fix in result.date_issues:
1328        if fix.changed:
1329            console.print(
1330                f"  [red]DATE MISMATCH[/red] Line {fix.line_number} (v{fix.version}): "
1331                f"changelog has {fix.old_date}, PR merged on {fix.new_date}"
1332            )
1333
1334    for error in result.errors:
1335        console.print(f"  [red]ERROR[/red] {error}")
1336
1337
1338@changelog_app.command(name="fix")
1339def changelog_fix(
1340    connector_name: Annotated[
1341        str | None,
1342        Parameter(help="Connector technical name (e.g., source-github)."),
1343    ] = None,
1344    all_connectors: Annotated[
1345        bool,
1346        Parameter("--all", help="Fix all connectors in the repository."),
1347    ] = False,
1348    repo_path: Annotated[
1349        str | None,
1350        Parameter(help="Path to the Airbyte monorepo. Can be inferred from context."),
1351    ] = None,
1352    lookback_days: Annotated[
1353        int | None,
1354        Parameter(help="Only fix entries with dates within this many days."),
1355    ] = None,
1356    dry_run: Annotated[
1357        bool,
1358        Parameter(help="Print changes without modifying files."),
1359    ] = False,
1360) -> None:
1361    """Fix changelog entry dates to match PR merge dates.
1362
1363    Looks up the actual merge date for each PR referenced in the changelog
1364    and updates the date column to match.
1365    """
1366    if not connector_name and not all_connectors:
1367        exit_with_error("Either --connector-name or --all must be specified.")
1368
1369    if connector_name and all_connectors:
1370        exit_with_error("Cannot specify both --connector-name and --all.")
1371
1372    if repo_path is None:
1373        cwd = Path.cwd()
1374        for parent in [cwd, *cwd.parents]:
1375            if (parent / CONNECTOR_PATH_PREFIX).exists():
1376                repo_path = str(parent)
1377                break
1378        if repo_path is None:
1379            exit_with_error(
1380                "Could not infer repo path. Please provide --repo-path or run from within the Airbyte monorepo."
1381            )
1382
1383    total_fixed = 0
1384    total_warnings = 0
1385
1386    if all_connectors:
1387        results = fix_all_changelog_dates(
1388            repo_path=repo_path, dry_run=dry_run, lookback_days=lookback_days
1389        )
1390        for result in results:
1391            if result.has_changes or result.warnings or result.errors:
1392                _print_fix_result(result)
1393                total_fixed += result.changed_count
1394                total_warnings += len(result.warnings)
1395    else:
1396        result = fix_changelog_dates(
1397            repo_path=repo_path,
1398            connector_name=connector_name,
1399            dry_run=dry_run,
1400            lookback_days=lookback_days,
1401        )
1402        _print_fix_result(result)
1403        total_fixed = result.changed_count
1404        total_warnings = len(result.warnings)
1405
1406    action = "Would fix" if dry_run else "Fixed"
1407    console.print(f"\n[bold]{action} {total_fixed} date(s).[/bold]")
1408    if total_warnings > 0:
1409        console.print(
1410            f"[yellow]{total_warnings} warning(s) about PR number mismatches.[/yellow]"
1411        )
1412
1413
1414def _print_fix_result(result: ChangelogFixResult) -> None:
1415    """Print a changelog fix result."""
1416    if not result.has_changes and not result.warnings and not result.errors:
1417        return
1418
1419    console.print(f"\n[bold]{result.connector}[/bold]")
1420
1421    for warning in result.warnings:
1422        console.print(
1423            f"  [yellow]WARNING[/yellow] Line {warning.line_number} (v{warning.version}): {warning.message}"
1424        )
1425
1426    for fix in result.fixes:
1427        if fix.changed:
1428            action = "Would fix" if result.dry_run else "Fixed"
1429            console.print(
1430                f"  [green]{action}[/green] Line {fix.line_number} (v{fix.version}): "
1431                f"{fix.old_date} -> {fix.new_date}"
1432            )
1433
1434    for error in result.errors:
1435        console.print(f"  [red]ERROR[/red] {error}")
1436
1437
1438@changelog_app.command(name="add")
1439def changelog_add(
1440    connector_name: Annotated[
1441        str,
1442        Parameter(help="Connector technical name (e.g., source-github)."),
1443    ],
1444    pr_number: Annotated[
1445        int,
1446        Parameter(help="PR number for the changelog entry."),
1447    ],
1448    message: Annotated[
1449        str,
1450        Parameter(help="Changelog entry message."),
1451    ],
1452    repo_path: Annotated[
1453        str | None,
1454        Parameter(help="Path to the Airbyte monorepo. Can be inferred from context."),
1455    ] = None,
1456    dry_run: Annotated[
1457        bool,
1458        Parameter(help="Print changes without modifying files."),
1459    ] = False,
1460) -> None:
1461    """Add a changelog entry for a connector using its current version.
1462
1463    Reads the version from metadata.yaml and writes a single changelog
1464    entry to the connector's documentation file.  Does not modify any
1465    version files.
1466    """
1467    if repo_path is None:
1468        cwd = Path.cwd()
1469        for parent in [cwd, *cwd.parents]:
1470            if (parent / CONNECTOR_PATH_PREFIX).exists():
1471                repo_path = str(parent)
1472                break
1473        if repo_path is None:
1474            exit_with_error(
1475                "Could not infer repo path. Please provide --repo-path or run from within the Airbyte monorepo."
1476            )
1477
1478    try:
1479        connector_path = get_connector_path(Path(repo_path), connector_name)
1480        version = get_current_version(connector_path)
1481    except (ConnectorNotFoundError, VersionNotFoundError) as e:
1482        exit_with_error(str(e))
1483
1484    doc_path = get_connector_doc_path(Path(repo_path), connector_name)
1485    if doc_path is None or not doc_path.exists():
1486        exit_with_error(f"Documentation file not found for {connector_name}.")
1487
1488    modified = update_changelog(
1489        doc_path=doc_path,
1490        new_version=version,
1491        changelog_message=message,
1492        pr_number=pr_number,
1493        dry_run=dry_run,
1494    )
1495
1496    action = "Would add" if dry_run else "Added"
1497    if modified:
1498        console.print(
1499            f"[green]{action} changelog entry for {connector_name} v{version}[/green]"
1500        )
1501    else:
1502        console.print(
1503            f"[yellow]No changes needed for {connector_name} v{version}[/yellow]"
1504        )
1505
1506
1507# Create the marketing-stub sub-app under connector
1508marketing_stub_app = App(
1509    name="marketing-stub",
1510    help="Marketing connector stub operations (local file validation and updates).",
1511)
1512connector_app.command(marketing_stub_app)
1513
1514# Path to connectors in the airbyte-enterprise repo
1515ENTERPRISE_CONNECTOR_PATH_PREFIX = "airbyte-integrations/connectors"
1516
1517
1518def _build_stub_from_metadata(
1519    connector_name: str,
1520    metadata: dict,
1521    existing_stub: dict | None = None,
1522) -> dict:
1523    """Build a connector stub from metadata.yaml.
1524
1525    Args:
1526        connector_name: The connector name (e.g., 'source-oracle-enterprise').
1527        metadata: The parsed metadata.yaml content.
1528        existing_stub: Optional existing stub to preserve extra fields from.
1529
1530    Returns:
1531        A connector stub dictionary.
1532    """
1533    data = metadata.get("data", {})
1534
1535    # Determine connector type for the stub
1536    connector_type = data.get("connectorType", "source")
1537    stub_type = f"enterprise_{connector_type}"
1538
1539    # Preserve existing stub ID if available, otherwise use connector name
1540    stub_id = (existing_stub.get("id") if existing_stub else None) or connector_name
1541
1542    # Get the icon URL - construct from icon filename if available
1543    icon_filename = data.get("icon", "")
1544    if icon_filename and not icon_filename.startswith("http"):
1545        # Construct icon URL from the standard GCS path
1546        icon_url = f"https://storage.googleapis.com/prod-airbyte-cloud-connector-metadata-service/resources/connector_stubs/v0/icons/{icon_filename}"
1547    else:
1548        icon_url = icon_filename or ""
1549
1550    # Build the stub
1551    stub: dict = {
1552        "id": stub_id,
1553        "name": data.get("name", connector_name.replace("-", " ").title()),
1554        "label": "enterprise",
1555        "icon": icon_url,
1556        "url": data.get("documentationUrl", ""),
1557        "type": stub_type,
1558    }
1559
1560    # Add definitionId if available
1561    definition_id = data.get("definitionId")
1562    if definition_id:
1563        stub["definitionId"] = definition_id
1564
1565    # Preserve extra fields from existing stub (like codename)
1566    if existing_stub:
1567        for key in existing_stub:
1568            if key not in stub:
1569                stub[key] = existing_stub[key]
1570
1571    return stub
1572
1573
1574@marketing_stub_app.command(name="check")
1575def marketing_stub_check(
1576    connector: Annotated[
1577        str | None,
1578        Parameter(help="Connector name to check (e.g., 'source-oracle-enterprise')."),
1579    ] = None,
1580    all_connectors: Annotated[
1581        bool,
1582        Parameter("--all", help="Check all stubs in the file."),
1583    ] = False,
1584    repo_root: Annotated[
1585        Path | None,
1586        Parameter(
1587            help="Path to the airbyte-enterprise repository root. Defaults to current directory."
1588        ),
1589    ] = None,
1590) -> None:
1591    """Validate marketing connector stub entries.
1592
1593    Checks that stub entries have valid required fields (id, name, url, icon)
1594    and optionally validates that the stub matches the connector's metadata.yaml.
1595
1596    Exit codes:
1597        0: All checks passed
1598        1: Validation errors found
1599
1600    Output:
1601        STDOUT: JSON validation result
1602        STDERR: Informational messages
1603
1604    Example:
1605        airbyte-ops local connector marketing-stub check --connector source-oracle-enterprise --repo-root /path/to/airbyte-enterprise
1606        airbyte-ops local connector marketing-stub check --all --repo-root /path/to/airbyte-enterprise
1607    """
1608    if not connector and not all_connectors:
1609        exit_with_error("Either --connector or --all must be specified.")
1610
1611    if connector and all_connectors:
1612        exit_with_error("Cannot specify both --connector and --all.")
1613
1614    if repo_root is None:
1615        repo_root = Path.cwd()
1616
1617    # Load local stubs
1618    try:
1619        stubs = load_local_stubs(repo_root)
1620    except FileNotFoundError as e:
1621        exit_with_error(str(e))
1622    except ValueError as e:
1623        exit_with_error(str(e))
1624
1625    stubs_to_check = stubs if all_connectors else []
1626    if connector:
1627        stub = find_stub_by_connector(stubs, connector)
1628        if stub is None:
1629            exit_with_error(
1630                f"Connector stub '{connector}' not found in {CONNECTOR_STUBS_FILE}"
1631            )
1632        stubs_to_check = [stub]
1633
1634    errors: list[dict] = []
1635    warnings: list[dict] = []
1636    placeholders: list[dict] = []
1637
1638    for stub in stubs_to_check:
1639        stub_id = stub.get("id", "<unknown>")
1640        stub_name = stub.get("name", stub_id)
1641
1642        # Check required fields
1643        required_fields = ["id", "name", "url", "icon"]
1644        for field in required_fields:
1645            if not stub.get(field):
1646                errors.append(
1647                    {"stub_id": stub_id, "error": f"Missing required field: {field}"}
1648                )
1649
1650        # Check if corresponding connector exists and validate against metadata
1651        connector_dir = repo_root / ENTERPRISE_CONNECTOR_PATH_PREFIX / stub_id
1652        metadata_file = connector_dir / METADATA_FILE_NAME
1653
1654        if metadata_file.exists():
1655            metadata = yaml.safe_load(metadata_file.read_text())
1656            data = metadata.get("data", {})
1657
1658            # Check if definitionId matches
1659            metadata_def_id = data.get("definitionId")
1660            stub_def_id = stub.get("definitionId")
1661            if metadata_def_id and stub_def_id and metadata_def_id != stub_def_id:
1662                errors.append(
1663                    {
1664                        "stub_id": stub_id,
1665                        "error": f"definitionId mismatch: stub has '{stub_def_id}', metadata has '{metadata_def_id}'",
1666                    }
1667                )
1668
1669            # Check if name matches
1670            metadata_name = data.get("name")
1671            if metadata_name and stub_name and metadata_name != stub_name:
1672                warnings.append(
1673                    {
1674                        "stub_id": stub_id,
1675                        "warning": f"name mismatch: stub has '{stub_name}', metadata has '{metadata_name}'",
1676                    }
1677                )
1678        else:
1679            # No connector directory - this is a registry placeholder for a future connector
1680            placeholders.append(
1681                {
1682                    "stub_id": stub_id,
1683                    "name": stub_name,
1684                }
1685            )
1686
1687    result = {
1688        "checked_count": len(stubs_to_check),
1689        "error_count": len(errors),
1690        "warning_count": len(warnings),
1691        "placeholder_count": len(placeholders),
1692        "valid": len(errors) == 0,
1693        "errors": errors,
1694        "warnings": warnings,
1695        "placeholders": placeholders,
1696    }
1697
1698    # Print placeholders as info (not warnings - these are valid registry placeholders)
1699    if placeholders:
1700        error_console.print(
1701            f"[blue]Found {len(placeholders)} registry placeholder(s) (no local directory):[/blue]"
1702        )
1703        for placeholder in placeholders:
1704            error_console.print(
1705                f"  Found Connector Registry Placeholder (no local directory): {placeholder['name']}"
1706            )
1707
1708    if errors:
1709        error_console.print(f"[red]Found {len(errors)} error(s):[/red]")
1710        for err in errors:
1711            error_console.print(f"  {err['stub_id']}: {err['error']}")
1712
1713    if warnings:
1714        error_console.print(f"[yellow]Found {len(warnings)} warning(s):[/yellow]")
1715        for warn in warnings:
1716            error_console.print(f"  {warn['stub_id']}: {warn['warning']}")
1717
1718    if not errors and not warnings:
1719        error_console.print(
1720            f"[green]All {len(stubs_to_check)} stub(s) passed validation[/green]"
1721        )
1722
1723    print_json(result)
1724
1725    if errors:
1726        exit_with_error("Validation failed", code=1)
1727
1728
1729@marketing_stub_app.command(name="sync")
1730def marketing_stub_sync(
1731    connector: Annotated[
1732        str | None,
1733        Parameter(help="Connector name to sync (e.g., 'source-oracle-enterprise')."),
1734    ] = None,
1735    all_connectors: Annotated[
1736        bool,
1737        Parameter("--all", help="Sync all connectors that have metadata.yaml files."),
1738    ] = False,
1739    repo_root: Annotated[
1740        Path | None,
1741        Parameter(
1742            help="Path to the airbyte-enterprise repository root. Defaults to current directory."
1743        ),
1744    ] = None,
1745    dry_run: Annotated[
1746        bool,
1747        Parameter(help="Show what would be synced without making changes."),
1748    ] = False,
1749) -> None:
1750    """Sync connector stub(s) from connector metadata.yaml file(s).
1751
1752    Reads the connector's metadata.yaml file and updates the corresponding
1753    entry in connector_stubs.json with the current values.
1754
1755    Exit codes:
1756        0: Sync successful (or dry-run completed)
1757        1: Error (connector not found, no metadata, etc.)
1758
1759    Output:
1760        STDOUT: JSON representation of the synced stub(s)
1761        STDERR: Informational messages
1762
1763    Example:
1764        airbyte-ops local connector marketing-stub sync --connector source-oracle-enterprise --repo-root /path/to/airbyte-enterprise
1765        airbyte-ops local connector marketing-stub sync --all --repo-root /path/to/airbyte-enterprise
1766        airbyte-ops local connector marketing-stub sync --connector source-oracle-enterprise --dry-run
1767    """
1768    if not connector and not all_connectors:
1769        exit_with_error("Either --connector or --all must be specified.")
1770
1771    if connector and all_connectors:
1772        exit_with_error("Cannot specify both --connector and --all.")
1773
1774    if repo_root is None:
1775        repo_root = Path.cwd()
1776
1777    # Load existing stubs
1778    try:
1779        stubs = load_local_stubs(repo_root)
1780    except FileNotFoundError:
1781        stubs = []
1782    except ValueError as e:
1783        exit_with_error(str(e))
1784
1785    # Determine which connectors to sync
1786    connectors_to_sync: list[str] = []
1787    if connector:
1788        connectors_to_sync = [connector]
1789    else:
1790        # Find all connectors with metadata.yaml in the enterprise connectors directory
1791        connectors_dir = repo_root / ENTERPRISE_CONNECTOR_PATH_PREFIX
1792        if connectors_dir.exists():
1793            for item in connectors_dir.iterdir():
1794                if item.is_dir() and (item / METADATA_FILE_NAME).exists():
1795                    connectors_to_sync.append(item.name)
1796        connectors_to_sync.sort()
1797
1798    if not connectors_to_sync:
1799        exit_with_error("No connectors found to sync.")
1800
1801    synced_stubs: list[dict] = []
1802    updated_count = 0
1803    added_count = 0
1804
1805    for conn_name in connectors_to_sync:
1806        connector_dir = repo_root / ENTERPRISE_CONNECTOR_PATH_PREFIX / conn_name
1807        metadata_file = connector_dir / METADATA_FILE_NAME
1808
1809        if not connector_dir.exists():
1810            if connector:
1811                exit_with_error(f"Connector directory not found: {connector_dir}")
1812            continue
1813
1814        if not metadata_file.exists():
1815            if connector:
1816                exit_with_error(f"Metadata file not found: {metadata_file}")
1817            continue
1818
1819        # Load metadata
1820        metadata = yaml.safe_load(metadata_file.read_text())
1821
1822        # Find existing stub if any
1823        existing_stub = find_stub_by_connector(stubs, conn_name)
1824
1825        # Build new stub from metadata
1826        new_stub = _build_stub_from_metadata(conn_name, metadata, existing_stub)
1827
1828        # Validate the new stub
1829        ConnectorStub(**new_stub)
1830
1831        if dry_run:
1832            action = "update" if existing_stub else "create"
1833            error_console.print(f"[DRY RUN] Would {action} stub for '{conn_name}'")
1834            synced_stubs.append(new_stub)
1835            continue
1836
1837        # Update or add the stub
1838        if existing_stub:
1839            # Find and replace
1840            for i, stub in enumerate(stubs):
1841                if stub.get("id") == existing_stub.get("id"):
1842                    stubs[i] = new_stub
1843                    break
1844            updated_count += 1
1845        else:
1846            stubs.append(new_stub)
1847            added_count += 1
1848
1849        synced_stubs.append(new_stub)
1850
1851    if not dry_run:
1852        # Save the updated stubs
1853        save_local_stubs(repo_root, stubs)
1854        error_console.print(
1855            f"[green]Synced {len(synced_stubs)} stub(s) to {CONNECTOR_STUBS_FILE} "
1856            f"({added_count} added, {updated_count} updated)[/green]"
1857        )
1858    else:
1859        error_console.print(
1860            f"[DRY RUN] Would sync {len(synced_stubs)} stub(s) to {CONNECTOR_STUBS_FILE}"
1861        )
1862
1863    print_json(
1864        synced_stubs if all_connectors else synced_stubs[0] if synced_stubs else {}
1865    )
1866
1867
1868# Create the release-block sub-app under connector
1869release_block_app = App(
1870    name="release-block",
1871    help="Manage release block markers for connectors.",
1872)
1873connector_app.command(release_block_app)
1874
1875
1876@release_block_app.command(name="add")
1877def release_block_add(
1878    name: Annotated[
1879        str,
1880        Parameter(help="Connector technical name (e.g., source-faker)."),
1881    ],
1882    reason: Annotated[
1883        str,
1884        Parameter(help="Human-readable reason for blocking the release."),
1885    ],
1886    repo_path: Annotated[
1887        str | None,
1888        Parameter(help="Path to the Airbyte monorepo. Can be inferred from context."),
1889    ] = None,
1890    yanked_version: Annotated[
1891        str | None,
1892        Parameter(help="Version that was yanked (for reference)."),
1893    ] = None,
1894    blocked_by: Annotated[
1895        str | None,
1896        Parameter(help="Email or identifier of the person requesting the block."),
1897    ] = None,
1898) -> None:
1899    """Add a `block-release.yaml` marker to prevent publishing a connector.
1900
1901    Creates a marker file in the connector's directory that causes the publish
1902    pipeline to skip the connector with a warning.
1903
1904    Example:
1905
1906        airbyte-ops local connector release-block add \\
1907            --name source-faker \\
1908            --reason "Version 5.0.1 yanked due to regression" \\
1909            --repo-path /path/to/airbyte
1910    """
1911    resolved_path = repo_path or os.environ.get("AIRBYTE_REPO_PATH", ".")
1912    result = add_release_block(
1913        repo_path=resolved_path,
1914        connector_name=name,
1915        reason=reason,
1916        yanked_version=yanked_version,
1917        blocked_by=blocked_by,
1918    )
1919    if result.success:
1920        error_console.print(f"[green]{result.message}[/green]")
1921    else:
1922        exit_with_error(result.message)
1923    print_json(result.to_dict())
1924
1925
1926@release_block_app.command(name="clear")
1927def release_block_clear(
1928    name: Annotated[
1929        str,
1930        Parameter(help="Connector technical name (e.g., source-faker)."),
1931    ],
1932    repo_path: Annotated[
1933        str | None,
1934        Parameter(help="Path to the Airbyte monorepo. Can be inferred from context."),
1935    ] = None,
1936) -> None:
1937    """Remove the `block-release.yaml` marker to allow publishing a connector.
1938
1939    Example:
1940
1941        airbyte-ops local connector release-block clear \\
1942            --name source-faker \\
1943            --repo-path /path/to/airbyte
1944    """
1945    resolved_path = repo_path or os.environ.get("AIRBYTE_REPO_PATH", ".")
1946    result = clear_release_block(
1947        repo_path=resolved_path,
1948        connector_name=name,
1949    )
1950    if result.success:
1951        error_console.print(f"[green]{result.message}[/green]")
1952    else:
1953        exit_with_error(result.message)
1954    print_json(result.to_dict())
1955
1956
1957ReleaseBlockListFormat = Literal["text", "json", "csv"]
1958
1959
1960@release_block_app.command(name="list")
1961def release_block_list(
1962    repo_path: Annotated[
1963        str | None,
1964        Parameter(help="Path to the Airbyte monorepo. Can be inferred from context."),
1965    ] = None,
1966    output_format: Annotated[
1967        ReleaseBlockListFormat,
1968        Parameter(
1969            help=(
1970                'Output format: "text" (human-readable), '
1971                '"json" (blocked connectors with block file contents), '
1972                '"csv" (comma-delimited connector names).'
1973            )
1974        ),
1975    ] = "text",
1976) -> None:
1977    """List all connectors that have a `block-release.yaml` marker.
1978
1979    Example:
1980
1981        airbyte-ops local connector release-block list --repo-path /path/to/airbyte
1982        airbyte-ops local connector release-block list --output-format json
1983        airbyte-ops local connector release-block list --output-format csv
1984    """
1985    resolved_path = repo_path or os.environ.get("AIRBYTE_REPO_PATH", ".")
1986    result = list_release_blocks(repo_path=resolved_path)
1987
1988    if output_format == "json":
1989        print_json(result.to_dict())
1990    elif output_format == "csv":
1991        names = [info.connector_name for info in result.blocked_connectors]
1992        sys.stdout.write(",".join(names) + "\n")
1993    else:
1994        # text format (default)
1995        if result.count == 0:
1996            console.print(
1997                "[green]No connectors are currently blocked from release.[/green]"
1998            )
1999        else:
2000            console.print(
2001                f"[yellow]{result.count} connector(s) blocked from release:[/yellow]"
2002            )
2003            for info in result.blocked_connectors:
2004                version_str = (
2005                    f" (yanked: {info.yanked_version})" if info.yanked_version else ""
2006                )
2007                console.print(f"  - {info.connector_name}: {info.reason}{version_str}")