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);
}
}