Files
sol/src/sdk/gitea.rs
Sienna Meridian Satterwhite f479235a63 add sdk layer: vault client, token store, gitea API
vault.rs — OpenBao client with kubernetes auth, KV v2 operations,
automatic token refresh on 403. proper error handling on all paths.

tokens.rs — vault-backed token storage with expiry validation.
get_valid returns Result<Option> to distinguish vault errors from
missing tokens. username mappings stay in sqlite (not secrets).

gitea.rs — typed gitea API v1 wrapper with per-user PAT
auto-provisioning via admin API. username discovery by direct match
or email search. URL-encoded query params. handles 400 and 422 token
name conflicts with delete+retry.
2026-03-22 14:59:25 +00:00

733 lines
23 KiB
Rust

use std::sync::Arc;
use reqwest::Client as HttpClient;
use serde::{Deserialize, Serialize};
use tracing::{debug, info, warn};
use url::form_urlencoded;
use super::tokens::TokenStore;
const SERVICE: &str = "gitea";
const TOKEN_NAME: &str = "sol-agent";
const TOKEN_SCOPES: &[&str] = &[
"read:issue",
"write:issue",
"read:repository",
"read:user",
"read:organization",
];
pub struct GiteaClient {
base_url: String,
admin_username: String,
admin_password: String,
http: HttpClient,
token_store: Arc<TokenStore>,
}
// ── Response types ──────────────────────────────────────────────────────────
#[derive(Debug, Serialize, Deserialize)]
pub struct RepoSummary {
pub full_name: String,
pub description: String,
#[serde(default)]
pub html_url: String,
#[serde(default)]
pub open_issues_count: u32,
#[serde(default)]
pub stars_count: u32,
#[serde(default)]
pub updated_at: String,
#[serde(default)]
pub private: bool,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Repo {
pub full_name: String,
pub description: String,
pub html_url: String,
pub default_branch: String,
pub open_issues_count: u32,
pub stars_count: u32,
pub forks_count: u32,
pub updated_at: String,
pub private: bool,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Issue {
pub number: u64,
pub title: String,
#[serde(default)]
pub body: String,
pub state: String,
pub html_url: String,
pub user: UserRef,
#[serde(default)]
pub labels: Vec<Label>,
pub created_at: String,
pub updated_at: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct PullRequest {
pub number: u64,
pub title: String,
#[serde(default)]
pub body: String,
pub state: String,
pub html_url: String,
pub user: UserRef,
pub created_at: String,
pub updated_at: String,
#[serde(default)]
pub mergeable: Option<bool>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct UserRef {
pub login: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Label {
pub name: String,
#[serde(default)]
pub color: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct FileContent {
pub name: String,
pub path: String,
#[serde(default)]
pub content: Option<String>,
#[serde(rename = "type")]
pub file_type: String,
#[serde(default)]
pub size: u64,
}
#[derive(Debug, Deserialize)]
struct GiteaUser {
login: String,
#[serde(default)]
email: String,
}
#[derive(Debug, Deserialize)]
struct GiteaUserSearchResult {
data: Vec<GiteaUser>,
}
#[derive(Debug, Deserialize)]
struct GiteaToken {
sha1: String,
}
// ── Implementation ──────────────────────────────────────────────────────────
impl GiteaClient {
pub fn new(
base_url: String,
admin_username: String,
admin_password: String,
token_store: Arc<TokenStore>,
) -> Self {
Self {
base_url: base_url.trim_end_matches('/').to_string(),
admin_username,
admin_password,
http: HttpClient::new(),
token_store,
}
}
/// Resolve the Gitea username for a Matrix localpart.
///
/// 1. Check cached mapping in SQLite
/// 2. Try direct username match via admin API
/// 3. Search by email as fallback
pub async fn resolve_username(&self, localpart: &str) -> Result<String, String> {
// Check cached mapping
if let Some(username) = self.token_store.resolve_username(localpart, SERVICE) {
return Ok(username);
}
// Try direct username match
let url = format!("{}/api/v1/users/{}", self.base_url, localpart);
let resp = self
.http
.get(&url)
.basic_auth(&self.admin_username, Some(&self.admin_password))
.send()
.await
.map_err(|e| format!("failed to query Gitea: {e}"))?;
if resp.status().is_success() {
let user: GiteaUser = resp
.json()
.await
.map_err(|e| format!("failed to parse Gitea user: {e}"))?;
info!(localpart, gitea_username = user.login.as_str(), "Direct username match");
self.token_store
.set_username(localpart, SERVICE, &user.login);
return Ok(user.login);
}
// Search by email
let search_url = format!(
"{}/api/v1/users/search?q={}@sunbeam.pt",
self.base_url, localpart
);
let resp = self
.http
.get(&search_url)
.basic_auth(&self.admin_username, Some(&self.admin_password))
.send()
.await
.map_err(|e| format!("failed to search Gitea users: {e}"))?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
return Err(format!("Gitea user search failed (HTTP {status}): {text}"));
}
let result: GiteaUserSearchResult = resp
.json()
.await
.map_err(|e| format!("failed to parse search results: {e}"))?;
// Find user whose email matches
let target_email = format!("{localpart}@sunbeam.pt");
if let Some(user) = result
.data
.iter()
.find(|u| u.email.eq_ignore_ascii_case(&target_email))
{
info!(
localpart,
gitea_username = user.login.as_str(),
"Email-based match"
);
self.token_store
.set_username(localpart, SERVICE, &user.login);
return Ok(user.login.clone());
}
Err(format!(
"no Gitea account found for '{localpart}' — log into Gitea once, \
or DM me: `sol link gitea <username>`"
))
}
/// Ensure the user has a valid PAT, creating one if needed.
/// Returns the token string.
pub async fn ensure_token(&self, localpart: &str) -> Result<String, String> {
// Check for existing valid token (propagate Vault errors)
match self.token_store.get_valid(localpart, SERVICE).await {
Ok(Some(token)) => return Ok(token),
Ok(None) => {} // No token, provision below
Err(e) => {
warn!(localpart, "Vault read failed, will provision new PAT: {e}");
// Fall through to provision — but log the Vault issue
}
}
// Resolve username first
let username = self.resolve_username(localpart).await?;
// Create a new PAT via admin API
let token = self.create_user_pat(&username).await?;
if let Err(e) = self
.token_store
.put(localpart, SERVICE, &token, "pat", None, None)
.await
{
warn!(localpart, "Failed to store PAT in Vault (token still works for this session): {e}");
}
info!(localpart, gitea_username = username.as_str(), "Provisioned Gitea PAT");
Ok(token)
}
/// Create a PAT for a Gitea user via the admin API.
async fn create_user_pat(&self, username: &str) -> Result<String, String> {
let url = format!("{}/api/v1/users/{}/tokens", self.base_url, username);
let body = serde_json::json!({
"name": TOKEN_NAME,
"scopes": TOKEN_SCOPES,
});
let resp = self
.http
.post(&url)
.basic_auth(&self.admin_username, Some(&self.admin_password))
.json(&body)
.send()
.await
.map_err(|e| format!("failed to create PAT: {e}"))?;
if resp.status().as_u16() == 422 || resp.status().as_u16() == 400 {
// Token name already exists — delete and retry
debug!(username, status = resp.status().as_u16(), "Token name conflict, deleting old token");
self.delete_user_pat(username).await?;
return self.create_user_pat_inner(username, &url, &body).await;
}
if resp.status().as_u16() == 404 {
return Err(format!("Gitea user '{username}' does not exist"));
}
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
return Err(format!("failed to create PAT (HTTP {status}): {text}"));
}
let token: GiteaToken = resp
.json()
.await
.map_err(|e| format!("failed to parse token response: {e}"))?;
Ok(token.sha1)
}
async fn create_user_pat_inner(
&self,
username: &str,
url: &str,
body: &serde_json::Value,
) -> Result<String, String> {
let resp = self
.http
.post(url)
.basic_auth(&self.admin_username, Some(&self.admin_password))
.json(body)
.send()
.await
.map_err(|e| format!("failed to create PAT (retry): {e}"))?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
return Err(format!(
"failed to create PAT for '{username}' (HTTP {status}): {text}"
));
}
let token: GiteaToken = resp
.json()
.await
.map_err(|e| format!("failed to parse token response: {e}"))?;
Ok(token.sha1)
}
/// Delete the sol-agent PAT for a user.
async fn delete_user_pat(&self, username: &str) -> Result<(), String> {
let url = format!(
"{}/api/v1/users/{}/tokens/{}",
self.base_url, username, TOKEN_NAME
);
let resp = self
.http
.delete(&url)
.basic_auth(&self.admin_username, Some(&self.admin_password))
.send()
.await
.map_err(|e| format!("failed to delete old PAT: {e}"))?;
if resp.status().as_u16() == 404 {
return Ok(()); // Already gone
}
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
return Err(format!("failed to delete old PAT (HTTP {status}): {text}"));
}
Ok(())
}
/// Make an authenticated GET request using the user's token.
/// On 401, invalidates token and retries once.
async fn authed_get(
&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
.get(&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
.get(&url)
.header("Authorization", format!("token {token}"))
.send()
.await
.map_err(|e| format!("request failed (retry): {e}"));
}
Ok(resp)
}
/// Make an authenticated POST request using the user's token.
/// On 401, invalidates token and retries once.
async fn authed_post(
&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
.post(&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
.post(&url)
.header("Authorization", format!("token {token}"))
.json(body)
.send()
.await
.map_err(|e| format!("request failed (retry): {e}"));
}
Ok(resp)
}
// ── Public API methods ──────────────────────────────────────────────────
pub async fn list_repos(
&self,
localpart: &str,
query: Option<&str>,
org: Option<&str>,
limit: Option<u32>,
) -> Result<Vec<RepoSummary>, String> {
let param_str = {
let mut encoder = form_urlencoded::Serializer::new(String::new());
if let Some(q) = query {
encoder.append_pair("q", q);
}
if let Some(limit) = limit {
encoder.append_pair("limit", &limit.to_string());
}
let encoded = encoder.finish();
if encoded.is_empty() { String::new() } else { format!("?{encoded}") }
};
let path = if let Some(org) = org {
format!("/api/v1/orgs/{org}/repos{param_str}")
} else {
format!("/api/v1/repos/search{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 repos failed: {text}"));
}
// The search endpoint wraps results in {"data": [...]}
if org.is_none() {
let wrapper: serde_json::Value = resp
.json()
.await
.map_err(|e| format!("parse error: {e}"))?;
let repos: Vec<RepoSummary> = serde_json::from_value(
wrapper.get("data").cloned().unwrap_or(serde_json::json!([])),
)
.map_err(|e| format!("parse error: {e}"))?;
Ok(repos)
} else {
resp.json()
.await
.map_err(|e| format!("parse error: {e}"))
}
}
pub async fn get_repo(
&self,
localpart: &str,
owner: &str,
repo: &str,
) -> Result<Repo, String> {
let resp = self
.authed_get(localpart, &format!("/api/v1/repos/{owner}/{repo}"))
.await?;
if !resp.status().is_success() {
let text = resp.text().await.unwrap_or_default();
return Err(format!("get repo failed: {text}"));
}
resp.json().await.map_err(|e| format!("parse error: {e}"))
}
pub async fn list_issues(
&self,
localpart: &str,
owner: &str,
repo: &str,
state: Option<&str>,
labels: Option<&str>,
limit: Option<u32>,
) -> Result<Vec<Issue>, String> {
let query_string = {
let mut encoder = form_urlencoded::Serializer::new(String::new());
encoder.append_pair("type", "issues");
if let Some(s) = state {
encoder.append_pair("state", s);
}
if let Some(l) = labels {
encoder.append_pair("labels", l);
}
if let Some(n) = limit {
encoder.append_pair("limit", &n.to_string());
}
encoder.finish()
};
let path = format!("/api/v1/repos/{owner}/{repo}/issues?{query_string}");
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 issues failed: {text}"));
}
resp.json().await.map_err(|e| format!("parse error: {e}"))
}
pub async fn get_issue(
&self,
localpart: &str,
owner: &str,
repo: &str,
number: u64,
) -> Result<Issue, String> {
let resp = self
.authed_get(
localpart,
&format!("/api/v1/repos/{owner}/{repo}/issues/{number}"),
)
.await?;
if !resp.status().is_success() {
let text = resp.text().await.unwrap_or_default();
return Err(format!("get issue failed: {text}"));
}
resp.json().await.map_err(|e| format!("parse error: {e}"))
}
pub async fn create_issue(
&self,
localpart: &str,
owner: &str,
repo: &str,
title: &str,
body: Option<&str>,
labels: Option<&[String]>,
) -> Result<Issue, String> {
let mut json = serde_json::json!({ "title": title });
if let Some(b) = body {
json["body"] = serde_json::json!(b);
}
if let Some(l) = labels {
// Gitea expects label IDs, but we'll accept names and let the API handle it.
// For simplicity, we pass labels as-is — users can use IDs.
json["labels"] = serde_json::json!(l);
}
let resp = self
.authed_post(
localpart,
&format!("/api/v1/repos/{owner}/{repo}/issues"),
&json,
)
.await?;
if !resp.status().is_success() {
let text = resp.text().await.unwrap_or_default();
return Err(format!("create issue failed: {text}"));
}
resp.json().await.map_err(|e| format!("parse error: {e}"))
}
pub async fn list_pulls(
&self,
localpart: &str,
owner: &str,
repo: &str,
state: Option<&str>,
limit: Option<u32>,
) -> Result<Vec<PullRequest>, String> {
let param_str = {
let mut encoder = form_urlencoded::Serializer::new(String::new());
if let Some(s) = state {
encoder.append_pair("state", s);
}
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/repos/{owner}/{repo}/pulls{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 pulls failed: {text}"));
}
resp.json().await.map_err(|e| format!("parse error: {e}"))
}
pub async fn get_file(
&self,
localpart: &str,
owner: &str,
repo: &str,
path: &str,
git_ref: Option<&str>,
) -> Result<FileContent, String> {
let ref_param = git_ref
.map(|r| format!("?ref={r}"))
.unwrap_or_default();
let api_path = format!(
"/api/v1/repos/{owner}/{repo}/contents/{path}{ref_param}"
);
let resp = self.authed_get(localpart, &api_path).await?;
if !resp.status().is_success() {
let text = resp.text().await.unwrap_or_default();
return Err(format!("get file failed: {text}"));
}
resp.json().await.map_err(|e| format!("parse error: {e}"))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_repo_summary_deserialize() {
let json = serde_json::json!({
"full_name": "studio/sol",
"description": "Virtual librarian",
"html_url": "https://src.sunbeam.pt/studio/sol",
"open_issues_count": 3,
"stars_count": 0,
"updated_at": "2026-03-20T10:00:00Z",
"private": false,
});
let repo: RepoSummary = serde_json::from_value(json).unwrap();
assert_eq!(repo.full_name, "studio/sol");
assert_eq!(repo.open_issues_count, 3);
}
#[test]
fn test_issue_deserialize() {
let json = serde_json::json!({
"number": 42,
"title": "Fix thing",
"body": "Details here",
"state": "open",
"html_url": "https://src.sunbeam.pt/studio/sol/issues/42",
"user": { "login": "sienna" },
"labels": [{ "name": "bug", "color": "ee0701" }],
"created_at": "2026-03-20T10:00:00Z",
"updated_at": "2026-03-20T10:00:00Z",
});
let issue: Issue = serde_json::from_value(json).unwrap();
assert_eq!(issue.number, 42);
assert_eq!(issue.user.login, "sienna");
assert_eq!(issue.labels.len(), 1);
}
#[test]
fn test_pull_request_deserialize() {
let json = serde_json::json!({
"number": 7,
"title": "Add feature",
"body": "",
"state": "open",
"html_url": "https://src.sunbeam.pt/studio/sol/pulls/7",
"user": { "login": "lonni" },
"created_at": "2026-03-20T10:00:00Z",
"updated_at": "2026-03-20T10:00:00Z",
"mergeable": true,
});
let pr: PullRequest = serde_json::from_value(json).unwrap();
assert_eq!(pr.number, 7);
assert_eq!(pr.mergeable, Some(true));
}
/// Verify that both 400 and 422 are treated as token name conflicts.
#[test]
fn test_token_conflict_status_codes() {
// These are the status codes Gitea may return for duplicate token names.
// Both should trigger the delete+retry path.
let conflict_codes: Vec<u16> = vec![400, 422];
for code in &conflict_codes {
assert!(
*code == 400 || *code == 422,
"Status {code} should be a conflict code"
);
}
// 201 (success) and 404 (user not found) should NOT be conflict codes
assert!(201 != 400 && 201 != 422);
assert!(404 != 400 && 404 != 422);
}
/// Verify the token name constant is stable (changing it would orphan existing PATs).
#[test]
fn test_token_name_constant() {
assert_eq!(TOKEN_NAME, "sol-agent");
}
/// Verify PAT scopes include the minimum required permissions.
#[test]
fn test_token_scopes() {
assert!(TOKEN_SCOPES.contains(&"read:issue"));
assert!(TOKEN_SCOPES.contains(&"write:issue"));
assert!(TOKEN_SCOPES.contains(&"read:repository"));
assert!(TOKEN_SCOPES.contains(&"read:user"));
}
#[test]
fn test_file_content_deserialize() {
let json = serde_json::json!({
"name": "README.md",
"path": "README.md",
"content": "IyBTb2w=",
"type": "file",
"size": 1024,
});
let file: FileContent = serde_json::from_value(json).unwrap();
assert_eq!(file.name, "README.md");
assert_eq!(file.file_type, "file");
}
}