use std::collections::BTreeMap;
use std::future::Future;
use std::path::Path;
use std::pin::Pin;
use std::sync::Arc;
use std::time::{Duration, Instant};
use serde::Serialize;
use serde_json::{Value, json};
use crate::config::schema::GithubConfig;
use crate::session::github::{SessionLinks, attribution_footer};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Repo {
pub owner: String,
pub name: String,
}
pub fn parse_remote(url: &str) -> Option<Repo> {
let trimmed = url.trim();
let trimmed = trimmed.strip_suffix(".git").unwrap_or(trimmed);
if let Some(rest) = trimmed
.strip_prefix("https://")
.or_else(|| trimmed.strip_prefix("http://"))
{
let rest = match rest.find('@') {
Some(at) if rest[..at].find('/').is_none() => &rest[at + 1..],
_ => rest,
};
let (host, path) = rest.split_once('/')?;
if host != "github.com" {
return None;
}
return owner_name(path);
}
let rest = trimmed.strip_prefix("ssh://").unwrap_or(trimmed);
let rest = rest.strip_prefix("git@github.com")?;
let rest = rest.strip_prefix(':').or_else(|| rest.strip_prefix('/'))?;
owner_name(rest)
}
fn owner_name(path: &str) -> Option<Repo> {
let (owner, name) = path.split_once('/')?;
if owner.is_empty() || name.is_empty() || name.contains('/') {
return None;
}
Some(Repo {
owner: owner.to_owned(),
name: name.to_owned(),
})
}
#[derive(Debug, Clone, PartialEq)]
pub struct Ran {
pub code: i32,
pub stdout: String,
pub stderr: String,
}
pub type Run = Arc<
dyn Fn(
Vec<String>,
Option<String>,
BTreeMap<String, String>,
) -> Pin<Box<dyn Future<Output = Ran> + Send>>
+ Send
+ Sync,
>;
pub type Sleep = Arc<dyn Fn(u64) -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + Sync>;
pub struct ApiCall {
pub method: String,
pub token: String,
pub body: Option<Value>,
}
pub struct ApiReply {
pub status: u16,
pub body: Value,
}
pub type Api =
Arc<dyn Fn(String, ApiCall) -> Pin<Box<dyn Future<Output = ApiReply> + Send>> + Send + Sync>;
#[derive(Debug, thiserror::Error)]
#[error("{0}")]
pub struct PullRequestError(pub String);
pub struct Request {
pub github: GithubConfig,
pub project_path: String,
pub repository: Option<String>,
pub title: String,
pub requested_by: String,
pub links: SessionLinks,
}
pub fn pull_request_body(summary: &str, requested_by: &str, links: &SessionLinks) -> String {
format!(
"{}\n\n---\n\n{}\n",
summary.trim(),
attribution_footer(requested_by, links)
)
}
const TOKEN_VARIABLE: &str = "ERRAND_GH_TOKEN";
const CREDENTIAL_HELPER: &str =
"!f() { echo \"username=x-access-token\"; echo \"password=$ERRAND_GH_TOKEN\"; }; f";
const SAFE_CONFIG: &[&str] = &[
"-c",
"core.hooksPath=/dev/null",
"-c",
"core.fsmonitor=false",
"-c",
"core.pager=cat",
"-c",
"credential.helper=",
"-c",
"http.proxy=",
"-c",
"http.sslVerify=true",
"-c",
"protocol.ext.allow=never",
"-c",
"log.showSignature=false",
"-c",
"merge.verifySignatures=false",
];
const SAFE_ENV: &[(&str, &str)] = &[
("GIT_CONFIG_NOSYSTEM", "1"),
("GIT_CONFIG_GLOBAL", "/dev/null"),
("GIT_TERMINAL_PROMPT", "0"),
];
fn git(
run: &Run,
cwd: &str,
args: &[&str],
token: Option<&str>,
) -> Pin<Box<dyn Future<Output = Ran> + Send>> {
let mut env: BTreeMap<String, String> = SAFE_ENV
.iter()
.map(|(name, value)| ((*name).to_owned(), (*value).to_owned()))
.collect();
if let Some(token) = token {
env.insert(TOKEN_VARIABLE.to_owned(), token.to_owned());
}
let mut command = vec!["git".to_owned()];
command.extend(SAFE_CONFIG.iter().map(|setting| (*setting).to_owned()));
command.extend(args.iter().map(|argument| (*argument).to_owned()));
run(command, Some(cwd.to_owned()), env)
}
async fn assert_contained(run: &Run, repo: &str) -> Result<(), PullRequestError> {
let dir = git(run, repo, &["rev-parse", "--absolute-git-dir"], None).await;
if dir.code != 0 {
return Err(PullRequestError(format!(
"there is no git repository at {repo}"
)));
}
let git_dir = dir.stdout.trim();
let inside = git_dir == format!("{repo}/.git")
|| git_dir.starts_with(&format!("{repo}/.git/"))
|| git_dir == repo
|| git_dir.starts_with(&format!("{repo}/"));
if !inside {
return Err(PullRequestError(
"this project's git directory is outside it, so it is not one the daemon will open"
.to_owned(),
));
}
Ok(())
}
pub async fn current_branch(run: &Run, project_path: &str) -> Result<String, PullRequestError> {
let head = git(
run,
project_path,
&["rev-parse", "--abbrev-ref", "HEAD"],
None,
)
.await;
if head.code != 0 {
return Err(PullRequestError(format!(
"git could not read a branch in {project_path}"
)));
}
let branch = head.stdout.trim();
if branch == "HEAD" {
return Err(PullRequestError(
"this project has no branch checked out".to_owned(),
));
}
Ok(branch.to_owned())
}
pub async fn upstream(run: &Run, project_path: &str) -> Result<Repo, PullRequestError> {
let remote = git(run, project_path, &["remote", "get-url", "origin"], None).await;
if remote.code != 0 {
return Err(PullRequestError(
"this project has no origin remote to open a pull request against".to_owned(),
));
}
parse_remote(&remote.stdout).ok_or_else(|| {
PullRequestError(format!(
"origin is not a GitHub remote: {}",
remote.stdout.trim()
))
})
}
fn is_work_tree(path: &Path) -> bool {
std::fs::symlink_metadata(path.join(".git")).is_ok_and(|meta| meta.is_dir())
}
fn repositories_in(project_path: &str) -> Vec<String> {
let Ok(entries) = std::fs::read_dir(project_path) else {
return Vec::new();
};
let mut names: Vec<String> = entries
.flatten()
.filter(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir()))
.map(|entry| entry.file_name().to_string_lossy().into_owned())
.filter(|name| is_work_tree(&Path::new(project_path).join(name)))
.collect();
names.sort();
names
}
pub fn find_repository(
project_path: &str,
named: Option<&str>,
) -> Result<String, PullRequestError> {
if let Some(named) = named.filter(|name| !name.is_empty()) {
if named.contains('/') || named == "." || named == ".." {
return Err(PullRequestError(format!(
"`{named}` is not the name of a repository in this session"
)));
}
let chosen = Path::new(project_path).join(named);
if !is_work_tree(&chosen) {
return Err(PullRequestError(format!(
"there is no repository called `{named}` in this session"
)));
}
return Ok(chosen.display().to_string());
}
if is_work_tree(Path::new(project_path)) {
return Ok(project_path.to_owned());
}
let found = repositories_in(project_path);
match found.as_slice() {
[one] => Ok(Path::new(project_path).join(one).display().to_string()),
[] => Err(PullRequestError(
"nothing in this session is a git repository yet, so there is nothing to open"
.to_owned(),
)),
many => Err(PullRequestError(format!(
"this session holds several repositories ({}), so say which one to open",
many.join(", ")
))),
}
}
pub const FORK_WAIT_MS: u64 = 30_000;
const FORK_POLL_MS: u64 = 1_000;
fn named(body: &Value) -> Option<Repo> {
let owner = body.get("owner")?.get("login")?.as_str()?;
Some(Repo {
owner: owner.to_owned(),
name: body.get("name")?.as_str()?.to_owned(),
})
}
fn detail(body: &Value) -> String {
body.get("message")
.and_then(Value::as_str)
.map_or_else(|| "GitHub refused it".to_owned(), str::to_owned)
}
fn first_line(text: &str) -> String {
text.trim()
.split_once('\n')
.map_or(text.trim(), |(first, _)| first)
.to_owned()
}
async fn fork_of(
api: &Api,
token: &str,
target: &Repo,
sleep: &Sleep,
) -> Result<Repo, PullRequestError> {
let made = api(
format!("/repos/{}/{}/forks", target.owner, target.name),
ApiCall {
method: "POST".to_owned(),
token: token.to_owned(),
body: None,
},
)
.await;
if made.status >= 400 {
let reach = if made.status == 403 || made.status == 404 {
", which the bot's token may not have access to"
} else {
""
};
return Err(PullRequestError(format!(
"could not fork {}/{}{}: {}",
target.owner,
target.name,
reach,
detail(&made.body)
)));
}
let fork = named(&made.body).ok_or_else(|| {
PullRequestError("GitHub accepted the fork but did not say where it put it".to_owned())
})?;
let deadline = Instant::now() + Duration::from_millis(FORK_WAIT_MS);
loop {
let there = api(
format!("/repos/{}/{}", fork.owner, fork.name),
ApiCall {
method: "GET".to_owned(),
token: token.to_owned(),
body: None,
},
)
.await;
if there.status == 200 {
return Ok(fork);
}
if Instant::now() >= deadline {
return Err(PullRequestError(format!(
"the fork {}/{} did not become available to push to",
fork.owner, fork.name
)));
}
sleep(FORK_POLL_MS).await;
}
}
async fn default_branch(api: &Api, token: &str, repo: &Repo) -> String {
let answer = api(
format!("/repos/{}/{}", repo.owner, repo.name),
ApiCall {
method: "GET".to_owned(),
token: token.to_owned(),
body: None,
},
)
.await;
answer
.body
.get("default_branch")
.and_then(Value::as_str)
.unwrap_or("main")
.to_owned()
}
async fn push_work(
run: &Run,
project_path: &str,
branch: &str,
url: &str,
token: &str,
) -> Result<String, PullRequestError> {
let staging = tempfile::tempdir()
.map_err(|error| PullRequestError(format!("could not prepare the push: {error}")))?;
let repository = staging.path().join("repository.git");
let cloned = git(
run,
staging.path().to_str().unwrap_or_default(),
&[
"clone",
"--shared",
"--bare",
"--quiet",
project_path,
&repository.display().to_string(),
],
None,
)
.await;
if cloned.code != 0 {
return Err(PullRequestError(format!(
"could not prepare the push: {}",
first_line(&cloned.stderr)
)));
}
let pushed = git(
run,
&repository.display().to_string(),
&[
"-c",
&format!("credential.helper={CREDENTIAL_HELPER}"),
"push",
"--force-with-lease",
url,
&format!("refs/heads/{branch}:refs/heads/{branch}"),
],
Some(token),
)
.await;
if pushed.code != 0 {
return Err(PullRequestError(format!(
"could not push {branch}: {}",
first_line(&pushed.stderr)
)));
}
let summary = git(
run,
&repository.display().to_string(),
&["log", "-1", "--format=%b"],
None,
)
.await;
Ok(summary.stdout)
}
pub async fn open_pull_request(
request: &Request,
run: &Run,
api: &Api,
sleep: &Sleep,
) -> Result<String, PullRequestError> {
let project_path = find_repository(&request.project_path, request.repository.as_deref())?;
assert_contained(run, &project_path).await?;
let branch = current_branch(run, &project_path).await?;
let target = upstream(run, &project_path).await?;
let fork = fork_of(api, &request.github.token, &target, sleep).await?;
let summary = push_work(
run,
&project_path,
&branch,
&format!("https://github.com/{}/{}.git", fork.owner, fork.name),
&request.github.token,
)
.await?;
let created = api(
format!("/repos/{}/{}/pulls", target.owner, target.name),
ApiCall {
method: "POST".to_owned(),
token: request.github.token.clone(),
body: Some(make_pull(
&request.title,
&fork.owner,
&branch,
&default_branch(api, &request.github.token, &target).await,
&pull_request_body(&summary, &request.requested_by, &request.links),
)),
},
)
.await;
if created.status >= 400 {
return Err(PullRequestError(format!(
"could not open the pull request: {}",
detail(&created.body)
)));
}
created
.body
.get("html_url")
.and_then(Value::as_str)
.map(str::to_owned)
.ok_or_else(|| {
PullRequestError("the pull request was created but GitHub did not say where".to_owned())
})
}
fn make_pull(title: &str, owner: &str, branch: &str, base: &str, body: &str) -> Value {
#[derive(Serialize)]
struct NewPull<'a> {
title: &'a str,
head: String,
base: &'a str,
body: &'a str,
maintainer_can_modify: bool,
}
json!(NewPull {
title,
head: format!("{owner}:{branch}"),
base,
body,
maintainer_can_modify: true,
})
}
pub fn pause(ms: u64) -> Pin<Box<dyn Future<Output = ()> + Send>> {
Box::pin(tokio::time::sleep(Duration::from_millis(ms)))
}
pub fn run_command(
command: Vec<String>,
cwd: Option<String>,
env: BTreeMap<String, String>,
) -> Pin<Box<dyn Future<Output = Ran> + Send>> {
Box::pin(async move {
let mut names = command.into_iter();
let program = names.next().unwrap_or_default();
let mut process = tokio::process::Command::new(&program);
process.args(names);
process.stdout(std::process::Stdio::piped());
process.stderr(std::process::Stdio::piped());
if let Some(cwd) = cwd {
process.current_dir(cwd);
}
process.env_clear();
process.env("PATH", std::env::var("PATH").unwrap_or_default());
process.env("HOME", std::env::var("HOME").unwrap_or_default());
for (name, value) in env {
process.env(name, value);
}
let output = match process.output().await {
Ok(output) => output,
Err(error) => {
return Ran {
code: 127,
stdout: String::new(),
stderr: error.to_string(),
};
}
};
Ran {
code: output.status.code().unwrap_or(127),
stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
}
})
}
const USER_AGENT: &str = concat!("errand/", env!("CARGO_PKG_VERSION"));
fn github_client() -> reqwest::Client {
reqwest::Client::builder()
.timeout(Duration::from_secs(30))
.user_agent(USER_AGENT)
.build()
.unwrap_or_default()
}
pub fn call_api(path: String, init: ApiCall) -> Pin<Box<dyn Future<Output = ApiReply> + Send>> {
Box::pin(async move {
let client = github_client();
let method =
reqwest::Method::from_bytes(init.method.as_bytes()).unwrap_or(reqwest::Method::GET);
let mut request = client
.request(method, format!("https://api.github.com{path}"))
.header("Authorization", format!("Bearer {}", init.token))
.header("Accept", "application/vnd.github+json")
.header("X-GitHub-Api-Version", "2022-11-28");
if let Some(body) = &init.body {
request = request
.header("Content-Type", "application/json")
.body(body.to_string());
}
match request.send().await {
Ok(response) => {
let status = response.status().as_u16();
let body = response.json::<Value>().await.unwrap_or_else(|_| json!({}));
ApiReply { status, body }
}
Err(error) => ApiReply {
status: 0,
body: json!({ "message": error.to_string() }),
},
}
})
}
#[cfg(test)]
mod tests;