use crate::{Issue, IssueTracker, Result, TrackerError};
use async_trait::async_trait;
use jiff::Zoned;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::process::Command;
use std::time::Duration;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ClaimResult {
Success,
AlreadyAssigned,
AssignedToOther { assignee: String },
NotFound,
PermissionDenied { reason: String },
TransientFailure { reason: String },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum ClaimStrategy {
#[default]
PreferRobot,
ApiOnly,
RobotOnly,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MergeStyle {
Merge,
Rebase,
Squash,
}
impl MergeStyle {
pub fn as_str(&self) -> &'static str {
match self {
MergeStyle::Merge => "merge",
MergeStyle::Rebase => "rebase",
MergeStyle::Squash => "squash",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GiteaMergeResult {
pub pr_number: u64,
pub merge_commit_sha: String,
pub title: String,
}
#[derive(Clone)]
pub struct GiteaConfig {
pub base_url: String,
pub token: String,
pub owner: String,
pub repo: String,
pub active_states: Vec<String>,
pub terminal_states: Vec<String>,
pub use_robot_api: bool,
pub robot_path: PathBuf,
pub claim_strategy: ClaimStrategy,
}
impl std::fmt::Debug for GiteaConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("GiteaConfig")
.field("base_url", &self.base_url)
.field("token", &"***REDACTED***")
.field("owner", &self.owner)
.field("repo", &self.repo)
.field("active_states", &self.active_states)
.field("terminal_states", &self.terminal_states)
.field("use_robot_api", &self.use_robot_api)
.field("robot_path", &self.robot_path)
.field("claim_strategy", &self.claim_strategy)
.finish()
}
}
impl GiteaConfig {
pub fn new(base_url: String, token: String, owner: String, repo: String) -> Self {
Self {
base_url,
token,
owner,
repo,
active_states: vec!["open".to_string()],
terminal_states: vec!["closed".to_string()],
use_robot_api: false,
robot_path: PathBuf::from("/home/alex/go/bin/gitea-robot"),
claim_strategy: ClaimStrategy::default(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StatusState {
Pending,
Success,
Failure,
Error,
}
impl StatusState {
pub fn as_str(&self) -> &'static str {
match self {
StatusState::Pending => "pending",
StatusState::Success => "success",
StatusState::Failure => "failure",
StatusState::Error => "error",
}
}
}
const STATUS_DESCRIPTION_MAX_CHARS: usize = 140;
const DEFAULT_STATUS_BACKOFF_MS: [u64; 3] = [1_000, 2_000, 4_000];
fn truncate_description(description: &str) -> String {
if description.chars().count() <= STATUS_DESCRIPTION_MAX_CHARS {
return description.to_string();
}
description
.chars()
.take(STATUS_DESCRIPTION_MAX_CHARS)
.collect()
}
pub struct GiteaTracker {
client: Client,
pub(crate) config: GiteaConfig,
status_backoff: Vec<Duration>,
}
#[derive(Debug, Deserialize)]
pub struct GiteaIssue {
pub id: u64,
pub number: u64,
pub title: String,
pub body: Option<String>,
pub state: String,
pub html_url: Option<String>,
pub created_at: Option<String>,
pub updated_at: Option<String>,
pub labels: Option<Vec<GiteaLabel>>,
}
#[derive(Debug, Deserialize)]
pub struct GiteaLabel {
pub id: i64,
pub name: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IssueComment {
pub id: u64,
#[serde(default)]
pub issue_number: u64,
pub body: String,
pub user: CommentUser,
pub created_at: String,
pub updated_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CommentUser {
pub login: String,
}
pub type GiteaComment = IssueComment;
impl GiteaTracker {
pub fn new(config: GiteaConfig) -> Result<Self> {
let client = Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
.map_err(|e| TrackerError::Api {
message: format!("Failed to create HTTP client: {e}"),
})?;
let status_backoff = DEFAULT_STATUS_BACKOFF_MS
.iter()
.map(|ms| Duration::from_millis(*ms))
.collect();
Ok(Self {
client,
config,
status_backoff,
})
}
pub fn with_status_backoff(mut self, backoff: Vec<Duration>) -> Self {
self.status_backoff = backoff;
self
}
pub fn owner(&self) -> &str {
&self.config.owner
}
pub fn repo(&self) -> &str {
&self.config.repo
}
pub(crate) fn build_request(
&self,
method: reqwest::Method,
url: &str,
) -> reqwest::RequestBuilder {
self.client
.request(method, url)
.header("Authorization", format!("token {}", self.config.token))
.header("Accept", "application/json")
}
fn normalise_issue(&self, gi: GiteaIssue) -> Issue {
let identifier = format!("{}/{}/{}", self.config.owner, self.config.repo, gi.number);
let labels: Vec<String> = gi
.labels
.unwrap_or_default()
.into_iter()
.map(|l| l.name.to_lowercase())
.collect();
let state = gi.state.to_lowercase();
Issue {
id: gi.id.to_string(),
identifier,
title: gi.title,
description: gi.body,
priority: None,
state,
branch_name: None,
url: gi.html_url,
labels,
blocked_by: Vec::new(),
pagerank_score: None,
created_at: gi.created_at.and_then(|s| parse_datetime(&s)),
updated_at: gi.updated_at.and_then(|s| parse_datetime(&s)),
}
}
pub async fn fetch_issue(&self, issue_number: u64) -> Result<GiteaIssue> {
let url = format!(
"{}/api/v1/repos/{}/{}/issues/{}",
self.config.base_url, self.config.owner, self.config.repo, issue_number
);
let response = self
.build_request(reqwest::Method::GET, &url)
.send()
.await?;
if !response.status().is_success() {
let status = response.status();
let text = response.text().await.unwrap_or_default();
return Err(TrackerError::Api {
message: format!(
"Gitea fetch_issue error {} on issue {}: {}",
status, issue_number, text
),
});
}
response.json().await.map_err(TrackerError::Http)
}
async fn fetch_issues_for_gitea_state(&self, gitea_state: &str) -> Result<Vec<Issue>> {
let url = format!(
"{}/api/v1/repos/{}/{}/issues",
self.config.base_url, self.config.owner, self.config.repo
);
let mut all_issues = Vec::new();
let mut page = 1u32;
loop {
let response = self
.build_request(reqwest::Method::GET, &url)
.query(&[("state", gitea_state), ("type", "issues"), ("limit", "50")])
.query(&[("page", page)])
.send()
.await?;
if !response.status().is_success() {
let status = response.status();
let text = response.text().await.unwrap_or_default();
return Err(TrackerError::Api {
message: format!(
"Gitea fetch issues error {} for state {}: {}",
status, gitea_state, text
),
});
}
let issues: Vec<GiteaIssue> = response.json().await.map_err(TrackerError::Http)?;
let issue_count = issues.len();
all_issues.extend(issues.into_iter().map(|gi| self.normalise_issue(gi)));
if issue_count < 50 {
break;
}
page += 1;
}
Ok(all_issues)
}
pub async fn fetch_open_issues(&self) -> Result<Vec<Issue>> {
self.fetch_issues_for_gitea_state("open").await
}
pub async fn post_comment(&self, issue_number: u64, body: &str) -> Result<IssueComment> {
let url = format!(
"{}/api/v1/repos/{}/{}/issues/{}/comments",
self.config.base_url, self.config.owner, self.config.repo, issue_number
);
let response = self
.build_request(reqwest::Method::POST, &url)
.json(&serde_json::json!({"body": body}))
.send()
.await?;
if !response.status().is_success() {
let status = response.status();
let text = response.text().await.unwrap_or_default();
return Err(TrackerError::Api {
message: format!(
"Gitea post_comment error {} on issue {}: {}",
status, issue_number, text
),
});
}
response.json().await.map_err(TrackerError::Http)
}
pub async fn create_issue(
&self,
title: &str,
body: &str,
labels: &[&str],
) -> Result<GiteaIssue> {
let url = format!(
"{}/api/v1/repos/{}/{}/issues",
self.config.base_url, self.config.owner, self.config.repo
);
let response = self
.build_request(reqwest::Method::POST, &url)
.json(&serde_json::json!({
"title": title,
"body": body,
}))
.send()
.await?;
if !response.status().is_success() {
let status = response.status();
let text = response.text().await.unwrap_or_default();
return Err(TrackerError::Api {
message: format!("Gitea create_issue error {}: {}", status, text),
});
}
let issue: GiteaIssue = response.json().await?;
if !labels.is_empty() {
if let Err(e) = self.set_issue_labels(issue.number, labels).await {
tracing::warn!(
issue_number = issue.number,
labels = ?labels,
error = %e,
"failed to set labels on newly created issue"
);
}
}
Ok(issue)
}
pub async fn list_labels(&self) -> Result<Vec<GiteaLabel>> {
let url = format!(
"{}/api/v1/repos/{}/{}/labels",
self.config.base_url, self.config.owner, self.config.repo
);
let response = self
.build_request(reqwest::Method::GET, &url)
.query(&[("limit", "50")])
.send()
.await?;
if !response.status().is_success() {
let status = response.status();
let text = response.text().await.unwrap_or_default();
return Err(TrackerError::Api {
message: format!("Gitea list_labels error {}: {}", status, text),
});
}
response.json().await.map_err(TrackerError::Http)
}
pub async fn set_issue_labels(
&self,
issue_number: u64,
label_names: &[&str],
) -> Result<Vec<i64>> {
let all_labels = self.list_labels().await?;
let mut label_ids: Vec<i64> = Vec::new();
for name in label_names {
if let Some(found) = all_labels.iter().find(|l| l.name == *name) {
label_ids.push(found.id);
} else {
tracing::warn!(label = %name, "label not found in repository, skipping");
}
}
if label_ids.is_empty() {
return Ok(Vec::new());
}
let url = format!(
"{}/api/v1/repos/{}/{}/issues/{}/labels",
self.config.base_url, self.config.owner, self.config.repo, issue_number
);
let response = self
.build_request(reqwest::Method::PUT, &url)
.json(&serde_json::json!({ "labels": label_ids }))
.send()
.await?;
if !response.status().is_success() {
let status = response.status();
let text = response.text().await.unwrap_or_default();
return Err(TrackerError::Api {
message: format!("Gitea set_issue_labels error {}: {}", status, text),
});
}
Ok(label_ids)
}
pub async fn assign_issue(&self, issue_number: u64, assignees: &[&str]) -> Result<()> {
let url = format!(
"{}/api/v1/repos/{}/{}/issues/{}",
self.config.base_url, self.config.owner, self.config.repo, issue_number
);
let response = self
.build_request(reqwest::Method::PATCH, &url)
.json(&serde_json::json!({"assignees": assignees}))
.send()
.await?;
if !response.status().is_success() {
let status = response.status();
let text = response.text().await.unwrap_or_default();
return Err(TrackerError::Api {
message: format!(
"Gitea assign_issue error {} on issue {}: {}",
status, issue_number, text
),
});
}
Ok(())
}
pub async fn fetch_issue_assignees(&self, issue_number: u64) -> Result<Vec<String>> {
let url = format!(
"{}/api/v1/repos/{}/{}/issues/{}",
self.config.base_url, self.config.owner, self.config.repo, issue_number
);
let response = self
.build_request(reqwest::Method::GET, &url)
.send()
.await?;
if !response.status().is_success() {
let status = response.status();
let text = response.text().await.unwrap_or_default();
return Err(TrackerError::Api {
message: format!(
"Gitea fetch_issue_assignees error {} on issue {}: {}",
status, issue_number, text
),
});
}
let body: serde_json::Value = response.json().await.map_err(TrackerError::Http)?;
let assignees = body
.get("assignees")
.and_then(|a| a.as_array())
.map(|arr| {
arr.iter()
.filter_map(|u| u.get("login").and_then(|l| l.as_str()).map(String::from))
.collect()
})
.unwrap_or_default();
Ok(assignees)
}
pub async fn search_issues_by_title(&self, keyword: &str) -> Result<Vec<u64>> {
let url = format!(
"{}/api/v1/repos/{}/{}/issues?state=open&q={}&type=issues",
self.config.base_url,
self.config.owner,
self.config.repo,
urlencoding::encode(keyword)
);
let response = self
.build_request(reqwest::Method::GET, &url)
.send()
.await?;
if !response.status().is_success() {
let status = response.status();
let text = response.text().await.unwrap_or_default();
return Err(TrackerError::Api {
message: format!("Gitea search issues error {}: {}", status, text),
});
}
let issues: Vec<GiteaIssue> = response.json().await.map_err(TrackerError::Http)?;
Ok(issues.into_iter().map(|i| i.number).collect())
}
pub async fn fetch_comments(
&self,
issue_number: u64,
since: Option<&str>,
) -> Result<Vec<IssueComment>> {
let mut url = format!(
"{}/api/v1/repos/{}/{}/issues/{}/comments",
self.config.base_url, self.config.owner, self.config.repo, issue_number
);
if let Some(since_ts) = since {
url.push_str(&format!("?since={}", since_ts));
}
let response = self
.build_request(reqwest::Method::GET, &url)
.send()
.await?;
if !response.status().is_success() {
let status = response.status();
let text = response.text().await.unwrap_or_default();
return Err(TrackerError::Api {
message: format!(
"Gitea comments GET error {} on issue {}: {}",
status, issue_number, text
),
});
}
response.json().await.map_err(TrackerError::Http)
}
pub async fn fetch_repo_comments(
&self,
since: Option<&str>,
limit: Option<u32>,
) -> Result<Vec<IssueComment>> {
self.fetch_repo_comments_page(since, limit, None).await
}
pub async fn fetch_repo_comments_page(
&self,
since: Option<&str>,
limit: Option<u32>,
page: Option<u32>,
) -> Result<Vec<IssueComment>> {
let mut url = format!(
"{}/api/v1/repos/{}/{}/issues/comments",
self.config.base_url, self.config.owner, self.config.repo
);
let mut params = Vec::new();
if let Some(since_ts) = since {
params.push(format!("since={}", since_ts));
}
if let Some(limit_val) = limit {
params.push(format!("limit={}", limit_val));
}
if let Some(page_val) = page {
params.push(format!("page={}", page_val));
}
if !params.is_empty() {
url.push('?');
url.push_str(¶ms.join("&"));
}
let response = self
.build_request(reqwest::Method::GET, &url)
.send()
.await?;
if !response.status().is_success() {
let status = response.status();
let text = response.text().await.unwrap_or_default();
return Err(TrackerError::Api {
message: format!("Gitea repo comments GET error {}: {}", status, text),
});
}
let text = response.text().await.map_err(TrackerError::Http)?;
let raw_comments: Vec<RepoComment> = match serde_json::from_str(&text) {
Ok(c) => c,
Err(e) => {
tracing::warn!(
error = %e,
body_preview = &text[..text.len().min(200)],
"failed to deserialise repo comments"
);
return Err(TrackerError::Api {
message: format!("repo comments deserialisation failed: {e}"),
});
}
};
Ok(raw_comments.into_iter().map(|rc| rc.into()).collect())
}
pub async fn list_open_prs(&self) -> Result<Vec<GiteaPrSummary>> {
let url = format!(
"{}/api/v1/repos/{}/{}/pulls",
self.config.base_url, self.config.owner, self.config.repo
);
let mut all = Vec::new();
let mut page = 1u32;
loop {
let response = self
.build_request(reqwest::Method::GET, &url)
.query(&[("state", "open"), ("limit", "50")])
.query(&[("page", page)])
.send()
.await?;
if !response.status().is_success() {
let status = response.status();
let text = response.text().await.unwrap_or_default();
return Err(TrackerError::Api {
message: format!("Gitea list_open_prs error {}: {}", status, text),
});
}
let raw: Vec<GiteaPullRequest> = response.json().await.map_err(TrackerError::Http)?;
let count = raw.len();
all.extend(raw.into_iter().map(GiteaPrSummary::from));
if count < 50 {
break;
}
page += 1;
}
Ok(all)
}
pub async fn merge_pull(
&self,
pr_number: u64,
merge_style: MergeStyle,
delete_branch: bool,
) -> Result<GiteaMergeResult> {
let merge_url = format!(
"{}/api/v1/repos/{}/{}/pulls/{}/merge",
self.config.base_url, self.config.owner, self.config.repo, pr_number
);
let merge_body = serde_json::json!({
"Do": merge_style.as_str(),
"delete_branch_after_merge": delete_branch,
});
let merge_response = self
.build_request(reqwest::Method::POST, &merge_url)
.json(&merge_body)
.send()
.await?;
if !merge_response.status().is_success() {
let status = merge_response.status();
let text = merge_response.text().await.unwrap_or_default();
return Err(TrackerError::Api {
message: format!(
"Gitea merge_pull error {} on PR {}: {}",
status, pr_number, text
),
});
}
let _ = merge_response.bytes().await;
let pr_url = format!(
"{}/api/v1/repos/{}/{}/pulls/{}",
self.config.base_url, self.config.owner, self.config.repo, pr_number
);
let pr_response = self
.build_request(reqwest::Method::GET, &pr_url)
.send()
.await?;
if !pr_response.status().is_success() {
let status = pr_response.status();
let text = pr_response.text().await.unwrap_or_default();
return Err(TrackerError::Api {
message: format!(
"Gitea merge_pull post-fetch error {} on PR {}: {}",
status, pr_number, text
),
});
}
let raw: serde_json::Value = pr_response.json().await.map_err(TrackerError::Http)?;
let merge_commit_sha = raw
.get("merge_commit_sha")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.or_else(|| {
raw.get("head")
.and_then(|h| h.get("sha"))
.and_then(|v| v.as_str())
})
.unwrap_or_default()
.to_string();
let title = raw
.get("title")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
Ok(GiteaMergeResult {
pr_number,
merge_commit_sha,
title,
})
}
pub async fn claim_issue(
&self,
agent_name: &str,
issue_number: u64,
strategy: ClaimStrategy,
) -> Result<ClaimResult> {
let current_assignees = match self.fetch_issue_assignees(issue_number).await {
Ok(assignees) => assignees,
Err(e) => {
tracing::warn!(
agent = %agent_name,
issue = issue_number,
error = %e,
"failed to fetch assignees, attempting claim anyway"
);
Vec::new()
}
};
if current_assignees.iter().any(|a| a == agent_name) {
return Ok(ClaimResult::AlreadyAssigned);
}
if !current_assignees.is_empty() {
return Ok(ClaimResult::AssignedToOther {
assignee: current_assignees.join(", "),
});
}
let result = match strategy {
ClaimStrategy::PreferRobot => {
match self.claim_via_robot(agent_name, issue_number).await {
Ok(result) => Ok(result),
Err(e) if Self::is_robot_unavailable_error(&e) => {
tracing::info!(
agent = %agent_name,
issue = issue_number,
"gitea-robot unavailable, falling back to API"
);
self.claim_via_api(agent_name, issue_number).await
}
Err(e) => Err(e),
}
}
ClaimStrategy::RobotOnly => self.claim_via_robot(agent_name, issue_number).await,
ClaimStrategy::ApiOnly => self.claim_via_api(agent_name, issue_number).await,
};
let result = result?;
if matches!(result, ClaimResult::Success) {
match self
.verify_assignment(agent_name, issue_number, Some(3), Some(500))
.await
{
Ok(true) => {}
Ok(false) => {
tracing::warn!(
agent = %agent_name,
issue = issue_number,
"Assignment verification failed after claim"
);
return Ok(ClaimResult::AssignedToOther {
assignee: "unknown (race condition)".to_string(),
});
}
Err(e) => {
tracing::warn!(
agent = %agent_name,
issue = issue_number,
error = %e,
"Failed to verify assignment after claim"
);
return Ok(ClaimResult::TransientFailure {
reason: format!("failed to verify assignment after claim: {e}"),
});
}
}
}
Ok(result)
}
async fn claim_via_robot(&self, agent_name: &str, issue_number: u64) -> Result<ClaimResult> {
let output = Command::new(&self.config.robot_path)
.env("GITEA_URL", &self.config.base_url)
.env("GITEA_TOKEN", &self.config.token)
.args([
"assign",
"--owner",
&self.config.owner,
"--repo",
&self.config.repo,
"--issue",
&issue_number.to_string(),
"--to",
agent_name,
])
.output()
.map_err(|e| TrackerError::Api {
message: format!("Failed to execute gitea-robot: {}", e),
})?;
if output.status.success() {
return Ok(ClaimResult::Success);
}
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
let combined = format!("{} {}", stderr, stdout);
if combined.contains("not found") || combined.contains("404") {
return Ok(ClaimResult::NotFound);
}
if combined.contains("already assigned")
|| combined.contains("conflict")
|| combined.contains("409")
{
return Ok(ClaimResult::AssignedToOther {
assignee: "unknown".to_string(),
});
}
if combined.contains("permission") || combined.contains("403") {
return Ok(ClaimResult::PermissionDenied {
reason: stderr.to_string(),
});
}
if combined.contains("timeout")
|| combined.contains("connection")
|| combined.contains("temporarily")
{
return Ok(ClaimResult::TransientFailure {
reason: stderr.to_string(),
});
}
Err(TrackerError::Api {
message: format!("gitea-robot assign failed: {} (stdout: {})", stderr, stdout),
})
}
async fn claim_via_api(&self, agent_name: &str, issue_number: u64) -> Result<ClaimResult> {
let url = format!(
"{}/api/v1/repos/{}/{}/issues/{}",
self.config.base_url, self.config.owner, self.config.repo, issue_number
);
let response = self
.build_request(reqwest::Method::GET, &url)
.send()
.await?;
if response.status() == 404 {
return Ok(ClaimResult::NotFound);
}
if !response.status().is_success() {
return Err(TrackerError::Api {
message: format!("Failed to fetch issue state: {}", response.status()),
});
}
let body: serde_json::Value = response.json().await?;
let assignees: Vec<String> = body
.get("assignees")
.and_then(|a| a.as_array())
.map(|arr| {
arr.iter()
.filter_map(|u| u.get("login").and_then(|l| l.as_str()).map(String::from))
.collect()
})
.unwrap_or_default();
if assignees.iter().any(|a| a == agent_name) {
return Ok(ClaimResult::AlreadyAssigned);
}
if !assignees.is_empty() {
return Ok(ClaimResult::AssignedToOther {
assignee: assignees.join(", "),
});
}
let patch_response = self
.build_request(reqwest::Method::PATCH, &url)
.json(&serde_json::json!({"assignees": [agent_name]}))
.send()
.await?;
match patch_response.status().as_u16() {
200 => Ok(ClaimResult::Success),
403 => Ok(ClaimResult::PermissionDenied {
reason: "Insufficient permissions to assign issue".to_string(),
}),
404 => Ok(ClaimResult::NotFound),
409 => Ok(ClaimResult::AssignedToOther {
assignee: "unknown (conflict)".to_string(),
}),
500..=599 => Ok(ClaimResult::TransientFailure {
reason: format!("Server error: {}", patch_response.status()),
}),
_ => Err(TrackerError::Api {
message: format!("Assignment failed: {}", patch_response.status()),
}),
}
}
pub async fn verify_assignment(
&self,
agent_name: &str,
issue_number: u64,
max_retries: Option<u32>,
retry_delay_ms: Option<u64>,
) -> Result<bool> {
let max_retries = max_retries.unwrap_or(3);
let retry_delay_ms = retry_delay_ms.unwrap_or(500);
for attempt in 0..max_retries {
match self.fetch_issue_assignees(issue_number).await {
Ok(assignees) => {
if assignees.iter().any(|a| a == agent_name) {
return Ok(true);
}
if attempt < max_retries - 1 {
tokio::time::sleep(tokio::time::Duration::from_millis(retry_delay_ms))
.await;
}
}
Err(e) => {
if attempt < max_retries - 1 {
tracing::warn!(
attempt = attempt + 1,
max_retries = max_retries,
error = %e,
"verify_assignment failed, retrying"
);
tokio::time::sleep(tokio::time::Duration::from_millis(retry_delay_ms))
.await;
} else {
return Err(e);
}
}
}
}
Ok(false)
}
#[allow(clippy::too_many_arguments)]
pub async fn set_commit_status(
&self,
owner: &str,
repo: &str,
sha: &str,
state: StatusState,
context: &str,
description: &str,
target_url: Option<&str>,
) -> Result<()> {
let url = format!(
"{}/api/v1/repos/{}/{}/statuses/{}",
self.config.base_url, owner, repo, sha
);
let truncated_description = truncate_description(description);
let mut body = serde_json::Map::new();
body.insert(
"state".to_string(),
serde_json::Value::String(state.as_str().to_string()),
);
body.insert(
"context".to_string(),
serde_json::Value::String(context.to_string()),
);
body.insert(
"description".to_string(),
serde_json::Value::String(truncated_description),
);
if let Some(url) = target_url {
body.insert(
"target_url".to_string(),
serde_json::Value::String(url.to_string()),
);
}
let body = serde_json::Value::Object(body);
let mut attempt = 0usize;
loop {
let response = self
.build_request(reqwest::Method::POST, &url)
.json(&body)
.send()
.await?;
let status = response.status();
if status.is_success() {
return Ok(());
}
let is_transient =
status.is_server_error() || status == reqwest::StatusCode::TOO_MANY_REQUESTS;
if !is_transient {
let text = response.text().await.unwrap_or_default();
return Err(TrackerError::Api {
message: format!(
"Gitea set_commit_status error {} on {}/{}@{} ctx={}: {}",
status, owner, repo, sha, context, text
),
});
}
if attempt >= self.status_backoff.len() {
let text = response.text().await.unwrap_or_default();
return Err(TrackerError::Api {
message: format!(
"Gitea set_commit_status exhausted retries (last status {}) on {}/{}@{} ctx={}: {}",
status, owner, repo, sha, context, text
),
});
}
let delay = self.status_backoff[attempt];
drop(response);
tokio::time::sleep(delay).await;
attempt += 1;
}
}
pub async fn list_commit_statuses(
&self,
owner: &str,
repo: &str,
sha: &str,
) -> Result<Vec<CommitStatusEntry>> {
let url = format!(
"{}/api/v1/repos/{}/{}/commits/{}/statuses",
self.config.base_url, owner, repo, sha
);
let response = self
.build_request(reqwest::Method::GET, &url)
.send()
.await?;
if !response.status().is_success() {
let status = response.status();
let text = response.text().await.unwrap_or_default();
return Err(TrackerError::Api {
message: format!(
"Gitea list_commit_statuses error {} on {}/{}@{}: {}",
status, owner, repo, sha, text
),
});
}
let entries: Vec<CommitStatusEntry> = response.json().await.map_err(TrackerError::Http)?;
Ok(entries)
}
pub async fn get_branch_protection(
&self,
owner: &str,
repo: &str,
branch: &str,
) -> Result<BranchProtection> {
let url = format!(
"{}/api/v1/repos/{}/{}/branch_protections/{}",
self.config.base_url, owner, repo, branch
);
let response = self
.build_request(reqwest::Method::GET, &url)
.send()
.await?;
if !response.status().is_success() {
let status = response.status();
let text = response.text().await.unwrap_or_default();
return Err(TrackerError::Api {
message: format!(
"Gitea get_branch_protection error {} on {}/{} branch {}: {}",
status, owner, repo, branch, text
),
});
}
response.json().await.map_err(TrackerError::Http)
}
fn is_robot_unavailable_error(error: &TrackerError) -> bool {
let err_str = error.to_string().to_lowercase();
err_str.contains("no such file or directory")
|| err_str.contains("not found")
|| err_str.contains("permission denied")
|| err_str.contains("cannot find")
}
}
#[async_trait]
impl IssueTracker for GiteaTracker {
async fn fetch_candidate_issues(&self) -> Result<Vec<Issue>> {
let active_states = self.config.active_states.clone();
self.fetch_issues_by_states(&active_states).await
}
async fn fetch_issue_states_by_ids(&self, ids: &[String]) -> Result<Vec<Issue>> {
let mut issues = Vec::with_capacity(ids.len());
for id in ids {
let issue_number = match id.parse::<u64>() {
Ok(issue_number) => issue_number,
Err(_) => {
return Err(TrackerError::Api {
message: format!("invalid Gitea issue id '{id}'"),
});
}
};
let issue = self.fetch_issue(issue_number).await?;
issues.push(self.normalise_issue(issue));
}
Ok(issues)
}
async fn fetch_issues_by_states(&self, states: &[String]) -> Result<Vec<Issue>> {
if states.is_empty() {
return Ok(vec![]);
}
let need_open = states.iter().any(|state| {
state.eq_ignore_ascii_case("open")
|| self
.config
.active_states
.iter()
.any(|active| active.eq_ignore_ascii_case(state))
});
let need_closed = states.iter().any(|state| {
state.eq_ignore_ascii_case("closed")
|| self
.config
.terminal_states
.iter()
.any(|terminal| terminal.eq_ignore_ascii_case(state))
});
let mut issues = Vec::new();
if need_open {
issues.extend(self.fetch_issues_for_gitea_state("open").await?);
}
if need_closed {
issues.extend(self.fetch_issues_for_gitea_state("closed").await?);
}
Ok(issues
.into_iter()
.filter(|issue| {
states
.iter()
.any(|state| state.eq_ignore_ascii_case(&issue.state))
})
.collect())
}
}
#[derive(Debug, Clone, serde::Deserialize)]
struct RepoComment {
id: u64,
#[serde(default)]
issue_url: Option<String>,
#[serde(default)]
pull_request_url: Option<String>,
user: CommentUser,
#[serde(default)]
body: Option<String>,
#[serde(default)]
created_at: Option<String>,
#[serde(default)]
updated_at: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GiteaPrSummary {
pub number: u64,
pub author_login: String,
pub head_sha: String,
pub base_ref: String,
pub diff_loc: u32,
}
#[derive(Debug, Clone, Deserialize)]
pub struct CommitStatusEntry {
pub context: String,
#[serde(rename = "status")]
pub state: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub target_url: Option<String>,
#[serde(default)]
pub created_at: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct BranchProtection {
#[serde(default)]
pub enable_status_check: bool,
#[serde(default)]
pub status_check_contexts: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct GiteaPullRequest {
number: u64,
#[serde(default)]
user: Option<CommentUser>,
head: Option<GiteaPrRef>,
base: Option<GiteaPrRef>,
#[serde(default)]
additions: Option<u32>,
#[serde(default)]
deletions: Option<u32>,
}
#[derive(Debug, Deserialize)]
struct GiteaPrRef {
#[serde(default)]
sha: Option<String>,
#[serde(default, rename = "ref")]
ref_name: Option<String>,
}
impl From<GiteaPullRequest> for GiteaPrSummary {
fn from(pr: GiteaPullRequest) -> Self {
let head_sha = pr
.head
.as_ref()
.and_then(|h| h.sha.clone())
.unwrap_or_default();
let base_ref = pr
.base
.as_ref()
.and_then(|b| b.ref_name.clone())
.unwrap_or_default();
let author_login = pr.user.map(|u| u.login).unwrap_or_default();
let diff_loc = pr.additions.unwrap_or(0) + pr.deletions.unwrap_or(0);
GiteaPrSummary {
number: pr.number,
author_login,
head_sha,
base_ref,
diff_loc,
}
}
}
impl From<RepoComment> for IssueComment {
fn from(rc: RepoComment) -> Self {
let url = rc
.issue_url
.as_deref()
.filter(|s| !s.is_empty())
.or_else(|| rc.pull_request_url.as_deref().filter(|s| !s.is_empty()))
.unwrap_or("");
let issue_number = url
.rsplit('/')
.next()
.and_then(|s| s.parse().ok())
.unwrap_or(0);
IssueComment {
id: rc.id,
issue_number,
body: rc.body.unwrap_or_default(),
user: rc.user,
created_at: rc.created_at.unwrap_or_default(),
updated_at: rc.updated_at.unwrap_or_default(),
}
}
}
fn parse_datetime(s: &str) -> Option<Zoned> {
s.parse::<jiff::Timestamp>()
.ok()
.map(|ts| ts.to_zoned(jiff::tz::TimeZone::UTC))
}
#[cfg(test)]
mod tests {
use super::*;
use wiremock::matchers::{method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
#[test]
fn gitea_config_debug_redacts_token() {
let cfg = GiteaConfig::new(
"https://git.terraphim.cloud".to_string(),
"gitea-secret-token-abcdef".to_string(),
"terraphim".to_string(),
"terraphim-ai".to_string(),
);
let out = format!("{:?}", cfg);
assert!(!out.contains("gitea-secret-token-abcdef"));
assert!(out.contains("***REDACTED***"));
assert!(out.contains("git.terraphim.cloud"));
assert!(out.contains("terraphim-ai"));
}
fn make_tracker(base_url: &str) -> GiteaTracker {
let config = GiteaConfig {
base_url: base_url.to_string(),
token: "test-token".to_string(),
owner: "testowner".to_string(),
repo: "testrepo".to_string(),
active_states: vec!["open".to_string()],
terminal_states: vec!["closed".to_string()],
use_robot_api: false,
robot_path: PathBuf::from("/home/alex/go/bin/gitea-robot"),
claim_strategy: ClaimStrategy::PreferRobot,
};
GiteaTracker::new(config).unwrap()
}
fn test_config() -> GiteaConfig {
GiteaConfig {
base_url: "https://git.example.com".into(),
token: "test-token".into(),
owner: "testowner".into(),
repo: "testrepo".into(),
active_states: vec!["open".into(), "Todo".into()],
terminal_states: vec!["closed".into(), "Done".into()],
use_robot_api: false,
robot_path: PathBuf::from("/home/alex/go/bin/gitea-robot"),
claim_strategy: ClaimStrategy::PreferRobot,
}
}
#[test]
fn normalise_issue_converts_fields() {
let config = test_config();
let tracker = GiteaTracker::new(config).unwrap();
let gi = GiteaIssue {
id: 42,
number: 123,
title: "Test Issue".into(),
body: Some("Description".into()),
state: "open".into(),
html_url: Some("https://example.com/issue/123".into()),
created_at: Some("2024-01-15T10:30:00Z".into()),
updated_at: Some("2024-01-15T11:00:00Z".into()),
labels: Some(vec![
GiteaLabel {
id: 1,
name: "bug".into(),
},
GiteaLabel {
id: 2,
name: "Priority:High".into(),
},
]),
};
let issue = tracker.normalise_issue(gi);
assert_eq!(issue.id, "42");
assert_eq!(issue.identifier, "testowner/testrepo/123");
assert_eq!(issue.title, "Test Issue");
assert_eq!(issue.description, Some("Description".into()));
assert_eq!(issue.state, "open");
assert!(issue.labels.contains(&"bug".to_string()));
assert!(issue.labels.contains(&"priority:high".to_string()));
}
#[test]
fn normalise_issue_lowercases_labels() {
let config = test_config();
let tracker = GiteaTracker::new(config).unwrap();
let gi = GiteaIssue {
id: 1,
number: 1,
title: "Test".into(),
body: None,
state: "open".into(),
html_url: None,
created_at: None,
updated_at: None,
labels: Some(vec![
GiteaLabel {
id: 1,
name: "BUG".into(),
},
GiteaLabel {
id: 2,
name: "FEATURE".into(),
},
]),
};
let issue = tracker.normalise_issue(gi);
assert!(
issue
.labels
.iter()
.all(|l| l.chars().all(|c| !c.is_uppercase()))
);
}
#[tokio::test]
async fn test_fetch_open_issues_paginates() {
let mock_server = MockServer::start().await;
let page_one: Vec<_> = (1..=50)
.map(|number| {
serde_json::json!({
"id": number,
"number": number,
"title": format!("Issue {number}"),
"state": "open"
})
})
.collect();
let page_two = serde_json::json!([
{
"id": 51,
"number": 51,
"title": "Issue 51",
"state": "open"
}
]);
Mock::given(method("GET"))
.and(path("/api/v1/repos/testowner/testrepo/issues"))
.and(query_param("state", "open"))
.and(query_param("type", "issues"))
.and(query_param("limit", "50"))
.and(query_param("page", "1"))
.respond_with(ResponseTemplate::new(200).set_body_json(page_one))
.expect(1)
.mount(&mock_server)
.await;
Mock::given(method("GET"))
.and(path("/api/v1/repos/testowner/testrepo/issues"))
.and(query_param("state", "open"))
.and(query_param("type", "issues"))
.and(query_param("limit", "50"))
.and(query_param("page", "2"))
.respond_with(ResponseTemplate::new(200).set_body_json(page_two))
.expect(1)
.mount(&mock_server)
.await;
let tracker = make_tracker(&mock_server.uri());
let issues = tracker.fetch_open_issues().await.unwrap();
assert_eq!(issues.len(), 51);
assert_eq!(issues.last().unwrap().identifier, "testowner/testrepo/51");
}
#[tokio::test]
async fn test_fetch_issues_by_states_fetches_closed_issues() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/repos/testowner/testrepo/issues"))
.and(query_param("state", "closed"))
.and(query_param("type", "issues"))
.and(query_param("limit", "50"))
.and(query_param("page", "1"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
{
"id": 200,
"number": 12,
"title": "Done issue",
"state": "closed"
}
])))
.expect(1)
.mount(&mock_server)
.await;
let tracker = make_tracker(&mock_server.uri());
let issues = tracker
.fetch_issues_by_states(&["closed".to_string()])
.await
.unwrap();
assert_eq!(issues.len(), 1);
assert_eq!(issues[0].state, "closed");
assert_eq!(issues[0].identifier, "testowner/testrepo/12");
}
#[tokio::test]
async fn test_post_comment_success() {
let mock_server = MockServer::start().await;
let comment_json = serde_json::json!({
"id": 42,
"body": "Test comment",
"user": {"login": "testbot"},
"created_at": "2026-03-31T12:00:00Z",
"updated_at": "2026-03-31T12:00:00Z"
});
Mock::given(method("POST"))
.and(path("/api/v1/repos/testowner/testrepo/issues/1/comments"))
.respond_with(ResponseTemplate::new(201).set_body_json(&comment_json))
.mount(&mock_server)
.await;
let tracker = make_tracker(&mock_server.uri());
let result = tracker.post_comment(1, "Test comment").await;
assert!(result.is_ok());
let comment = result.unwrap();
assert_eq!(comment.id, 42);
assert_eq!(comment.body, "Test comment");
assert_eq!(comment.user.login, "testbot");
}
#[tokio::test]
async fn test_post_comment_error_returns_api_error() {
let mock_server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/repos/testowner/testrepo/issues/999/comments"))
.respond_with(ResponseTemplate::new(403).set_body_string("forbidden"))
.mount(&mock_server)
.await;
let tracker = make_tracker(&mock_server.uri());
let result = tracker.post_comment(999, "body").await;
assert!(result.is_err());
let err_str = format!("{}", result.unwrap_err());
assert!(
err_str.contains("403"),
"Expected 403 in error: {}",
err_str
);
}
#[tokio::test]
async fn test_fetch_comments_without_since() {
let mock_server = MockServer::start().await;
let comments_json = serde_json::json!([
{
"id": 1,
"body": "First",
"user": {"login": "alice"},
"created_at": "2026-03-31T10:00:00Z",
"updated_at": "2026-03-31T10:00:00Z"
},
{
"id": 2,
"body": "Second",
"user": {"login": "bob"},
"created_at": "2026-03-31T11:00:00Z",
"updated_at": "2026-03-31T11:00:00Z"
}
]);
Mock::given(method("GET"))
.and(path("/api/v1/repos/testowner/testrepo/issues/5/comments"))
.respond_with(ResponseTemplate::new(200).set_body_json(&comments_json))
.mount(&mock_server)
.await;
let tracker = make_tracker(&mock_server.uri());
let result = tracker.fetch_comments(5, None).await;
assert!(result.is_ok());
let comments = result.unwrap();
assert_eq!(comments.len(), 2);
assert_eq!(comments[0].body, "First");
assert_eq!(comments[1].user.login, "bob");
}
#[tokio::test]
async fn test_fetch_comments_with_since() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/repos/testowner/testrepo/issues/5/comments"))
.and(query_param("since", "2026-03-31T00:00:00Z"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
.mount(&mock_server)
.await;
let tracker = make_tracker(&mock_server.uri());
let result = tracker
.fetch_comments(5, Some("2026-03-31T00:00:00Z"))
.await;
assert!(result.is_ok());
assert_eq!(result.unwrap().len(), 0);
}
#[tokio::test]
async fn test_fetch_comments_error() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/repos/testowner/testrepo/issues/404/comments"))
.respond_with(ResponseTemplate::new(404).set_body_string("not found"))
.mount(&mock_server)
.await;
let tracker = make_tracker(&mock_server.uri());
let result = tracker.fetch_comments(404, None).await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_fetch_repo_comments() {
let mock_server = MockServer::start().await;
let comments_json = serde_json::json!([
{
"id": 100,
"issue_url": "https://example.com/api/v1/repos/testowner/testrepo/issues/5",
"body": "Hello @adf:security-sentinel",
"user": {"login": "root"},
"created_at": "2026-04-04T10:00:00Z",
"updated_at": "2026-04-04T10:00:00Z"
},
{
"id": 101,
"issue_url": "https://example.com/api/v1/repos/testowner/testrepo/issues/7",
"body": null,
"user": {"login": "system"},
"created_at": "2026-04-04T11:00:00Z",
"updated_at": "2026-04-04T11:00:00Z"
}
]);
Mock::given(method("GET"))
.and(path("/api/v1/repos/testowner/testrepo/issues/comments"))
.and(query_param("since", "2026-04-04T00:00:00Z"))
.respond_with(ResponseTemplate::new(200).set_body_json(&comments_json))
.mount(&mock_server)
.await;
let tracker = make_tracker(&mock_server.uri());
let result = tracker
.fetch_repo_comments(Some("2026-04-04T00:00:00Z"), Some(50))
.await;
assert!(
result.is_ok(),
"fetch_repo_comments failed: {:?}",
result.err()
);
let comments = result.unwrap();
assert_eq!(comments.len(), 2);
assert_eq!(comments[0].issue_number, 5);
assert_eq!(comments[1].issue_number, 7);
assert!(comments[0].body.contains("@adf:security-sentinel"));
assert_eq!(comments[1].body, "") }
#[tokio::test]
async fn test_fetch_repo_comments_missing_fields() {
let mock_server = MockServer::start().await;
let comments_json = serde_json::json!([
{
"id": 200,
"user": {"login": "bot"},
"created_at": "2026-04-04T12:00:00Z",
"updated_at": "2026-04-04T12:00:00Z"
}
]);
Mock::given(method("GET"))
.and(path("/api/v1/repos/testowner/testrepo/issues/comments"))
.respond_with(ResponseTemplate::new(200).set_body_json(&comments_json))
.mount(&mock_server)
.await;
let tracker = make_tracker(&mock_server.uri());
let result = tracker.fetch_repo_comments(None, None).await;
assert!(
result.is_ok(),
"should handle missing issue_url and body: {:?}",
result.err()
);
let comments = result.unwrap();
assert_eq!(comments.len(), 1);
assert_eq!(comments[0].issue_number, 0); assert_eq!(comments[0].body, "") }
#[tokio::test]
async fn test_repo_comments_pr_comment_extracts_pr_number_from_pull_request_url() {
let mock_server = wiremock::MockServer::start().await;
let comments_json = serde_json::json!([
{
"id": 9880,
"user": {"login": "root"},
"body": "@adf:reviewer please check",
"issue_url": "",
"pull_request_url": "https://example.com/api/v1/repos/testowner/testrepo/pulls/28",
"created_at": "2026-04-21T17:02:07+02:00",
"updated_at": "2026-04-21T17:02:07+02:00"
},
{
"id": 9879,
"user": {"login": "merge-coordinator"},
"body": "verdict",
"issue_url": "https://example.com/api/v1/repos/testowner/testrepo/issues/113",
"pull_request_url": "",
"created_at": "2026-04-21T16:49:46+02:00",
"updated_at": "2026-04-21T16:49:46+02:00"
}
]);
Mock::given(method("GET"))
.and(path("/api/v1/repos/testowner/testrepo/issues/comments"))
.respond_with(ResponseTemplate::new(200).set_body_json(&comments_json))
.mount(&mock_server)
.await;
let tracker = make_tracker(&mock_server.uri());
let comments = tracker
.fetch_repo_comments(None, None)
.await
.expect("fetch should succeed");
assert_eq!(comments.len(), 2);
let pr_comment = comments.iter().find(|c| c.id == 9880).unwrap();
assert_eq!(
pr_comment.issue_number, 28,
"PR comment must resolve to PR number via pull_request_url fallback"
);
let issue_comment = comments.iter().find(|c| c.id == 9879).unwrap();
assert_eq!(
issue_comment.issue_number, 113,
"issue comment resolution through issue_url still works"
);
}
#[tokio::test]
async fn test_issue_comment_deserialisation() {
let json = r#"{
"id": 100,
"body": "Hello @adf:security-sentinel",
"user": {"login": "root"},
"created_at": "2026-03-31T14:00:00+02:00",
"updated_at": "2026-03-31T14:00:00+02:00"
}"#;
let comment: IssueComment = serde_json::from_str(json).unwrap();
assert_eq!(comment.id, 100);
assert!(comment.body.contains("@adf:security-sentinel"));
assert_eq!(comment.user.login, "root");
}
#[tokio::test]
async fn test_assign_issue_success() {
let mock_server = MockServer::start().await;
Mock::given(method("PATCH"))
.and(path("/api/v1/repos/testowner/testrepo/issues/42"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": 100,
"number": 42,
"title": "Test",
"state": "open"
})))
.mount(&mock_server)
.await;
let tracker = make_tracker(&mock_server.uri());
let result = tracker.assign_issue(42, &["quality-coordinator"]).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_assign_issue_error() {
let mock_server = MockServer::start().await;
Mock::given(method("PATCH"))
.and(path("/api/v1/repos/testowner/testrepo/issues/99"))
.respond_with(ResponseTemplate::new(403).set_body_string("forbidden"))
.mount(&mock_server)
.await;
let tracker = make_tracker(&mock_server.uri());
let result = tracker.assign_issue(99, &["unknown-agent"]).await;
assert!(result.is_err());
let err_str = format!("{}", result.unwrap_err());
assert!(
err_str.contains("403"),
"Expected 403 in error: {}",
err_str
);
}
#[tokio::test]
async fn test_fetch_issue_assignees_returns_logins() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/repos/testowner/testrepo/issues/42"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": 100,
"number": 42,
"title": "Test issue",
"state": "open",
"assignees": [
{"id": 1, "login": "security-sentinel"},
{"id": 2, "login": "test-guardian"}
]
})))
.mount(&mock_server)
.await;
let tracker = make_tracker(&mock_server.uri());
let assignees = tracker.fetch_issue_assignees(42).await.unwrap();
assert_eq!(assignees, vec!["security-sentinel", "test-guardian"]);
}
#[tokio::test]
async fn test_fetch_issue_assignees_empty() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/repos/testowner/testrepo/issues/99"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": 200,
"number": 99,
"title": "Unassigned issue",
"state": "open",
"assignees": []
})))
.mount(&mock_server)
.await;
let tracker = make_tracker(&mock_server.uri());
let assignees = tracker.fetch_issue_assignees(99).await.unwrap();
assert!(assignees.is_empty());
}
#[tokio::test]
async fn test_fetch_issue_assignees_error() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/repos/testowner/testrepo/issues/404"))
.respond_with(ResponseTemplate::new(404).set_body_string("not found"))
.mount(&mock_server)
.await;
let tracker = make_tracker(&mock_server.uri());
let result = tracker.fetch_issue_assignees(404).await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_claim_issue_already_assigned() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/repos/testowner/testrepo/issues/42"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": 100,
"number": 42,
"title": "Test issue",
"state": "open",
"assignees": [
{"id": 1, "login": "quality-coordinator"}
]
})))
.mount(&mock_server)
.await;
let tracker = make_tracker(&mock_server.uri());
let result = tracker
.claim_issue("quality-coordinator", 42, ClaimStrategy::ApiOnly)
.await;
assert_eq!(result.unwrap(), ClaimResult::AlreadyAssigned);
}
#[tokio::test]
async fn test_claim_issue_assigned_to_other() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/repos/testowner/testrepo/issues/42"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": 100,
"number": 42,
"title": "Test issue",
"state": "open",
"assignees": [
{"id": 1, "login": "other-agent"}
]
})))
.mount(&mock_server)
.await;
let tracker = make_tracker(&mock_server.uri());
let result = tracker
.claim_issue("quality-coordinator", 42, ClaimStrategy::ApiOnly)
.await;
assert_eq!(
result.unwrap(),
ClaimResult::AssignedToOther {
assignee: "other-agent".to_string()
}
);
}
#[tokio::test]
async fn test_claim_issue_success_api() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/repos/testowner/testrepo/issues/42"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": 100,
"number": 42,
"title": "Test issue",
"state": "open",
"assignees": []
})))
.up_to_n_times(2)
.expect(2)
.mount(&mock_server)
.await;
Mock::given(method("GET"))
.and(path("/api/v1/repos/testowner/testrepo/issues/42"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": 100,
"number": 42,
"title": "Test issue",
"state": "open",
"assignees": [{"id": 1, "login": "quality-coordinator"}]
})))
.mount(&mock_server)
.await;
Mock::given(method("PATCH"))
.and(path("/api/v1/repos/testowner/testrepo/issues/42"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": 100,
"number": 42,
"title": "Test issue",
"state": "open",
"assignees": [{"id": 1, "login": "quality-coordinator"}]
})))
.mount(&mock_server)
.await;
let tracker = make_tracker(&mock_server.uri());
let result = tracker
.claim_issue("quality-coordinator", 42, ClaimStrategy::ApiOnly)
.await;
assert_eq!(result.unwrap(), ClaimResult::Success);
}
#[tokio::test]
async fn test_claim_issue_not_found() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/repos/testowner/testrepo/issues/999"))
.respond_with(ResponseTemplate::new(404).set_body_string("not found"))
.mount(&mock_server)
.await;
let tracker = make_tracker(&mock_server.uri());
let result = tracker
.claim_issue("quality-coordinator", 999, ClaimStrategy::ApiOnly)
.await;
assert_eq!(result.unwrap(), ClaimResult::NotFound);
}
#[tokio::test]
async fn test_claim_issue_permission_denied() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/repos/testowner/testrepo/issues/42"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": 100,
"number": 42,
"title": "Test issue",
"state": "open",
"assignees": []
})))
.mount(&mock_server)
.await;
Mock::given(method("PATCH"))
.and(path("/api/v1/repos/testowner/testrepo/issues/42"))
.respond_with(ResponseTemplate::new(403).set_body_string("forbidden"))
.mount(&mock_server)
.await;
let tracker = make_tracker(&mock_server.uri());
let result = tracker
.claim_issue("quality-coordinator", 42, ClaimStrategy::ApiOnly)
.await;
assert!(matches!(
result.unwrap(),
ClaimResult::PermissionDenied { .. }
));
}
#[tokio::test]
async fn test_verify_assignment_with_retry() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/repos/testowner/testrepo/issues/42"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": 100,
"number": 42,
"title": "Test issue",
"state": "open",
"assignees": [{"id": 1, "login": "quality-coordinator"}]
})))
.mount(&mock_server)
.await;
let tracker = make_tracker(&mock_server.uri());
let verified = tracker
.verify_assignment("quality-coordinator", 42, Some(3), Some(100))
.await;
assert!(verified.unwrap());
}
#[tokio::test]
async fn test_verify_assignment_fails_after_retries() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/repos/testowner/testrepo/issues/42"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": 100,
"number": 42,
"title": "Test issue",
"state": "open",
"assignees": [{"id": 1, "login": "other-agent"}]
})))
.mount(&mock_server)
.await;
let tracker = make_tracker(&mock_server.uri());
let verified = tracker
.verify_assignment("quality-coordinator", 42, Some(2), Some(100))
.await;
assert!(!verified.unwrap());
}
#[tokio::test]
async fn test_claim_strategy_api_only_uses_no_robot() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/repos/testowner/testrepo/issues/42"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": 100,
"number": 42,
"title": "Test issue",
"state": "open",
"assignees": []
})))
.up_to_n_times(2)
.expect(2)
.mount(&mock_server)
.await;
Mock::given(method("GET"))
.and(path("/api/v1/repos/testowner/testrepo/issues/42"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": 100,
"number": 42,
"title": "Test issue",
"state": "open",
"assignees": [{"id": 1, "login": "test-agent"}]
})))
.mount(&mock_server)
.await;
Mock::given(method("PATCH"))
.and(path("/api/v1/repos/testowner/testrepo/issues/42"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": 100,
"number": 42,
"title": "Test issue",
"state": "open",
"assignees": [{"id": 1, "login": "test-agent"}]
})))
.mount(&mock_server)
.await;
let tracker = make_tracker(&mock_server.uri());
let result = tracker
.claim_issue("test-agent", 42, ClaimStrategy::ApiOnly)
.await;
assert!(matches!(result.unwrap(), ClaimResult::Success));
}
#[tokio::test]
async fn test_claim_issue_returns_assigned_to_other_when_verification_fails() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/repos/testowner/testrepo/issues/42"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": 100,
"number": 42,
"title": "Test issue",
"state": "open",
"assignees": []
})))
.up_to_n_times(2)
.expect(2)
.mount(&mock_server)
.await;
Mock::given(method("GET"))
.and(path("/api/v1/repos/testowner/testrepo/issues/42"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": 100,
"number": 42,
"title": "Test issue",
"state": "open",
"assignees": [{"id": 1, "login": "other-agent"}]
})))
.mount(&mock_server)
.await;
Mock::given(method("PATCH"))
.and(path("/api/v1/repos/testowner/testrepo/issues/42"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": 100,
"number": 42,
"title": "Test issue",
"state": "open",
"assignees": [{"id": 1, "login": "quality-coordinator"}]
})))
.mount(&mock_server)
.await;
let tracker = make_tracker(&mock_server.uri());
let result = tracker
.claim_issue("quality-coordinator", 42, ClaimStrategy::ApiOnly)
.await
.unwrap();
assert_eq!(
result,
ClaimResult::AssignedToOther {
assignee: "unknown (race condition)".to_string()
}
);
}
mod status_tests {
use super::super::*;
use axum::{
Router,
extract::{Path, State},
http::StatusCode,
response::IntoResponse,
routing::post,
};
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
use tokio::net::TcpListener;
#[derive(Default)]
struct CapturedRequest {
path: std::sync::Mutex<Option<String>>,
body: std::sync::Mutex<Option<serde_json::Value>>,
}
struct ServerState {
calls: AtomicUsize,
captured: CapturedRequest,
status_sequence: Vec<u16>,
}
async fn handle_status(
Path((owner, repo, sha)): Path<(String, String, String)>,
State(state): State<Arc<ServerState>>,
body: axum::body::Bytes,
) -> impl IntoResponse {
let idx = state.calls.fetch_add(1, Ordering::SeqCst);
let captured_path = format!("/api/v1/repos/{}/{}/statuses/{}", owner, repo, sha);
*state.captured.path.lock().unwrap() = Some(captured_path);
if let Ok(parsed) = serde_json::from_slice::<serde_json::Value>(&body) {
*state.captured.body.lock().unwrap() = Some(parsed);
}
let pick = state
.status_sequence
.get(idx)
.copied()
.or_else(|| state.status_sequence.last().copied())
.unwrap_or(200);
StatusCode::from_u16(pick).unwrap_or(StatusCode::OK)
}
async fn spawn_server(status_sequence: Vec<u16>) -> (String, Arc<ServerState>) {
let state = Arc::new(ServerState {
calls: AtomicUsize::new(0),
captured: CapturedRequest::default(),
status_sequence,
});
let app = Router::new()
.route(
"/api/v1/repos/{owner}/{repo}/statuses/{sha}",
post(handle_status),
)
.with_state(state.clone());
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
axum::serve(listener, app).await.ok();
});
(format!("http://{}", addr), state)
}
fn make_status_tracker(base_url: &str) -> GiteaTracker {
let config = GiteaConfig {
base_url: base_url.to_string(),
token: "test-token".to_string(),
owner: "ignored".to_string(),
repo: "ignored".to_string(),
active_states: vec!["open".to_string()],
terminal_states: vec!["closed".to_string()],
use_robot_api: false,
robot_path: PathBuf::from("/home/alex/go/bin/gitea-robot"),
claim_strategy: ClaimStrategy::PreferRobot,
};
GiteaTracker::new(config).unwrap().with_status_backoff(vec![
Duration::from_millis(1),
Duration::from_millis(1),
Duration::from_millis(1),
])
}
#[tokio::test]
async fn set_commit_status_posts_correct_payload() {
let (base_url, state) = spawn_server(vec![201]).await;
let tracker = make_status_tracker(&base_url);
tracker
.set_commit_status(
"o",
"r",
"abc123",
StatusState::Success,
"adf/test",
"hello",
None,
)
.await
.expect("status post should succeed");
assert_eq!(state.calls.load(Ordering::SeqCst), 1);
assert_eq!(
state.captured.path.lock().unwrap().as_deref(),
Some("/api/v1/repos/o/r/statuses/abc123")
);
let body = state
.captured
.body
.lock()
.unwrap()
.clone()
.expect("body captured");
assert_eq!(body["state"], "success");
assert_eq!(body["context"], "adf/test");
assert_eq!(body["description"], "hello");
assert!(
body.get("target_url").is_none(),
"target_url should be omitted when None, got {body:?}"
);
}
#[tokio::test]
async fn set_commit_status_retries_on_5xx() {
let (base_url, state) = spawn_server(vec![503, 200]).await;
let tracker = make_status_tracker(&base_url);
tracker
.set_commit_status(
"o",
"r",
"sha",
StatusState::Pending,
"adf/test",
"retrying",
None,
)
.await
.expect("retry should succeed");
assert_eq!(state.calls.load(Ordering::SeqCst), 2);
}
#[tokio::test]
async fn set_commit_status_no_retry_on_4xx() {
let (base_url, state) = spawn_server(vec![401]).await;
let tracker = make_status_tracker(&base_url);
let result = tracker
.set_commit_status(
"o",
"r",
"sha",
StatusState::Failure,
"adf/test",
"auth fail",
None,
)
.await;
assert!(result.is_err(), "401 must surface as Err");
assert_eq!(
state.calls.load(Ordering::SeqCst),
1,
"4xx must not be retried"
);
}
#[tokio::test]
async fn set_commit_status_truncates_long_description() {
let (base_url, state) = spawn_server(vec![201]).await;
let tracker = make_status_tracker(&base_url);
let long = "x".repeat(200);
tracker
.set_commit_status(
"o",
"r",
"sha",
StatusState::Success,
"adf/test",
&long,
Some("https://example.com/report"),
)
.await
.expect("status post should succeed");
let body = state.captured.body.lock().unwrap().clone().unwrap();
let desc = body["description"].as_str().unwrap();
assert_eq!(desc.chars().count(), 140);
assert!(desc.chars().all(|c| c == 'x'));
assert_eq!(body["target_url"], "https://example.com/report");
}
}
#[test]
fn commit_status_entry_deserialises_status_field() {
let json = r#"{"id":33,"status":"failure","context":"ci-native.yml / lint","description":"ok","target_url":"/logs","created_at":"2026-05-01T00:00:00Z"}"#;
let entry: CommitStatusEntry = serde_json::from_str(json).expect("should deserialise");
assert_eq!(entry.state, "failure");
assert_eq!(entry.context, "ci-native.yml / lint");
}
#[test]
fn commit_status_entry_deserialises_pending() {
let json = r#"{"id":34,"status":"pending","context":"adf/pr-reviewer"}"#;
let entry: CommitStatusEntry = serde_json::from_str(json).expect("should deserialise");
assert_eq!(entry.state, "pending");
assert_eq!(entry.context, "adf/pr-reviewer");
}
}