use std::fs;
use std::io::{IsTerminal, Read};
use std::path::{Path, PathBuf};
use std::process::Command;
use anyhow::{anyhow, Result};
use inquire::{Select, Text};
use serde::Serialize;
use crate::cli::{
Cli, OutputFormat, PullCommand, PullCreateArgs, PullListArgs,
PullMergeArgs, PullReviewArgs, PullShowArgs,
};
pub async fn run(cli: &Cli, cmd: PullCommand) -> Result<()> {
match cmd {
PullCommand::List(args) => list(cli, args).await,
PullCommand::Create(args) => create(args).await,
PullCommand::Show(args) => show(cli, args).await,
PullCommand::Review(args) => review(args).await,
PullCommand::Merge(args) => merge(args).await,
}
}
async fn list(cli: &Cli, args: PullListArgs) -> Result<()> {
let session = crate::util::load_session_with_refresh().await?;
let auth = crate::ops::auth::PdsAuth::from_session(&session)?;
let pds = session
.pds
.clone()
.or_else(|| std::env::var("TANGLED_PDS_BASE").ok())
.unwrap_or_else(|| "https://bsky.social".into());
let target_repo_refs = if let Some(repo) = &args.repo {
let (owner, name) = parse_repo_ref(repo, &session.handle);
let info =
crate::ops::repo::get_repo_info(&pds, owner, name, &auth).await?;
Some(vec![info.issue_repo_ref(), info.repo_at_uri()])
} else {
None
};
let mut pulls =
crate::ops::pull::list_pulls(&pds, &session.did, None, &auth).await?;
if let Some(repo_refs) = target_repo_refs.as_ref() {
pulls.retain(|it| {
repo_refs
.iter()
.any(|repo_ref| repo_ref == it.pull.target_repo())
});
}
if matches!(cli.format, OutputFormat::Json | OutputFormat::Yaml) {
return crate::util::print_serialized(cli.format, &pulls);
}
if pulls.is_empty() {
println!("No pulls found (showing only those you created)");
} else {
let mut repo_cache: std::collections::HashMap<String, String> =
std::collections::HashMap::new();
let mut rows = Vec::with_capacity(pulls.len());
for item in pulls {
let target = item.pull.target_repo().to_string();
let target_display = if let Some(cached) = repo_cache.get(&target) {
cached.clone()
} else {
let display =
crate::ops::repo::repo_display_name(&pds, &target, &auth)
.await
.unwrap_or_else(|_| target.clone());
repo_cache.insert(target, display.clone());
display
};
rows.push([item.rkey, item.pull.title, target_display]);
}
crate::util::print_table(["RKEY", "TITLE", "TARGET"], rows);
}
Ok(())
}
async fn create(args: PullCreateArgs) -> Result<()> {
let session = crate::util::load_session_with_refresh().await?;
let auth = crate::ops::auth::PdsAuth::from_session(&session)?;
let pds = session
.pds
.clone()
.or_else(|| std::env::var("TANGLED_PDS_BASE").ok())
.unwrap_or_else(|| "https://bsky.social".into());
let inferred = if args.repo.is_none() {
Some(crate::util::current_git_repo_context()?)
} else {
None
};
let repo_spec;
let repo = if let Some(repo) = args.repo.as_ref() {
repo.as_str()
} else if let Some(context) = inferred.as_ref() {
repo_spec = context.repo_spec();
repo_spec.as_str()
} else {
return Err(anyhow!(
"--repo is required for pull create outside a Tangled checkout"
));
};
let (owner, name) = parse_repo_ref(repo, "");
let info =
crate::ops::repo::get_repo_info(&pds, owner, name, &auth).await?;
if let Some(context) = inferred.as_ref() {
validate_remote_matches_repo(context, &info)?;
}
let target = resolve_pull_target(
args.target.as_deref(),
&info,
&pds,
&session.handle,
&auth,
)
.await?;
let base_buf;
let base = if let Some(base) = args.base.as_deref() {
base
} else {
base_buf = inferred
.as_ref()
.and_then(|context| context.default_branch.clone())
.unwrap_or_else(|| "main".to_string());
base_buf.as_str()
};
let head_buf;
let head = if let Some(head) = args.head.as_deref() {
head
} else {
head_buf = inferred
.as_ref()
.and_then(|context| context.current_branch.clone())
.ok_or_else(|| {
anyhow!("could not infer current branch; pass --head")
})?;
head_buf.as_str()
};
if let Some(context) = inferred.as_ref() {
ensure_source_branch_pushed(context, head)?;
}
let patch = format_patch_series(base, head, Path::new("."))?;
if patch.trim().is_empty() {
return Err(anyhow!("no changes between base and head"));
}
let title_buf = resolve_title(args.title.as_deref(), head, base)?;
let repo_root = git2::Repository::discover(".")
.ok()
.and_then(|repo| repo.workdir().map(Path::to_path_buf));
let body_buf = resolve_body(&args, repo_root.as_deref())?;
let source_repo_ref =
match (info.repo_did.as_deref(), target.repo_did.as_str()) {
(Some(source), target) if source != target => Some(source),
_ => None,
};
let rkey = crate::ops::pull::create_pull(
&pds,
&auth,
&session.did,
&target.repo_did,
base,
source_repo_ref,
head,
&patch,
title_buf.as_str(),
body_buf.as_deref(),
)
.await?;
println!(
"Created pull rkey={} targeting {} branch {}",
rkey, target.display, base
);
let url = resolve_pull_list_url(&target, &pds, &auth).await;
if let Some(url) = url {
println!(
"Open pull: {} (rkey {}; it may take a moment to appear)",
url, rkey
);
}
Ok(())
}
#[derive(Debug, Clone)]
struct PullTarget {
display: String,
repo_did: String,
legacy_at_uri: Option<String>,
web_path: Option<String>,
}
impl PullTarget {
fn pull_list_url(&self) -> Option<String> {
let path = self.web_path.as_deref()?;
let base = std::env::var("TANGLED_WEB_BASE")
.unwrap_or_else(|_| "https://tangled.org".to_string());
Some(format!("{}/{}/pulls", base.trim_end_matches('/'), path))
}
}
async fn resolve_pull_target(
requested: Option<&str>,
source: &crate::ops::types::RepoRecord,
pds_base: &str,
default_owner: &str,
auth: &crate::ops::auth::PdsAuth,
) -> Result<PullTarget> {
if let Some(target) = requested {
return pull_target_from_spec(
target,
source,
pds_base,
default_owner,
auth,
)
.await;
}
if source.source.is_some() && std::io::stdin().is_terminal() {
let choices = vec![
format!("this repo ({})", source.name),
format!(
"fork source ({})",
source.source.as_deref().unwrap_or_default()
),
];
let selected =
Select::new("Target repository", choices.clone()).prompt()?;
if selected == choices[1] {
return pull_target_from_spec(
"source",
source,
pds_base,
default_owner,
auth,
)
.await;
}
}
pull_target_from_repo_record(source, pds_base, auth).await
}
async fn pull_target_from_spec(
spec: &str,
source: &crate::ops::types::RepoRecord,
pds_base: &str,
default_owner: &str,
auth: &crate::ops::auth::PdsAuth,
) -> Result<PullTarget> {
match spec {
"self" => pull_target_from_repo_record(source, pds_base, auth).await,
"source" => {
let repo_did = source.source.clone().ok_or_else(|| {
anyhow!("current repo does not declare a fork source")
})?;
Ok(PullTarget {
display: repo_did.clone(),
repo_did,
legacy_at_uri: None,
web_path: None,
})
}
did if did.starts_with("did:") => Ok(PullTarget {
display: did.to_string(),
repo_did: did.to_string(),
legacy_at_uri: None,
web_path: None,
}),
repo_ref => {
let (owner, name) = parse_repo_ref(repo_ref, default_owner);
let info =
crate::ops::repo::get_repo_info(pds_base, owner, name, auth)
.await?;
pull_target_from_repo_record(&info, pds_base, auth).await
}
}
}
async fn pull_target_from_repo_record(
info: &crate::ops::types::RepoRecord,
pds_base: &str,
auth: &crate::ops::auth::PdsAuth,
) -> Result<PullTarget> {
let repo_did = info.repo_did.clone().ok_or_else(|| {
anyhow!(
"repo {} has no repoDid; cannot create a modern pull",
info.name
)
})?;
let owner =
crate::ops::repo::resolve_did_to_handle(pds_base, &info.did, auth)
.await
.unwrap_or_else(|_| info.did.clone());
let slug = repo_slug(info.name.as_str(), info.rkey.as_str());
Ok(PullTarget {
display: repo_did,
repo_did: info.repo_did.clone().unwrap_or_default(),
legacy_at_uri: Some(info.repo_at_uri()),
web_path: Some(format!("{}/{}", owner, slug)),
})
}
async fn resolve_pull_list_url(
target: &PullTarget,
pds_base: &str,
auth: &crate::ops::auth::PdsAuth,
) -> Option<String> {
if let Some(url) = target.pull_list_url() {
return Some(url);
}
let described =
describe_repo_for_web_url(&target.repo_did, pds_base, auth).await;
if let Ok((owner_did, rkey, name)) = described {
let owner =
crate::ops::repo::resolve_did_to_handle(pds_base, &owner_did, auth)
.await
.unwrap_or(owner_did);
let slug = repo_slug(name.as_deref().unwrap_or(""), &rkey);
return PullTarget {
display: target.display.clone(),
repo_did: target.repo_did.clone(),
legacy_at_uri: target.legacy_at_uri.clone(),
web_path: Some(format!("{}/{}", owner, slug)),
}
.pull_list_url();
}
None
}
async fn describe_repo_for_web_url(
repo_did: &str,
pds_base: &str,
auth: &crate::ops::auth::PdsAuth,
) -> Result<(String, String, Option<String>)> {
let resolver_base = std::env::var("TANGLED_API_BASE")
.unwrap_or_else(|_| crate::ops::DEFAULT_API_BASE.to_string());
let described =
crate::ops::repo::describe_repo(&resolver_base, repo_did, None).await?;
let repo = crate::ops::repo::get_repo_by_rkey(
pds_base,
&described.owner_did,
&described.rkey,
auth,
)
.await
.ok();
Ok((
described.owner_did,
described.rkey,
repo.map(|repo| repo.name),
))
}
fn repo_slug(name: &str, rkey: &str) -> String {
if name.is_empty() {
rkey.to_string()
} else {
name.to_string()
}
}
fn resolve_title(
provided: Option<&str>,
head: &str,
base: &str,
) -> Result<String> {
if let Some(title) = provided {
return Ok(title.to_string());
}
let default = format!("{} -> {}", head, base);
if std::io::stdin().is_terminal() {
return Text::new("Title")
.with_default(&default)
.prompt()
.map_err(Into::into);
}
Ok(default)
}
fn resolve_body(
args: &PullCreateArgs,
repo_root: Option<&Path>,
) -> Result<Option<String>> {
if let Some(body) = args.body.as_deref() {
return Ok(Some(body.to_string()));
}
if let Some(path) = args.body_file.as_deref() {
return read_body_file(path).map(Some);
}
let templates = if args.no_template {
Vec::new()
} else if let Some(root) = repo_root {
discover_pull_templates(root)?
} else {
Vec::new()
};
let initial = if let Some(template) = args.template.as_deref() {
Some(read_requested_template(&templates, template)?)
} else if std::io::stdin().is_terminal() {
select_template_body(&templates)?
} else if templates.len() == 1 {
Some(fs::read_to_string(&templates[0].path)?)
} else {
None
};
if args.editor {
return crate::commands::edit_body(initial.as_deref().unwrap_or(""));
}
if std::io::stdin().is_terminal() {
return crate::commands::prompt_body_editor_or_skip(initial.as_deref());
}
Ok(initial.filter(|body| !body.trim().is_empty()))
}
fn validate_remote_matches_repo(
context: &crate::util::GitRepoContext,
info: &crate::ops::types::RepoRecord,
) -> Result<()> {
let host = context.host.as_str();
let repo_knot = info.knot.as_str();
let compatible = host == "tangled.org" && repo_knot == "knot1.tangled.sh"
|| host == repo_knot;
if compatible {
Ok(())
} else {
Err(anyhow!(
"inferred remote {} points at {}, but PDS record for {} uses {}; pass --repo explicitly",
context.remote_name,
context.host,
context.repo_spec(),
info.knot
))
}
}
fn ensure_source_branch_pushed(
context: &crate::util::GitRepoContext,
branch: &str,
) -> Result<()> {
let status = Command::new("git")
.arg("ls-remote")
.arg("--exit-code")
.arg("--heads")
.arg(&context.remote_url)
.arg(format!("refs/heads/{}", branch))
.output()?;
if status.status.success() {
return Ok(());
}
if status.status.code() == Some(2) {
return Err(anyhow!(
"source branch {} is not on remote {}; push it first with: git push -u {} {}",
branch,
context.remote_name,
context.remote_name,
branch
));
}
let stderr = String::from_utf8_lossy(&status.stderr);
Err(anyhow!(
"could not check whether source branch {} exists on {}: {}",
branch,
context.remote_name,
stderr.trim()
))
}
fn format_patch_series(base: &str, head: &str, cwd: &Path) -> Result<String> {
let revs = Command::new("git")
.arg("rev-list")
.arg("--reverse")
.arg("--no-merges")
.arg(format!("{}..{}", base, head))
.current_dir(cwd)
.output()?;
if !revs.status.success() {
let stderr = String::from_utf8_lossy(&revs.stderr);
return Err(anyhow!(
"failed to list commits between {} and {}: {}",
base,
head,
stderr.trim()
));
}
let commits = String::from_utf8_lossy(&revs.stdout)
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.map(str::to_string)
.collect::<Vec<_>>();
let mut patch = String::new();
for (idx, commit) in commits.iter().enumerate() {
if idx > 0 {
patch.push('\n');
}
let output = Command::new("git")
.arg("format-patch")
.arg("-1")
.arg(commit)
.arg("--stdout")
.current_dir(cwd)
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(anyhow!(
"failed to format patch for commit {}: {}",
commit,
stderr.trim()
));
}
patch.push_str(&String::from_utf8_lossy(&output.stdout));
if !patch.ends_with('\n') {
patch.push('\n');
}
}
Ok(patch)
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct PullTemplate {
path: PathBuf,
repo_relative_path: String,
label: String,
}
fn discover_pull_templates(repo_root: &Path) -> Result<Vec<PullTemplate>> {
let mut templates = Vec::new();
for root in ["", "docs", ".github"] {
let dir = if root.is_empty() {
repo_root.to_path_buf()
} else {
repo_root.join(root)
};
if !dir.is_dir() {
continue;
}
for entry in fs::read_dir(&dir)? {
let entry = entry?;
let path = entry.path();
if path.is_file() && is_default_pull_template(&path) {
templates.push(template_from_path(repo_root, path)?);
}
}
for entry in fs::read_dir(&dir)? {
let entry = entry?;
let path = entry.path();
if !path.is_dir() || !is_pull_template_dir(&path) {
continue;
}
for child in fs::read_dir(path)? {
let child = child?;
let child_path = child.path();
if child_path.is_file()
&& is_supported_template_file(&child_path)
{
templates.push(template_from_path(repo_root, child_path)?);
}
}
}
}
templates.sort_by(|a, b| a.repo_relative_path.cmp(&b.repo_relative_path));
Ok(templates)
}
fn is_default_pull_template(path: &Path) -> bool {
path.file_stem()
.and_then(|stem| stem.to_str())
.is_some_and(|stem| stem.eq_ignore_ascii_case("PULL_REQUEST_TEMPLATE"))
&& is_supported_template_file(path)
}
fn is_pull_template_dir(path: &Path) -> bool {
path.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.eq_ignore_ascii_case("PULL_REQUEST_TEMPLATE"))
}
fn is_supported_template_file(path: &Path) -> bool {
path.extension()
.and_then(|ext| ext.to_str())
.is_some_and(|ext| {
ext.eq_ignore_ascii_case("md") || ext.eq_ignore_ascii_case("txt")
})
}
fn template_from_path(repo_root: &Path, path: PathBuf) -> Result<PullTemplate> {
let repo_relative_path = path
.strip_prefix(repo_root)?
.to_string_lossy()
.replace('\\', "/");
let label = path
.file_stem()
.and_then(|stem| stem.to_str())
.unwrap_or(&repo_relative_path)
.to_string();
Ok(PullTemplate {
path,
repo_relative_path,
label,
})
}
fn read_requested_template(
templates: &[PullTemplate],
requested: &str,
) -> Result<String> {
let template = templates
.iter()
.find(|template| {
template.label.eq_ignore_ascii_case(requested)
|| template.repo_relative_path.eq_ignore_ascii_case(requested)
|| template
.path
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.eq_ignore_ascii_case(requested))
})
.ok_or_else(|| anyhow!("pull template not found: {}", requested))?;
Ok(fs::read_to_string(&template.path)?)
}
fn select_template_body(templates: &[PullTemplate]) -> Result<Option<String>> {
if templates.is_empty() {
return Ok(None);
}
let mut choices = templates
.iter()
.map(|template| template.repo_relative_path.clone())
.collect::<Vec<_>>();
choices.push("No template".to_string());
let selected = Select::new("Pull template", choices).prompt()?;
if selected == "No template" {
Ok(None)
} else {
let template = templates
.iter()
.find(|template| template.repo_relative_path == selected)
.ok_or_else(|| anyhow!("pull template not found: {}", selected))?;
Ok(Some(fs::read_to_string(&template.path)?))
}
}
fn read_body_file(path: &str) -> Result<String> {
if path == "-" {
let mut body = String::new();
std::io::stdin().read_to_string(&mut body)?;
Ok(body)
} else {
Ok(fs::read_to_string(path)?)
}
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct PullShowOutput<'a> {
author_did: &'a str,
rkey: &'a str,
pull: &'a crate::ops::types::Pull,
#[serde(skip_serializing_if = "Option::is_none")]
patch: Option<&'a str>,
}
async fn show(cli: &Cli, args: PullShowArgs) -> Result<()> {
let session = crate::util::load_session_with_refresh().await?;
let auth = crate::ops::auth::PdsAuth::from_session(&session)?;
let (did, rkey) = parse_record_id(&args.id, &session.did)?;
let pds = session
.pds
.clone()
.or_else(|| std::env::var("TANGLED_PDS_BASE").ok())
.unwrap_or_else(|| "https://bsky.social".into());
let pull =
crate::ops::pull::get_pull_record(&pds, &did, &rkey, &auth).await?;
let patch = if args.diff {
Some(crate::ops::pull::pull_patch(&pds, &did, &pull, &auth).await?)
} else {
None
};
if matches!(cli.format, OutputFormat::Json | OutputFormat::Yaml) {
let output = PullShowOutput {
author_did: &did,
rkey: &rkey,
pull: &pull,
patch: patch.as_deref(),
};
return crate::util::print_serialized(cli.format, &output);
}
println!("TITLE: {}", pull.title);
if !pull.body.is_empty() {
println!("BODY:\n{}", pull.body);
}
println!("TARGET: {} @ {}", pull.target_repo(), pull.target_branch());
if let Some(patch) = patch {
println!("PATCH:\n{}", patch);
}
Ok(())
}
async fn review(args: PullReviewArgs) -> Result<()> {
let session = crate::util::load_session_with_refresh().await?;
let auth = crate::ops::auth::PdsAuth::from_session(&session)?;
let (did, rkey) = parse_record_id(&args.id, &session.did)?;
let pds = session
.pds
.clone()
.or_else(|| std::env::var("TANGLED_PDS_BASE").ok())
.unwrap_or_else(|| "https://bsky.social".into());
let pr_at = format!("at://{}/sh.tangled.repo.pull/{}", did, rkey);
let note = if let Some(c) = args.comment.as_deref() {
c
} else if args.approve {
"LGTM"
} else if args.request_changes {
"Requesting changes"
} else {
""
};
if note.is_empty() {
return Err(anyhow!(
"provide --comment or --approve/--request-changes"
));
}
crate::ops::pull::comment_pull(&pds, &auth, &session.did, &pr_at, note)
.await?;
println!("Review comment posted");
Ok(())
}
async fn merge(args: PullMergeArgs) -> Result<()> {
let session = crate::util::load_session_with_refresh().await?;
let auth = crate::ops::auth::PdsAuth::from_session(&session)?;
let (did, rkey) = parse_record_id(&args.id, &session.did)?;
let pds = session
.pds
.clone()
.or_else(|| std::env::var("TANGLED_PDS_BASE").ok())
.unwrap_or_else(|| "https://bsky.social".into());
let pull =
crate::ops::pull::get_pull_record(&pds, &did, &rkey, &auth).await?;
let target = resolve_merge_target(&pds, &pull, &auth).await?;
crate::ops::pull::merge_pull(
&target.knot,
&did,
&rkey,
&target.owner_did,
&target.name,
&pds,
&auth,
)
.await?;
println!("Merged pull {}:{}", did, rkey);
Ok(())
}
#[derive(Debug, Clone)]
struct MergeTarget {
owner_did: String,
name: String,
knot: String,
}
async fn resolve_merge_target(
pds_base: &str,
pull: &crate::ops::types::Pull,
auth: &crate::ops::auth::PdsAuth,
) -> Result<MergeTarget> {
let target_repo = pull
.target_repo
.as_deref()
.filter(|repo| repo.starts_with("at://"))
.unwrap_or_else(|| pull.target_repo());
let (owner_did, repo_rkey) = if target_repo.starts_with("at://") {
parse_repo_at_uri(target_repo)?
} else if target_repo.starts_with("did:") {
let resolver_base = std::env::var("TANGLED_API_BASE")
.unwrap_or_else(|_| crate::ops::DEFAULT_API_BASE.to_string());
let described =
crate::ops::repo::describe_repo(&resolver_base, target_repo, None)
.await?;
(described.owner_did, described.rkey)
} else {
return Err(anyhow!("Invalid target repo reference: {}", target_repo));
};
let repo = crate::ops::repo::get_repo_by_rkey(
pds_base, &owner_did, &repo_rkey, auth,
)
.await?;
let name = if repo.name.is_empty() {
repo_rkey.clone()
} else {
repo.name
};
let knot = repo.knot.ok_or_else(|| {
anyhow!("target repo record {} has no knot", repo_rkey)
})?;
Ok(MergeTarget {
owner_did,
name,
knot,
})
}
fn parse_repo_at_uri(uri: &str) -> Result<(String, String)> {
let parts: Vec<&str> = uri
.strip_prefix("at://")
.unwrap_or(uri)
.split('/')
.collect();
if parts.len() < 3 || parts[1] != "sh.tangled.repo" {
return Err(anyhow!("Invalid target repo AT-URI: {}", uri));
}
Ok((parts[0].to_string(), parts[2].to_string()))
}
fn parse_repo_ref<'a>(
spec: &'a str,
default_owner: &'a str,
) -> (&'a str, &'a str) {
if let Some((owner, name)) = spec.split_once('/') {
if !owner.is_empty() {
(owner, name)
} else {
(default_owner, name)
}
} else {
(default_owner, spec)
}
}
fn parse_record_id<'a>(
id: &'a str,
default_did: &'a str,
) -> Result<(String, String)> {
if let Some(rest) = id.strip_prefix("at://") {
let parts: Vec<&str> = rest.split('/').collect();
if parts.len() >= 4 {
return Ok((parts[0].to_string(), parts[3].to_string()));
}
}
if let Some((did, rkey)) = id.split_once(':') {
return Ok((did.to_string(), rkey.to_string()));
}
Ok((default_did.to_string(), id.to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn discovers_default_and_named_pull_templates_case_insensitively() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
fs::write(root.join("PULL_REQUEST_TEMPLATE.MD"), "root").unwrap();
fs::create_dir(root.join(".github")).unwrap();
fs::write(
root.join(".github").join("pull_request_template.txt"),
"github",
)
.unwrap();
fs::create_dir(root.join("docs")).unwrap();
fs::create_dir(root.join("docs").join("Pull_Request_Template"))
.unwrap();
fs::write(
root.join("docs")
.join("Pull_Request_Template")
.join("feature.Md"),
"feature",
)
.unwrap();
fs::write(root.join("docs").join("ISSUE_TEMPLATE.md"), "ignore")
.unwrap();
let templates = discover_pull_templates(root).unwrap();
let paths = templates
.iter()
.map(|template| template.repo_relative_path.as_str())
.collect::<Vec<_>>();
assert_eq!(
paths,
vec![
".github/pull_request_template.txt",
"PULL_REQUEST_TEMPLATE.MD",
"docs/Pull_Request_Template/feature.Md"
]
);
}
#[test]
fn explicit_template_can_match_label_file_name_or_repo_relative_path() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
fs::create_dir(root.join(".github")).unwrap();
fs::create_dir(root.join(".github").join("PULL_REQUEST_TEMPLATE"))
.unwrap();
fs::write(
root.join(".github")
.join("PULL_REQUEST_TEMPLATE")
.join("bug.md"),
"bug template",
)
.unwrap();
let templates = discover_pull_templates(root).unwrap();
assert_eq!(
read_requested_template(&templates, "bug").unwrap(),
"bug template"
);
assert_eq!(
read_requested_template(&templates, "BUG.MD").unwrap(),
"bug template"
);
assert_eq!(
read_requested_template(
&templates,
".github/PULL_REQUEST_TEMPLATE/bug.md"
)
.unwrap(),
"bug template"
);
}
#[tokio::test]
async fn target_source_uses_fork_source_repo_did() {
let source = crate::ops::types::RepoRecord {
did: "did:plc:owner".to_string(),
name: "fork".to_string(),
rkey: "fork".to_string(),
knot: "knot1.tangled.sh".to_string(),
description: None,
source: Some("did:plc:upstream".to_string()),
spindle: None,
repo_did: Some("did:plc:fork".to_string()),
};
let target = pull_target_from_spec(
"source",
&source,
"https://bsky.social",
"owner.test",
&crate::ops::auth::PdsAuth::None,
)
.await
.unwrap();
assert_eq!(target.repo_did, "did:plc:upstream");
assert_eq!(target.legacy_at_uri, None);
}
#[test]
fn parses_legacy_repo_at_uri_for_merge_target() {
let (owner, rkey) =
parse_repo_at_uri("at://did:plc:owner/sh.tangled.repo/tangled-cli")
.unwrap();
assert_eq!(owner, "did:plc:owner");
assert_eq!(rkey, "tangled-cli");
assert!(
parse_repo_at_uri("at://did:plc:owner/sh.tangled.issue/1").is_err()
);
}
#[test]
fn pull_target_formats_web_pulls_url() {
let target = PullTarget {
display: "did:plc:repo".to_string(),
repo_did: "did:plc:repo".to_string(),
legacy_at_uri: None,
web_path: Some("dzejkop.bsky.social/tangled-cli".to_string()),
};
assert_eq!(
target.pull_list_url().as_deref(),
Some("https://tangled.org/dzejkop.bsky.social/tangled-cli/pulls")
);
}
#[test]
fn format_patch_series_matches_tangled_single_commit_mbox_shape() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
git(root, &["init"]);
git(root, &["config", "user.name", "Tangled Test"]);
git(root, &["config", "user.email", "test@example.com"]);
fs::write(root.join("file.txt"), "base\n").unwrap();
git(root, &["add", "file.txt"]);
git(root, &["commit", "-m", "base"]);
let base = git_stdout(root, &["rev-parse", "HEAD"]);
fs::write(root.join("file.txt"), "base\nfirst\n").unwrap();
git(root, &["commit", "-am", "first change"]);
fs::write(root.join("other.txt"), "second\n").unwrap();
git(root, &["add", "other.txt"]);
git(root, &["commit", "-m", "second change"]);
let patch = format_patch_series(&base, "HEAD", root).unwrap();
let from_count =
patch.lines().filter(|line| is_mbox_separator(line)).count();
assert_eq!(
from_count, 2,
"expected one mbox section per non-base commit:\n{}",
patch
);
assert!(
patch.contains("Subject: [PATCH] first change"),
"first commit should be encoded as a single unnumbered patch:\n{}",
patch
);
assert!(
patch.contains("Subject: [PATCH] second change"),
"second commit should be encoded as a single unnumbered patch:\n{}",
patch
);
assert!(
!patch.contains("[PATCH 1/2]") && !patch.contains("[PATCH 2/2]"),
"Tangled appview accepts the unnumbered single-commit mbox shape, not a numbered series:\n{}",
patch
);
let mut blank_lines_before_following_sections = Vec::new();
let mut seen_section = false;
let mut trailing_blank_lines = 0;
for line in patch.lines() {
if is_mbox_separator(line) {
if seen_section {
blank_lines_before_following_sections
.push(trailing_blank_lines);
}
seen_section = true;
trailing_blank_lines = 0;
} else if line.is_empty() {
trailing_blank_lines += 1;
} else {
trailing_blank_lines = 0;
}
}
assert_eq!(
blank_lines_before_following_sections,
vec![2],
"web-created Tangled blobs separate appended single-commit mboxes with two blank lines:\n{}",
patch
);
}
fn git(cwd: &Path, args: &[&str]) {
let output = Command::new("git")
.args(args)
.current_dir(cwd)
.output()
.unwrap();
assert!(
output.status.success(),
"git {:?} failed\nstdout:\n{}\nstderr:\n{}",
args,
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
fn git_stdout(cwd: &Path, args: &[&str]) -> String {
let output = Command::new("git")
.args(args)
.current_dir(cwd)
.output()
.unwrap();
assert!(
output.status.success(),
"git {:?} failed\nstdout:\n{}\nstderr:\n{}",
args,
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8(output.stdout).unwrap().trim().to_string()
}
fn is_mbox_separator(line: &str) -> bool {
line.starts_with("From ")
&& line.ends_with(" Mon Sep 17 00:00:00 2001")
&& line
.strip_prefix("From ")
.and_then(|rest| rest.split_once(' '))
.is_some_and(|(sha, _)| {
sha.len() == 40
&& sha.chars().all(|ch| ch.is_ascii_hexdigit())
})
}
}