# Git Board — Starlark Backend # # 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) # ═══════════════════════════════════════════════ # Surface API routes (/s/git-board/api/*) # ═══════════════════════════════════════════════ def on_request(req): path = req["path"] method = req["method"] if method == "GET" and path == "/repos": return _handle_repos(req) elif method == "GET" and path == "/board": return _handle_board(req) elif method == "GET" and path.startswith("/ci/"): return _handle_ci(req, 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_board(req): """Combined issues + PRs for kanban board.""" q = req.get("query", {}) owner = _str(q.get("owner", "")) or settings.get("default_owner") or "" repo = _str(q.get("repo", "")) or settings.get("default_repo") or "" 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", ""), }) return _resp(200, board) def _handle_ci(req, 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: return _resp(502, {"error": "CI status request failed"}) return _resp(200, data) # ═══════════════════════════════════════════════ # LLM Tools (called by AI during chat) # ═══════════════════════════════════════════════ def on_tool_call(tool_name, params): platform = _platform() 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)) 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)} elif tool_name == "get_issue": num = int(params.get("number", 0)) data = _git_get(_issue_path(platform, 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, } 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) 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", "")} 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) if data == None: return {"error": "failed to update issue"} return {"number": data.get("number", 0), "state": data.get("state", ""), "title": data.get("title", "")} 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", "")}) if data == None: return {"error": "failed to add comment"} return {"id": data.get("id", 0), "body": data.get("body", "")} elif tool_name == "list_pull_requests": state = params.get("state", "open") data = _git_get(_prs_path(platform, 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)} elif tool_name == "get_ci_status": ref = params.get("ref", "") data = _git_get(_ci_path(platform, 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 {"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 # ═══════════════════════════════════════════════ def _resp(status, data): return {"status": status, "body": json.encode(data), "headers": {"Content-Type": "application/json"}} def _str(v): """Coerce Starlark query param to string.""" if v == None: return "" return str(v)