V0.38.5 git board rewrite (#238)
Co-authored-by: gobha <jasafpro@gmail.com> Co-committed-by: gobha <jasafpro@gmail.com>
This commit is contained in:
@@ -1,13 +1,24 @@
|
||||
# Git Board — Starlark Backend
|
||||
# Git Board — Starlark Backend (v0.2.0)
|
||||
#
|
||||
# Library consumer — delegates all Gitea API work to gitea-client.
|
||||
#
|
||||
# Entry points:
|
||||
# on_request(req) → surface API routes
|
||||
# on_tool_call(tool_name, params) → LLM tool execution
|
||||
#
|
||||
# Modules:
|
||||
# http — outbound HTTP to git platform (requires api.http permission)
|
||||
# settings — admin: platform, base_url / user: api_token, default_owner, default_repo
|
||||
# json — json.encode() / json.decode() (universal, no permission needed)
|
||||
# connections — resolve gitea connection (connections.read)
|
||||
# lib — load gitea-client library
|
||||
# json — encode/decode (universal)
|
||||
# settings — user: default_owner, default_repo
|
||||
|
||||
gitea = lib.require("gitea-client")
|
||||
|
||||
|
||||
def _conn():
|
||||
"""Resolve the first available gitea connection."""
|
||||
return connections.get("gitea")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# Surface API routes (/s/git-board/api/*)
|
||||
@@ -17,39 +28,33 @@ def on_request(req):
|
||||
path = req["path"]
|
||||
method = req["method"]
|
||||
|
||||
conn = _conn()
|
||||
if conn == None:
|
||||
return _resp(400, {"error": "no gitea connection configured — add one in Settings > Connections"})
|
||||
|
||||
if method == "GET" and path == "/repos":
|
||||
return _handle_repos(req)
|
||||
return _handle_repos(conn)
|
||||
elif method == "GET" and path == "/board":
|
||||
return _handle_board(req)
|
||||
return _handle_board(conn, req)
|
||||
elif method == "GET" and path.startswith("/issue/"):
|
||||
return _handle_get_issue(conn, path[len("/issue/"):])
|
||||
elif method == "POST" and path.startswith("/issue/"):
|
||||
return _handle_post_issue(conn, path[len("/issue/"):], req)
|
||||
elif method == "GET" and path.startswith("/ci/"):
|
||||
return _handle_ci(req, path[len("/ci/"):])
|
||||
return _handle_ci(conn, path[len("/ci/"):])
|
||||
|
||||
return _resp(404, {"error": "not found"})
|
||||
|
||||
|
||||
def _handle_repos(req):
|
||||
"""List repositories accessible to the user."""
|
||||
platform = _platform()
|
||||
data = _git_get(_repos_path(platform))
|
||||
if data == None:
|
||||
return _resp(502, {"error": "git platform request failed"})
|
||||
|
||||
# Normalize — Gitea returns list, GitHub returns {items: [...]}
|
||||
repos = data if type(data) == "list" else data.get("items", data.get("repositories", []))
|
||||
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 _resp(200, {"data": out})
|
||||
def _handle_repos(conn):
|
||||
"""List repositories accessible via the connection."""
|
||||
repos = gitea.get_repos(conn)
|
||||
if repos == None:
|
||||
return _resp(502, {"error": "gitea request failed"})
|
||||
return _resp(200, {"data": repos})
|
||||
|
||||
|
||||
def _handle_board(req):
|
||||
def _handle_board(conn, req):
|
||||
"""Combined issues + PRs for kanban board."""
|
||||
q = req.get("query", {})
|
||||
owner = _str(q.get("owner", "")) or settings.get("default_owner") or ""
|
||||
@@ -57,51 +62,64 @@ def _handle_board(req):
|
||||
if not owner or not repo:
|
||||
return _resp(400, {"error": "owner and repo required (query params or user settings)"})
|
||||
|
||||
platform = _platform()
|
||||
|
||||
issues = _git_get(_issues_path(platform, owner, repo, "open")) or []
|
||||
prs = _git_get(_prs_path(platform, owner, repo, "open")) or []
|
||||
|
||||
board = {"issues": [], "pull_requests": []}
|
||||
for i in issues:
|
||||
labels = [l.get("name", "") for l in (i.get("labels") or [])]
|
||||
board["issues"].append({
|
||||
"number": i.get("number", 0),
|
||||
"title": i.get("title", ""),
|
||||
"state": i.get("state", ""),
|
||||
"labels": labels,
|
||||
"assignee": (i.get("assignee") or {}).get("login", ""),
|
||||
"created_at": i.get("created_at", ""),
|
||||
"updated_at": i.get("updated_at", ""),
|
||||
"html_url": i.get("html_url", ""),
|
||||
})
|
||||
for p in prs:
|
||||
board["pull_requests"].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", ""),
|
||||
"mergeable": p.get("mergeable", None),
|
||||
"user": (p.get("user") or {}).get("login", ""),
|
||||
"created_at": p.get("created_at", ""),
|
||||
"html_url": p.get("html_url", ""),
|
||||
})
|
||||
issues = gitea.get_issues(conn, owner, repo, "open") or []
|
||||
prs = gitea.get_prs(conn, owner, repo, "open") or []
|
||||
|
||||
board = {"issues": issues, "pull_requests": prs}
|
||||
return _resp(200, board)
|
||||
|
||||
|
||||
def _handle_ci(req, ref_path):
|
||||
def _handle_get_issue(conn, issue_path):
|
||||
"""Get issue detail: /issue/:owner/:repo/:number"""
|
||||
parts = issue_path.split("/", 2)
|
||||
if len(parts) < 3:
|
||||
return _resp(400, {"error": "path must be /issue/:owner/:repo/:number"})
|
||||
owner, repo, num = parts[0], parts[1], int(parts[2])
|
||||
data = gitea.get_issue(conn, owner, repo, num)
|
||||
if data == None:
|
||||
return _resp(404, {"error": "issue not found"})
|
||||
return _resp(200, data)
|
||||
|
||||
|
||||
def _handle_post_issue(conn, issue_path, req):
|
||||
"""Update issue or add comment: /issue/:owner/:repo/:number[/comment]"""
|
||||
parts = issue_path.split("/", 3)
|
||||
if len(parts) < 3:
|
||||
return _resp(400, {"error": "path must be /issue/:owner/:repo/:number"})
|
||||
owner, repo, num = parts[0], parts[1], int(parts[2])
|
||||
action = parts[3] if len(parts) > 3 else ""
|
||||
|
||||
body = json.decode(req.get("body", "{}"))
|
||||
|
||||
if action == "comment":
|
||||
text = body.get("body", "")
|
||||
if not text:
|
||||
return _resp(400, {"error": "comment body required"})
|
||||
result = gitea.add_comment(conn, owner, repo, num, text)
|
||||
if result == None:
|
||||
return _resp(502, {"error": "failed to add comment"})
|
||||
return _resp(201, result)
|
||||
|
||||
# Default: update issue fields (state, title, body, assignee)
|
||||
title = body.get("title", "")
|
||||
issue_body = body.get("body", "")
|
||||
state = body.get("state", "")
|
||||
result = gitea.update_issue(conn, owner, repo, num, title, issue_body, state)
|
||||
if result == None:
|
||||
return _resp(502, {"error": "failed to update issue"})
|
||||
return _resp(200, result)
|
||||
|
||||
|
||||
def _handle_ci(conn, ref_path):
|
||||
"""CI status for owner/repo/ref."""
|
||||
parts = ref_path.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]
|
||||
platform = _platform()
|
||||
data = _git_get(_ci_path(platform, owner, repo, ref))
|
||||
if data == None:
|
||||
result = gitea.get_ci_status(conn, owner, repo, ref)
|
||||
if result == None:
|
||||
return _resp(502, {"error": "CI status request failed"})
|
||||
return _resp(200, data)
|
||||
return _resp(200, result)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
@@ -109,211 +127,70 @@ def _handle_ci(req, ref_path):
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
def on_tool_call(tool_name, params):
|
||||
platform = _platform()
|
||||
conn = _conn()
|
||||
if conn == None:
|
||||
return {"error": "no gitea connection configured — add one in Settings > Connections"}
|
||||
|
||||
owner = params.get("owner", "")
|
||||
repo = params.get("repo", "")
|
||||
|
||||
if tool_name == "list_issues":
|
||||
state = params.get("state", "open")
|
||||
data = _git_get(_issues_path(platform, owner, repo, state))
|
||||
data = gitea.get_issues(conn, owner, repo, state)
|
||||
if data == None:
|
||||
return {"error": "failed to list issues"}
|
||||
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 {"issues": items, "count": len(items)}
|
||||
return {"issues": data, "count": len(data)}
|
||||
|
||||
elif tool_name == "get_issue":
|
||||
num = int(params.get("number", 0))
|
||||
data = _git_get(_issue_path(platform, owner, repo, num))
|
||||
data = gitea.get_issue(conn, owner, repo, num)
|
||||
if data == None:
|
||||
return {"error": "issue not found"}
|
||||
# Also fetch comments
|
||||
comments_data = _git_get(_issue_path(platform, owner, repo, num) + "/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,
|
||||
}
|
||||
return data
|
||||
|
||||
elif tool_name == "create_issue":
|
||||
body = {"title": params.get("title", "")}
|
||||
if params.get("body", ""):
|
||||
body["body"] = params["body"]
|
||||
if params.get("labels", ""):
|
||||
body["labels"] = [l.strip() for l in params["labels"].split(",") if l.strip()]
|
||||
data = _git_post(_issues_path(platform, owner, repo, ""), body)
|
||||
data = gitea.create_issue(conn, owner, repo,
|
||||
params.get("title", ""),
|
||||
params.get("body", ""),
|
||||
params.get("labels", ""))
|
||||
if data == None:
|
||||
return {"error": "failed to create issue"}
|
||||
return {"number": data.get("number", 0), "title": data.get("title", ""), "html_url": data.get("html_url", "")}
|
||||
return data
|
||||
|
||||
elif tool_name == "update_issue":
|
||||
num = int(params.get("number", 0))
|
||||
patch = {}
|
||||
if params.get("title", ""):
|
||||
patch["title"] = params["title"]
|
||||
if params.get("body", ""):
|
||||
patch["body"] = params["body"]
|
||||
if params.get("state", ""):
|
||||
patch["state"] = params["state"]
|
||||
data = _git_patch(_issue_path(platform, owner, repo, num), patch)
|
||||
data = gitea.update_issue(conn, owner, repo, num,
|
||||
params.get("title", ""),
|
||||
params.get("body", ""),
|
||||
params.get("state", ""))
|
||||
if data == None:
|
||||
return {"error": "failed to update issue"}
|
||||
return {"number": data.get("number", 0), "state": data.get("state", ""), "title": data.get("title", "")}
|
||||
return data
|
||||
|
||||
elif tool_name == "add_comment":
|
||||
num = int(params.get("number", 0))
|
||||
data = _git_post(_issue_path(platform, owner, repo, num) + "/comments", {"body": params.get("body", "")})
|
||||
data = gitea.add_comment(conn, owner, repo, num, params.get("body", ""))
|
||||
if data == None:
|
||||
return {"error": "failed to add comment"}
|
||||
return {"id": data.get("id", 0), "body": data.get("body", "")}
|
||||
return data
|
||||
|
||||
elif tool_name == "list_pull_requests":
|
||||
state = params.get("state", "open")
|
||||
data = _git_get(_prs_path(platform, owner, repo, state))
|
||||
data = gitea.get_prs(conn, owner, repo, state)
|
||||
if data == None:
|
||||
return {"error": "failed to list PRs"}
|
||||
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 {"pull_requests": items, "count": len(items)}
|
||||
return {"pull_requests": data, "count": len(data)}
|
||||
|
||||
elif tool_name == "get_ci_status":
|
||||
ref = params.get("ref", "")
|
||||
data = _git_get(_ci_path(platform, owner, repo, ref))
|
||||
data = gitea.get_ci_status(conn, owner, repo, ref)
|
||||
if data == None:
|
||||
return {"error": "failed to get CI status"}
|
||||
# Normalize: Gitea returns {statuses: [...]}, GitHub returns {state, statuses: [...]}
|
||||
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)}
|
||||
return data
|
||||
|
||||
return {"error": "unknown tool: " + tool_name}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# Git platform abstraction
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
def _platform():
|
||||
return settings.get("platform") or "gitea"
|
||||
|
||||
def _base_url():
|
||||
url = settings.get("base_url") or ""
|
||||
if url.endswith("/"):
|
||||
url = url[:-1]
|
||||
return url
|
||||
|
||||
def _token():
|
||||
return settings.get("api_token") or ""
|
||||
|
||||
def _api_prefix(platform):
|
||||
# Gitea: /api/v1, GitHub: "" (api.github.com already has /repos etc)
|
||||
if platform == "github":
|
||||
return ""
|
||||
return "/api/v1"
|
||||
|
||||
def _repos_path(platform):
|
||||
if platform == "github":
|
||||
return "/user/repos?per_page=50&sort=updated"
|
||||
return "/repos/search?limit=50&sort=updated"
|
||||
|
||||
def _issues_path(platform, owner, repo, state):
|
||||
if platform == "github":
|
||||
return "/repos/" + owner + "/" + repo + "/issues?state=" + state + "&per_page=50"
|
||||
return "/repos/" + owner + "/" + repo + "/issues?state=" + state + "&limit=50&type=issues"
|
||||
|
||||
def _issue_path(platform, owner, repo, number):
|
||||
return "/repos/" + owner + "/" + repo + "/issues/" + str(number)
|
||||
|
||||
def _prs_path(platform, owner, repo, state):
|
||||
if platform == "github":
|
||||
return "/repos/" + owner + "/" + repo + "/pulls?state=" + state + "&per_page=50"
|
||||
return "/repos/" + owner + "/" + repo + "/pulls?state=" + state + "&limit=50"
|
||||
|
||||
def _ci_path(platform, owner, repo, ref):
|
||||
if platform == "github":
|
||||
return "/repos/" + owner + "/" + repo + "/commits/" + ref + "/status"
|
||||
return "/repos/" + owner + "/" + repo + "/commits/" + ref + "/status"
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# HTTP helpers
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
def _auth_headers():
|
||||
token = _token()
|
||||
if not token:
|
||||
return {}
|
||||
platform = _platform()
|
||||
if platform == "github":
|
||||
return {"Authorization": "Bearer " + token, "Accept": "application/vnd.github.v3+json"}
|
||||
return {"Authorization": "token " + token}
|
||||
|
||||
def _git_get(path):
|
||||
url = _base_url() + _api_prefix(_platform()) + path
|
||||
resp = http.get(url=url, headers=_auth_headers())
|
||||
if int(resp["status"]) >= 400:
|
||||
return None
|
||||
body = resp.get("body", "")
|
||||
if not body:
|
||||
return None
|
||||
return json.decode(body)
|
||||
|
||||
def _git_post(path, data):
|
||||
url = _base_url() + _api_prefix(_platform()) + path
|
||||
resp = http.post(url=url, body=json.encode(data), headers=dict(_auth_headers(), **{"Content-Type": "application/json"}))
|
||||
if int(resp["status"]) >= 400:
|
||||
return None
|
||||
body = resp.get("body", "")
|
||||
if not body:
|
||||
return None
|
||||
return json.decode(body)
|
||||
|
||||
def _git_patch(path, data):
|
||||
url = _base_url() + _api_prefix(_platform()) + path
|
||||
resp = http.post(url=url, body=json.encode(data), headers=dict(_auth_headers(), **{"Content-Type": "application/json", "X-HTTP-Method-Override": "PATCH"}))
|
||||
if int(resp["status"]) >= 400:
|
||||
return None
|
||||
body = resp.get("body", "")
|
||||
if not body:
|
||||
return None
|
||||
return json.decode(body)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# Response helpers
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
Reference in New Issue
Block a user