airbyte_ops_mcp.mcp.github_ops

MCP tools for GitHub operations: CI workflow triggering/status, Docker image info, and issue/PR subscriptions.

MCP reference

MCP primitives registered by the github_ops module of the airbyte-internal-ops server: 7 tool(s), 0 prompt(s), 0 resource(s).

Tools (7)

check_ci_workflow_status

Check the status of a GitHub Actions workflow run.

You can provide either:

  • A full workflow URL (workflow_url parameter), OR
  • The component parts (owner, repo, run_id parameters)

Returns the current status, conclusion, and other details about the workflow run.

Uses the CI trigger token (GITHUB_CI_WORKFLOW_TRIGGER_PAT) so that workflow runs in private repositories are accessible.

Parameters:

Name Type Required Default Description
workflow_url string | null no null Full GitHub Actions workflow run URL (e.g., 'https://github.com/owner/repo/actions/runs/12345')
owner string | null no null Repository owner (e.g., 'airbytehq')
repo string | null no null Repository name (e.g., 'airbyte')
run_id integer | null no null Workflow run ID

Show input JSON schema

{
  "additionalProperties": false,
  "properties": {
    "workflow_url": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Full GitHub Actions workflow run URL (e.g., 'https://github.com/owner/repo/actions/runs/12345')"
    },
    "owner": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Repository owner (e.g., 'airbytehq')"
    },
    "repo": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Repository name (e.g., 'airbyte')"
    },
    "run_id": {
      "anyOf": [
        {
          "type": "integer"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Workflow run ID"
    }
  },
  "type": "object"
}

Show output JSON schema

{
  "description": "Response model for check_ci_workflow_status MCP tool.",
  "properties": {
    "run_id": {
      "type": "integer"
    },
    "status": {
      "type": "string"
    },
    "conclusion": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ]
    },
    "workflow_name": {
      "type": "string"
    },
    "head_branch": {
      "type": "string"
    },
    "head_sha": {
      "type": "string"
    },
    "html_url": {
      "type": "string"
    },
    "created_at": {
      "type": "string"
    },
    "updated_at": {
      "type": "string"
    },
    "run_started_at": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null
    },
    "jobs_url": {
      "type": "string"
    },
    "jobs": {
      "default": [],
      "items": {
        "description": "Information about a single job in a workflow run.",
        "properties": {
          "job_id": {
            "type": "integer"
          },
          "name": {
            "type": "string"
          },
          "status": {
            "type": "string"
          },
          "conclusion": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "default": null
          },
          "started_at": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "default": null
          },
          "completed_at": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "default": null
          }
        },
        "required": [
          "job_id",
          "name",
          "status"
        ],
        "type": "object"
      },
      "type": "array"
    }
  },
  "required": [
    "run_id",
    "status",
    "conclusion",
    "workflow_name",
    "head_branch",
    "head_sha",
    "html_url",
    "created_at",
    "updated_at",
    "jobs_url"
  ],
  "type": "object"
}

get_docker_image_info

Check if a Docker image exists on DockerHub.

Returns information about the image if it exists, or indicates if it doesn't exist. This is useful for confirming that a pre-release connector was successfully published.

Parameters:

Name Type Required Default Description
image string yes — Docker image name (e.g., 'airbyte/source-github')
tag string yes — Image tag (e.g., '2.1.5-preview.abc1234')

Show input JSON schema

{
  "additionalProperties": false,
  "properties": {
    "image": {
      "description": "Docker image name (e.g., 'airbyte/source-github')",
      "type": "string"
    },
    "tag": {
      "description": "Image tag (e.g., '2.1.5-preview.abc1234')",
      "type": "string"
    }
  },
  "required": [
    "image",
    "tag"
  ],
  "type": "object"
}

Show output JSON schema

{
  "description": "Response model for get_docker_image_info MCP tool.",
  "properties": {
    "exists": {
      "type": "boolean"
    },
    "image": {
      "type": "string"
    },
    "tag": {
      "type": "string"
    },
    "full_name": {
      "type": "string"
    },
    "digest": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null
    },
    "last_updated": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null
    },
    "size_bytes": {
      "anyOf": [
        {
          "type": "integer"
        },
        {
          "type": "null"
        }
      ],
      "default": null
    },
    "architecture": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null
    },
    "os": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null
    }
  },
  "required": [
    "exists",
    "image",
    "tag",
    "full_name"
  ],
  "type": "object"
}

list_github_subscriptions

List all active GitHub issue/PR subscriptions for this session.

Returns the list of GitHub issues and PRs that this session is currently subscribed to, along with their expiry times.

Parameters:

Name Type Required Default Description
agent_session_url string yes — Your Devin session URL. Use the session URL from your system prompt.

Show input JSON schema

{
  "additionalProperties": false,
  "properties": {
    "agent_session_url": {
      "description": "Your Devin session URL. Use the session URL from your system prompt.",
      "type": "string"
    }
  },
  "required": [
    "agent_session_url"
  ],
  "type": "object"
}

Show output JSON schema

{
  "description": "Response from the list_github_subscriptions tool.",
  "properties": {
    "success": {
      "description": "Whether the listing was successful",
      "type": "boolean"
    },
    "message": {
      "description": "Human-readable status message",
      "type": "string"
    },
    "subscriptions": {
      "description": "List of active subscriptions with id, github_url, expires_at",
      "items": {
        "additionalProperties": {
          "type": "string"
        },
        "type": "object"
      },
      "type": "array"
    }
  },
  "required": [
    "success",
    "message"
  ],
  "type": "object"
}

