Skip to content

Commit 7699ba3

Browse files
authored
feat: add supports for install plugin from GitHub repo releases
Add GitHub release installation for plugins
1 parent 9ac8b1a commit 7699ba3

File tree

8 files changed

+712
-88
lines changed

8 files changed

+712
-88
lines changed

pkg/api/http/controller/groups/plugins.py

Lines changed: 131 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
import base64
44
import quart
5+
import re
6+
import httpx
57

68
from .....core import taskmgr
79
from .. import group
@@ -48,7 +50,9 @@ async def _(author: str, plugin_name: str) -> str:
4850
delete_data = quart.request.args.get('delete_data', 'false').lower() == 'true'
4951
ctx = taskmgr.TaskContext.new()
5052
wrapper = self.ap.task_mgr.create_user_task(
51-
self.ap.plugin_connector.delete_plugin(author, plugin_name, delete_data=delete_data, task_context=ctx),
53+
self.ap.plugin_connector.delete_plugin(
54+
author, plugin_name, delete_data=delete_data, task_context=ctx
55+
),
5256
kind='plugin-operation',
5357
name=f'plugin-remove-{plugin_name}',
5458
label=f'Removing plugin {plugin_name}',
@@ -90,23 +94,145 @@ async def _(author: str, plugin_name: str) -> quart.Response:
9094

9195
return quart.Response(icon_data, mimetype=mime_type)
9296

97+
@self.route('/github/releases', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
98+
async def _() -> str:
99+
"""Get releases from a GitHub repository URL"""
100+
data = await quart.request.json
101+
repo_url = data.get('repo_url', '')
102+
103+
# Parse GitHub repository URL to extract owner and repo
104+
# Supports: https://github.com/owner/repo or github.com/owner/repo
105+
pattern = r'github\.com/([^/]+)/([^/]+?)(?:\.git)?(?:/.*)?$'
106+
match = re.search(pattern, repo_url)
107+
108+
if not match:
109+
return self.http_status(400, -1, 'Invalid GitHub repository URL')
110+
111+
owner, repo = match.groups()
112+
113+
try:
114+
# Fetch releases from GitHub API
115+
url = f'https://api.github.com/repos/{owner}/{repo}/releases'
116+
async with httpx.AsyncClient(
117+
trust_env=True,
118+
follow_redirects=True,
119+
timeout=10,
120+
) as client:
121+
response = await client.get(url)
122+
response.raise_for_status()
123+
releases = response.json()
124+
125+
# Format releases data for frontend
126+
formatted_releases = []
127+
for release in releases:
128+
formatted_releases.append(
129+
{
130+
'id': release['id'],
131+
'tag_name': release['tag_name'],
132+
'name': release['name'],
133+
'published_at': release['published_at'],
134+
'prerelease': release['prerelease'],
135+
'draft': release['draft'],
136+
}
137+
)
138+
139+
return self.success(data={'releases': formatted_releases, 'owner': owner, 'repo': repo})
140+
except httpx.RequestError as e:
141+
return self.http_status(500, -1, f'Failed to fetch releases: {str(e)}')
142+
143+
@self.route(
144+
'/github/release-assets',
145+
methods=['POST'],
146+
auth_type=group.AuthType.USER_TOKEN,
147+
)
148+
async def _() -> str:
149+
"""Get assets from a specific GitHub release"""
150+
data = await quart.request.json
151+
owner = data.get('owner', '')
152+
repo = data.get('repo', '')
153+
release_id = data.get('release_id', '')
154+
155+
if not all([owner, repo, release_id]):
156+
return self.http_status(400, -1, 'Missing required parameters')
157+
158+
try:
159+
# Fetch release assets from GitHub API
160+
url = f'https://api.github.com/repos/{owner}/{repo}/releases/{release_id}'
161+
async with httpx.AsyncClient(
162+
trust_env=True,
163+
follow_redirects=True,
164+
timeout=10,
165+
) as client:
166+
response = await client.get(
167+
url,
168+
)
169+
response.raise_for_status()
170+
release = response.json()
171+
172+
# Format assets data for frontend
173+
formatted_assets = []
174+
for asset in release.get('assets', []):
175+
formatted_assets.append(
176+
{
177+
'id': asset['id'],
178+
'name': asset['name'],
179+
'size': asset['size'],
180+
'download_url': asset['browser_download_url'],
181+
'content_type': asset['content_type'],
182+
}
183+
)
184+
185+
# add zipball as a downloadable asset
186+
# formatted_assets.append(
187+
# {
188+
# "id": 0,
189+
# "name": "Source code (zip)",
190+
# "size": -1,
191+
# "download_url": release["zipball_url"],
192+
# "content_type": "application/zip",
193+
# }
194+
# )
195+
196+
return self.success(data={'assets': formatted_assets})
197+
except httpx.RequestError as e:
198+
return self.http_status(500, -1, f'Failed to fetch release assets: {str(e)}')
199+
93200
@self.route('/install/github', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
94201
async def _() -> str:
202+
"""Install plugin from GitHub release asset"""
95203
data = await quart.request.json
204+
asset_url = data.get('asset_url', '')
205+
owner = data.get('owner', '')
206+
repo = data.get('repo', '')
207+
release_tag = data.get('release_tag', '')
208+
209+
if not asset_url:
210+
return self.http_status(400, -1, 'Missing asset_url parameter')
96211

97212
ctx = taskmgr.TaskContext.new()
98-
short_source_str = data['source'][-8:]
213+
install_info = {
214+
'asset_url': asset_url,
215+
'owner': owner,
216+
'repo': repo,
217+
'release_tag': release_tag,
218+
'github_url': f'https://github.com/{owner}/{repo}',
219+
}
220+
99221
wrapper = self.ap.task_mgr.create_user_task(
100-
self.ap.plugin_mgr.install_plugin(data['source'], task_context=ctx),
222+
self.ap.plugin_connector.install_plugin(PluginInstallSource.GITHUB, install_info, task_context=ctx),
101223
kind='plugin-operation',
102224
name='plugin-install-github',
103-
label=f'Installing plugin from github ...{short_source_str}',
225+
label=f'Installing plugin from GitHub {owner}/{repo}@{release_tag}',
104226
context=ctx,
105227
)
106228

107229
return self.success(data={'task_id': wrapper.id})
108230

109-
@self.route('/install/marketplace', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
231+
@self.route(
232+
'/install/marketplace',
233+
methods=['POST'],
234+
auth_type=group.AuthType.USER_TOKEN,
235+
)
110236
async def _() -> str:
111237
data = await quart.request.json
112238

pkg/plugin/connector.py

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,19 +6,24 @@
66
import typing
77
import os
88
import sys
9-
9+
import httpx
1010
from async_lru import alru_cache
1111

1212
from ..core import app
1313
from . import handler
1414
from ..utils import platform
15-
from langbot_plugin.runtime.io.controllers.stdio import client as stdio_client_controller
15+
from langbot_plugin.runtime.io.controllers.stdio import (
16+
client as stdio_client_controller,
17+
)
1618
from langbot_plugin.runtime.io.controllers.ws import client as ws_client_controller
1719
from langbot_plugin.api.entities import events
1820
from langbot_plugin.api.entities import context
1921
import langbot_plugin.runtime.io.connection as base_connection
2022
from langbot_plugin.api.definition.components.manifest import ComponentManifest
21-
from langbot_plugin.api.entities.builtin.command import context as command_context, errors as command_errors
23+
from langbot_plugin.api.entities.builtin.command import (
24+
context as command_context,
25+
errors as command_errors,
26+
)
2227
from langbot_plugin.runtime.plugin.mgr import PluginInstallSource
2328
from ..core import taskmgr
2429

@@ -71,7 +76,9 @@ async def initialize(self):
7176
return
7277

7378
async def new_connection_callback(connection: base_connection.Connection):
74-
async def disconnect_callback(rchandler: handler.RuntimeConnectionHandler) -> bool:
79+
async def disconnect_callback(
80+
rchandler: handler.RuntimeConnectionHandler,
81+
) -> bool:
7582
if platform.get_platform() == 'docker' or platform.use_websocket_to_connect_plugin_runtime():
7683
self.ap.logger.error('Disconnected from plugin runtime, trying to reconnect...')
7784
await self.runtime_disconnect_callback(self)
@@ -98,7 +105,8 @@ async def disconnect_callback(rchandler: handler.RuntimeConnectionHandler) -> bo
98105
)
99106

100107
async def make_connection_failed_callback(
101-
ctrl: ws_client_controller.WebSocketClientController, exc: Exception = None
108+
ctrl: ws_client_controller.WebSocketClientController,
109+
exc: Exception = None,
102110
) -> None:
103111
if exc is not None:
104112
self.ap.logger.error(f'Failed to connect to plugin runtime({ws_url}): {exc}')
@@ -150,6 +158,25 @@ async def install_plugin(
150158
install_info['plugin_file_key'] = file_key
151159
del install_info['plugin_file']
152160
self.ap.logger.info(f'Transfered file {file_key} to plugin runtime')
161+
elif install_source == PluginInstallSource.GITHUB:
162+
# download and transfer file
163+
try:
164+
async with httpx.AsyncClient(
165+
trust_env=True,
166+
follow_redirects=True,
167+
timeout=20,
168+
) as client:
169+
response = await client.get(
170+
install_info['asset_url'],
171+
)
172+
response.raise_for_status()
173+
file_bytes = response.content
174+
file_key = await self.handler.send_file(file_bytes, 'lbpkg')
175+
install_info['plugin_file_key'] = file_key
176+
self.ap.logger.info(f'Transfered file {file_key} to plugin runtime')
177+
except Exception as e:
178+
self.ap.logger.error(f'Failed to download file from GitHub: {e}')
179+
raise Exception(f'Failed to download file from GitHub: {e}')
153180

154181
async for ret in self.handler.install_plugin(install_source.value, install_info):
155182
current_action = ret.get('current_action', None)
@@ -163,7 +190,10 @@ async def install_plugin(
163190
task_context.trace(trace)
164191

165192
async def upgrade_plugin(
166-
self, plugin_author: str, plugin_name: str, task_context: taskmgr.TaskContext | None = None
193+
self,
194+
plugin_author: str,
195+
plugin_name: str,
196+
task_context: taskmgr.TaskContext | None = None,
167197
) -> dict[str, Any]:
168198
async for ret in self.handler.upgrade_plugin(plugin_author, plugin_name):
169199
current_action = ret.get('current_action', None)

0 commit comments

Comments
 (0)