#![cfg_attr(docsrs, feature(doc_cfg))]
#![deny(rustdoc::broken_intra_doc_links)]
use std::path::Path;
use processkit::ProcessRunner;
pub use processkit::{Error, ProcessResult, Result};
#[cfg(feature = "cancellation")]
#[cfg_attr(docsrs, doc(cfg(feature = "cancellation")))]
pub use processkit::CancellationToken;
mod parse;
pub use parse::{Issue, PullRequest, Release};
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct PrCreate {
pub title: String,
pub body: String,
pub head: Option<String>,
pub base: Option<String>,
}
impl PrCreate {
pub fn new(title: impl Into<String>, body: impl Into<String>) -> Self {
Self {
title: title.into(),
body: body.into(),
head: None,
base: None,
}
}
pub fn head(mut self, head: impl Into<String>) -> Self {
self.head = Some(head.into());
self
}
pub fn base(mut self, base: impl Into<String>) -> Self {
self.base = Some(base.into());
self
}
}
pub const BINARY: &str = "tea";
const PR_FIELDS: &str = "index,title,state,head,base,url";
const ISSUE_FIELDS: &str = "index,title,state,body,url";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum MergeStrategy {
Merge,
Squash,
Rebase,
}
impl MergeStrategy {
fn style(self) -> &'static str {
match self {
MergeStrategy::Merge => "merge",
MergeStrategy::Squash => "squash",
MergeStrategy::Rebase => "rebase",
}
}
}
#[cfg_attr(feature = "mock", mockall::automock)]
#[async_trait::async_trait]
pub trait GiteaApi: Send + Sync {
async fn run(&self, args: &[String]) -> Result<String>;
async fn run_raw(&self, args: &[String]) -> Result<ProcessResult<String>>;
async fn version(&self) -> Result<String>;
async fn auth_status(&self) -> Result<bool>;
async fn pr_list(&self, dir: &Path) -> Result<Vec<PullRequest>>;
async fn pr_view(&self, dir: &Path, number: u64) -> Result<PullRequest>;
async fn pr_create(&self, dir: &Path, spec: PrCreate) -> Result<String>;
async fn pr_merge(&self, dir: &Path, number: u64, strategy: MergeStrategy) -> Result<()>;
async fn pr_close(&self, dir: &Path, number: u64) -> Result<()>;
async fn issue_list(&self, dir: &Path) -> Result<Vec<Issue>>;
async fn issue_view(&self, dir: &Path, number: u64) -> Result<Issue>;
async fn issue_create(&self, dir: &Path, title: &str, body: &str) -> Result<String>;
async fn release_list(&self, dir: &Path) -> Result<Vec<Release>>;
}
processkit::cli_client!(
pub struct Gitea => BINARY
);
#[async_trait::async_trait]
impl<R: ProcessRunner> GiteaApi for Gitea<R> {
async fn run(&self, args: &[String]) -> Result<String> {
self.core.run(self.core.command(args)).await
}
async fn run_raw(&self, args: &[String]) -> Result<ProcessResult<String>> {
self.core.output(self.core.command(args)).await
}
async fn version(&self) -> Result<String> {
self.core.run(self.core.command(["--version"])).await
}
async fn auth_status(&self) -> Result<bool> {
let res = self
.core
.output(self.core.command(["login", "list", "--output", "json"]))
.await?;
if res.code() != Some(0) {
if res.code().is_none() {
res.ensure_success()?;
}
return Ok(false);
}
let json = res.stdout().trim();
if json.is_empty() {
return Ok(false);
}
let logins: Vec<serde_json::Value> = parse::from_json(json)?;
Ok(!logins.is_empty())
}
async fn pr_list(&self, dir: &Path) -> Result<Vec<PullRequest>> {
self.core
.try_parse(
self.core.command_in(
dir,
[
"pr", "list", "--limit", "100", "--fields", PR_FIELDS, "--output", "json",
],
),
parse::parse_pr_list,
)
.await
}
async fn pr_view(&self, dir: &Path, number: u64) -> Result<PullRequest> {
let prs = self
.core
.try_parse(
self.core.command_in(
dir,
[
"pr", "list", "--state", "all", "--limit", "999", "--fields", PR_FIELDS,
"--output", "json",
],
),
parse::parse_pr_list,
)
.await?;
prs.into_iter()
.find(|pr| pr.number == number)
.ok_or_else(|| Error::Parse {
program: BINARY.to_string(),
message: format!("no pull request #{number} in `tea pr list`"),
})
}
async fn pr_create(&self, dir: &Path, spec: PrCreate) -> Result<String> {
let mut args = vec![
"pr",
"create",
"--title",
spec.title.as_str(),
"--description",
spec.body.as_str(),
];
if let Some(head) = spec.head.as_deref() {
args.push("--head");
args.push(head);
}
if let Some(base) = spec.base.as_deref() {
args.push("--base");
args.push(base);
}
self.core.run(self.core.command_in(dir, args)).await
}
async fn pr_merge(&self, dir: &Path, number: u64, strategy: MergeStrategy) -> Result<()> {
let n = number.to_string();
self.core
.run_unit(self.core.command_in(
dir,
["pr", "merge", n.as_str(), "--style", strategy.style()],
))
.await
}
async fn pr_close(&self, dir: &Path, number: u64) -> Result<()> {
let n = number.to_string();
self.core
.run_unit(self.core.command_in(dir, ["pr", "close", n.as_str()]))
.await
}
async fn issue_list(&self, dir: &Path) -> Result<Vec<Issue>> {
self.core
.try_parse(
self.core.command_in(
dir,
[
"issues",
"list",
"--limit",
"100",
"--fields",
ISSUE_FIELDS,
"--output",
"json",
],
),
parse::parse_issue_list,
)
.await
}
async fn issue_view(&self, dir: &Path, number: u64) -> Result<Issue> {
let n = number.to_string();
self.core
.try_parse(
self.core
.command_in(dir, ["issues", n.as_str(), "--output", "json"]),
parse::parse_issue,
)
.await
}
async fn issue_create(&self, dir: &Path, title: &str, body: &str) -> Result<String> {
self.core
.run(self.core.command_in(
dir,
["issues", "create", "--title", title, "--description", body],
))
.await
}
async fn release_list(&self, dir: &Path) -> Result<Vec<Release>> {
self.core
.try_parse(
self.core.command_in(
dir,
["releases", "list", "--limit", "100", "--output", "json"],
),
parse::parse_release_list,
)
.await
}
}
impl<R: ProcessRunner> Gitea<R> {
pub async fn run_args(&self, args: &[&str]) -> Result<String> {
self.core.run(self.core.command(args)).await
}
pub async fn run_raw_args(&self, args: &[&str]) -> Result<ProcessResult<String>> {
self.core.output(self.core.command(args)).await
}
pub fn at<'a>(&'a self, dir: &'a Path) -> GiteaAt<'a, R> {
GiteaAt { tea: self, dir }
}
}
pub struct GiteaAt<'a, R: ProcessRunner = processkit::JobRunner> {
tea: &'a Gitea<R>,
dir: &'a Path,
}
impl<R: ProcessRunner> Clone for GiteaAt<'_, R> {
fn clone(&self) -> Self {
*self
}
}
impl<R: ProcessRunner> Copy for GiteaAt<'_, R> {}
macro_rules! gitea_at_forwarders {
(
bare { $( fn $bn:ident( $($ba:ident: $bt:ty),* $(,)? ) -> $br:ty; )* }
dir { $( fn $dn:ident( $($da:ident: $dt:ty),* $(,)? ) -> $dr:ty; )* }
) => {
impl<'a, R: ProcessRunner> GiteaAt<'a, R> {
$(
#[doc = concat!("Bound form of [`Gitea`]'s `", stringify!($bn), "`.")]
pub async fn $bn(&self, $($ba: $bt),*) -> $br {
self.tea.$bn($($ba),*).await
}
)*
$(
#[doc = concat!("Bound form of [`Gitea`]'s `", stringify!($dn), "` (with `dir` pre-bound).")]
pub async fn $dn(&self, $($da: $dt),*) -> $dr {
self.tea.$dn(self.dir, $($da),*).await
}
)*
}
};
}
gitea_at_forwarders! {
bare {
fn run(args: &[String]) -> Result<String>;
fn run_raw(args: &[String]) -> Result<ProcessResult<String>>;
fn run_args(args: &[&str]) -> Result<String>;
fn run_raw_args(args: &[&str]) -> Result<ProcessResult<String>>;
fn version() -> Result<String>;
fn auth_status() -> Result<bool>;
}
dir {
fn pr_list() -> Result<Vec<PullRequest>>;
fn pr_view(number: u64) -> Result<PullRequest>;
fn pr_create(spec: PrCreate) -> Result<String>;
fn pr_merge(number: u64, strategy: MergeStrategy) -> Result<()>;
fn pr_close(number: u64) -> Result<()>;
fn issue_list() -> Result<Vec<Issue>>;
fn issue_view(number: u64) -> Result<Issue>;
fn issue_create(title: &str, body: &str) -> Result<String>;
fn release_list() -> Result<Vec<Release>>;
}
}
#[cfg(test)]
mod tests {
use super::*;
use processkit::{RecordingRunner, Reply, ScriptedRunner};
#[test]
fn binary_name_is_tea() {
assert_eq!(BINARY, "tea");
}
#[allow(dead_code)]
fn bound_view_is_copy_for_default_runner() {
fn assert_copy<T: Copy>() {}
assert_copy::<GiteaAt<'static, processkit::JobRunner>>();
}
#[tokio::test]
async fn bound_view_matches_dir_taking_calls() {
let dir = Path::new("/repo");
let rec = RecordingRunner::replying(Reply::ok("[]"));
let tea = Gitea::with_runner(&rec);
tea.pr_list(dir).await.unwrap();
tea.at(dir).pr_list().await.unwrap();
tea.pr_close(dir, 7).await.unwrap();
tea.at(dir).pr_close(7).await.unwrap();
let calls = rec.calls();
assert_eq!(calls[0].args_str(), calls[1].args_str());
assert_eq!(calls[2].args_str(), calls[3].args_str());
assert_eq!(calls[1].cwd.as_deref(), Some(dir.as_os_str()));
}
#[tokio::test]
async fn run_args_forwards_str_slices() {
let tea = Gitea::with_runner(ScriptedRunner::new().on(["whoami"], Reply::ok("me\n")));
assert_eq!(tea.run_args(&["whoami"]).await.unwrap(), "me");
}
#[tokio::test]
async fn pr_list_parses_scripted_json() {
let json = r#"[{"index":"7","title":"Add X","state":"open","head":"feat/x","base":"main","url":"u"}]"#;
let tea = Gitea::with_runner(ScriptedRunner::new().on(["pr", "list"], Reply::ok(json)));
let prs = tea.pr_list(Path::new(".")).await.expect("pr_list");
assert_eq!(prs.len(), 1);
assert_eq!(prs[0].number, 7);
assert_eq!(prs[0].head_branch, "feat/x");
}
#[tokio::test]
async fn pr_view_filters_listing_by_number() {
let json = r#"[
{"index":"7","title":"Seven","state":"open","head":"a","base":"main","url":"u"},
{"index":"9","title":"Nine","state":"merged","head":"b","base":"main","url":"u"}
]"#;
let tea = Gitea::with_runner(ScriptedRunner::new().on(["pr", "list"], Reply::ok(json)));
let pr = tea.pr_view(Path::new("."), 9).await.expect("pr_view");
assert_eq!(pr.title, "Nine");
assert!(pr.merged);
}
#[tokio::test]
async fn pr_view_requests_all_states_and_errors_when_missing() {
let rec = RecordingRunner::replying(Reply::ok("[]"));
let tea = Gitea::with_runner(&rec);
let err = tea.pr_view(Path::new("/repo"), 5).await.unwrap_err();
assert!(matches!(err, Error::Parse { .. }));
assert_eq!(
rec.only_call().args_str(),
[
"pr",
"list",
"--state",
"all",
"--limit",
"999",
"--fields",
"index,title,state,head,base,url",
"--output",
"json"
]
);
}
#[tokio::test]
async fn pr_list_pins_limit_and_fields() {
let rec = RecordingRunner::replying(Reply::ok("[]"));
let tea = Gitea::with_runner(&rec);
tea.pr_list(Path::new("/repo")).await.expect("pr_list");
assert_eq!(
rec.only_call().args_str(),
[
"pr",
"list",
"--limit",
"100",
"--fields",
"index,title,state,head,base,url",
"--output",
"json"
]
);
}
#[tokio::test]
async fn auth_status_counts_logins() {
let yes = Gitea::with_runner(
ScriptedRunner::new().on(["login", "list"], Reply::ok(r#"[{"name":"gitea"}]"#)),
);
assert!(yes.auth_status().await.unwrap());
let no = Gitea::with_runner(ScriptedRunner::new().on(["login", "list"], Reply::ok("[]")));
assert!(!no.auth_status().await.unwrap());
let empty = Gitea::with_runner(ScriptedRunner::new().on(["login", "list"], Reply::ok("")));
assert!(!empty.auth_status().await.unwrap());
let failed = Gitea::with_runner(
ScriptedRunner::new().on(["login", "list"], Reply::fail(1, "no config")),
);
assert!(!failed.auth_status().await.unwrap());
let weird =
Gitea::with_runner(ScriptedRunner::new().on(["login", "list"], Reply::fail(2, "boom")));
assert!(!weird.auth_status().await.unwrap());
}
#[tokio::test]
async fn auth_status_errors_on_timeout() {
let tea = Gitea::with_runner(ScriptedRunner::new().on(["login", "list"], Reply::timeout()));
assert!(matches!(
tea.auth_status().await.unwrap_err(),
Error::Timeout { .. }
));
}
#[tokio::test]
async fn pr_create_appends_head_and_base() {
let rec = RecordingRunner::replying(Reply::ok("#9\n"));
let tea = Gitea::with_runner(&rec);
tea.pr_create(
Path::new("/repo"),
PrCreate::new("T", "B").head("feat/x").base("main"),
)
.await
.expect("pr_create");
assert_eq!(
rec.only_call().args_str(),
[
"pr",
"create",
"--title",
"T",
"--description",
"B",
"--head",
"feat/x",
"--base",
"main"
]
);
}
#[tokio::test]
async fn pr_merge_and_close_build_expected_argv() {
let rec = RecordingRunner::replying(Reply::ok(""));
let tea = Gitea::with_runner(&rec);
tea.pr_merge(Path::new("/repo"), 5, MergeStrategy::Squash)
.await
.expect("merge");
assert_eq!(
rec.only_call().args_str(),
["pr", "merge", "5", "--style", "squash"]
);
let rec = RecordingRunner::replying(Reply::ok(""));
let tea = Gitea::with_runner(&rec);
tea.pr_close(Path::new("/repo"), 5).await.expect("close");
assert_eq!(rec.only_call().args_str(), ["pr", "close", "5"]);
}
#[tokio::test]
async fn issue_list_parses_scripted_json() {
let json = r#"[{"index":"12","title":"Bug","state":"open","body":"broken","url":"u"}]"#;
let tea = Gitea::with_runner(ScriptedRunner::new().on(["issues", "list"], Reply::ok(json)));
let issues = tea.issue_list(Path::new(".")).await.expect("issue_list");
assert_eq!(issues.len(), 1);
assert_eq!(issues[0].number, 12);
assert_eq!(issues[0].title, "Bug");
}
#[tokio::test]
async fn issue_list_pins_limit_and_fields() {
let rec = RecordingRunner::replying(Reply::ok("[]"));
let tea = Gitea::with_runner(&rec);
tea.issue_list(Path::new("/repo"))
.await
.expect("issue_list");
assert_eq!(
rec.only_call().args_str(),
[
"issues",
"list",
"--limit",
"100",
"--fields",
"index,title,state,body,url",
"--output",
"json"
]
);
}
#[tokio::test]
async fn issue_view_uses_bare_index_and_parses_object() {
let rec = RecordingRunner::replying(Reply::ok(
r#"{"index":7,"title":"One","state":"closed","body":"b","url":"u"}"#,
));
let tea = Gitea::with_runner(&rec);
let issue = tea
.issue_view(Path::new("/repo"), 7)
.await
.expect("issue_view");
assert_eq!(issue.number, 7);
assert_eq!(issue.state, "closed");
assert_eq!(
rec.only_call().args_str(),
["issues", "7", "--output", "json"]
);
}
#[tokio::test]
async fn issue_create_builds_argv_and_returns_output() {
let rec = RecordingRunner::replying(Reply::ok("#12 Bug\nhttps://gitea/issues/12\n"));
let tea = Gitea::with_runner(&rec);
let out = tea
.issue_create(Path::new("/repo"), "Bug", "broken")
.await
.expect("issue_create");
assert_eq!(out, "#12 Bug\nhttps://gitea/issues/12");
assert_eq!(
rec.only_call().args_str(),
[
"issues",
"create",
"--title",
"Bug",
"--description",
"broken"
]
);
}
#[tokio::test]
async fn release_list_parses_scripted_json() {
let json = r#"[{"tag-_name":"0.1","title":"First","status":"released","published _at":"2023-07-26T13:02:36Z","tar/_zip url":"https://gitea/0.1.tar.gz\nhttps://gitea/0.1.zip"}]"#;
let tea =
Gitea::with_runner(ScriptedRunner::new().on(["releases", "list"], Reply::ok(json)));
let releases = tea
.release_list(Path::new("."))
.await
.expect("release_list");
assert_eq!(releases.len(), 1);
assert_eq!(releases[0].tag, "0.1");
assert_eq!(releases[0].title, "First");
assert_eq!(releases[0].url, "");
assert!(!releases[0].draft);
}
#[tokio::test]
async fn release_list_pins_limit_100() {
let rec = RecordingRunner::replying(Reply::ok("[]"));
let tea = Gitea::with_runner(&rec);
tea.release_list(Path::new("/repo"))
.await
.expect("release_list");
assert_eq!(
rec.only_call().args_str(),
["releases", "list", "--limit", "100", "--output", "json"]
);
}
#[cfg(feature = "mock")]
#[tokio::test]
async fn consumer_mocks_the_interface() {
let mut mock = MockGiteaApi::new();
mock.expect_auth_status().returning(|| Ok(true));
assert!(mock.auth_status().await.unwrap());
}
}
#[doc = include_str!("../docs/gitea.md")]
#[allow(rustdoc::broken_intra_doc_links)]
pub mod guide {}