V0.38.4 full composition (#237)
Co-authored-by: gobha <jasafpro@gmail.com> Co-committed-by: gobha <jasafpro@gmail.com>
This commit is contained in:
@@ -412,6 +412,65 @@ all = connections.list("gitea") # all accessible
|
|||||||
Each returned dict contains `id`, `type`, `name`, `scope` plus all
|
Each returned dict contains `id`, `type`, `name`, `scope` plus all
|
||||||
config fields with secrets decrypted.
|
config fields with secrets decrypted.
|
||||||
|
|
||||||
|
### Connection Type Discovery (v0.38.4)
|
||||||
|
|
||||||
|
**Auth:** Authenticated user (no specific permission required)
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /connection-types → {"data": [...connection type entries]}
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns the merged set of connection types declared by all active packages.
|
||||||
|
When multiple packages declare the same type name, library declarations
|
||||||
|
take precedence over non-library declarations.
|
||||||
|
|
||||||
|
**Response entry:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "gitea",
|
||||||
|
"label": "Gitea Instance",
|
||||||
|
"package_id": "gitea-client",
|
||||||
|
"package_title": "Gitea API Client",
|
||||||
|
"fields": {
|
||||||
|
"base_url": {"type": "url", "required": "true", "label": "Server URL"},
|
||||||
|
"api_token": {"type": "secret", "required": "true", "label": "API Token"},
|
||||||
|
"org": {"type": "string", "required": "false", "label": "Default Org"}
|
||||||
|
},
|
||||||
|
"scopes": ["global", "team", "personal"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Used by all three Connections management UIs (Settings, Admin, Team Admin)
|
||||||
|
to populate the connection type dropdown. Replaces client-side manifest
|
||||||
|
scanning which required admin permissions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Library Composition (v0.38.4)
|
||||||
|
|
||||||
|
Libraries declare connection types in their manifest `connections[]` field.
|
||||||
|
Consumers depend on the library and use its exported functions, passing
|
||||||
|
connection dicts obtained from `connections.get()`.
|
||||||
|
|
||||||
|
**End-to-end pattern:**
|
||||||
|
|
||||||
|
1. Library declares `connections: [{ "type": "gitea", ... }]` in manifest
|
||||||
|
2. Library exports functions that accept a `conn` parameter
|
||||||
|
3. Admin installs library and grants permissions (`api.http`, `connections.read`, etc.)
|
||||||
|
4. User creates a connection of the library's type via Settings > Connections
|
||||||
|
5. Consumer declares `dependencies: { "gitea-client": ">=1.0.0" }` in manifest
|
||||||
|
6. Consumer loads library: `gitea = lib.load("gitea-client")`
|
||||||
|
7. Consumer resolves connection: `conn = connections.get("gitea")`
|
||||||
|
8. Consumer calls library: `gitea.get_repos(conn)`
|
||||||
|
|
||||||
|
The library function executes with the **library's** permission context.
|
||||||
|
The consumer does not need `api.http` or `db.*` permissions — those are
|
||||||
|
the library's concern.
|
||||||
|
|
||||||
|
**Browser-side consumers** can also call the library's REST endpoints
|
||||||
|
directly (`GET /s/gitea-client/api/repos`) without Starlark, enabling
|
||||||
|
pure `type: "surface"` packages.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Library Packages (v0.38.2)
|
### Library Packages (v0.38.2)
|
||||||
|
|||||||
72
packages/gitea-client/manifest.json
Normal file
72
packages/gitea-client/manifest.json
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
{
|
||||||
|
"id": "gitea-client",
|
||||||
|
"title": "Gitea API Client",
|
||||||
|
"type": "library",
|
||||||
|
"tier": "starlark",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Shared Gitea client — connections, API helpers, optional data caching.",
|
||||||
|
"author": "switchboard",
|
||||||
|
|
||||||
|
"permissions": ["api.http", "connections.read", "db.read", "db.write"],
|
||||||
|
|
||||||
|
"exports": [
|
||||||
|
"get_repos", "get_issues", "get_issue", "create_issue",
|
||||||
|
"update_issue", "add_comment", "get_prs", "get_ci_status"
|
||||||
|
],
|
||||||
|
|
||||||
|
"api_routes": [
|
||||||
|
{"method": "GET", "path": "/repos"},
|
||||||
|
{"method": "GET", "path": "/issues"},
|
||||||
|
{"method": "GET", "path": "/issues/*"},
|
||||||
|
{"method": "POST", "path": "/issues"},
|
||||||
|
{"method": "GET", "path": "/prs"},
|
||||||
|
{"method": "GET", "path": "/ci/*"},
|
||||||
|
{"method": "POST", "path": "/sync"}
|
||||||
|
],
|
||||||
|
|
||||||
|
"connections": [
|
||||||
|
{
|
||||||
|
"type": "gitea",
|
||||||
|
"label": "Gitea Instance",
|
||||||
|
"fields": {
|
||||||
|
"base_url": {"type": "url", "required": "true", "label": "Server URL"},
|
||||||
|
"api_token": {"type": "secret", "required": "true", "label": "API Token"},
|
||||||
|
"org": {"type": "string", "required": "false", "label": "Default Org"}
|
||||||
|
},
|
||||||
|
"scopes": ["global", "team", "personal"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
|
||||||
|
"db_tables": {
|
||||||
|
"repos": {
|
||||||
|
"columns": {
|
||||||
|
"connection_id": "text",
|
||||||
|
"full_name": "text",
|
||||||
|
"owner": "text",
|
||||||
|
"name": "text",
|
||||||
|
"description": "text",
|
||||||
|
"html_url": "text",
|
||||||
|
"open_issues": "integer",
|
||||||
|
"synced_at": "timestamp"
|
||||||
|
},
|
||||||
|
"indexes": [["connection_id"]]
|
||||||
|
},
|
||||||
|
"issues": {
|
||||||
|
"columns": {
|
||||||
|
"connection_id": "text",
|
||||||
|
"repo_full_name": "text",
|
||||||
|
"number": "integer",
|
||||||
|
"title": "text",
|
||||||
|
"state": "text",
|
||||||
|
"labels": "text",
|
||||||
|
"assignee": "text",
|
||||||
|
"body": "text",
|
||||||
|
"created_at": "text",
|
||||||
|
"synced_at": "timestamp"
|
||||||
|
},
|
||||||
|
"indexes": [["connection_id", "repo_full_name"]]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"schema_version": 1
|
||||||
|
}
|
||||||
173
packages/gitea-client/script.star
Normal file
173
packages/gitea-client/script.star
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
# Gitea API Client — Library Package
|
||||||
|
#
|
||||||
|
# Shared Gitea client providing connection-backed API helpers.
|
||||||
|
# Consumers call lib.require("gitea-client") and pass a conn dict
|
||||||
|
# obtained from connections.get("gitea").
|
||||||
|
#
|
||||||
|
# Entry points:
|
||||||
|
# on_request(req) — REST API routes for browser-side consumers
|
||||||
|
# Exported functions — Starlark consumers via lib.require()
|
||||||
|
#
|
||||||
|
# Modules available (via library permissions):
|
||||||
|
# http — outbound HTTP (api.http)
|
||||||
|
# connections — credential resolution (connections.read)
|
||||||
|
# db — ext_gitea_client_* tables (db.read, db.write)
|
||||||
|
# json — encode/decode (universal)
|
||||||
|
|
||||||
|
load("star/http_helpers.star", "gitea_get", "gitea_post", "gitea_patch", "auth_headers", "resp")
|
||||||
|
load("star/repos.star", "get_repos")
|
||||||
|
load("star/issues.star", "get_issues", "get_issue", "create_issue", "update_issue", "add_comment")
|
||||||
|
load("star/ci.star", "get_ci_status")
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════
|
||||||
|
# REST API routes (/s/gitea-client/api/*)
|
||||||
|
# ═══════════════════════════════════════════════
|
||||||
|
|
||||||
|
def on_request(req):
|
||||||
|
path = req["path"]
|
||||||
|
method = req["method"]
|
||||||
|
|
||||||
|
conn = _resolve_conn()
|
||||||
|
if conn == None:
|
||||||
|
return resp(400, {"error": "no gitea connection configured"})
|
||||||
|
|
||||||
|
if method == "GET" and path == "/repos":
|
||||||
|
repos = get_repos(conn)
|
||||||
|
if repos == None:
|
||||||
|
return resp(502, {"error": "gitea request failed"})
|
||||||
|
return resp(200, {"data": repos})
|
||||||
|
|
||||||
|
elif method == "GET" and path == "/issues":
|
||||||
|
q = req.get("query", {})
|
||||||
|
owner = _str(q.get("owner", ""))
|
||||||
|
repo = _str(q.get("repo", ""))
|
||||||
|
state = _str(q.get("state", "")) or "open"
|
||||||
|
if not owner or not repo:
|
||||||
|
return resp(400, {"error": "owner and repo query params required"})
|
||||||
|
issues = get_issues(conn, owner, repo, state)
|
||||||
|
if issues == None:
|
||||||
|
return resp(502, {"error": "gitea request failed"})
|
||||||
|
return resp(200, {"data": issues, "count": len(issues)})
|
||||||
|
|
||||||
|
elif method == "GET" and path.startswith("/issues/"):
|
||||||
|
parts = path[len("/issues/"):].split("/", 2)
|
||||||
|
if len(parts) < 3:
|
||||||
|
return resp(400, {"error": "path must be /issues/:owner/:repo/:number"})
|
||||||
|
owner, repo, num = parts[0], parts[1], int(parts[2])
|
||||||
|
issue = get_issue(conn, owner, repo, num)
|
||||||
|
if issue == None:
|
||||||
|
return resp(404, {"error": "issue not found"})
|
||||||
|
return resp(200, issue)
|
||||||
|
|
||||||
|
elif method == "POST" and path == "/issues":
|
||||||
|
body = json.decode(req.get("body", "{}"))
|
||||||
|
result = create_issue(conn, body.get("owner", ""), body.get("repo", ""),
|
||||||
|
body.get("title", ""), body.get("body", ""),
|
||||||
|
body.get("labels", ""))
|
||||||
|
if result == None:
|
||||||
|
return resp(502, {"error": "failed to create issue"})
|
||||||
|
return resp(201, result)
|
||||||
|
|
||||||
|
elif method == "GET" and path == "/prs":
|
||||||
|
q = req.get("query", {})
|
||||||
|
owner = _str(q.get("owner", ""))
|
||||||
|
repo = _str(q.get("repo", ""))
|
||||||
|
state = _str(q.get("state", "")) or "open"
|
||||||
|
if not owner or not repo:
|
||||||
|
return resp(400, {"error": "owner and repo query params required"})
|
||||||
|
prs = get_prs(conn, owner, repo, state)
|
||||||
|
if prs == None:
|
||||||
|
return resp(502, {"error": "gitea request failed"})
|
||||||
|
return resp(200, {"data": prs, "count": len(prs)})
|
||||||
|
|
||||||
|
elif method == "GET" and path.startswith("/ci/"):
|
||||||
|
parts = path[len("/ci/"):].split("/", 2)
|
||||||
|
if len(parts) < 3:
|
||||||
|
return resp(400, {"error": "path must be /ci/:owner/:repo/:ref"})
|
||||||
|
owner, repo, ref = parts[0], parts[1], parts[2]
|
||||||
|
result = get_ci_status(conn, owner, repo, ref)
|
||||||
|
if result == None:
|
||||||
|
return resp(502, {"error": "CI status request failed"})
|
||||||
|
return resp(200, result)
|
||||||
|
|
||||||
|
elif method == "POST" and path == "/sync":
|
||||||
|
return _handle_sync(conn, req)
|
||||||
|
|
||||||
|
return resp(404, {"error": "not found"})
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════
|
||||||
|
# PR helper (not large enough for its own file)
|
||||||
|
# ═══════════════════════════════════════════════
|
||||||
|
|
||||||
|
def get_prs(conn, owner, repo, state):
|
||||||
|
"""List pull requests for a repository."""
|
||||||
|
state = state or "open"
|
||||||
|
path = "/repos/" + owner + "/" + repo + "/pulls?state=" + state + "&limit=50"
|
||||||
|
data = gitea_get(conn, path)
|
||||||
|
if data == None:
|
||||||
|
return None
|
||||||
|
items = []
|
||||||
|
for p in data:
|
||||||
|
items.append({
|
||||||
|
"number": p.get("number", 0),
|
||||||
|
"title": p.get("title", ""),
|
||||||
|
"state": p.get("state", ""),
|
||||||
|
"head": p.get("head", {}).get("ref", ""),
|
||||||
|
"base": p.get("base", {}).get("ref", ""),
|
||||||
|
"user": (p.get("user") or {}).get("login", ""),
|
||||||
|
})
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════
|
||||||
|
# Sync handler — cache repos/issues to db tables
|
||||||
|
# ═══════════════════════════════════════════════
|
||||||
|
|
||||||
|
def _handle_sync(conn, req):
|
||||||
|
"""Sync repos and issues into local cache tables."""
|
||||||
|
repos = get_repos(conn)
|
||||||
|
if repos == None:
|
||||||
|
return resp(502, {"error": "failed to fetch repos"})
|
||||||
|
|
||||||
|
conn_id = conn.get("id", "default")
|
||||||
|
|
||||||
|
# Clear and re-insert repos
|
||||||
|
db.exec("DELETE FROM repos WHERE connection_id = ?", [conn_id])
|
||||||
|
for r in repos:
|
||||||
|
db.exec(
|
||||||
|
"INSERT INTO repos (connection_id, full_name, owner, name, description, html_url, open_issues, synced_at) VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now'))",
|
||||||
|
[conn_id, r["full_name"], r["owner"], r["name"], r.get("description", ""), r.get("html_url", ""), r.get("open_issues", 0)]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Sync open issues for each repo
|
||||||
|
issue_count = 0
|
||||||
|
db.exec("DELETE FROM issues WHERE connection_id = ?", [conn_id])
|
||||||
|
for r in repos:
|
||||||
|
issues = get_issues(conn, r["owner"], r["name"], "open")
|
||||||
|
if issues == None:
|
||||||
|
continue
|
||||||
|
for i in issues:
|
||||||
|
labels_str = ",".join(i.get("labels", []))
|
||||||
|
db.exec(
|
||||||
|
"INSERT INTO issues (connection_id, repo_full_name, number, title, state, labels, assignee, body, created_at, synced_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))",
|
||||||
|
[conn_id, r["full_name"], i["number"], i["title"], i["state"], labels_str, i.get("assignee", ""), "", i.get("created_at", "")]
|
||||||
|
)
|
||||||
|
issue_count += 1
|
||||||
|
|
||||||
|
return resp(200, {"repos": len(repos), "issues": issue_count})
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════
|
||||||
|
# Helpers
|
||||||
|
# ═══════════════════════════════════════════════
|
||||||
|
|
||||||
|
def _resolve_conn():
|
||||||
|
"""Resolve the first available gitea connection."""
|
||||||
|
return connections.get("gitea")
|
||||||
|
|
||||||
|
def _str(v):
|
||||||
|
if v == None:
|
||||||
|
return ""
|
||||||
|
return str(v)
|
||||||
25
packages/gitea-client/star/ci.star
Normal file
25
packages/gitea-client/star/ci.star
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# gitea-client — CI status operations
|
||||||
|
|
||||||
|
load("star/http_helpers.star", "gitea_get")
|
||||||
|
|
||||||
|
def get_ci_status(conn, owner, repo, ref):
|
||||||
|
"""Get CI/CD job status for a commit or branch reference."""
|
||||||
|
path = "/repos/" + owner + "/" + repo + "/commits/" + ref + "/status"
|
||||||
|
data = gitea_get(conn, path)
|
||||||
|
if data == None:
|
||||||
|
return None
|
||||||
|
statuses = data.get("statuses", [])
|
||||||
|
items = []
|
||||||
|
for s in statuses:
|
||||||
|
items.append({
|
||||||
|
"context": s.get("context", s.get("name", "")),
|
||||||
|
"state": s.get("state", s.get("status", "")),
|
||||||
|
"description": s.get("description", ""),
|
||||||
|
"target_url": s.get("target_url", ""),
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
"ref": ref,
|
||||||
|
"state": data.get("state", ""),
|
||||||
|
"statuses": items,
|
||||||
|
"count": len(items),
|
||||||
|
}
|
||||||
60
packages/gitea-client/star/http_helpers.star
Normal file
60
packages/gitea-client/star/http_helpers.star
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
# gitea-client — HTTP helpers
|
||||||
|
#
|
||||||
|
# Shared HTTP utilities for Gitea API calls.
|
||||||
|
# All functions take a `conn` dict from connections.get("gitea").
|
||||||
|
|
||||||
|
def auth_headers(conn):
|
||||||
|
"""Build authorization headers from a connection dict."""
|
||||||
|
token = conn.get("api_token", "")
|
||||||
|
if not token:
|
||||||
|
return {}
|
||||||
|
return {"Authorization": "token " + token}
|
||||||
|
|
||||||
|
def _base(conn):
|
||||||
|
"""Return base URL with trailing slash stripped."""
|
||||||
|
url = conn.get("base_url", "")
|
||||||
|
if url.endswith("/"):
|
||||||
|
url = url[:-1]
|
||||||
|
return url
|
||||||
|
|
||||||
|
def gitea_get(conn, path):
|
||||||
|
"""GET request to Gitea API. Returns decoded body or None."""
|
||||||
|
url = _base(conn) + "/api/v1" + path
|
||||||
|
resp = http.get(url=url, headers=auth_headers(conn))
|
||||||
|
if int(resp["status"]) >= 400:
|
||||||
|
return None
|
||||||
|
body = resp.get("body", "")
|
||||||
|
if not body:
|
||||||
|
return None
|
||||||
|
return json.decode(body)
|
||||||
|
|
||||||
|
def gitea_post(conn, path, data):
|
||||||
|
"""POST request to Gitea API. Returns decoded body or None."""
|
||||||
|
url = _base(conn) + "/api/v1" + path
|
||||||
|
hdrs = dict(auth_headers(conn), **{"Content-Type": "application/json"})
|
||||||
|
resp = http.post(url=url, body=json.encode(data), headers=hdrs)
|
||||||
|
if int(resp["status"]) >= 400:
|
||||||
|
return None
|
||||||
|
body = resp.get("body", "")
|
||||||
|
if not body:
|
||||||
|
return None
|
||||||
|
return json.decode(body)
|
||||||
|
|
||||||
|
def gitea_patch(conn, path, data):
|
||||||
|
"""PATCH request to Gitea API (via X-HTTP-Method-Override). Returns decoded body or None."""
|
||||||
|
url = _base(conn) + "/api/v1" + path
|
||||||
|
hdrs = dict(auth_headers(conn), **{
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-HTTP-Method-Override": "PATCH",
|
||||||
|
})
|
||||||
|
resp = http.post(url=url, body=json.encode(data), headers=hdrs)
|
||||||
|
if int(resp["status"]) >= 400:
|
||||||
|
return None
|
||||||
|
body = resp.get("body", "")
|
||||||
|
if not body:
|
||||||
|
return None
|
||||||
|
return json.decode(body)
|
||||||
|
|
||||||
|
def resp(status, data):
|
||||||
|
"""Build a standard JSON response dict."""
|
||||||
|
return {"status": status, "body": json.encode(data), "headers": {"Content-Type": "application/json"}}
|
||||||
91
packages/gitea-client/star/issues.star
Normal file
91
packages/gitea-client/star/issues.star
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
# gitea-client — Issue operations
|
||||||
|
|
||||||
|
load("star/http_helpers.star", "gitea_get", "gitea_post", "gitea_patch")
|
||||||
|
|
||||||
|
def get_issues(conn, owner, repo, state):
|
||||||
|
"""List issues for a repository."""
|
||||||
|
state = state or "open"
|
||||||
|
path = "/repos/" + owner + "/" + repo + "/issues?state=" + state + "&limit=50&type=issues"
|
||||||
|
data = gitea_get(conn, path)
|
||||||
|
if data == None:
|
||||||
|
return None
|
||||||
|
items = []
|
||||||
|
for i in data:
|
||||||
|
labels = [l.get("name", "") for l in (i.get("labels") or [])]
|
||||||
|
items.append({
|
||||||
|
"number": i.get("number", 0),
|
||||||
|
"title": i.get("title", ""),
|
||||||
|
"state": i.get("state", ""),
|
||||||
|
"labels": labels,
|
||||||
|
"assignee": (i.get("assignee") or {}).get("login", ""),
|
||||||
|
})
|
||||||
|
return items
|
||||||
|
|
||||||
|
def get_issue(conn, owner, repo, number):
|
||||||
|
"""Get full issue details including comments."""
|
||||||
|
path = "/repos/" + owner + "/" + repo + "/issues/" + str(number)
|
||||||
|
data = gitea_get(conn, path)
|
||||||
|
if data == None:
|
||||||
|
return None
|
||||||
|
comments_data = gitea_get(conn, path + "/comments") or []
|
||||||
|
comments = []
|
||||||
|
for c in comments_data:
|
||||||
|
comments.append({
|
||||||
|
"user": (c.get("user") or {}).get("login", ""),
|
||||||
|
"body": c.get("body", ""),
|
||||||
|
"created_at": c.get("created_at", ""),
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
"number": data.get("number", 0),
|
||||||
|
"title": data.get("title", ""),
|
||||||
|
"body": data.get("body", ""),
|
||||||
|
"state": data.get("state", ""),
|
||||||
|
"labels": [l.get("name", "") for l in (data.get("labels") or [])],
|
||||||
|
"assignee": (data.get("assignee") or {}).get("login", ""),
|
||||||
|
"created_at": data.get("created_at", ""),
|
||||||
|
"comments": comments,
|
||||||
|
}
|
||||||
|
|
||||||
|
def create_issue(conn, owner, repo, title, body, labels):
|
||||||
|
"""Create a new issue."""
|
||||||
|
path = "/repos/" + owner + "/" + repo + "/issues"
|
||||||
|
payload = {"title": title}
|
||||||
|
if body:
|
||||||
|
payload["body"] = body
|
||||||
|
if labels:
|
||||||
|
payload["labels"] = [l.strip() for l in labels.split(",") if l.strip()]
|
||||||
|
data = gitea_post(conn, path, payload)
|
||||||
|
if data == None:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"number": data.get("number", 0),
|
||||||
|
"title": data.get("title", ""),
|
||||||
|
"html_url": data.get("html_url", ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
def update_issue(conn, owner, repo, number, title, body, state):
|
||||||
|
"""Update an existing issue."""
|
||||||
|
path = "/repos/" + owner + "/" + repo + "/issues/" + str(number)
|
||||||
|
patch = {}
|
||||||
|
if title:
|
||||||
|
patch["title"] = title
|
||||||
|
if body:
|
||||||
|
patch["body"] = body
|
||||||
|
if state:
|
||||||
|
patch["state"] = state
|
||||||
|
data = gitea_patch(conn, path, patch)
|
||||||
|
if data == None:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"number": data.get("number", 0),
|
||||||
|
"state": data.get("state", ""),
|
||||||
|
"title": data.get("title", ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
def add_comment(conn, owner, repo, number, body):
|
||||||
|
"""Add a comment to an issue."""
|
||||||
|
path = "/repos/" + owner + "/" + repo + "/issues/" + str(number) + "/comments"
|
||||||
|
data = gitea_post(conn, path, {"body": body})
|
||||||
|
if data == None:
|
||||||
|
return None
|
||||||
|
return {"id": data.get("id", 0), "body": data.get("body", "")}
|
||||||
21
packages/gitea-client/star/repos.star
Normal file
21
packages/gitea-client/star/repos.star
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# gitea-client — Repository operations
|
||||||
|
|
||||||
|
load("star/http_helpers.star", "gitea_get")
|
||||||
|
|
||||||
|
def get_repos(conn):
|
||||||
|
"""List repositories accessible via the connection."""
|
||||||
|
data = gitea_get(conn, "/repos/search?limit=50&sort=updated")
|
||||||
|
if data == None:
|
||||||
|
return None
|
||||||
|
repos = data if type(data) == "list" else data.get("data", [])
|
||||||
|
out = []
|
||||||
|
for r in repos:
|
||||||
|
out.append({
|
||||||
|
"full_name": r.get("full_name", ""),
|
||||||
|
"name": r.get("name", ""),
|
||||||
|
"owner": r.get("owner", {}).get("login", ""),
|
||||||
|
"description": r.get("description", ""),
|
||||||
|
"html_url": r.get("html_url", ""),
|
||||||
|
"open_issues": r.get("open_issues_count", 0),
|
||||||
|
})
|
||||||
|
return out
|
||||||
240
packages/sdk-test-runner/js/domains/composition.js
Normal file
240
packages/sdk-test-runner/js/domains/composition.js
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
/**
|
||||||
|
* SDK Test Runner — Domain: composition (v0.38.4)
|
||||||
|
*
|
||||||
|
* Tests the full library composition pattern:
|
||||||
|
* library declares connection types → consumer depends on library →
|
||||||
|
* connection created → types endpoint reflects it → cleanup.
|
||||||
|
* Requires admin role.
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
var T = window.SDKR;
|
||||||
|
if (!T) return;
|
||||||
|
|
||||||
|
// ── Minimal zip builder (same as dependencies.js) ───────────
|
||||||
|
function buildZip(entries) {
|
||||||
|
var localHeaders = [];
|
||||||
|
var centralHeaders = [];
|
||||||
|
var offset = 0;
|
||||||
|
entries.forEach(function (entry) {
|
||||||
|
var nameBytes = new TextEncoder().encode(entry.name);
|
||||||
|
var dataBytes = new TextEncoder().encode(entry.content);
|
||||||
|
var local = new Uint8Array(30 + nameBytes.length + dataBytes.length);
|
||||||
|
var dv = new DataView(local.buffer);
|
||||||
|
dv.setUint32(0, 0x04034b50, true);
|
||||||
|
dv.setUint16(4, 20, true);
|
||||||
|
dv.setUint16(8, 0, true);
|
||||||
|
dv.setUint16(10, 0, true);
|
||||||
|
dv.setUint16(12, 0, true);
|
||||||
|
dv.setUint32(14, crc32(dataBytes), true);
|
||||||
|
dv.setUint32(18, dataBytes.length, true);
|
||||||
|
dv.setUint32(22, dataBytes.length, true);
|
||||||
|
dv.setUint16(26, nameBytes.length, true);
|
||||||
|
dv.setUint16(28, 0, true);
|
||||||
|
local.set(nameBytes, 30);
|
||||||
|
local.set(dataBytes, 30 + nameBytes.length);
|
||||||
|
localHeaders.push(local);
|
||||||
|
var central = new Uint8Array(46 + nameBytes.length);
|
||||||
|
var cdv = new DataView(central.buffer);
|
||||||
|
cdv.setUint32(0, 0x02014b50, true);
|
||||||
|
cdv.setUint16(4, 20, true);
|
||||||
|
cdv.setUint16(6, 20, true);
|
||||||
|
cdv.setUint16(28, nameBytes.length, true);
|
||||||
|
cdv.setUint32(16, crc32(dataBytes), true);
|
||||||
|
cdv.setUint32(20, dataBytes.length, true);
|
||||||
|
cdv.setUint32(24, dataBytes.length, true);
|
||||||
|
cdv.setUint32(42, offset, true);
|
||||||
|
central.set(nameBytes, 46);
|
||||||
|
centralHeaders.push(central);
|
||||||
|
offset += local.length;
|
||||||
|
});
|
||||||
|
var centralSize = centralHeaders.reduce(function (s, c) { return s + c.length; }, 0);
|
||||||
|
var eocd = new Uint8Array(22);
|
||||||
|
var edv = new DataView(eocd.buffer);
|
||||||
|
edv.setUint32(0, 0x06054b50, true);
|
||||||
|
edv.setUint16(8, entries.length, true);
|
||||||
|
edv.setUint16(10, entries.length, true);
|
||||||
|
edv.setUint32(12, centralSize, true);
|
||||||
|
edv.setUint32(16, offset, true);
|
||||||
|
var result = new Uint8Array(offset + centralSize + 22);
|
||||||
|
var pos = 0;
|
||||||
|
localHeaders.forEach(function (h) { result.set(h, pos); pos += h.length; });
|
||||||
|
centralHeaders.forEach(function (h) { result.set(h, pos); pos += h.length; });
|
||||||
|
result.set(eocd, pos);
|
||||||
|
return new Blob([result], { type: 'application/zip' });
|
||||||
|
}
|
||||||
|
|
||||||
|
var crcTable = null;
|
||||||
|
function crc32(bytes) {
|
||||||
|
if (!crcTable) {
|
||||||
|
crcTable = new Uint32Array(256);
|
||||||
|
for (var n = 0; n < 256; n++) {
|
||||||
|
var c = n;
|
||||||
|
for (var k = 0; k < 8; k++) c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
|
||||||
|
crcTable[n] = c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var crc = 0xFFFFFFFF;
|
||||||
|
for (var i = 0; i < bytes.length; i++) crc = crcTable[(crc ^ bytes[i]) & 0xFF] ^ (crc >>> 8);
|
||||||
|
return (crc ^ 0xFFFFFFFF) >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makePkgFile(manifest, extraFiles) {
|
||||||
|
var entries = [{ name: 'manifest.json', content: JSON.stringify(manifest) }];
|
||||||
|
if (extraFiles) entries = entries.concat(extraFiles);
|
||||||
|
var blob = buildZip(entries);
|
||||||
|
return new File([blob], manifest.id + '.pkg', { type: 'application/zip' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tests ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
T.registerDomain('composition', async function () {
|
||||||
|
|
||||||
|
if (!window.sw || !sw.isAdmin) {
|
||||||
|
await T.test('composition', 'guard', 'skip — not admin', async function () {
|
||||||
|
T.assert(true, 'skipped (user is not admin)');
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var ts = Date.now();
|
||||||
|
var libId = 'comp-test-lib-' + ts;
|
||||||
|
var consumerId = 'comp-test-consumer-' + ts;
|
||||||
|
var connType = 'comp-test-svc-' + ts;
|
||||||
|
var connId = null;
|
||||||
|
|
||||||
|
// ── 1. Install library with connection type ────────────
|
||||||
|
|
||||||
|
await T.test('composition', 'install-library', 'install library with connection type declaration', async function () {
|
||||||
|
var manifest = {
|
||||||
|
id: libId,
|
||||||
|
title: 'Composition Test Library',
|
||||||
|
type: 'library',
|
||||||
|
tier: 'starlark',
|
||||||
|
version: '1.0.0',
|
||||||
|
exports: ['get_data'],
|
||||||
|
permissions: ['api.http', 'connections.read'],
|
||||||
|
connections: [{
|
||||||
|
type: connType,
|
||||||
|
label: 'Test Service',
|
||||||
|
fields: {
|
||||||
|
base_url: { type: 'url', required: 'true', label: 'Server URL' },
|
||||||
|
api_token: { type: 'secret', required: 'true', label: 'API Token' }
|
||||||
|
},
|
||||||
|
scopes: ['global', 'team', 'personal']
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
var file = makePkgFile(manifest, [
|
||||||
|
{ name: 'script.star', content: 'def get_data():\n return {"ok": True}\n' }
|
||||||
|
]);
|
||||||
|
var r = await sw.api.admin.packages.install(file);
|
||||||
|
T.assert(r && r.id === libId, 'library should install');
|
||||||
|
T.assert(r.type === 'library', 'type should be library');
|
||||||
|
});
|
||||||
|
|
||||||
|
T.cleanup.push(async function () {
|
||||||
|
if (libId) try { await sw.api.admin.packages.del(libId); } catch (_) {}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 2. Grant library permissions ────────────
|
||||||
|
|
||||||
|
await T.test('composition', 'grant-permissions', 'grant library declared permissions', async function () {
|
||||||
|
var r = await sw.api.post('/api/v1/admin/extensions/' + libId + '/permissions/grant-all', {});
|
||||||
|
T.assert(true, 'permissions granted (or already granted)');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 3. Connection type appears in discovery endpoint ────
|
||||||
|
|
||||||
|
await T.test('composition', 'types-include-library', 'connection-types endpoint includes library type', async function () {
|
||||||
|
var types = await sw.api.connectionTypes.list();
|
||||||
|
T.assert(Array.isArray(types), 'should be array');
|
||||||
|
var found = types.find(function (t) { return t.type === connType; });
|
||||||
|
T.assert(found, 'library connection type should appear: ' + connType);
|
||||||
|
T.assert(found.package_id === libId, 'package_id should be library');
|
||||||
|
T.assert(found.label === 'Test Service', 'label should match');
|
||||||
|
T.assert(found.fields && found.fields.api_token, 'should have api_token field');
|
||||||
|
T.assert(found.fields.api_token.type === 'secret', 'api_token should be secret type');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 4. Create connection of library's type ────────────
|
||||||
|
|
||||||
|
await T.test('composition', 'create-connection', 'create connection of library connection type', async function () {
|
||||||
|
var r = await sw.api.admin.connections.create({
|
||||||
|
type: connType,
|
||||||
|
package_id: libId,
|
||||||
|
name: 'Comp Test Connection',
|
||||||
|
config: { base_url: 'https://comp-test.example.com', api_token: 'test-token-12345' }
|
||||||
|
});
|
||||||
|
T.assert(r && r.id, 'should create connection');
|
||||||
|
T.assert(r.type === connType, 'type should match');
|
||||||
|
connId = r.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
T.cleanup.push(async function () {
|
||||||
|
if (connId) try { await sw.api.admin.connections.del(connId); } catch (_) {}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 5. Install consumer with dependency on library ────
|
||||||
|
|
||||||
|
await T.test('composition', 'install-consumer', 'install consumer depending on library', async function () {
|
||||||
|
var manifest = {
|
||||||
|
id: consumerId,
|
||||||
|
title: 'Composition Test Consumer',
|
||||||
|
type: 'extension',
|
||||||
|
tier: 'starlark',
|
||||||
|
version: '0.1.0',
|
||||||
|
permissions: ['connections.read'],
|
||||||
|
tools: [{ name: 'comp_noop', description: 'test tool', parameters: {} }],
|
||||||
|
dependencies: {}
|
||||||
|
};
|
||||||
|
manifest.dependencies[libId] = '>=1.0.0';
|
||||||
|
var file = makePkgFile(manifest, [
|
||||||
|
{ name: 'script.star', content: 'lib = lib.require("' + libId + '")\n\ndef on_tool_call(tool_name, params):\n return lib.get_data()\n' }
|
||||||
|
]);
|
||||||
|
var r = await sw.api.admin.packages.install(file);
|
||||||
|
T.assert(r && r.id === consumerId, 'consumer should install');
|
||||||
|
});
|
||||||
|
|
||||||
|
T.cleanup.push(async function () {
|
||||||
|
if (consumerId) try { await sw.api.admin.packages.del(consumerId); } catch (_) {}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 6. Verify dependency recorded ────────────
|
||||||
|
|
||||||
|
await T.test('composition', 'dependency-recorded', 'consumer depends on library', async function () {
|
||||||
|
var r = await sw.api.admin.packages.dependencies(consumerId);
|
||||||
|
T.assert(r && r.data, 'should return data');
|
||||||
|
var dep = r.data.find(function (d) { return d.library_id === libId; });
|
||||||
|
T.assert(dep, 'dependency on library should exist');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 7. Cleanup: consumer, connection, library ────────────
|
||||||
|
|
||||||
|
await T.test('composition', 'cleanup-consumer', 'uninstall consumer', async function () {
|
||||||
|
await sw.api.admin.packages.del(consumerId);
|
||||||
|
consumerId = null;
|
||||||
|
T.assert(true, 'consumer uninstalled');
|
||||||
|
});
|
||||||
|
|
||||||
|
await T.test('composition', 'cleanup-connection', 'delete connection', async function () {
|
||||||
|
await sw.api.admin.connections.del(connId);
|
||||||
|
connId = null;
|
||||||
|
T.assert(true, 'connection deleted');
|
||||||
|
});
|
||||||
|
|
||||||
|
await T.test('composition', 'cleanup-library', 'uninstall library', async function () {
|
||||||
|
await sw.api.admin.packages.del(libId);
|
||||||
|
libId = null;
|
||||||
|
T.assert(true, 'library uninstalled');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 8. Verify type removed after uninstall ────────────
|
||||||
|
|
||||||
|
await T.test('composition', 'types-removed', 'connection type removed after library uninstall', async function () {
|
||||||
|
var types = await sw.api.connectionTypes.list();
|
||||||
|
var found = types.find(function (t) { return t.type === connType; });
|
||||||
|
T.assert(!found, 'type should not appear after library uninstall');
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -95,6 +95,22 @@
|
|||||||
T.assert(r.id === globalId, 'should match global connection by name');
|
T.assert(r.id === globalId, 'should match global connection by name');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Connection Type Discovery (v0.38.4) ──
|
||||||
|
|
||||||
|
await T.test('connections', 'types-list', 'GET /connection-types returns array', async function () {
|
||||||
|
var r = await sw.api.connectionTypes.list();
|
||||||
|
T.assert(Array.isArray(r), 'should be array');
|
||||||
|
});
|
||||||
|
|
||||||
|
await T.test('connections', 'types-include-sdk-test', 'connection types include sdk-test-conn from global conn', async function () {
|
||||||
|
// The global connection we created above references package_id sdk-test-pkg
|
||||||
|
// but that package may not exist. The endpoint scans active package manifests,
|
||||||
|
// so this test verifies the endpoint returns without error. Actual type matching
|
||||||
|
// is tested in the composition domain with a real library.
|
||||||
|
var r = await sw.api.connectionTypes.list();
|
||||||
|
T.assert(Array.isArray(r), 'should be array (may be empty if no packages declare connections)');
|
||||||
|
});
|
||||||
|
|
||||||
// ── Cleanup ──────────────────────────────
|
// ── Cleanup ──────────────────────────────
|
||||||
|
|
||||||
await T.test('connections', 'personal-delete', 'delete personal connection', async function () {
|
await T.test('connections', 'personal-delete', 'delete personal connection', async function () {
|
||||||
|
|||||||
@@ -77,6 +77,7 @@
|
|||||||
'domains/packages.js',
|
'domains/packages.js',
|
||||||
'domains/connections.js',
|
'domains/connections.js',
|
||||||
'domains/dependencies.js',
|
'domains/dependencies.js',
|
||||||
|
'domains/composition.js',
|
||||||
// UI (must come last)
|
// UI (must come last)
|
||||||
'ui.js'
|
'ui.js'
|
||||||
];
|
];
|
||||||
|
|||||||
103
server/handlers/connection_types.go
Normal file
103
server/handlers/connection_types.go
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
// v0.38.4: Connection type discovery endpoint.
|
||||||
|
// Returns merged connection types from all active packages.
|
||||||
|
// Library declarations take precedence over non-library declarations.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"chat-switchboard/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ConnectionTypeEntry is a single connection type in the API response.
|
||||||
|
type ConnectionTypeEntry struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
PackageID string `json:"package_id"`
|
||||||
|
PackageTitle string `json:"package_title"`
|
||||||
|
Fields map[string]map[string]string `json:"fields"`
|
||||||
|
Scopes []string `json:"scopes,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConnectionTypeHandler serves GET /api/v1/connection-types.
|
||||||
|
type ConnectionTypeHandler struct {
|
||||||
|
stores store.Stores
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewConnectionTypeHandler(s store.Stores) *ConnectionTypeHandler {
|
||||||
|
return &ConnectionTypeHandler{stores: s}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListConnectionTypes returns all connection types declared by active packages.
|
||||||
|
// Library packages take precedence when multiple packages declare the same type.
|
||||||
|
func (h *ConnectionTypeHandler) ListConnectionTypes(c *gin.Context) {
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
pkgs, err := h.stores.Packages.List(ctx)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list packages"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
type entry struct {
|
||||||
|
ct ConnectionTypeEntry
|
||||||
|
isLibrary bool
|
||||||
|
}
|
||||||
|
seen := map[string]*entry{}
|
||||||
|
|
||||||
|
for _, pkg := range pkgs {
|
||||||
|
if pkg.Status != "active" || !pkg.Enabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
connsRaw, ok := pkg.Manifest["connections"]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
connsJSON, _ := json.Marshal(connsRaw)
|
||||||
|
var conns []struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
Fields map[string]map[string]string `json:"fields"`
|
||||||
|
Scopes []string `json:"scopes"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(connsJSON, &conns); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
isLib := pkg.Type == "library"
|
||||||
|
for _, cd := range conns {
|
||||||
|
if cd.Type == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
existing, exists := seen[cd.Type]
|
||||||
|
// Library declarations take precedence
|
||||||
|
if exists && !isLib && existing.isLibrary {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
label := cd.Label
|
||||||
|
if label == "" {
|
||||||
|
label = cd.Type
|
||||||
|
}
|
||||||
|
seen[cd.Type] = &entry{
|
||||||
|
ct: ConnectionTypeEntry{
|
||||||
|
Type: cd.Type,
|
||||||
|
Label: label,
|
||||||
|
PackageID: pkg.ID,
|
||||||
|
PackageTitle: pkg.Title,
|
||||||
|
Fields: cd.Fields,
|
||||||
|
Scopes: cd.Scopes,
|
||||||
|
},
|
||||||
|
isLibrary: isLib,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]ConnectionTypeEntry, 0, len(seen))
|
||||||
|
for _, e := range seen {
|
||||||
|
result = append(result, e.ct)
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"data": result})
|
||||||
|
}
|
||||||
@@ -814,6 +814,10 @@ func main() {
|
|||||||
protected.GET("/api-configs/:id/models", provCfg.ListModels)
|
protected.GET("/api-configs/:id/models", provCfg.ListModels)
|
||||||
protected.POST("/api-configs/:id/models/fetch", provCfg.FetchModels)
|
protected.POST("/api-configs/:id/models/fetch", provCfg.FetchModels)
|
||||||
|
|
||||||
|
// Connection Type Discovery (v0.38.4)
|
||||||
|
connTypeH := handlers.NewConnectionTypeHandler(stores)
|
||||||
|
protected.GET("/connection-types", connTypeH.ListConnectionTypes)
|
||||||
|
|
||||||
// Extension Connections (personal scope — v0.38.1)
|
// Extension Connections (personal scope — v0.38.1)
|
||||||
connH := handlers.NewConnectionHandler(stores, keyResolver)
|
connH := handlers.NewConnectionHandler(stores, keyResolver)
|
||||||
protected.GET("/connections", connH.ListConnections)
|
protected.GET("/connections", connH.ListConnections)
|
||||||
|
|||||||
@@ -8888,6 +8888,31 @@ paths:
|
|||||||
$ref: '#/components/schemas/ExtDependency'
|
$ref: '#/components/schemas/ExtDependency'
|
||||||
'401':
|
'401':
|
||||||
$ref: '#/components/responses/Unauthorized'
|
$ref: '#/components/responses/Unauthorized'
|
||||||
|
# ── Connection Type Discovery (v0.38.4) ─────────────────────────────
|
||||||
|
/api/v1/connection-types:
|
||||||
|
get:
|
||||||
|
tags: [Extensions]
|
||||||
|
summary: List available connection types
|
||||||
|
description: |
|
||||||
|
Returns merged connection types from all active packages.
|
||||||
|
Library declarations take precedence over non-library declarations.
|
||||||
|
Any authenticated user can call this endpoint.
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Connection type list
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
data:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/ConnectionType'
|
||||||
|
'401':
|
||||||
|
$ref: '#/components/responses/Unauthorized'
|
||||||
# ── Extension Connections (v0.38.1) ─────────────────────────────────
|
# ── Extension Connections (v0.38.1) ─────────────────────────────────
|
||||||
/api/v1/connections:
|
/api/v1/connections:
|
||||||
get:
|
get:
|
||||||
@@ -12334,6 +12359,39 @@ components:
|
|||||||
updated_at:
|
updated_at:
|
||||||
type: string
|
type: string
|
||||||
format: date-time
|
format: date-time
|
||||||
|
ConnectionType:
|
||||||
|
type: object
|
||||||
|
description: "v0.38.4: Available connection type from an installed package"
|
||||||
|
properties:
|
||||||
|
type:
|
||||||
|
type: string
|
||||||
|
description: Connection type identifier (e.g. gitea, slack)
|
||||||
|
label:
|
||||||
|
type: string
|
||||||
|
description: Human-readable label
|
||||||
|
package_id:
|
||||||
|
type: string
|
||||||
|
description: Package that declares this connection type
|
||||||
|
package_title:
|
||||||
|
type: string
|
||||||
|
description: Title of the declaring package
|
||||||
|
fields:
|
||||||
|
type: object
|
||||||
|
description: Field definitions (name → {type, required, label})
|
||||||
|
additionalProperties:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
type:
|
||||||
|
type: string
|
||||||
|
required:
|
||||||
|
type: string
|
||||||
|
label:
|
||||||
|
type: string
|
||||||
|
scopes:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
description: Allowed scopes (global, team, personal)
|
||||||
ExtDependency:
|
ExtDependency:
|
||||||
type: object
|
type: object
|
||||||
description: "v0.38.2: Package dependency record (consumer → library)"
|
description: "v0.38.2: Package dependency record (consumer → library)"
|
||||||
|
|||||||
@@ -194,6 +194,11 @@ export function createDomains(restClient) {
|
|||||||
resolve: (type, name) => rc.get('/api/v1/connections/resolve' + _qs({ type, name })),
|
resolve: (type, name) => rc.get('/api/v1/connections/resolve' + _qs({ type, name })),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ── 10c. Connection Types (v0.38.4) ──
|
||||||
|
connectionTypes: {
|
||||||
|
list: () => rc.get('/api/v1/connection-types'),
|
||||||
|
},
|
||||||
|
|
||||||
// ── 11. Notifications ──────────────────
|
// ── 11. Notifications ──────────────────
|
||||||
notifications: {
|
notifications: {
|
||||||
list: (opts) => rc.get('/api/v1/notifications' + _qs(opts)),
|
list: (opts) => rc.get('/api/v1/notifications' + _qs(opts)),
|
||||||
|
|||||||
@@ -26,19 +26,11 @@ export default function ConnectionsSection() {
|
|||||||
|
|
||||||
const loadTypes = useCallback(async () => {
|
const loadTypes = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const pkgs = await sw.api.admin.packages.list();
|
const types = await sw.api.connectionTypes.list();
|
||||||
const types = [];
|
setConnTypes((types || []).map(ct => ({
|
||||||
const seen = {};
|
type: ct.type, label: ct.label || ct.type,
|
||||||
for (const pkg of (pkgs || [])) {
|
packageId: ct.package_id, fields: ct.fields || {}
|
||||||
const conns = pkg.manifest?.connections || [];
|
})));
|
||||||
for (const cd of conns) {
|
|
||||||
if (!seen[cd.type]) {
|
|
||||||
seen[cd.type] = true;
|
|
||||||
types.push({ type: cd.type, label: cd.label || cd.type, packageId: pkg.id, fields: cd.fields || {} });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setConnTypes(types);
|
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -26,19 +26,11 @@ export function ConnectionsSection() {
|
|||||||
|
|
||||||
const loadTypes = useCallback(async () => {
|
const loadTypes = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const pkgs = await sw.api.admin.packages.list();
|
const types = await sw.api.connectionTypes.list();
|
||||||
const types = [];
|
setConnTypes((types || []).map(ct => ({
|
||||||
const seen = {};
|
type: ct.type, label: ct.label || ct.type,
|
||||||
for (const pkg of (pkgs || [])) {
|
packageId: ct.package_id, fields: ct.fields || {}
|
||||||
const conns = pkg.manifest?.connections || [];
|
})));
|
||||||
for (const cd of conns) {
|
|
||||||
if (!seen[cd.type]) {
|
|
||||||
seen[cd.type] = true;
|
|
||||||
types.push({ type: cd.type, label: cd.label || cd.type, packageId: pkg.id, fields: cd.fields || {} });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setConnTypes(types);
|
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -26,19 +26,11 @@ export default function ConnectionsSection({ teamId }) {
|
|||||||
|
|
||||||
const loadTypes = useCallback(async () => {
|
const loadTypes = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const pkgs = await sw.api.admin.packages.list();
|
const types = await sw.api.connectionTypes.list();
|
||||||
const types = [];
|
setConnTypes((types || []).map(ct => ({
|
||||||
const seen = {};
|
type: ct.type, label: ct.label || ct.type,
|
||||||
for (const pkg of (pkgs || [])) {
|
packageId: ct.package_id, fields: ct.fields || {}
|
||||||
const conns = pkg.manifest?.connections || [];
|
})));
|
||||||
for (const cd of conns) {
|
|
||||||
if (!seen[cd.type]) {
|
|
||||||
seen[cd.type] = true;
|
|
||||||
types.push({ type: cd.type, label: cd.label || cd.type, packageId: pkg.id, fields: cd.fields || {} });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setConnTypes(types);
|
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user