request_pr_ai_review

Request an AI code review on a pull request.

Requires GITHUB_CI_WORKFLOW_TRIGGER_PAT, a PAT for a GitHub user with a Copilot seat. The request is verified through GraphQL reviewRequests; a successful mutation response alone is not sufficient.

Parameters:

Name Type Required Default Description
repo string yes — Airbyte repository name, optionally prefixed with 'airbytehq/'
pr_number integer yes — Pull request number
request_to enum("DEFAULT", "Copilot") | array<enum("DEFAULT", "Copilot")> no "DEFAULT" AI reviewer to request; defaults to Copilot and accepts a single reviewer or a list of reviewers

Show input JSON schema

{
  "additionalProperties": false,
  "properties": {
    "repo": {
      "description": "Airbyte repository name, optionally prefixed with 'airbytehq/'",
      "type": "string"
    },
    "pr_number": {
      "description": "Pull request number",
      "type": "integer"
    },
    "request_to": {
      "anyOf": [
        {
          "description": "AI reviewer targets supported by pull request review requests.",
          "enum": [
            "DEFAULT",
            "Copilot"
          ],
          "type": "string"
        },
        {
          "items": {
            "description": "AI reviewer targets supported by pull request review requests.",
            "enum": [
              "DEFAULT",
              "Copilot"
            ],
            "type": "string"
          },
          "type": "array"
        }
      ],
      "default": "DEFAULT",
      "description": "AI reviewer to request; defaults to Copilot and accepts a single reviewer or a list of reviewers"
    }
  },
  "required": [
    "repo",
    "pr_number"
  ],
  "type": "object"
}

Show output JSON schema

{
  "description": "Response model for `request_pr_ai_review`.",
  "properties": {
    "requested": {
      "type": "boolean"
    },
    "reviewers": {
      "items": {
        "type": "string"
      },
      "type": "array"
    },
    "message": {
      "type": "string"
    }
  },
  "required": [
    "requested",
    "reviewers",
    "message"
  ],
  "type": "object"
}

subscribe_to_github_issue

Subscribe to notifications on a GitHub issue or pull request.

Creates a subscription that will deliver real-time notifications back to your Devin session when activity occurs on the specified GitHub issue or PR. Notifications are triggered by GitHub webhooks and delivered within seconds.

If you are already subscribed to the same issue/PR, the subscription is updated (TTL extended, watch events merged).

Use this tool when you need to monitor a GitHub issue or PR for changes, new comments, merges, closures, or other activity.

Parameters:

Name Type Required Default Description
github_url string yes — The GitHub issue or PR URL to subscribe to. Examples: https://github.com/airbytehq/airbyte/issues/123 or https://github.com/airbytehq/airbyte/pull/456
agent_session_url string yes — Your Devin session URL so notifications can be delivered back to your session. Use the session URL from your system prompt.
watch_events array<string> | null no null Optional list of event types to watch. Valid values: 'comment', 'close', 'merge', 'reopen', 'label', 'synchronize', 'ready_for_review', 'assigned'. Defaults to all events if not specified.
ttl_hours integer no 240 Number of hours until the subscription expires. Default is 240 (10 days).
slack_users_cc string | null no null Optional comma-delimited list of Slack user tags to CC on notifications. Example: '<@U12345>, <@U67890>'.

Show input JSON schema

{
  "additionalProperties": false,
  "properties": {
    "github_url": {
      "description": "The GitHub issue or PR URL to subscribe to. Examples: https://github.com/airbytehq/airbyte/issues/123 or https://github.com/airbytehq/airbyte/pull/456",
      "type": "string"
    },
    "agent_session_url": {
      "description": "Your Devin session URL so notifications can be delivered back to your session. Use the session URL from your system prompt.",
      "type": "string"
    },
    "watch_events": {
      "anyOf": [
        {
          "items": {
            "type": "string"
          },
          "type": "array"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Optional list of event types to watch. Valid values: 'comment', 'close', 'merge', 'reopen', 'label', 'synchronize', 'ready_for_review', 'assigned'. Defaults to all events if not specified."
    },
    "ttl_hours": {
      "default": 240,
      "description": "Number of hours until the subscription expires. Default is 240 (10 days).",
      "type": "integer"
    },
    "slack_users_cc": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Optional comma-delimited list of Slack user tags to CC on notifications. Example: '<@U12345>, <@U67890>'."
    }
  },
  "required": [
    "github_url",
    "agent_session_url"
  ],
  "type": "object"
}

Show output JSON schema

{
  "description": "Response from the subscribe_to_github_issue tool.",
  "properties": {
    "success": {
      "description": "Whether the subscription was created successfully",
      "type": "boolean"
    },
    "message": {
      "description": "Human-readable status message",
      "type": "string"
    },
    "subscription_id": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "ID of the created or updated subscription"
    },
    "github_url": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "GitHub URL being watched"
    },
    "expires_at": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "When the subscription expires (ISO 8601)"
    }
  },
  "required": [
    "success",
    "message"
  ],
  "type": "object"
}

trigger_ci_workflow

Trigger a GitHub Actions CI workflow via workflow_dispatch.

This tool triggers a workflow in any GitHub repository that has workflow_dispatch enabled. It resolves PR numbers to branch names automatically since GitHub's workflow_dispatch API only accepts branch names, not refs/pull/{pr}/head format. By default, it blocks until the dispatched workflow completes, up to the configured timeout.

Requires GITHUB_CI_WORKFLOW_TRIGGER_PAT or GITHUB_TOKEN environment variable with 'actions:write' permission.

