mirror of
https://github.com/langgenius/dify-plugin-sdks.git
synced 2026-07-22 10:25:23 -04:00
b182fd2668
- Refactored `TriggerInvokeResponse` to use `Mapping[str, Any]` for event representation. - Renamed `properties` to `variables` in the `Event` class for clarity and updated the description accordingly. - Expanded the event options in the GitHub trigger configuration to allow selection of all events. - Removed the deprecated `issue_comment` trigger implementation and its associated files. - Bumped version in `manifest.yaml` to 0.0.9 to reflect these changes. These updates improve the flexibility and clarity of the GitHub trigger integration, allowing for a broader range of event handling.
530 lines
22 KiB
Python
530 lines
22 KiB
Python
import hashlib
|
|
import hmac
|
|
import secrets
|
|
import time
|
|
import urllib.parse
|
|
import uuid
|
|
from collections.abc import Mapping
|
|
from typing import Any
|
|
|
|
import requests
|
|
from utils.dynamic_options import fetch_repositories
|
|
from werkzeug import Request, Response
|
|
|
|
from dify_plugin.entities import ParameterOption
|
|
from dify_plugin.entities.oauth import TriggerOAuthCredentials
|
|
from dify_plugin.entities.trigger import Subscription, TriggerDispatch, Unsubscription
|
|
from dify_plugin.errors.trigger import (
|
|
SubscriptionError,
|
|
TriggerDispatchError,
|
|
TriggerProviderCredentialValidationError,
|
|
TriggerProviderOAuthError,
|
|
TriggerValidationError,
|
|
)
|
|
from dify_plugin.interfaces.trigger import TriggerProvider
|
|
|
|
|
|
class GithubProvider(TriggerProvider):
|
|
_AUTH_URL = "https://github.com/login/oauth/authorize"
|
|
_TOKEN_URL = "https://github.com/login/oauth/access_token"
|
|
_API_USER_URL = "https://api.github.com/user"
|
|
|
|
def _oauth_get_authorization_url(self, redirect_uri: str, system_credentials: Mapping[str, Any]) -> str:
|
|
"""
|
|
Generate the authorization URL for the Github OAuth.
|
|
"""
|
|
state = secrets.token_urlsafe(16)
|
|
params = {
|
|
"client_id": system_credentials["client_id"],
|
|
"redirect_uri": redirect_uri,
|
|
# must contain webhook scope
|
|
"scope": system_credentials.get("scope", "read:user admin:repo_hook"),
|
|
"state": state,
|
|
}
|
|
return f"{self._AUTH_URL}?{urllib.parse.urlencode(params)}"
|
|
|
|
def _oauth_get_credentials(
|
|
self, redirect_uri: str, system_credentials: Mapping[str, Any], request: Request
|
|
) -> TriggerOAuthCredentials:
|
|
"""
|
|
Exchange code for access_token.
|
|
"""
|
|
code = request.args.get("code")
|
|
if not code:
|
|
raise TriggerProviderOAuthError("No code provided")
|
|
# Optionally: validate state here
|
|
|
|
data = {
|
|
"client_id": system_credentials["client_id"],
|
|
"client_secret": system_credentials["client_secret"],
|
|
"code": code,
|
|
"redirect_uri": redirect_uri,
|
|
}
|
|
headers = {"Accept": "application/json"}
|
|
response = requests.post(self._TOKEN_URL, data=data, headers=headers, timeout=10)
|
|
response_json = response.json()
|
|
access_tokens = response_json.get("access_token")
|
|
if not access_tokens:
|
|
raise TriggerProviderOAuthError(f"Error in GitHub OAuth: {response_json}")
|
|
|
|
return TriggerOAuthCredentials(credentials={"access_tokens": access_tokens}, expires_at=-1)
|
|
|
|
def _validate_credentials(self, credentials: dict) -> None:
|
|
try:
|
|
if "access_tokens" not in credentials or not credentials.get("access_tokens"):
|
|
raise TriggerProviderCredentialValidationError("GitHub API Access Token is required.")
|
|
headers = {
|
|
"Authorization": f"Bearer {credentials['access_tokens']}",
|
|
"Accept": "application/vnd.github+json",
|
|
}
|
|
response = requests.get(self._API_USER_URL, headers=headers, timeout=10)
|
|
if response.status_code != 200:
|
|
raise TriggerProviderCredentialValidationError(response.json().get("message"))
|
|
except Exception as e:
|
|
raise TriggerProviderCredentialValidationError(str(e)) from e
|
|
|
|
def _dispatch_event(self, subscription: Subscription, request: Request) -> TriggerDispatch:
|
|
"""
|
|
Dispatch GitHub webhook events to appropriate triggers
|
|
"""
|
|
# Verify webhook signature if secret is provided
|
|
webhook_secret = subscription.properties.get("webhook_secret")
|
|
if webhook_secret:
|
|
signature = request.headers.get("X-Hub-Signature-256")
|
|
if not signature:
|
|
raise TriggerValidationError("Missing webhook signature")
|
|
|
|
# Verify the signature
|
|
expected_signature = (
|
|
"sha256=" + hmac.new(webhook_secret.encode(), request.get_data(), hashlib.sha256).hexdigest()
|
|
)
|
|
|
|
if not hmac.compare_digest(signature, expected_signature):
|
|
raise TriggerValidationError("Invalid webhook signature")
|
|
|
|
event_type = request.headers.get("X-GitHub-Event")
|
|
if not event_type:
|
|
raise TriggerDispatchError("Missing GitHub event type header")
|
|
|
|
try:
|
|
# GitHub webhooks can send data as form-encoded or JSON
|
|
content_type = request.headers.get("Content-Type", "")
|
|
|
|
if "application/x-www-form-urlencoded" in content_type:
|
|
# For form-encoded data, the payload is in the 'payload' field
|
|
import json
|
|
|
|
form_data = request.form.get("payload")
|
|
if not form_data:
|
|
raise TriggerDispatchError("Missing payload in form data")
|
|
payload = json.loads(form_data)
|
|
else:
|
|
# For JSON content type or when Content-Type is missing/other
|
|
payload = request.get_json(force=True)
|
|
|
|
if not payload:
|
|
raise TriggerDispatchError("Empty request body")
|
|
except Exception as e:
|
|
raise TriggerDispatchError(f"Failed to parse payload: {e}") from e
|
|
|
|
# Get the action from payload if present
|
|
action = payload.get("action", "")
|
|
|
|
response = Response(response='{"status": "ok"}', status=200, mimetype="application/json")
|
|
|
|
# Create list of triggers to dispatch
|
|
triggers = []
|
|
|
|
# Map GitHub events to trigger names based on event type and action
|
|
if event_type == "issue_comment":
|
|
if action == "created":
|
|
triggers.append("issue_comment_created")
|
|
elif action == "edited":
|
|
triggers.append("issue_comment_edited")
|
|
elif action == "deleted":
|
|
triggers.append("issue_comment_deleted")
|
|
|
|
elif event_type == "issues":
|
|
if action == "opened":
|
|
triggers.append("issue_opened")
|
|
elif action == "closed":
|
|
triggers.append("issue_closed")
|
|
elif action == "reopened":
|
|
triggers.append("issue_reopened")
|
|
elif action == "edited":
|
|
triggers.append("issue_edited")
|
|
elif action == "assigned":
|
|
triggers.append("issue_assigned")
|
|
elif action == "unassigned":
|
|
triggers.append("issue_unassigned")
|
|
elif action == "labeled":
|
|
triggers.append("issue_labeled")
|
|
elif action == "unlabeled":
|
|
triggers.append("issue_unlabeled")
|
|
|
|
elif event_type == "pull_request":
|
|
if action == "opened":
|
|
triggers.append("pull_request_opened")
|
|
elif action == "closed":
|
|
triggers.append("pull_request_closed")
|
|
elif action == "reopened":
|
|
triggers.append("pull_request_reopened")
|
|
elif action == "edited":
|
|
triggers.append("pull_request_edited")
|
|
elif action == "synchronize":
|
|
triggers.append("pull_request_synchronize")
|
|
elif action == "converted_to_draft":
|
|
triggers.append("pull_request_converted_to_draft")
|
|
elif action == "ready_for_review":
|
|
triggers.append("pull_request_ready_for_review")
|
|
elif action == "locked":
|
|
triggers.append("pull_request_locked")
|
|
elif action == "unlocked":
|
|
triggers.append("pull_request_unlocked")
|
|
elif action == "assigned":
|
|
triggers.append("pull_request_assigned")
|
|
elif action == "unassigned":
|
|
triggers.append("pull_request_unassigned")
|
|
elif action == "labeled":
|
|
triggers.append("pull_request_labeled")
|
|
elif action == "unlabeled":
|
|
triggers.append("pull_request_unlabeled")
|
|
elif action == "review_requested":
|
|
triggers.append("pull_request_review_requested")
|
|
elif action == "review_request_removed":
|
|
triggers.append("pull_request_review_request_removed")
|
|
elif action == "auto_merge_enabled":
|
|
triggers.append("pull_request_auto_merge_enabled")
|
|
elif action == "auto_merge_disabled":
|
|
triggers.append("pull_request_auto_merge_disabled")
|
|
|
|
elif event_type == "pull_request_review":
|
|
if action == "submitted":
|
|
triggers.append("pull_request_review_submitted")
|
|
elif action == "edited":
|
|
triggers.append("pull_request_review_edited")
|
|
elif action == "dismissed":
|
|
triggers.append("pull_request_review_dismissed")
|
|
|
|
elif event_type == "pull_request_review_comment":
|
|
if action == "created":
|
|
triggers.append("pull_request_review_comment_created")
|
|
elif action == "edited":
|
|
triggers.append("pull_request_review_comment_edited")
|
|
elif action == "deleted":
|
|
triggers.append("pull_request_review_comment_deleted")
|
|
|
|
elif event_type == "push":
|
|
triggers.append("push")
|
|
|
|
elif event_type == "create":
|
|
triggers.append("create")
|
|
|
|
elif event_type == "delete":
|
|
triggers.append("delete")
|
|
|
|
elif event_type == "commit_comment":
|
|
triggers.append("commit_comment")
|
|
|
|
elif event_type == "release":
|
|
if action == "published":
|
|
triggers.append("release_published")
|
|
elif action == "unpublished":
|
|
triggers.append("release_unpublished")
|
|
elif action == "created":
|
|
triggers.append("release_created")
|
|
elif action == "edited":
|
|
triggers.append("release_edited")
|
|
elif action == "deleted":
|
|
triggers.append("release_deleted")
|
|
elif action == "prereleased":
|
|
triggers.append("release_prereleased")
|
|
elif action == "released":
|
|
triggers.append("release_released")
|
|
|
|
elif event_type == "repository":
|
|
if action == "created":
|
|
triggers.append("repository_created")
|
|
elif action == "deleted":
|
|
triggers.append("repository_deleted")
|
|
elif action == "archived":
|
|
triggers.append("repository_archived")
|
|
elif action == "unarchived":
|
|
triggers.append("repository_unarchived")
|
|
elif action == "edited":
|
|
triggers.append("repository_edited")
|
|
elif action == "renamed":
|
|
triggers.append("repository_renamed")
|
|
elif action == "transferred":
|
|
triggers.append("repository_transferred")
|
|
elif action == "publicized":
|
|
triggers.append("repository_publicized")
|
|
elif action == "privatized":
|
|
triggers.append("repository_privatized")
|
|
|
|
elif event_type == "fork":
|
|
triggers.append("fork")
|
|
|
|
elif event_type == "star":
|
|
if action == "created":
|
|
triggers.append("star_created")
|
|
elif action == "deleted":
|
|
triggers.append("star_deleted")
|
|
|
|
elif event_type == "watch":
|
|
triggers.append("watch")
|
|
|
|
elif event_type == "member":
|
|
if action == "added":
|
|
triggers.append("member_added")
|
|
elif action == "removed":
|
|
triggers.append("member_removed")
|
|
elif action == "edited":
|
|
triggers.append("member_edited")
|
|
|
|
elif event_type == "workflow_run":
|
|
if action == "requested":
|
|
triggers.append("workflow_run_requested")
|
|
elif action == "completed":
|
|
triggers.append("workflow_run_completed")
|
|
elif action == "in_progress":
|
|
triggers.append("workflow_run_in_progress")
|
|
|
|
elif event_type == "workflow_dispatch":
|
|
triggers.append("workflow_dispatch")
|
|
|
|
elif event_type == "deployment":
|
|
triggers.append("deployment_created")
|
|
|
|
elif event_type == "deployment_status":
|
|
triggers.append("deployment_status_created")
|
|
|
|
elif event_type == "discussion":
|
|
if action == "created":
|
|
triggers.append("discussion_created")
|
|
elif action == "edited":
|
|
triggers.append("discussion_edited")
|
|
elif action == "deleted":
|
|
triggers.append("discussion_deleted")
|
|
elif action == "transferred":
|
|
triggers.append("discussion_transferred")
|
|
elif action == "pinned":
|
|
triggers.append("discussion_pinned")
|
|
elif action == "unpinned":
|
|
triggers.append("discussion_unpinned")
|
|
elif action == "labeled":
|
|
triggers.append("discussion_labeled")
|
|
elif action == "unlabeled":
|
|
triggers.append("discussion_unlabeled")
|
|
elif action == "locked":
|
|
triggers.append("discussion_locked")
|
|
elif action == "unlocked":
|
|
triggers.append("discussion_unlocked")
|
|
elif action == "category_changed":
|
|
triggers.append("discussion_category_changed")
|
|
elif action == "answered":
|
|
triggers.append("discussion_answered")
|
|
elif action == "unanswered":
|
|
triggers.append("discussion_unanswered")
|
|
|
|
elif event_type == "discussion_comment":
|
|
if action == "created":
|
|
triggers.append("discussion_comment_created")
|
|
elif action == "edited":
|
|
triggers.append("discussion_comment_edited")
|
|
elif action == "deleted":
|
|
triggers.append("discussion_comment_deleted")
|
|
|
|
elif event_type == "milestone":
|
|
if action == "created":
|
|
triggers.append("milestone_created")
|
|
elif action == "closed":
|
|
triggers.append("milestone_closed")
|
|
elif action == "opened":
|
|
triggers.append("milestone_opened")
|
|
elif action == "edited":
|
|
triggers.append("milestone_edited")
|
|
elif action == "deleted":
|
|
triggers.append("milestone_deleted")
|
|
|
|
elif event_type == "label":
|
|
if action == "created":
|
|
triggers.append("label_created")
|
|
elif action == "edited":
|
|
triggers.append("label_edited")
|
|
elif action == "deleted":
|
|
triggers.append("label_deleted")
|
|
|
|
elif event_type == "gollum":
|
|
triggers.append("wiki_page_updated")
|
|
|
|
elif event_type == "ping":
|
|
triggers.append("ping")
|
|
|
|
elif event_type == "package":
|
|
if action == "published":
|
|
triggers.append("package_published")
|
|
elif action == "updated":
|
|
triggers.append("package_updated")
|
|
|
|
else:
|
|
# For unhandled events, create a generic trigger name
|
|
if action:
|
|
triggers.append(f"{event_type}_{action}")
|
|
else:
|
|
triggers.append(event_type)
|
|
|
|
# Return the trigger dispatch
|
|
if triggers:
|
|
return TriggerDispatch(triggers=triggers, response=response)
|
|
else:
|
|
# No matching triggers, return empty
|
|
return TriggerDispatch(triggers=[], response=response)
|
|
|
|
def _subscribe(self, endpoint: str, credentials: Mapping[str, Any], parameters: Mapping[str, Any]) -> Subscription:
|
|
"""
|
|
Create a GitHub webhook subscription for issue comment events
|
|
"""
|
|
# Extract parameters
|
|
webhook_id = uuid.uuid4().hex
|
|
webhook_secret = webhook_id
|
|
repository = parameters.get("repository") # format: "owner/repo"
|
|
events = parameters.get("events", ["issue_comment", "issues"])
|
|
|
|
if not repository:
|
|
raise ValueError("repository is required (format: owner/repo)")
|
|
|
|
# Parse repository owner and name
|
|
try:
|
|
owner, repo = repository.split("/")
|
|
except ValueError:
|
|
raise ValueError("repository must be in format 'owner/repo'") from None
|
|
|
|
# Create webhook using GitHub API
|
|
url = f"https://api.github.com/repos/{owner}/{repo}/hooks"
|
|
headers = {
|
|
"Authorization": f"Bearer {credentials.get('access_tokens')}",
|
|
"Accept": "application/vnd.github+json",
|
|
}
|
|
|
|
webhook_data = {
|
|
"name": "web",
|
|
"active": True,
|
|
"events": events,
|
|
"config": {"url": endpoint, "content_type": "json", "insecure_ssl": "0"},
|
|
}
|
|
|
|
# Add secret if provided
|
|
if webhook_secret:
|
|
webhook_data["config"]["secret"] = webhook_secret
|
|
|
|
try:
|
|
response = requests.post(url, json=webhook_data, headers=headers, timeout=10)
|
|
if response.status_code == 201:
|
|
webhook = response.json()
|
|
# Return subscription with webhook details
|
|
return Subscription(
|
|
expires_at=int(time.time()) + 30 * 24 * 60 * 60, # 30 days expiration
|
|
endpoint=endpoint,
|
|
properties={
|
|
"external_id": str(webhook["id"]),
|
|
"repository": repository,
|
|
"events": events,
|
|
"webhook_secret": webhook_secret,
|
|
"active": webhook["active"],
|
|
},
|
|
)
|
|
else:
|
|
response_data = response.json() if response.content else {}
|
|
error_msg = response_data.get("message", "Unknown error")
|
|
error_details = response_data.get("errors", [])
|
|
|
|
# Log detailed error information for debugging
|
|
print(f"GitHub webhook creation failed with status {response.status_code}")
|
|
print(f"Request URL: {url}")
|
|
print(f"Request data: {webhook_data}")
|
|
print(f"Response: {response_data}")
|
|
|
|
detailed_error = f"Failed to create GitHub webhook: {error_msg}"
|
|
if error_details:
|
|
detailed_error += f" Details: {error_details}"
|
|
|
|
raise SubscriptionError(
|
|
detailed_error,
|
|
error_code="WEBHOOK_CREATION_FAILED",
|
|
external_response=response_data,
|
|
)
|
|
except requests.RequestException as e:
|
|
raise SubscriptionError(f"Network error while creating webhook: {e}", error_code="NETWORK_ERROR") from e
|
|
|
|
def _unsubscribe(self, endpoint: str, subscription: Subscription, credentials: Mapping[str, Any]) -> Unsubscription:
|
|
"""
|
|
Remove a GitHub webhook subscription
|
|
"""
|
|
# Extract webhook details from properties
|
|
external_id = subscription.properties.get("external_id")
|
|
repository = subscription.properties.get("repository")
|
|
|
|
if not external_id or not repository:
|
|
return Unsubscription(
|
|
success=False, message="Missing webhook ID or repository information", error_code="MISSING_PROPERTIES"
|
|
)
|
|
|
|
# Parse repository
|
|
try:
|
|
owner, repo = repository.split("/")
|
|
except ValueError:
|
|
return Unsubscription(
|
|
success=False, message="Invalid repository format in properties", error_code="INVALID_REPOSITORY"
|
|
)
|
|
|
|
# Delete webhook using GitHub API
|
|
url = f"https://api.github.com/repos/{owner}/{repo}/hooks/{external_id}"
|
|
headers = {
|
|
"Authorization": f"Bearer {credentials.get('access_tokens')}",
|
|
"Accept": "application/vnd.github+json",
|
|
}
|
|
|
|
try:
|
|
response = requests.delete(url, headers=headers, timeout=10)
|
|
if response.status_code == 204:
|
|
return Unsubscription(
|
|
success=True, message=f"Successfully removed webhook {external_id} from {repository}"
|
|
)
|
|
elif response.status_code == 404:
|
|
return Unsubscription(
|
|
success=False,
|
|
message=f"Webhook {external_id} not found in repository {repository}",
|
|
error_code="WEBHOOK_NOT_FOUND",
|
|
)
|
|
else:
|
|
return Unsubscription(
|
|
success=False,
|
|
message=f"Failed to delete webhook: {response.json().get('message', 'Unknown error')}",
|
|
error_code="API_ERROR",
|
|
external_response=response.json(),
|
|
)
|
|
except requests.RequestException as e:
|
|
return Unsubscription(
|
|
success=False, message=f"Network error while deleting webhook: {e}", error_code="NETWORK_ERROR"
|
|
)
|
|
|
|
def _refresh(self, endpoint: str, subscription: Subscription, credentials: Mapping[str, Any]) -> Subscription:
|
|
"""
|
|
Refresh a GitHub webhook subscription (extend expiration)
|
|
GitHub webhooks don't expire, so we just extend our internal expiration
|
|
"""
|
|
# Simply return the subscription with extended expiration
|
|
# GitHub webhooks don't have built-in expiration
|
|
return Subscription(
|
|
expires_at=int(time.time()) + 30 * 24 * 60 * 60, # Extend by 30 days
|
|
endpoint=endpoint, # Keep the same endpoint
|
|
properties=subscription.properties, # Keep the same properties
|
|
)
|
|
|
|
def _fetch_parameter_options(self, parameter: str) -> list[ParameterOption]:
|
|
if parameter == "repository":
|
|
return fetch_repositories(self.runtime.credentials.get("access_tokens"))
|
|
|
|
return []
|