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.
733 lines
23 KiB
Rust
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");
|
|
}
|
|
}
|