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