use anyhow::{Context, Result, bail};
use serde_json::Value;
use crate::git;
use super::json::{all_reviews, optional_bool, optional_string, parse_state, required_string};
use super::{
CHECK_GRACE_POLLS, MergeBlocker, ReviewProvider, ReviewRequest, ReviewState, WaitOutcome,
check_poll_interval, checks_timed_out, command_output, merge_with_resettle,
review_merged_out_of_band,
};
pub(super) struct GiteaProvider;
const DRAFT_PREFIX: &str = "WIP: ";
impl ReviewProvider for GiteaProvider {
fn review_for_branch(&self, branch: &str) -> Result<Option<ReviewRequest>> {
find_review(branch, false)
}
fn review_for_branch_including_closed(&self, branch: &str) -> Result<Option<ReviewRequest>> {
find_review(branch, true)
}
fn create_review(&self, branch: &str, base: &str, draft: bool) -> Result<String> {
let title = git::commit_subject(branch)?;
let body = git::commit_body(branch)?;
let description = if body.trim().is_empty() {
&title
} else {
&body
};
let title = if draft {
format!("{DRAFT_PREFIX}{title}")
} else {
title.clone()
};
command_output(
"tea",
&[
"pr",
"create",
"--head",
branch,
"--base",
base,
"--title",
&title,
"--description",
description,
],
)
}
fn update_review_base(&self, review: &ReviewRequest, base: &str) -> Result<String> {
let endpoint = format!("repos/{}/pulls/{}", repo_slug()?, review.id_value());
let data = serde_json::json!({ "base": base }).to_string();
command_output(
"tea",
&["api", "--method", "PATCH", &endpoint, "--data", &data],
)
}
fn review_body(&self, review: &ReviewRequest) -> Result<String> {
Ok(optional_string(&api_pull(review.id_value())?, "body"))
}
fn update_review_body(&self, review: &ReviewRequest, body: &str) -> Result<String> {
command_output(
"tea",
&["pr", "edit", review.id_value(), "--description", body],
)
}
fn merge_review(&self, review: &ReviewRequest, strategy: &str, _auto: bool) -> Result<String> {
let style = match strategy {
"rebase" => "rebase",
"merge" => "merge",
_ => "squash",
};
let args = vec!["pr", "merge", review.id_value(), "--style", style];
wait_until_mergeable(review.id_value());
merge_with_resettle(
|| wait_until_mergeable(review.id_value()),
|| command_output("tea", &args),
)
}
fn merge_blocker(&self, _review: &ReviewRequest) -> Result<MergeBlocker> {
Ok(MergeBlocker::None)
}
fn wait_for_checks(&self, review: &ReviewRequest) -> Result<WaitOutcome> {
let head_sha = api_pull(review.id_value())?
.get("head")
.and_then(|head| head.get("sha"))
.and_then(Value::as_str)
.unwrap_or_default()
.to_owned();
if head_sha.is_empty() {
return Ok(WaitOutcome::Passed);
}
let endpoint = format!("repos/{}/commits/{head_sha}/status", repo_slug()?);
let started = std::time::Instant::now();
let timeout = crate::settings::check_timeout()?;
let mut no_status = 0u32;
loop {
let value: Value = serde_json::from_str(&command_output("tea", &["api", &endpoint])?)
.context("failed to parse gitea status JSON")?;
let state = optional_string(&value, "state");
let none_yet =
value.get("total_count").and_then(Value::as_u64) == Some(0) || state.is_empty();
match state.as_str() {
_ if none_yet && no_status >= CHECK_GRACE_POLLS => return Ok(WaitOutcome::Passed),
_ if none_yet => no_status += 1,
"success" => return Ok(WaitOutcome::Passed),
"failure" | "error" => return Ok(WaitOutcome::Failed),
_ => no_status = 0,
}
if let Some(timeout) = timeout
&& started.elapsed() >= timeout
{
return Err(checks_timed_out(review, timeout));
}
if review_merged_out_of_band(self, review)? {
return Ok(WaitOutcome::Landed);
}
std::thread::sleep(check_poll_interval());
}
}
fn open_reviews(&self) -> Result<Vec<ReviewRequest>> {
list_pulls("open")
}
fn mark_ready(&self, review: &ReviewRequest) -> Result<String> {
let title = review
.title
.strip_prefix(DRAFT_PREFIX)
.unwrap_or(&review.title);
command_output("tea", &["pr", "edit", review.id_value(), "--title", title])
}
fn close_review(&self, review: &ReviewRequest, _delete_branch: bool) -> Result<String> {
command_output("tea", &["pr", "close", review.id_value()])
}
fn open_review(&self, review: &ReviewRequest) -> Result<String> {
command_output("tea", &["open", &format!("pulls/{}", review.id_value())])
}
}
fn find_review(branch: &str, include_closed: bool) -> Result<Option<ReviewRequest>> {
let mut matches: Vec<ReviewRequest> = list_pulls("all")?
.into_iter()
.filter(|review| review.branch == branch)
.collect();
matches.sort_by_key(|review| state_rank(&review.state));
Ok(matches.into_iter().find(|review| match review.state {
ReviewState::Open | ReviewState::Merged => true,
ReviewState::Closed | ReviewState::Unknown(_) => include_closed,
}))
}
fn state_rank(state: &ReviewState) -> u8 {
match state {
ReviewState::Open => 0,
ReviewState::Merged => 1,
ReviewState::Closed => 2,
ReviewState::Unknown(_) => 3,
}
}
fn api_pull(id_value: &str) -> Result<Value> {
let endpoint = format!("repos/{}/pulls/{id_value}", repo_slug()?);
serde_json::from_str(&command_output("tea", &["api", &endpoint])?)
.context("failed to parse gitea pull JSON")
}
fn list_pulls(state: &str) -> Result<Vec<ReviewRequest>> {
const PAGE_SIZE: usize = 50;
const MAX_PAGES: usize = 50;
let slug = repo_slug()?;
let mut reviews = Vec::new();
for page in 1..=MAX_PAGES {
let endpoint = format!("repos/{slug}/pulls?state={state}&page={page}&limit={PAGE_SIZE}");
let batch = all_reviews(
&command_output("tea", &["api", &endpoint])?,
gitea_review_from,
)?;
let full_page = batch.len() == PAGE_SIZE;
reviews.extend(batch);
if !full_page {
break;
}
}
Ok(reviews)
}
const MERGE_SETTLE_POLLS: u32 = 5;
const MERGE_SETTLE_INTERVAL: std::time::Duration = std::time::Duration::from_secs(2);
fn wait_until_mergeable(id_value: &str) {
for attempt in 0..MERGE_SETTLE_POLLS {
if attempt > 0 {
std::thread::sleep(MERGE_SETTLE_INTERVAL);
}
if api_pull(id_value)
.ok()
.and_then(|pull| pull.get("mergeable").and_then(Value::as_bool))
== Some(true)
{
return;
}
}
}
fn gitea_review_from(review: &Value) -> Result<ReviewRequest> {
let state = if optional_bool(review, "merged") {
ReviewState::Merged
} else {
parse_state(&required_string(review, &["state"])?)
};
Ok(ReviewRequest {
id: format!("#{}", required_string(review, &["number", "index", "id"])?),
branch: branch_ref(review, &["head", "head_branch", "headBranch"])?,
base: branch_ref(review, &["base", "base_branch", "baseBranch"])?,
state,
url: required_string(review, &["html_url", "htmlUrl", "url"])?,
title: optional_string(review, "title"),
draft: optional_bool(review, "draft"),
})
}
fn branch_ref(review: &Value, keys: &[&str]) -> Result<String> {
for key in keys {
let Some(field) = review.get(*key) else {
continue;
};
if let Some(value) = field.as_str() {
return Ok(value.to_owned());
}
for nested in ["ref", "name", "label"] {
if let Some(value) = field.get(nested).and_then(Value::as_str) {
return Ok(value.to_owned());
}
}
}
bail!("provider JSON missing branch field: {}", keys.join(" or "));
}
fn repo_slug() -> Result<String> {
let remote = crate::settings::remote()?;
let url = git::remote_url(&remote)?.with_context(|| format!("remote {remote} has no URL"))?;
slug_from_url(&url).with_context(|| format!("could not parse owner/repo from {url}"))
}
fn slug_from_url(url: &str) -> Option<String> {
let rest = url.split_once("://").map_or(url, |(_, rest)| rest);
let rest = rest.rsplit_once('@').map_or(rest, |(_, rest)| rest); let rest = rest.trim_end_matches('/');
let rest = rest.strip_suffix(".git").unwrap_or(rest);
let parts: Vec<&str> = rest.split(['/', ':']).filter(|s| !s.is_empty()).collect();
if parts.len() < 3 {
return None;
}
Some(format!(
"{}/{}",
parts[parts.len() - 2],
parts[parts.len() - 1]
))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn gitea_review_from_maps_merged_closed_state() {
let merged = gitea_review_from(&serde_json::json!({
"number": 7, "state": "closed", "merged": true,
"head": {"ref": "feature/b"}, "base": {"ref": "feature/a"},
"html_url": "https://gitea.com/owner/repo/pulls/7", "title": "B"
}))
.expect("parse");
assert_eq!(merged.id, "#7");
assert_eq!(merged.branch, "feature/b");
assert_eq!(merged.base, "feature/a");
assert_eq!(merged.state, ReviewState::Merged);
assert_eq!(merged.url, "https://gitea.com/owner/repo/pulls/7");
let closed = gitea_review_from(&serde_json::json!({
"number": 8, "state": "closed", "merged": false,
"head": {"ref": "x"}, "base": {"ref": "main"},
"html_url": "https://gitea.com/o/r/pulls/8"
}))
.expect("parse");
assert_eq!(closed.state, ReviewState::Closed);
}
#[test]
fn gitea_review_from_tolerates_flat_branch_strings() {
let review = gitea_review_from(&serde_json::json!({
"index": 3, "state": "open",
"head": "feature/x", "base": "main",
"url": "https://gitea.com/o/r/pulls/3"
}))
.expect("parse");
assert_eq!(review.id, "#3");
assert_eq!(review.branch, "feature/x");
assert_eq!(review.base, "main");
assert_eq!(review.state, ReviewState::Open);
}
#[test]
fn slug_from_url_handles_url_shapes() {
assert_eq!(
slug_from_url("https://gitea.com/owner/repo.git").as_deref(),
Some("owner/repo")
);
assert_eq!(
slug_from_url("git@gitea.com:owner/repo.git").as_deref(),
Some("owner/repo")
);
assert_eq!(
slug_from_url("ssh://git@gitea.example.com:2222/owner/repo").as_deref(),
Some("owner/repo")
);
assert_eq!(
slug_from_url("https://user:token@codeberg.org/owner/repo").as_deref(),
Some("owner/repo")
);
assert_eq!(slug_from_url("https://gitea.com/").as_deref(), None);
}
}