Parameters:

Name Type Required Default Description
owner string yes — Repository owner (e.g., 'airbytehq')
repo string yes — Repository name (e.g., 'airbyte')
workflow_file string yes — Workflow file name (e.g., 'connector-regression-test.yml')
workflow_definition_ref string | null no null Branch name or PR number for the workflow definition to use. If a PR number (integer string) is provided, it resolves to the PR's head branch name. If a branch name is provided, it is used directly. Defaults to 'main' if not specified, or AIRBYTE_OPS_DEFAULT_WORKFLOW_BRANCH_OVERRIDE when set for local testing.
inputs object | null no null Workflow inputs as a dictionary of string key-value pairs. These are passed to the workflow_dispatch event.
wait_for_completion boolean no true Block until the workflow run completes and report its conclusion. Set False to return immediately after dispatch.
max_wait_seconds integer no 600 Maximum seconds to wait when wait_for_completion is True. On timeout the tool returns with completed=False; poll with check_ci_workflow_status.

Show input JSON schema

{
  "additionalProperties": false,
  "properties": {
    "owner": {
      "description": "Repository owner (e.g., 'airbytehq')",
      "type": "string"
    },
    "repo": {
      "description": "Repository name (e.g., 'airbyte')",
      "type": "string"
    },
    "workflow_file": {
      "description": "Workflow file name (e.g., 'connector-regression-test.yml')",
      "type": "string"
    },
    "workflow_definition_ref": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Branch name or PR number for the workflow definition to use. If a PR number (integer string) is provided, it resolves to the PR's head branch name. If a branch name is provided, it is used directly. Defaults to 'main' if not specified, or AIRBYTE_OPS_DEFAULT_WORKFLOW_BRANCH_OVERRIDE when set for local testing."
    },
    "inputs": {
      "anyOf": [
        {
          "additionalProperties": {
            "type": "string"
          },
          "type": "object"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Workflow inputs as a dictionary of string key-value pairs. These are passed to the workflow_dispatch event."
    },
    "wait_for_completion": {
      "default": true,
      "description": "Block until the workflow run completes and report its conclusion. Set False to return immediately after dispatch.",
      "type": "boolean"
    },
    "max_wait_seconds": {
      "default": 600,
      "description": "Maximum seconds to wait when wait_for_completion is True. On timeout the tool returns with completed=False; poll with check_ci_workflow_status.",
      "type": "integer"
    }
  },
  "required": [
    "owner",
    "repo",
    "workflow_file"
  ],
  "type": "object"
}

Show output JSON schema

{
  "description": "Response model for trigger_ci_workflow MCP tool.",
  "properties": {
    "success": {
      "type": "boolean"
    },
    "message": {
      "type": "string"
    },
    "workflow_url": {
      "type": "string"
    },
    "run_id": {
      "anyOf": [
        {
          "type": "integer"
        },
        {
          "type": "null"
        }
      ],
      "default": null
    },
    "run_url": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null
    },
    "completed": {
      "default": false,
      "type": "boolean"
    },
    "status": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null
    },
    "conclusion": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null
    },
    "failed_jobs": {
      "anyOf": [
        {
          "items": {
            "type": "string"
          },
          "type": "array"
        },
        {
          "type": "null"
        }
      ],
      "default": null
    }
  },
  "required": [
    "success",
    "message",
    "workflow_url"
  ],
  "type": "object"
}

unsubscribe_from_github_issue

Unsubscribe from notifications on a GitHub issue or pull request.

Removes an active subscription so you will no longer receive notifications for the specified issue/PR.

You can unsubscribe by:

  • Providing a specific subscription_id
  • Providing a github_url + session_url to unsubscribe from that specific issue/PR
  • Providing only session_url to unsubscribe from all issues/PRs

Parameters:

Name Type Required Default Description
agent_session_url string yes — Your Devin session URL. Use the session URL from your system prompt.
github_url string | null no null The GitHub issue or PR URL to unsubscribe from. If not provided, all subscriptions for this session are removed.
subscription_id string | null no null Optional specific subscription ID to remove. Use this if you know the exact subscription to cancel.

Show input JSON schema

{
  "additionalProperties": false,
  "properties": {
    "agent_session_url": {
      "description": "Your Devin session URL. Use the session URL from your system prompt.",
      "type": "string"
    },
    "github_url": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "The GitHub issue or PR URL to unsubscribe from. If not provided, all subscriptions for this session are removed."
    },
    "subscription_id": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Optional specific subscription ID to remove. Use this if you know the exact subscription to cancel."
    }
  },
  "required": [
    "agent_session_url"
  ],
  "type": "object"
}

Show output JSON schema

