diff --git a/src/sdk/gitea.rs b/src/sdk/gitea.rs index 48983f6..2ae9a67 100644 --- a/src/sdk/gitea.rs +++ b/src/sdk/gitea.rs @@ -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, + #[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 { + 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 { + 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, + auto_init: Option, + default_branch: Option<&str>, + ) -> Result { + 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, + archived: Option, + default_branch: Option<&str>, + ) -> Result { + 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 { + 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, + ) -> Result, 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 { + 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, 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 { + 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 { + 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 { + 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, + ) -> Result { + 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, 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 { + 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 { + 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, 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 { + 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, 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); + } } diff --git a/src/tools/devtools.rs b/src/tools/devtools.rs index 46f72f2..782863b 100644 --- a/src/tools/devtools.rs +++ b/src/tools/devtools.rs @@ -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> = 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(¬ifs).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 { ), 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 { "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": {} + }), + ), ] }