use std::{fmt, process::Command};
use anyhow::{Context, Result, anyhow, bail};
use crate::git;
use crate::settings;
mod github;
mod gitlab;
mod json;
use github::GitHubProvider;
use gitlab::GitLabProvider;
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub enum ProviderKind {
GitHub,
GitLab,
}
impl ProviderKind {
fn parse(value: &str) -> Option<Self> {
match value.to_ascii_lowercase().as_str() {
"github" | "gh" => Some(Self::GitHub),
"gitlab" | "glab" => Some(Self::GitLab),
_ => None,
}
}
}
impl fmt::Display for ProviderKind {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::GitHub => write!(formatter, "github"),
Self::GitLab => write!(formatter, "gitlab"),
}
}
}
#[derive(Debug, Eq, PartialEq)]
pub struct DetectedProvider {
pub kind: ProviderKind,
pub source: ProviderSource,
}
#[derive(Debug, Eq, PartialEq)]
pub enum ProviderSource {
Config,
Remote { remote: String, url: String },
}
impl fmt::Display for ProviderSource {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Config => write!(formatter, "config"),
Self::Remote { remote, url } => write!(formatter, "remote {remote} ({url})"),
}
}
}
#[derive(Debug, Eq, PartialEq)]
pub enum ReviewState {
Open,
Merged,
Closed,
Unknown(String),
}
#[derive(Debug, Eq, PartialEq)]
pub struct ReviewRequest {
pub id: String,
pub branch: String,
pub base: String,
pub state: ReviewState,
pub url: String,
pub title: String,
}
pub trait ReviewProvider {
fn review_for_branch(&self, branch: &str) -> Result<Option<ReviewRequest>>;
fn review_for_branch_including_closed(&self, branch: &str) -> Result<Option<ReviewRequest>>;
fn create_review(&self, branch: &str, base: &str) -> Result<String>;
fn update_review_base(&self, review: &ReviewRequest, base: &str) -> Result<String>;
fn review_body(&self, review: &ReviewRequest) -> Result<String>;
fn update_review_body(&self, review: &ReviewRequest, body: &str) -> Result<String>;
fn merge_review(&self, review: &ReviewRequest, strategy: &str, auto: bool) -> Result<String>;
}
pub fn detect_provider() -> Result<DetectedProvider> {
if let Some(value) = git::config_get(settings::PROVIDER_KEY)? {
let Some(kind) = ProviderKind::parse(&value) else {
bail!("unsupported stk.provider value {value:?}; expected github or gitlab");
};
return Ok(DetectedProvider {
kind,
source: ProviderSource::Config,
});
}
let remote = settings::remote()?;
let Some(url) = git::remote_url(&remote)? else {
bail!("could not detect provider: remote {remote:?} does not exist");
};
let Some(kind) = detect_provider_from_url(&url) else {
bail!("could not detect provider from remote {remote} ({url})");
};
Ok(DetectedProvider {
kind,
source: ProviderSource::Remote { remote, url },
})
}
fn detect_provider_from_url(url: &str) -> Option<ProviderKind> {
let normalized = url.to_ascii_lowercase();
if normalized.contains("github.com:") || normalized.contains("github.com/") {
Some(ProviderKind::GitHub)
} else if normalized.contains("gitlab.com:") || normalized.contains("gitlab.com/") {
Some(ProviderKind::GitLab)
} else {
None
}
}
pub(crate) fn review_provider(kind: ProviderKind) -> Box<dyn ReviewProvider> {
match kind {
ProviderKind::GitHub => Box::new(GitHubProvider),
ProviderKind::GitLab => Box::new(GitLabProvider),
}
}
fn command_output(program: &str, args: &[&str]) -> Result<String> {
let output = Command::new(program)
.args(args)
.output()
.with_context(|| format!("failed to run {program}"))?;
if output.status.success() {
Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
} else {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
if stderr.is_empty() {
Err(anyhow!("{program} exited with status {}", output.status))
} else {
Err(anyhow!("{program} failed: {stderr}"))
}
}
}
impl fmt::Display for ReviewState {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Open => write!(formatter, "open"),
Self::Merged => write!(formatter, "merged"),
Self::Closed => write!(formatter, "closed"),
Self::Unknown(state) => write!(formatter, "{state}"),
}
}
}
impl ReviewRequest {
pub(crate) fn id_value(&self) -> &str {
self.id
.strip_prefix('#')
.or_else(|| self.id.strip_prefix('!'))
.unwrap_or(&self.id)
}
}