{
  "description": "Response from the unsubscribe_from_github_issue tool.",
  "properties": {
    "success": {
      "description": "Whether the unsubscribe was successful",
      "type": "boolean"
    },
    "message": {
      "description": "Human-readable status message",
      "type": "string"
    },
    "deleted_count": {
      "default": 0,
      "description": "Number of subscriptions removed",
      "type": "integer"
    }
  },
  "required": [
    "success",
    "message"
  ],
  "type": "object"
}

  1# Copyright (c) 2025 Airbyte, Inc., all rights reserved.
  2"""MCP tools for GitHub operations: CI workflow triggering/status, Docker image info, and issue/PR subscriptions.
  3
  4## MCP reference
  5
  6.. include:: ../../../docs/mcp-generated/github_ops.md
  7    :start-line: 2
  8"""
  9
 10from __future__ import annotations
 11
 12__all__: list[str] = []
 13
 14import logging
 15import os
 16import re
 17from typing import Annotated
 18
 19import requests
 20from fastmcp import FastMCP
 21from fastmcp_extensions import mcp_tool, register_mcp_tools
 22from pydantic import BaseModel, Field
 23
 24from airbyte_ops_mcp.github_actions import (
 25    format_failed_jobs,
 26    get_workflow_jobs,
 27    resolve_default_workflow_branch,
 28    trigger_workflow_dispatch,
 29    wait_for_workflow_completion,
 30)
 31from airbyte_ops_mcp.github_api import (
 32    GITHUB_API_BASE,
 33    AgentEnum,
 34    get_pr_head_ref,
 35    resolve_ci_trigger_github_token,
 36    resolve_copilot_review_github_token,
 37)
 38from airbyte_ops_mcp.github_api import (
 39    request_pr_ai_review as request_pr_ai_review_api,
 40)
 41
 42DOCKERHUB_API_BASE = "https://hub.docker.com/v2"
 43
 44
 45class JobInfo(BaseModel):
 46    """Information about a single job in a workflow run."""
 47
 48    job_id: int
 49    name: str
 50    status: str
 51    conclusion: str | None = None
 52    started_at: str | None = None
 53    completed_at: str | None = None
 54
 55
 56class WorkflowRunStatus(BaseModel):
 57    """Response model for check_ci_workflow_status MCP tool."""
 58
 59    run_id: int
 60    status: str
 61    conclusion: str | None
 62    workflow_name: str
 63    head_branch: str
 64    head_sha: str
 65    html_url: str
 66    created_at: str
 67    updated_at: str
 68    run_started_at: str | None = None
 69    jobs_url: str
 70    jobs: list[JobInfo] = []
 71
 72
 73def _parse_workflow_url(url: str) -> tuple[str, str, int]:
 74    """Parse a GitHub Actions workflow run URL into components.
 75
 76    Args:
 77        url: GitHub Actions workflow run URL
 78            (e.g., "https://github.com/owner/repo/actions/runs/12345")
 79
 80    Returns:
 81        Tuple of (owner, repo, run_id)
 82
 83    Raises:
 84        ValueError: If URL format is invalid.
 85    """
 86    pattern = r"https://github\.com/([^/]+)/([^/]+)/actions/runs/(\d+)"
 87    match = re.match(pattern, url)
 88    if not match:
 89        raise ValueError(
 90            f"Invalid workflow URL format: {url}. "
 91            "Expected format: https://github.com/owner/repo/actions/runs/12345"
 92        )
 93    return match.group(1), match.group(2), int(match.group(3))
 94
 95
 96def _get_workflow_run(
 97    owner: str,
 98    repo: str,
 99    run_id: int,
100    token: str,
101) -> dict:
102    """Get workflow run details from GitHub API.
103
104    Args:
105        owner: Repository owner (e.g., "airbytehq")
106        repo: Repository name (e.g., "airbyte")
107        run_id: Workflow run ID
108        token: GitHub API token
109
110    Returns:
111        Workflow run data dictionary.
112
113    Raises:
114        ValueError: If workflow run not found.
115        requests.HTTPError: If API request fails.
116    """
117    url = f"{GITHUB_API_BASE}/repos/{owner}/{repo}/actions/runs/{run_id}"
118    headers = {
119        "Authorization": f"Bearer {token}",
120        "Accept": "application/vnd.github+json",
121        "X-GitHub-Api-Version": "2022-11-28",
122    }
123
124    response = requests.get(url, headers=headers, timeout=30)
125    if response.status_code == 404:
126        raise ValueError(f"Workflow run {owner}/{repo}/actions/runs/{run_id} not found")
127    response.raise_for_status()
128
129    return response.json()
130
131
132@mcp_tool(
133    read_only=True,
134    idempotent=True,
135    open_world=True,
136)
137def check_ci_workflow_status(
138    workflow_url: Annotated[
139        str | None,
140        Field(
141            description="Full GitHub Actions workflow run URL (e.g., 'https://github.com/owner/repo/actions/runs/12345')"
142        ),
143    ] = None,
144    owner: Annotated[
145        str | None,
146        Field(description="Repository owner (e.g., 'airbytehq')"),
147    ] = None,
148    repo: Annotated[
149        str | None,
150        Field(description="Repository name (e.g., 'airbyte')"),
151    ] = None,
152    run_id: Annotated[
153        int | None,
154        Field(description="Workflow run ID"),
155    ] = None,
156) -> WorkflowRunStatus:
157    """Check the status of a GitHub Actions workflow run.
158
159    You can provide either:
160    - A full workflow URL (workflow_url parameter), OR
161    - The component parts (owner, repo, run_id parameters)
162
163    Returns the current status, conclusion, and other details about the workflow run.
164
165    Uses the CI trigger token (GITHUB_CI_WORKFLOW_TRIGGER_PAT) so that
166    workflow runs in private repositories are accessible.
167    """
168    # Guard: Validate input parameters
169    if workflow_url:
170        # Parse URL to get components
171        owner, repo, run_id = _parse_workflow_url(workflow_url)
172    elif owner and repo and run_id:
173        # Use provided components
174        pass
175    else:
176        raise ValueError(
177            "Must provide either workflow_url OR all of (owner, repo, run_id)"
178        )
179
180    # Guard: Check for required token
181    # Use the CI trigger token (same as trigger_ci_workflow) so that
182    # private-repo workflow runs are accessible.
183    token = resolve_ci_trigger_github_token()
184
185    # Get workflow run details
186    run_data = _get_workflow_run(owner, repo, run_id, token)
187
188    # Get jobs for the workflow run, passing the same token
189    workflow_jobs = get_workflow_jobs(owner, repo, run_id, token=token)
190
191    # Convert dataclass objects to Pydantic models for the response
192    jobs = [
193        JobInfo(
194            job_id=job.job_id,
195            name=job.name,
196            status=job.status,
197            conclusion=job.conclusion,
198            started_at=job.started_at,
199            completed_at=job.completed_at,
200        )
201        for job in workflow_jobs
202    ]
203
204    return WorkflowRunStatus(
205        run_id=run_data["id"],
206        status=run_data["status"],
207        conclusion=run_data["conclusion"],
208        workflow_name=run_data["name"],
209        head_branch=run_data["head_branch"],
210        head_sha=run_data["head_sha"],
211        html_url=run_data["html_url"],
212        created_at=run_data["created_at"],
213        updated_at=run_data["updated_at"],
214        run_started_at=run_data.get("run_started_at"),
215        jobs_url=run_data["jobs_url"],
216        jobs=jobs,
217    )
218
219
220class TriggerCIWorkflowResult(BaseModel):
221    """Response model for trigger_ci_workflow MCP tool."""
222
223    success: bool
224    message: str
225    workflow_url: str
226    run_id: int | None = None
227    run_url: str | None = None
228    completed: bool = False
229    status: str | None = None
230    conclusion: str | None = None
231    failed_jobs: list[str] | None = None
232
233
234class AIReviewResult(BaseModel):
235    """Response model for `request_pr_ai_review`."""
236
237    requested: bool
238    reviewers: list[str]
239    message: str
240
241
242def _normalize_airbyte_repo(repo: str) -> str:
243    """Normalize an Airbyte repository name and reject other owners."""
244    if not repo or repo.count("/") > 1:
245        raise ValueError(
246            f"Invalid repository '{repo}': expected '<repo>' or 'airbytehq/<repo>'."
247        )
248    if "/" not in repo:
249        return repo
250    owner, repository = repo.split("/")
251    if owner != "airbytehq":
252        raise ValueError(
253            f"Repository owner must be 'airbytehq', but received '{owner}'."
254        )
255    if not repository:
256        raise ValueError(
257            f"Invalid repository '{repo}': repository name cannot be empty."
258        )
259    return repository
260
261
262@mcp_tool(
263    read_only=False,
264    idempotent=False,
265    open_world=True,
266)
267def request_pr_ai_review(
268    repo: Annotated[
269        str,
270        Field(
271            description="Airbyte repository name, optionally prefixed with 'airbytehq/'"
272        ),
273    ],
274    pr_number: Annotated[
275        int,
276        Field(description="Pull request number"),
277    ],
278    request_to: Annotated[
279        AgentEnum | list[AgentEnum],
280        Field(
281            description="AI reviewer to request; defaults to Copilot and accepts "
282            "a single reviewer or a list of reviewers"
283        ),
284    ] = AgentEnum.DEFAULT,
285) -> AIReviewResult:
286    """Request an AI code review on a pull request.
287
288    Requires `GITHUB_CI_WORKFLOW_TRIGGER_PAT`, a PAT for a GitHub user with a
289    Copilot seat. The request is verified through GraphQL `reviewRequests`;
290    a successful mutation response alone is not sufficient.
291    """
292    normalized_repo = _normalize_airbyte_repo(repo)
293    token = resolve_copilot_review_github_token()
294    result = request_pr_ai_review_api(
295        "airbytehq", normalized_repo, pr_number, token, request_to
296    )
297    return AIReviewResult(
298        requested=result.requested,
299        reviewers=result.reviewers,
300        message=result.message,
301    )
302
303
304@mcp_tool(
305    read_only=False,
306    idempotent=False,
307    open_world=True,
308)
309def trigger_ci_workflow(
310    owner: Annotated[
311        str,
312        Field(description="Repository owner (e.g., 'airbytehq')"),
313    ],
314    repo: Annotated[
315        str,
316        Field(description="Repository name (e.g., 'airbyte')"),
317    ],
318    workflow_file: Annotated[
319        str,
320        Field(description="Workflow file name (e.g., 'connector-regression-test.yml')"),
321    ],
322    workflow_definition_ref: Annotated[
323        str | None,
324        Field(
325            description="Branch name or PR number for the workflow definition to use. "
326            "If a PR number (integer string) is provided, it resolves to the PR's head branch name. "
327            "If a branch name is provided, it is used directly. "
328            "Defaults to 'main' if not specified, "
329            "or AIRBYTE_OPS_DEFAULT_WORKFLOW_BRANCH_OVERRIDE when set for local testing."
330        ),
331    ] = None,
332    inputs: Annotated[
333        dict[str, str] | None,
334        Field(
335            description="Workflow inputs as a dictionary of string key-value pairs. "
336            "These are passed to the workflow_dispatch event."
337        ),
338    ] = None,
339    wait_for_completion: Annotated[
340        bool,
341        Field(
342            description="Block until the workflow run completes and report its conclusion. "
343            "Set False to return immediately after dispatch."
344        ),
345    ] = True,
346    max_wait_seconds: Annotated[
347        int,
348        Field(
349            description="Maximum seconds to wait when wait_for_completion is True. "
350            "On timeout the tool returns with completed=False; poll with "
351            "check_ci_workflow_status."
352        ),
353    ] = 600,
354) -> TriggerCIWorkflowResult:
355    """Trigger a GitHub Actions CI workflow via workflow_dispatch.
356
357    This tool triggers a workflow in any GitHub repository that has workflow_dispatch
358    enabled. It resolves PR numbers to branch names automatically since GitHub's
359    workflow_dispatch API only accepts branch names, not refs/pull/{pr}/head format.
360    By default, it blocks until the dispatched workflow completes, up to the configured timeout.
361
362    Requires GITHUB_CI_WORKFLOW_TRIGGER_PAT or GITHUB_TOKEN environment variable
363    with 'actions:write' permission.
364    """
365    # Guard: Check for required token
366    token = resolve_ci_trigger_github_token()
367
368    if workflow_definition_ref:
369        if workflow_definition_ref.isdigit():
370            pr_head_info = get_pr_head_ref(
371                owner,
372                repo,
373                int(workflow_definition_ref),
374                token,
375            )
376            resolved_ref = pr_head_info.ref
377        else:
378            resolved_ref = workflow_definition_ref
379    else:
380        resolved_ref = resolve_default_workflow_branch("main")
381
382    # Trigger the workflow
383    result = trigger_workflow_dispatch(
384        owner=owner,
385        repo=repo,
386        workflow_file=workflow_file,
387        ref=resolved_ref,
388        inputs=inputs or {},
389        token=token,
390        find_run=True,
391    )
392
393    # Build response message
394    if result.run_id:
395        message = f"Successfully triggered workflow {workflow_file} on {owner}/{repo} (ref: {resolved_ref}). Run ID: {result.run_id}"
396    else:
397        message = f"Successfully triggered workflow {workflow_file} on {owner}/{repo} (ref: {resolved_ref}). Run ID not yet available."
398
399    if not wait_for_completion:
400        return TriggerCIWorkflowResult(
401            success=True,
402            message=message,
403            workflow_url=result.workflow_url,
404            run_id=result.run_id,
405            run_url=result.run_url,
406        )
407
408    if result.run_id is None:
409        return TriggerCIWorkflowResult(
410            success=True,
411            message=(
412                f"Successfully triggered workflow {workflow_file} on {owner}/{repo} "
413                f"(ref: {resolved_ref}), but the run ID could not be discovered, "
414                f"so completion could not be verified. Check workflow URL: "
415                f"{result.workflow_url}"
416            ),
417            workflow_url=result.workflow_url,
418            run_id=None,
419            run_url=result.run_url,
420        )
421
422    bounded_max_wait_seconds = max(10, min(max_wait_seconds, 1800))
423    run_status = wait_for_workflow_completion(
424        owner=owner,
425        repo=repo,
426        run_id=result.run_id,
427        token=token,
428        poll_interval_seconds=10.0,
429        max_wait_seconds=bounded_max_wait_seconds,
430    )
431    run_url = run_status.run_url or result.run_url or result.workflow_url
432
433    if run_status.succeeded:
434        return TriggerCIWorkflowResult(
435            success=True,
436            message=f"Workflow {workflow_file} completed successfully. Run: {run_url}",
437            workflow_url=result.workflow_url,
438            run_id=result.run_id,
439            run_url=run_url,
440            completed=True,
441            status=run_status.status,
442            conclusion=run_status.conclusion,
443        )
444
445    if run_status.failed:
446        failed_jobs = format_failed_jobs(run_status)
447        failed_jobs_message = (
448            f" Non-successful jobs: {', '.join(failed_jobs)}" if failed_jobs else ""
449        )
450        return TriggerCIWorkflowResult(
451            success=False,
452            message=(
453                f"Workflow run did not succeed (conclusion={run_status.conclusion}). "
454                f"Run: {run_url}.{failed_jobs_message}"
455            ),
456            workflow_url=result.workflow_url,
457            run_id=result.run_id,
458            run_url=run_url,
459            completed=True,
460            status=run_status.status,
461            conclusion=run_status.conclusion,
462            failed_jobs=failed_jobs,
463        )
464
465    return TriggerCIWorkflowResult(
466        success=True,
467        message=(
468            f"Workflow has not yet completed, and the {bounded_max_wait_seconds}s "
469            f"timeout has elapsed (status={run_status.status}). "
470            f"Poll with check_ci_workflow_status: "
471            f"{run_url}"
472        ),
473        workflow_url=result.workflow_url,
474        run_id=result.run_id,
475        run_url=run_url,
476        completed=False,
477        status=run_status.status,
478        conclusion=run_status.conclusion,
479    )
480
481
482class DockerImageInfo(BaseModel):
483    """Response model for get_docker_image_info MCP tool."""
484
485    exists: bool
486    image: str
487    tag: str
488    full_name: str
489    digest: str | None = None
490    last_updated: str | None = None
491    size_bytes: int | None = None
492    architecture: str | None = None
493    os: str | None = None
494
495
496def _check_dockerhub_image(
497    image: str,
498    tag: str,
499) -> dict | None:
500    """Check if a Docker image tag exists on DockerHub.
501
502    Args:
503        image: Docker image name (e.g., "airbyte/source-github")
504        tag: Image tag (e.g., "2.1.5-preview.abc1234")
505
506    Returns:
507        Tag data dictionary if found, None if not found.
508    """
509    # DockerHub API endpoint for tag info
510    url = f"{DOCKERHUB_API_BASE}/repositories/{image}/tags/{tag}"
511
512    response = requests.get(url, timeout=30)
513    if response.status_code == 404:
514        return None
515    response.raise_for_status()
516
517    return response.json()
518
519
520@mcp_tool(
521    read_only=True,
522    idempotent=True,
523    open_world=True,
524)
525def get_docker_image_info(
526    image: Annotated[
527        str,
528        Field(description="Docker image name (e.g., 'airbyte/source-github')"),
529    ],
530    tag: Annotated[
531        str,
532        Field(description="Image tag (e.g., '2.1.5-preview.abc1234')"),
533    ],
534) -> DockerImageInfo:
535    """Check if a Docker image exists on DockerHub.
536
537    Returns information about the image if it exists, or indicates if it doesn't exist.
538    This is useful for confirming that a pre-release connector was successfully published.
539    """
540    full_name = f"{image}:{tag}"
541    tag_data = _check_dockerhub_image(image, tag)
542
543    if not tag_data:
544        return DockerImageInfo(
545            exists=False,
546            image=image,
547            tag=tag,
548            full_name=full_name,
549        )
550
551    # Extract image details from the first image in the list (if available)
552    images = tag_data.get("images", [])
553    first_image = images[0] if images else {}
554
555    return DockerImageInfo(
556        exists=True,
557        image=image,
558        tag=tag,
559        full_name=full_name,
560        digest=tag_data.get("digest"),
561        last_updated=tag_data.get("last_updated"),
562        size_bytes=first_image.get("size"),
563        architecture=first_image.get("architecture"),
564        os=first_image.get("os"),
565    )
566
567
568logger = logging.getLogger(__name__)
569
570SUBSCRIPTION_API_URL_ENV = "SUBSCRIPTION_API_URL"
571
572SUBSCRIPTION_API_TOKEN_ENV = "SUBSCRIPTION_API_BEARER_TOKEN"
573
574
575def _get_api_url() -> str:
576    """Get the subscription API base URL."""
577    url = os.environ.get(SUBSCRIPTION_API_URL_ENV)
578    if not url:
579        raise ValueError(
580            f"{SUBSCRIPTION_API_URL_ENV} environment variable is not set. "
581            "Cannot reach the GitHub subscriptions backend."
582        )
583    return url.rstrip("/")
584
585
586def _get_api_token() -> str:
587    """Get the subscription API bearer token."""
588    token = os.environ.get(SUBSCRIPTION_API_TOKEN_ENV)
589    if not token:
590        raise ValueError(
591            f"{SUBSCRIPTION_API_TOKEN_ENV} environment variable is not set. "
592            "Cannot authenticate to the GitHub subscriptions backend."
593        )
594    return token
595
596
597def _api_headers() -> dict[str, str]:
598    """Build headers for API requests."""
599    return {
600        "Authorization": f"Bearer {_get_api_token()}",
601        "Content-Type": "application/json",
602    }
603
604
605class SubscribeResponse(BaseModel):
606    """Response from the subscribe_to_github_issue tool."""
607
608    success: bool = Field(
609        description="Whether the subscription was created successfully"
610    )
611    message: str = Field(description="Human-readable status message")
612    subscription_id: str | None = Field(
613        default=None,
614        description="ID of the created or updated subscription",
615    )
616    github_url: str | None = Field(
617        default=None,
618        description="GitHub URL being watched",
619    )
620    expires_at: str | None = Field(
621        default=None,
622        description="When the subscription expires (ISO 8601)",
623    )
624
625
626class UnsubscribeResponse(BaseModel):
627    """Response from the unsubscribe_from_github_issue tool."""
628
629    success: bool = Field(description="Whether the unsubscribe was successful")
630    message: str = Field(description="Human-readable status message")
631    deleted_count: int = Field(
632        default=0,
633        description="Number of subscriptions removed",
634    )
635
636
637class ListSubscriptionsResponse(BaseModel):
638    """Response from the list_github_subscriptions tool."""
639
640    success: bool = Field(description="Whether the listing was successful")
641    message: str = Field(description="Human-readable status message")
642    subscriptions: list[dict[str, str]] = Field(
643        default_factory=list,
644        description="List of active subscriptions with id, github_url, expires_at",
645    )
646
647
648@mcp_tool(
649    read_only=False,
650    idempotent=True,
651    open_world=True,
652)
653def subscribe_to_github_issue(
654    github_url: Annotated[
655        str,
656        "The GitHub issue or PR URL to subscribe to. "
657        "Examples: https://github.com/airbytehq/airbyte/issues/123 "
658        "or https://github.com/airbytehq/airbyte/pull/456",
659    ],
660    agent_session_url: Annotated[
661        str,
662        "Your Devin session URL so notifications can be delivered back to "
663        "your session. Use the session URL from your system prompt.",
664    ],
665    watch_events: Annotated[
666        list[str] | None,
667        "Optional list of event types to watch. Valid values: "
668        "'comment', 'close', 'merge', 'reopen', 'label', 'synchronize', "
669        "'ready_for_review', 'assigned'. Defaults to all events if not specified.",
670    ] = None,
671    ttl_hours: Annotated[
672        int,
673        "Number of hours until the subscription expires. Default is 240 (10 days).",
674    ] = 240,
675    slack_users_cc: Annotated[
676        str | None,
677        "Optional comma-delimited list of Slack user tags to CC on "
678        "notifications. Example: '<@U12345>, <@U67890>'.",
679    ] = None,
680) -> SubscribeResponse:
681    """Subscribe to notifications on a GitHub issue or pull request.
682
683    Creates a subscription that will deliver real-time notifications back
684    to your Devin session when activity occurs on the specified GitHub
685    issue or PR. Notifications are triggered by GitHub webhooks and
686    delivered within seconds.
687
688    If you are already subscribed to the same issue/PR, the subscription
689    is updated (TTL extended, watch events merged).
690
691    Use this tool when you need to monitor a GitHub issue or PR for
692    changes, new comments, merges, closures, or other activity.
693    """
694    try:
695        api_url = _get_api_url()
696        body: dict[str, str | list[str] | int | None] = {
697            "github_url": github_url,
698            "session_url": agent_session_url,
699            "ttl_hours": ttl_hours,
700        }
701        if watch_events:
702            body["watch_events"] = watch_events
703        if slack_users_cc:
704            body["slack_users_cc"] = slack_users_cc
705
706        response = requests.post(
707            f"{api_url}/subscriptions",
708            json=body,
709            headers=_api_headers(),
710            timeout=10,
711        )
712        response.raise_for_status()
713        data = response.json()
714
715        return SubscribeResponse(
716            success=True,
717            message=(
718                f"Subscribed to {github_url}. "
719                f"You will receive notifications in this session until "
720                f"{data.get('expires_at', 'expiry unknown')}."
721            ),
722            subscription_id=data.get("id"),
723            github_url=github_url,
724            expires_at=data.get("expires_at"),
725        )
726
727    except ValueError as e:
728        return SubscribeResponse(
729            success=False,
730            message=f"Configuration error: {e}",
731        )
732    except requests.RequestException as e:
733        logger.exception("Failed to create subscription")
734        return SubscribeResponse(
735            success=False,
736            message=f"Failed to create subscription: {e}",
737        )
738
739
740@mcp_tool(
741    read_only=False,
742    idempotent=True,
743    open_world=True,
744)
745def unsubscribe_from_github_issue(
746    agent_session_url: Annotated[
747        str,
748        "Your Devin session URL. Use the session URL from your system prompt.",
749    ],
750    github_url: Annotated[
751        str | None,
752        "The GitHub issue or PR URL to unsubscribe from. "
753        "If not provided, all subscriptions for this session are removed.",
754    ] = None,
755    subscription_id: Annotated[
756        str | None,
757        "Optional specific subscription ID to remove. "
758        "Use this if you know the exact subscription to cancel.",
759    ] = None,
760) -> UnsubscribeResponse:
761    """Unsubscribe from notifications on a GitHub issue or pull request.
762
763    Removes an active subscription so you will no longer receive
764    notifications for the specified issue/PR.
765
766    You can unsubscribe by:
767    - Providing a specific subscription_id
768    - Providing a github_url + session_url to unsubscribe from that specific issue/PR
769    - Providing only session_url to unsubscribe from all issues/PRs
770    """
771    try:
772        api_url = _get_api_url()
773
774        if subscription_id:
775            # Delete by ID
776            response = requests.delete(
777                f"{api_url}/subscriptions/{subscription_id}",
778                headers=_api_headers(),
779                timeout=10,
780            )
781        else:
782            # Delete by match
783            params: dict[str, str] = {"session_url": agent_session_url}
784            if github_url:
785                params["github_url"] = github_url
786            response = requests.delete(
787                f"{api_url}/subscriptions",
788                params=params,
789                headers=_api_headers(),
790                timeout=10,
791            )
792
793        response.raise_for_status()
794        data = response.json()
795        count = data.get("deleted_count", 0)
796
797        return UnsubscribeResponse(
798            success=True,
799            message=f"Removed {count} subscription(s).",
800            deleted_count=count,
801        )
802
803    except ValueError as e:
804        return UnsubscribeResponse(
805            success=False,
806            message=f"Configuration error: {e}",
807        )
808    except requests.RequestException as e:
809        logger.exception("Failed to unsubscribe")
810        return UnsubscribeResponse(
811            success=False,
812            message=f"Failed to unsubscribe: {e}",
813        )
814
815
816@mcp_tool(
817    read_only=True,
818    idempotent=True,
819    open_world=True,
820)
821def list_github_subscriptions(
822    agent_session_url: Annotated[
823        str,
824        "Your Devin session URL. Use the session URL from your system prompt.",
825    ],
826) -> ListSubscriptionsResponse:
827    """List all active GitHub issue/PR subscriptions for this session.
828
829    Returns the list of GitHub issues and PRs that this session is
830    currently subscribed to, along with their expiry times.
831    """
832    try:
833        api_url = _get_api_url()
834
835        response = requests.get(
836            f"{api_url}/subscriptions",
837            params={"session_url": agent_session_url},
838            headers=_api_headers(),
839            timeout=10,
840        )
841        response.raise_for_status()
842        data = response.json()
843
844        subs = [
845            {
846                "id": s["id"],
847                "github_url": s["github_url"],
848                "watch_events": ", ".join(s.get("watch_events", [])),
849                "expires_at": s.get("expires_at", "unknown"),
850            }
851            for s in data
852        ]
853
854        if not subs:
855            return ListSubscriptionsResponse(
856                success=True,
857                message="No active subscriptions for this session.",
858                subscriptions=[],
859            )
860
861        return ListSubscriptionsResponse(
862            success=True,
863            message=f"Found {len(subs)} active subscription(s).",
864            subscriptions=subs,
865        )
866
867    except ValueError as e:
868        return ListSubscriptionsResponse(
869            success=False,
870            message=f"Configuration error: {e}",
871        )
872    except requests.RequestException as e:
873        logger.exception("Failed to list subscriptions")
874        return ListSubscriptionsResponse(
875            success=False,
876            message=f"Failed to list subscriptions: {e}",
877        )
878
879
880def register_github_ops_tools(app: FastMCP) -> None:
881    """Register github_ops tools with the FastMCP app."""
882    register_mcp_tools(app, mcp_module=__name__)