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