use serde::{Deserialize, Serialize};
use crate::error::Error;
use crate::grit_server_auth::{not_grit_server_or_http, ServerAuthError, ServerUrl};
use crate::transport::http::HttpClient;
pub(crate) const SPACE_PATH: &str = ".grit/space";
pub(crate) const INTEGRATE_PATH: &str = ".grit/integrate";
const JSON_CONTENT_TYPE: &str = "application/json";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Space {
pub project: String,
pub url: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub upstream: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub upstream_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target_branch: Option<String>,
#[serde(default)]
pub followed: Vec<String>,
#[serde(default)]
pub shared: Vec<String>,
pub can_integrate: bool,
#[serde(default)]
pub created: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct IntegrateBranchRequest {
pub branch: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub without: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WaitingHead {
pub user: String,
pub head: String,
pub commits: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum IntegrateBranchStatus {
Merged,
AlreadyIntegrated,
Started,
Refused,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct IntegrateBranchReply {
pub status: IntegrateBranchStatus,
pub project: String,
pub target_branch: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub previous: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub head: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pull: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub waiting: Vec<WaitingHead>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub unshared: Vec<String>,
}
#[must_use]
pub fn hosted_project(url: &str) -> Option<(ServerUrl, String)> {
let server = ServerUrl::parse(url).ok()?;
let parsed = url::Url::parse(url.trim()).ok()?;
let path = parsed.path().trim_matches('/');
let path = path.strip_suffix(".git").unwrap_or(path);
let (owner, name) = path.split_once('/')?;
let segment = |s: &str| !s.is_empty() && !s.contains('/') && !s.starts_with('.');
(segment(owner) && segment(name)).then(|| (server, format!("{owner}/{name}")))
}
pub fn fetch_space(
http: &dyn HttpClient,
server: &ServerUrl,
project: &str,
) -> std::result::Result<Space, ServerAuthError> {
let url = format!("{}{SPACE_PATH}/{project}", server.root_url());
let body = post_json(http, server, &url, b"{}")?;
serde_json::from_slice(&body).map_err(|_| ServerAuthError::NotGritServer(server.root_url()))
}
pub fn integrate_branch(
http: &dyn HttpClient,
server: &ServerUrl,
project: &str,
request: &IntegrateBranchRequest,
) -> std::result::Result<IntegrateBranchReply, ServerAuthError> {
let url = format!("{}{INTEGRATE_PATH}/{project}", server.root_url());
let request = serde_json::to_vec(request)
.map_err(|e| ServerAuthError::Http(Error::Message(e.to_string())))?;
let body = post_json(http, server, &url, &request)?;
serde_json::from_slice(&body).map_err(|_| ServerAuthError::NotGritServer(server.root_url()))
}
fn post_json(
http: &dyn HttpClient,
server: &ServerUrl,
url: &str,
body: &[u8],
) -> std::result::Result<Vec<u8>, ServerAuthError> {
http.post(url, JSON_CONTENT_TYPE, JSON_CONTENT_TYPE, body, None)
.map_err(|e| match e {
Error::Auth { .. } => ServerAuthError::TokenRejected,
other => not_grit_server_or_http(server, other),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hosted_projects_are_two_segments() {
let (server, project) =
hosted_project("https://grithub.dev/gitbutler.com/grit.git").unwrap();
assert_eq!(server.root_url(), "https://grithub.dev/");
assert_eq!(project, "gitbutler.com/grit");
let (server, project) = hosted_project("http://127.0.0.1:8080/alice/proj/").unwrap();
assert_eq!(server.root_url(), "http://127.0.0.1:8080/");
assert_eq!(project, "alice/proj");
assert!(hosted_project("https://grithub.dev/alice").is_none());
assert!(hosted_project("https://grithub.dev/a/b/c").is_none());
assert!(hosted_project("git@github.com:a/b.git").is_none());
assert!(hosted_project("/local/path/a/b").is_none());
}
#[test]
fn replies_round_trip() {
let reply = IntegrateBranchReply {
status: IntegrateBranchStatus::AlreadyIntegrated,
project: "example.com/widget".to_owned(),
target_branch: "main".to_owned(),
previous: None,
head: Some("abc".to_owned()),
pull: Some(3),
url: None,
message: None,
waiting: Vec::new(),
unshared: Vec::new(),
};
let json = serde_json::to_string(&reply).unwrap();
assert!(json.contains("\"already_integrated\""), "{json}");
assert_eq!(
serde_json::from_str::<IntegrateBranchReply>(&json).unwrap(),
reply
);
let space: Space = serde_json::from_str(
r#"{"project":"alice/widget","url":"https://h/alice/widget.git","can_integrate":false}"#,
)
.unwrap();
assert!(space.followed.is_empty() && !space.created && space.upstream.is_none());
}
struct Canned {
answer: std::sync::Mutex<Option<crate::error::Result<Vec<u8>>>>,
seen: std::sync::Mutex<Vec<(String, Vec<u8>)>>,
}
impl Canned {
fn new(answer: crate::error::Result<Vec<u8>>) -> Self {
Self {
answer: std::sync::Mutex::new(Some(answer)),
seen: std::sync::Mutex::new(Vec::new()),
}
}
}
impl HttpClient for Canned {
fn get(&self, _url: &str, _git_protocol: Option<&str>) -> crate::error::Result<Vec<u8>> {
unreachable!("space requests only POST")
}
fn post(
&self,
url: &str,
content_type: &str,
_accept: &str,
body: &[u8],
_git_protocol: Option<&str>,
) -> crate::error::Result<Vec<u8>> {
assert_eq!(content_type, JSON_CONTENT_TYPE);
self.seen
.lock()
.unwrap()
.push((url.to_owned(), body.to_vec()));
self.answer.lock().unwrap().take().unwrap()
}
}
#[test]
fn spaces_are_asked_for_by_project() {
let http = Canned::new(Ok(br#"{"project":"alice/widget","url":"https://grithub.dev/alice/widget.git","upstream":"example.com/widget","target_branch":"main","followed":["main"],"can_integrate":true,"created":true}"#.to_vec()));
let server = ServerUrl::parse("https://grithub.dev").unwrap();
let space = fetch_space(&http, &server, "example.com/widget").unwrap();
assert_eq!(space.project, "alice/widget");
assert_eq!(space.upstream.as_deref(), Some("example.com/widget"));
assert_eq!(space.followed, ["main"]);
assert!(space.can_integrate && space.created);
let seen = http.seen.lock().unwrap();
assert_eq!(
seen[0].0,
"https://grithub.dev/.grit/space/example.com/widget"
);
let refused = Canned::new(Err(Error::Auth {
url: "https://grithub.dev".to_owned(),
reason: crate::error::AuthFailure::Rejected,
}));
assert!(matches!(
fetch_space(&refused, &server, "example.com/widget"),
Err(ServerAuthError::TokenRejected)
));
}
#[test]
fn integrating_sends_the_branch() {
let http = Canned::new(Ok(br#"{"status":"refused","project":"example.com/widget","target_branch":"main","url":"https://grithub.dev/bob/widget/pulls/new/fix","message":"only members integrate"}"#.to_vec()));
let server = ServerUrl::parse("https://grithub.dev").unwrap();
let reply = integrate_branch(
&http,
&server,
"bob/widget",
&IntegrateBranchRequest {
branch: "fix".to_owned(),
without: Vec::new(),
},
)
.unwrap();
assert_eq!(reply.status, IntegrateBranchStatus::Refused);
assert_eq!(reply.message.as_deref(), Some("only members integrate"));
let seen = http.seen.lock().unwrap();
assert_eq!(seen[0].0, "https://grithub.dev/.grit/integrate/bob/widget");
assert_eq!(seen[0].1, br#"{"branch":"fix"}"#);
}
}