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:
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
|
||||
Reference in New Issue
Block a user