expand gitea tools: 17 new operations (24 total)

repos: create, edit, fork, list org repos
issues: edit, list comments, create comment
PRs: get, create, merge
branches: list, create, delete
orgs: list user orgs, get org
notifications: list

added write:repository and read:notification PAT scopes.
new response types: Comment, Branch, Organization, Notification.
authed_patch and authed_delete helpers with 401 retry.
URL-encoded query params throughout.
This commit is contained in:
2026-03-23 01:41:08 +00:00
parent 822a597a87
commit 8e7c572381
2 changed files with 1347 additions and 1 deletions

View File

@@ -13,8 +13,10 @@ const TOKEN_SCOPES: &[&str] = &[
"read:issue",
"write:issue",
"read:repository",
"write:repository",
"read:user",
"read:organization",
"read:notification",
];
pub struct GiteaClient {
@@ -110,6 +112,72 @@ pub struct FileContent {
pub size: u64,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Comment {
pub id: u64,
#[serde(default)]
pub body: String,
pub user: UserRef,
#[serde(default)]
pub html_url: String,
pub created_at: String,
pub updated_at: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Branch {
pub name: String,
#[serde(default)]
pub commit: BranchCommit,
#[serde(default)]
pub protected: bool,
}
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct BranchCommit {
#[serde(default)]
pub id: String,
#[serde(default)]
pub message: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Organization {
#[serde(default)]
pub id: u64,
#[serde(default)]
pub username: String,
#[serde(default)]
pub full_name: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub avatar_url: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Notification {
pub id: u64,
#[serde(default)]
pub subject: NotificationSubject,
#[serde(default)]
pub repository: Option<RepoSummary>,
#[serde(default)]
pub unread: bool,
#[serde(default)]
pub updated_at: String,
}
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct NotificationSubject {
#[serde(default)]
pub title: String,
#[serde(default)]
pub url: String,
#[serde(default, rename = "type")]
pub subject_type: String,
}
#[derive(Debug, Deserialize)]
struct GiteaUser {
login: String,
@@ -424,6 +492,77 @@ impl GiteaClient {
Ok(resp)
}
/// Make an authenticated PATCH request using the user's token.
/// On 401, invalidates token and retries once.
async fn authed_patch(
&self,
localpart: &str,
path: &str,
body: &serde_json::Value,
) -> Result<reqwest::Response, String> {
let token = self.ensure_token(localpart).await?;
let url = format!("{}{}", self.base_url, path);
let resp = self
.http
.patch(&url)
.header("Authorization", format!("token {token}"))
.json(body)
.send()
.await
.map_err(|e| format!("request failed: {e}"))?;
if resp.status().as_u16() == 401 {
debug!(localpart, "Token rejected, re-provisioning");
self.token_store.delete(localpart, SERVICE).await;
let token = self.ensure_token(localpart).await?;
return self
.http
.patch(&url)
.header("Authorization", format!("token {token}"))
.json(body)
.send()
.await
.map_err(|e| format!("request failed (retry): {e}"));
}
Ok(resp)
}
/// Make an authenticated DELETE request using the user's token.
/// On 401, invalidates token and retries once.
async fn authed_delete(
&self,
localpart: &str,
path: &str,
) -> Result<reqwest::Response, String> {
let token = self.ensure_token(localpart).await?;
let url = format!("{}{}", self.base_url, path);
let resp = self
.http
.delete(&url)
.header("Authorization", format!("token {token}"))
.send()
.await
.map_err(|e| format!("request failed: {e}"))?;
if resp.status().as_u16() == 401 {
debug!(localpart, "Token rejected, re-provisioning");
self.token_store.delete(localpart, SERVICE).await;
let token = self.ensure_token(localpart).await?;
return self
.http
.delete(&url)
.header("Authorization", format!("token {token}"))
.send()
.await
.map_err(|e| format!("request failed (retry): {e}"));
}
Ok(resp)
}
// ── Public API methods ──────────────────────────────────────────────────
pub async fn list_repos(
@@ -625,6 +764,429 @@ impl GiteaClient {
}
resp.json().await.map_err(|e| format!("parse error: {e}"))
}
// ── Repos ───────────────────────────────────────────────────────────────
pub async fn create_repo(
&self,
localpart: &str,
name: &str,
org: Option<&str>,
description: Option<&str>,
private: Option<bool>,
auto_init: Option<bool>,
default_branch: Option<&str>,
) -> Result<Repo, String> {
let mut json = serde_json::json!({ "name": name });
if let Some(d) = description {
json["description"] = serde_json::json!(d);
}
if let Some(p) = private {
json["private"] = serde_json::json!(p);
}
if let Some(a) = auto_init {
json["auto_init"] = serde_json::json!(a);
}
if let Some(b) = default_branch {
json["default_branch"] = serde_json::json!(b);
}
let path = if let Some(org) = org {
format!("/api/v1/orgs/{org}/repos")
} else {
"/api/v1/user/repos".to_string()
};
let resp = self.authed_post(localpart, &path, &json).await?;
if !resp.status().is_success() {
let text = resp.text().await.unwrap_or_default();
return Err(format!("create repo failed: {text}"));
}
resp.json().await.map_err(|e| format!("parse error: {e}"))
}
pub async fn edit_repo(
&self,
localpart: &str,
owner: &str,
repo: &str,
description: Option<&str>,
private: Option<bool>,
archived: Option<bool>,
default_branch: Option<&str>,
) -> Result<Repo, String> {
let mut json = serde_json::json!({});
if let Some(d) = description {
json["description"] = serde_json::json!(d);
}
if let Some(p) = private {
json["private"] = serde_json::json!(p);
}
if let Some(a) = archived {
json["archived"] = serde_json::json!(a);
}
if let Some(b) = default_branch {
json["default_branch"] = serde_json::json!(b);
}
let resp = self
.authed_patch(
localpart,
&format!("/api/v1/repos/{owner}/{repo}"),
&json,
)
.await?;
if !resp.status().is_success() {
let text = resp.text().await.unwrap_or_default();
return Err(format!("edit repo failed: {text}"));
}
resp.json().await.map_err(|e| format!("parse error: {e}"))
}
pub async fn fork_repo(
&self,
localpart: &str,
owner: &str,
repo: &str,
new_name: Option<&str>,
) -> Result<Repo, String> {
let mut json = serde_json::json!({});
if let Some(n) = new_name {
json["name"] = serde_json::json!(n);
}
let resp = self
.authed_post(
localpart,
&format!("/api/v1/repos/{owner}/{repo}/forks"),
&json,
)
.await?;
if !resp.status().is_success() {
let text = resp.text().await.unwrap_or_default();
return Err(format!("fork repo failed: {text}"));
}
resp.json().await.map_err(|e| format!("parse error: {e}"))
}
pub async fn list_org_repos(
&self,
localpart: &str,
org: &str,
limit: Option<u32>,
) -> Result<Vec<RepoSummary>, String> {
let param_str = {
let mut encoder = form_urlencoded::Serializer::new(String::new());
if let Some(n) = limit {
encoder.append_pair("limit", &n.to_string());
}
let encoded = encoder.finish();
if encoded.is_empty() { String::new() } else { format!("?{encoded}") }
};
let path = format!("/api/v1/orgs/{org}/repos{param_str}");
let resp = self.authed_get(localpart, &path).await?;
if !resp.status().is_success() {
let text = resp.text().await.unwrap_or_default();
return Err(format!("list org repos failed: {text}"));
}
resp.json().await.map_err(|e| format!("parse error: {e}"))
}
// ── Issues ──────────────────────────────────────────────────────────────
pub async fn edit_issue(
&self,
localpart: &str,
owner: &str,
repo: &str,
number: u64,
title: Option<&str>,
body: Option<&str>,
state: Option<&str>,
assignees: Option<&[String]>,
) -> Result<Issue, String> {
let mut json = serde_json::json!({});
if let Some(t) = title {
json["title"] = serde_json::json!(t);
}
if let Some(b) = body {
json["body"] = serde_json::json!(b);
}
if let Some(s) = state {
json["state"] = serde_json::json!(s);
}
if let Some(a) = assignees {
json["assignees"] = serde_json::json!(a);
}
let resp = self
.authed_patch(
localpart,
&format!("/api/v1/repos/{owner}/{repo}/issues/{number}"),
&json,
)
.await?;
if !resp.status().is_success() {
let text = resp.text().await.unwrap_or_default();
return Err(format!("edit issue failed: {text}"));
}
resp.json().await.map_err(|e| format!("parse error: {e}"))
}
pub async fn list_comments(
&self,
localpart: &str,
owner: &str,
repo: &str,
number: u64,
) -> Result<Vec<Comment>, String> {
let resp = self
.authed_get(
localpart,
&format!("/api/v1/repos/{owner}/{repo}/issues/{number}/comments"),
)
.await?;
if !resp.status().is_success() {
let text = resp.text().await.unwrap_or_default();
return Err(format!("list comments failed: {text}"));
}
resp.json().await.map_err(|e| format!("parse error: {e}"))
}
pub async fn create_comment(
&self,
localpart: &str,
owner: &str,
repo: &str,
number: u64,
body: &str,
) -> Result<Comment, String> {
let json = serde_json::json!({ "body": body });
let resp = self
.authed_post(
localpart,
&format!("/api/v1/repos/{owner}/{repo}/issues/{number}/comments"),
&json,
)
.await?;
if !resp.status().is_success() {
let text = resp.text().await.unwrap_or_default();
return Err(format!("create comment failed: {text}"));
}
resp.json().await.map_err(|e| format!("parse error: {e}"))
}
// ── Pull requests ───────────────────────────────────────────────────────
pub async fn get_pull(
&self,
localpart: &str,
owner: &str,
repo: &str,
number: u64,
) -> Result<PullRequest, String> {
let resp = self
.authed_get(
localpart,
&format!("/api/v1/repos/{owner}/{repo}/pulls/{number}"),
)
.await?;
if !resp.status().is_success() {
let text = resp.text().await.unwrap_or_default();
return Err(format!("get pull failed: {text}"));
}
resp.json().await.map_err(|e| format!("parse error: {e}"))
}
pub async fn create_pull(
&self,
localpart: &str,
owner: &str,
repo: &str,
title: &str,
head: &str,
base: &str,
body: Option<&str>,
) -> Result<PullRequest, String> {
let mut json = serde_json::json!({
"title": title,
"head": head,
"base": base,
});
if let Some(b) = body {
json["body"] = serde_json::json!(b);
}
let resp = self
.authed_post(
localpart,
&format!("/api/v1/repos/{owner}/{repo}/pulls"),
&json,
)
.await?;
if !resp.status().is_success() {
let text = resp.text().await.unwrap_or_default();
return Err(format!("create pull failed: {text}"));
}
resp.json().await.map_err(|e| format!("parse error: {e}"))
}
pub async fn merge_pull(
&self,
localpart: &str,
owner: &str,
repo: &str,
number: u64,
method: Option<&str>,
delete_branch: Option<bool>,
) -> Result<serde_json::Value, String> {
let mut json = serde_json::json!({
"Do": method.unwrap_or("merge"),
});
if let Some(d) = delete_branch {
json["delete_branch_after_merge"] = serde_json::json!(d);
}
let resp = self
.authed_post(
localpart,
&format!("/api/v1/repos/{owner}/{repo}/pulls/{number}/merge"),
&json,
)
.await?;
if !resp.status().is_success() {
let text = resp.text().await.unwrap_or_default();
return Err(format!("merge pull failed: {text}"));
}
// Merge returns empty body on success (204/200)
Ok(serde_json::json!({"status": "merged", "number": number}))
}
// ── Branches ────────────────────────────────────────────────────────────
pub async fn list_branches(
&self,
localpart: &str,
owner: &str,
repo: &str,
) -> Result<Vec<Branch>, String> {
let resp = self
.authed_get(
localpart,
&format!("/api/v1/repos/{owner}/{repo}/branches"),
)
.await?;
if !resp.status().is_success() {
let text = resp.text().await.unwrap_or_default();
return Err(format!("list branches failed: {text}"));
}
resp.json().await.map_err(|e| format!("parse error: {e}"))
}
pub async fn create_branch(
&self,
localpart: &str,
owner: &str,
repo: &str,
branch_name: &str,
from_branch: Option<&str>,
) -> Result<Branch, String> {
let mut json = serde_json::json!({
"new_branch_name": branch_name,
});
if let Some(f) = from_branch {
json["old_branch_name"] = serde_json::json!(f);
}
let resp = self
.authed_post(
localpart,
&format!("/api/v1/repos/{owner}/{repo}/branches"),
&json,
)
.await?;
if !resp.status().is_success() {
let text = resp.text().await.unwrap_or_default();
return Err(format!("create branch failed: {text}"));
}
resp.json().await.map_err(|e| format!("parse error: {e}"))
}
pub async fn delete_branch(
&self,
localpart: &str,
owner: &str,
repo: &str,
branch: &str,
) -> Result<serde_json::Value, String> {
let resp = self
.authed_delete(
localpart,
&format!("/api/v1/repos/{owner}/{repo}/branches/{branch}"),
)
.await?;
if !resp.status().is_success() {
let text = resp.text().await.unwrap_or_default();
return Err(format!("delete branch failed: {text}"));
}
Ok(serde_json::json!({"status": "deleted", "branch": branch}))
}
// ── Organizations ───────────────────────────────────────────────────────
pub async fn list_orgs(
&self,
localpart: &str,
username: &str,
) -> Result<Vec<Organization>, String> {
let resp = self
.authed_get(
localpart,
&format!("/api/v1/users/{username}/orgs"),
)
.await?;
if !resp.status().is_success() {
let text = resp.text().await.unwrap_or_default();
return Err(format!("list orgs failed: {text}"));
}
resp.json().await.map_err(|e| format!("parse error: {e}"))
}
pub async fn get_org(
&self,
localpart: &str,
org: &str,
) -> Result<Organization, String> {
let resp = self
.authed_get(
localpart,
&format!("/api/v1/orgs/{org}"),
)
.await?;
if !resp.status().is_success() {
let text = resp.text().await.unwrap_or_default();
return Err(format!("get org failed: {text}"));
}
resp.json().await.map_err(|e| format!("parse error: {e}"))
}
// ── Notifications ───────────────────────────────────────────────────────
pub async fn list_notifications(
&self,
localpart: &str,
) -> Result<Vec<Notification>, String> {
let resp = self
.authed_get(localpart, "/api/v1/notifications")
.await?;
if !resp.status().is_success() {
let text = resp.text().await.unwrap_or_default();
return Err(format!("list notifications failed: {text}"));
}
resp.json().await.map_err(|e| format!("parse error: {e}"))
}
}
#[cfg(test)]
@@ -729,4 +1291,132 @@ mod tests {
assert_eq!(file.name, "README.md");
assert_eq!(file.file_type, "file");
}
#[test]
fn test_comment_deserialize() {
let json = serde_json::json!({
"id": 99,
"body": "looks good to me",
"user": { "login": "lonni" },
"html_url": "https://src.sunbeam.pt/studio/sol/issues/1#issuecomment-99",
"created_at": "2026-03-22T10:00:00Z",
"updated_at": "2026-03-22T10:00:00Z",
});
let comment: Comment = serde_json::from_value(json).unwrap();
assert_eq!(comment.id, 99);
assert_eq!(comment.body, "looks good to me");
assert_eq!(comment.user.login, "lonni");
}
#[test]
fn test_branch_deserialize() {
let json = serde_json::json!({
"name": "feature/auth",
"commit": { "id": "abc123def456", "message": "add login flow" },
"protected": false,
});
let branch: Branch = serde_json::from_value(json).unwrap();
assert_eq!(branch.name, "feature/auth");
assert_eq!(branch.commit.id, "abc123def456");
assert!(!branch.protected);
}
#[test]
fn test_branch_minimal() {
let json = serde_json::json!({ "name": "main" });
let branch: Branch = serde_json::from_value(json).unwrap();
assert_eq!(branch.name, "main");
assert_eq!(branch.commit.id, ""); // default
}
#[test]
fn test_organization_deserialize() {
let json = serde_json::json!({
"id": 1,
"username": "studio",
"full_name": "Sunbeam Studios",
"description": "Game studio",
"avatar_url": "https://src.sunbeam.pt/avatars/1",
});
let org: Organization = serde_json::from_value(json).unwrap();
assert_eq!(org.username, "studio");
assert_eq!(org.full_name, "Sunbeam Studios");
}
#[test]
fn test_notification_deserialize() {
let json = serde_json::json!({
"id": 42,
"subject": { "title": "New issue", "url": "/api/v1/...", "type": "Issue" },
"repository": { "full_name": "studio/sol", "description": "" },
"unread": true,
"updated_at": "2026-03-22T10:00:00Z",
});
let notif: Notification = serde_json::from_value(json).unwrap();
assert_eq!(notif.id, 42);
assert!(notif.unread);
assert_eq!(notif.subject.title, "New issue");
assert_eq!(notif.subject.subject_type, "Issue");
assert_eq!(notif.repository.as_ref().unwrap().full_name, "studio/sol");
}
#[test]
fn test_notification_minimal() {
let json = serde_json::json!({ "id": 1 });
let notif: Notification = serde_json::from_value(json).unwrap();
assert_eq!(notif.id, 1);
assert!(!notif.unread);
assert!(notif.repository.is_none());
}
#[test]
fn test_token_scopes_include_write_repo() {
// New tools need write:repository for create/edit/fork/branch operations
assert!(TOKEN_SCOPES.contains(&"write:repository"));
}
#[test]
fn test_token_scopes_include_notifications() {
assert!(TOKEN_SCOPES.contains(&"read:notification"));
}
#[test]
fn test_pull_request_with_refs() {
let json = serde_json::json!({
"number": 3,
"title": "Add auth",
"body": "implements OIDC",
"state": "open",
"html_url": "https://src.sunbeam.pt/studio/sol/pulls/3",
"user": { "login": "sienna" },
"head": { "label": "feature/auth", "ref": "feature/auth", "sha": "abc123" },
"base": { "label": "main", "ref": "main", "sha": "def456" },
"mergeable": true,
"created_at": "2026-03-22T10:00:00Z",
"updated_at": "2026-03-22T10:00:00Z",
});
let pr: PullRequest = serde_json::from_value(json).unwrap();
assert_eq!(pr.number, 3);
assert_eq!(pr.mergeable, Some(true));
}
#[test]
fn test_repo_full_deserialize() {
let json = serde_json::json!({
"full_name": "studio/marathon",
"description": "P2P game engine",
"html_url": "https://src.sunbeam.pt/studio/marathon",
"default_branch": "mainline",
"open_issues_count": 121,
"stars_count": 0,
"forks_count": 2,
"updated_at": "2026-03-06T13:21:24Z",
"private": false,
});
let repo: Repo = serde_json::from_value(json).unwrap();
assert_eq!(repo.full_name, "studio/marathon");
assert_eq!(repo.default_branch, "mainline");
assert_eq!(repo.open_issues_count, 121);
assert_eq!(repo.forks_count, 2);
}
}

View File

@@ -136,6 +136,270 @@ pub async fn execute(
Err(e) => Ok(json!({"error": e}).to_string()),
}
}
"gitea_create_repo" => {
let name = args["name"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("missing 'name'"))?;
let org = args["org"].as_str();
let description = args["description"].as_str();
let private = args["private"].as_bool();
let auto_init = args["auto_init"].as_bool();
let default_branch = args["default_branch"].as_str();
match gitea
.create_repo(localpart, name, org, description, private, auto_init, default_branch)
.await
{
Ok(r) => Ok(serde_json::to_string(&r).unwrap_or_default()),
Err(e) => Ok(json!({"error": e}).to_string()),
}
}
"gitea_edit_repo" => {
let owner = args["owner"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("missing 'owner'"))?;
let repo = args["repo"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("missing 'repo'"))?;
let description = args["description"].as_str();
let private = args["private"].as_bool();
let archived = args["archived"].as_bool();
let default_branch = args["default_branch"].as_str();
match gitea
.edit_repo(localpart, owner, repo, description, private, archived, default_branch)
.await
{
Ok(r) => Ok(serde_json::to_string(&r).unwrap_or_default()),
Err(e) => Ok(json!({"error": e}).to_string()),
}
}
"gitea_fork_repo" => {
let owner = args["owner"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("missing 'owner'"))?;
let repo = args["repo"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("missing 'repo'"))?;
let new_name = args["new_name"].as_str();
match gitea.fork_repo(localpart, owner, repo, new_name).await {
Ok(r) => Ok(serde_json::to_string(&r).unwrap_or_default()),
Err(e) => Ok(json!({"error": e}).to_string()),
}
}
"gitea_list_org_repos" => {
let org = args["org"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("missing 'org'"))?;
let limit = args["limit"].as_u64().map(|n| n as u32);
match gitea.list_org_repos(localpart, org, limit).await {
Ok(repos) => Ok(serde_json::to_string(&repos).unwrap_or_default()),
Err(e) => Ok(json!({"error": e}).to_string()),
}
}
"gitea_edit_issue" => {
let owner = args["owner"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("missing 'owner'"))?;
let repo = args["repo"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("missing 'repo'"))?;
let number = args["number"]
.as_u64()
.ok_or_else(|| anyhow::anyhow!("missing 'number'"))?;
let title = args["title"].as_str();
let body = args["body"].as_str();
let state = args["state"].as_str();
let assignees: Option<Vec<String>> = args["assignees"]
.as_array()
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect());
match gitea
.edit_issue(localpart, owner, repo, number, title, body, state, assignees.as_deref())
.await
{
Ok(issue) => Ok(serde_json::to_string(&issue).unwrap_or_default()),
Err(e) => Ok(json!({"error": e}).to_string()),
}
}
"gitea_list_comments" => {
let owner = args["owner"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("missing 'owner'"))?;
let repo = args["repo"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("missing 'repo'"))?;
let number = args["number"]
.as_u64()
.ok_or_else(|| anyhow::anyhow!("missing 'number'"))?;
match gitea.list_comments(localpart, owner, repo, number).await {
Ok(comments) => Ok(serde_json::to_string(&comments).unwrap_or_default()),
Err(e) => Ok(json!({"error": e}).to_string()),
}
}
"gitea_create_comment" => {
let owner = args["owner"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("missing 'owner'"))?;
let repo = args["repo"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("missing 'repo'"))?;
let number = args["number"]
.as_u64()
.ok_or_else(|| anyhow::anyhow!("missing 'number'"))?;
let body = args["body"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("missing 'body'"))?;
match gitea
.create_comment(localpart, owner, repo, number, body)
.await
{
Ok(comment) => Ok(serde_json::to_string(&comment).unwrap_or_default()),
Err(e) => Ok(json!({"error": e}).to_string()),
}
}
"gitea_get_pull" => {
let owner = args["owner"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("missing 'owner'"))?;
let repo = args["repo"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("missing 'repo'"))?;
let number = args["number"]
.as_u64()
.ok_or_else(|| anyhow::anyhow!("missing 'number'"))?;
match gitea.get_pull(localpart, owner, repo, number).await {
Ok(pr) => Ok(serde_json::to_string(&pr).unwrap_or_default()),
Err(e) => Ok(json!({"error": e}).to_string()),
}
}
"gitea_create_pull" => {
let owner = args["owner"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("missing 'owner'"))?;
let repo = args["repo"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("missing 'repo'"))?;
let title = args["title"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("missing 'title'"))?;
let head = args["head"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("missing 'head'"))?;
let base = args["base"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("missing 'base'"))?;
let body = args["body"].as_str();
match gitea
.create_pull(localpart, owner, repo, title, head, base, body)
.await
{
Ok(pr) => Ok(serde_json::to_string(&pr).unwrap_or_default()),
Err(e) => Ok(json!({"error": e}).to_string()),
}
}
"gitea_merge_pull" => {
let owner = args["owner"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("missing 'owner'"))?;
let repo = args["repo"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("missing 'repo'"))?;
let number = args["number"]
.as_u64()
.ok_or_else(|| anyhow::anyhow!("missing 'number'"))?;
let method = args["method"].as_str();
let delete_branch = args["delete_branch"].as_bool();
match gitea
.merge_pull(localpart, owner, repo, number, method, delete_branch)
.await
{
Ok(result) => Ok(serde_json::to_string(&result).unwrap_or_default()),
Err(e) => Ok(json!({"error": e}).to_string()),
}
}
"gitea_list_branches" => {
let owner = args["owner"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("missing 'owner'"))?;
let repo = args["repo"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("missing 'repo'"))?;
match gitea.list_branches(localpart, owner, repo).await {
Ok(branches) => Ok(serde_json::to_string(&branches).unwrap_or_default()),
Err(e) => Ok(json!({"error": e}).to_string()),
}
}
"gitea_create_branch" => {
let owner = args["owner"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("missing 'owner'"))?;
let repo = args["repo"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("missing 'repo'"))?;
let branch_name = args["branch_name"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("missing 'branch_name'"))?;
let from_branch = args["from_branch"].as_str();
match gitea
.create_branch(localpart, owner, repo, branch_name, from_branch)
.await
{
Ok(branch) => Ok(serde_json::to_string(&branch).unwrap_or_default()),
Err(e) => Ok(json!({"error": e}).to_string()),
}
}
"gitea_delete_branch" => {
let owner = args["owner"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("missing 'owner'"))?;
let repo = args["repo"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("missing 'repo'"))?;
let branch = args["branch"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("missing 'branch'"))?;
match gitea.delete_branch(localpart, owner, repo, branch).await {
Ok(result) => Ok(serde_json::to_string(&result).unwrap_or_default()),
Err(e) => Ok(json!({"error": e}).to_string()),
}
}
"gitea_list_orgs" => {
let username = args["username"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("missing 'username'"))?;
match gitea.list_orgs(localpart, username).await {
Ok(orgs) => Ok(serde_json::to_string(&orgs).unwrap_or_default()),
Err(e) => Ok(json!({"error": e}).to_string()),
}
}
"gitea_get_org" => {
let org = args["org"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("missing 'org'"))?;
match gitea.get_org(localpart, org).await {
Ok(o) => Ok(serde_json::to_string(&o).unwrap_or_default()),
Err(e) => Ok(json!({"error": e}).to_string()),
}
}
"gitea_list_notifications" => {
match gitea.list_notifications(localpart).await {
Ok(notifs) => Ok(serde_json::to_string(&notifs).unwrap_or_default()),
Err(e) => Ok(json!({"error": e}).to_string()),
}
}
_ => anyhow::bail!("Unknown devtools tool: {name}"),
}
}
@@ -297,7 +561,10 @@ pub fn tool_definitions() -> Vec<mistralai_client::v1::tool::Tool> {
),
Tool::new(
"gitea_get_file".into(),
"Get the contents of a file from a repository.".into(),
"Get file contents or list directory entries. Use with path='' to list the repo root. \
Use with a directory path to list its contents. Use with a file path to get the file. \
This is how you explore and browse repositories."
.into(),
json!({
"type": "object",
"properties": {
@@ -321,5 +588,394 @@ pub fn tool_definitions() -> Vec<mistralai_client::v1::tool::Tool> {
"required": ["owner", "repo", "path"]
}),
),
// ── Repos (new) ─────────────────────────────────────────────────────
Tool::new(
"gitea_create_repo".into(),
"Create a new repository for the requesting user, or under an org.".into(),
json!({
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Repository name"
},
"org": {
"type": "string",
"description": "Organization to create the repo under (omit for personal repo)"
},
"description": {
"type": "string",
"description": "Repository description"
},
"private": {
"type": "boolean",
"description": "Whether the repo is private (default: false)"
},
"auto_init": {
"type": "boolean",
"description": "Initialize with a README (default: false)"
},
"default_branch": {
"type": "string",
"description": "Default branch name (default: main)"
}
},
"required": ["name"]
}),
),
Tool::new(
"gitea_edit_repo".into(),
"Update repository settings (description, visibility, archived, default branch).".into(),
json!({
"type": "object",
"properties": {
"owner": {
"type": "string",
"description": "Repository owner"
},
"repo": {
"type": "string",
"description": "Repository name"
},
"description": {
"type": "string",
"description": "New description"
},
"private": {
"type": "boolean",
"description": "Set visibility"
},
"archived": {
"type": "boolean",
"description": "Archive or unarchive the repo"
},
"default_branch": {
"type": "string",
"description": "Set default branch"
}
},
"required": ["owner", "repo"]
}),
),
Tool::new(
"gitea_fork_repo".into(),
"Fork a repository into the requesting user's account.".into(),
json!({
"type": "object",
"properties": {
"owner": {
"type": "string",
"description": "Repository owner"
},
"repo": {
"type": "string",
"description": "Repository name"
},
"new_name": {
"type": "string",
"description": "Name for the forked repo (default: same as original)"
}
},
"required": ["owner", "repo"]
}),
),
Tool::new(
"gitea_list_org_repos".into(),
"List repositories belonging to an organization.".into(),
json!({
"type": "object",
"properties": {
"org": {
"type": "string",
"description": "Organization name"
},
"limit": {
"type": "integer",
"description": "Max results (default 20)"
}
},
"required": ["org"]
}),
),
// ── Issues (new) ────────────────────────────────────────────────────
Tool::new(
"gitea_edit_issue".into(),
"Update an issue (title, body, state, assignees).".into(),
json!({
"type": "object",
"properties": {
"owner": {
"type": "string",
"description": "Repository owner"
},
"repo": {
"type": "string",
"description": "Repository name"
},
"number": {
"type": "integer",
"description": "Issue number"
},
"title": {
"type": "string",
"description": "New title"
},
"body": {
"type": "string",
"description": "New body (markdown)"
},
"state": {
"type": "string",
"description": "Set state: open or closed"
},
"assignees": {
"type": "array",
"items": { "type": "string" },
"description": "Usernames to assign"
}
},
"required": ["owner", "repo", "number"]
}),
),
Tool::new(
"gitea_list_comments".into(),
"List comments on an issue.".into(),
json!({
"type": "object",
"properties": {
"owner": {
"type": "string",
"description": "Repository owner"
},
"repo": {
"type": "string",
"description": "Repository name"
},
"number": {
"type": "integer",
"description": "Issue number"
}
},
"required": ["owner", "repo", "number"]
}),
),
Tool::new(
"gitea_create_comment".into(),
"Add a comment to an issue. Authored by the requesting user.".into(),
json!({
"type": "object",
"properties": {
"owner": {
"type": "string",
"description": "Repository owner"
},
"repo": {
"type": "string",
"description": "Repository name"
},
"number": {
"type": "integer",
"description": "Issue number"
},
"body": {
"type": "string",
"description": "Comment body (markdown)"
}
},
"required": ["owner", "repo", "number", "body"]
}),
),
// ── Pull requests (new) ─────────────────────────────────────────────
Tool::new(
"gitea_get_pull".into(),
"Get details of a specific pull request by number.".into(),
json!({
"type": "object",
"properties": {
"owner": {
"type": "string",
"description": "Repository owner"
},
"repo": {
"type": "string",
"description": "Repository name"
},
"number": {
"type": "integer",
"description": "Pull request number"
}
},
"required": ["owner", "repo", "number"]
}),
),
Tool::new(
"gitea_create_pull".into(),
"Create a pull request. Authored by the requesting user.".into(),
json!({
"type": "object",
"properties": {
"owner": {
"type": "string",
"description": "Repository owner"
},
"repo": {
"type": "string",
"description": "Repository name"
},
"title": {
"type": "string",
"description": "PR title"
},
"head": {
"type": "string",
"description": "Source branch"
},
"base": {
"type": "string",
"description": "Target branch"
},
"body": {
"type": "string",
"description": "PR description (markdown)"
}
},
"required": ["owner", "repo", "title", "head", "base"]
}),
),
Tool::new(
"gitea_merge_pull".into(),
"Merge a pull request.".into(),
json!({
"type": "object",
"properties": {
"owner": {
"type": "string",
"description": "Repository owner"
},
"repo": {
"type": "string",
"description": "Repository name"
},
"number": {
"type": "integer",
"description": "Pull request number"
},
"method": {
"type": "string",
"description": "Merge method: merge, rebase, or squash (default: merge)"
},
"delete_branch": {
"type": "boolean",
"description": "Delete head branch after merge (default: false)"
}
},
"required": ["owner", "repo", "number"]
}),
),
// ── Branches (new) ──────────────────────────────────────────────────
Tool::new(
"gitea_list_branches".into(),
"List branches in a repository.".into(),
json!({
"type": "object",
"properties": {
"owner": {
"type": "string",
"description": "Repository owner"
},
"repo": {
"type": "string",
"description": "Repository name"
}
},
"required": ["owner", "repo"]
}),
),
Tool::new(
"gitea_create_branch".into(),
"Create a new branch in a repository.".into(),
json!({
"type": "object",
"properties": {
"owner": {
"type": "string",
"description": "Repository owner"
},
"repo": {
"type": "string",
"description": "Repository name"
},
"branch_name": {
"type": "string",
"description": "Name for the new branch"
},
"from_branch": {
"type": "string",
"description": "Branch to create from (default: default branch)"
}
},
"required": ["owner", "repo", "branch_name"]
}),
),
Tool::new(
"gitea_delete_branch".into(),
"Delete a branch from a repository.".into(),
json!({
"type": "object",
"properties": {
"owner": {
"type": "string",
"description": "Repository owner"
},
"repo": {
"type": "string",
"description": "Repository name"
},
"branch": {
"type": "string",
"description": "Branch name to delete"
}
},
"required": ["owner", "repo", "branch"]
}),
),
// ── Organizations (new) ─────────────────────────────────────────────
Tool::new(
"gitea_list_orgs".into(),
"List organizations a user belongs to.".into(),
json!({
"type": "object",
"properties": {
"username": {
"type": "string",
"description": "Username to list orgs for"
}
},
"required": ["username"]
}),
),
Tool::new(
"gitea_get_org".into(),
"Get details about an organization.".into(),
json!({
"type": "object",
"properties": {
"org": {
"type": "string",
"description": "Organization name"
}
},
"required": ["org"]
}),
),
// ── Notifications (new) ─────────────────────────────────────────────
Tool::new(
"gitea_list_notifications".into(),
"List unread notifications for the requesting user.".into(),
json!({
"type": "object",
"properties": {}
}),
),
]
}