mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
## Summary - adopt the finalized NIP-MP project model so one project can enumerate and switch between multiple NIP-34 repositories - add project and repository navigation, activity summaries, existing-repository attachment, and repository access-channel management - preserve privacy-safe activation provenance for agent-authored patches, pull requests, issues, and associated commits ## Test plan - [x] Run desktop typecheck and unit tests - [x] Run focused NIP-MP, repository access, and provenance tests - [x] Run Rust formatting and desktop lint checks - [x] Run the complete pre-push suite after merging current `main` - [ ] Manually verify project creation, repository attachment, switching, and access repair on staging - [ ] Manually verify public-channel and private-agent origin labels on newly created Git activity Related: [#4695](https://github.com/block/buzz/pull/4695) --------- Signed-off-by: Thomas Petersen <thomasp@squareup.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com> Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz> Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
886 lines
29 KiB
Rust
886 lines
29 KiB
Rust
//! Local clone and pull-request merge commands for the Projects workflow.
|
|
|
|
use super::project_git::{first_output_line, normalize_branch_option};
|
|
use super::project_git_diff::clean_commit;
|
|
use super::project_git_exec::{
|
|
build_git_auth_config_for_keys, build_git_clone_auth_config, clone_url_owner, run_git,
|
|
validate_local_clone_url, validate_local_clone_url_for_workspace, validate_workspace_clone_url,
|
|
GitAuthConfig,
|
|
};
|
|
use super::project_repo_paths::{
|
|
canonical_repos_roots, canonicalize_repos_root, default_repos_root_candidates,
|
|
find_local_repo_dir, local_repo_candidates,
|
|
};
|
|
use crate::app_state::AppState;
|
|
use crate::managed_agents::{load_managed_agents, spawn_key_refusal};
|
|
use crate::relay::submit_signed_event_with_keys;
|
|
use nostr::{Event, EventBuilder, JsonUtil, Keys, Kind, Tag, Timestamp};
|
|
use serde::{Deserialize, Serialize};
|
|
use tauri::{AppHandle, State};
|
|
|
|
#[derive(Serialize)]
|
|
pub struct ProjectRepoCloneResult {
|
|
pub path: String,
|
|
pub cloned: bool,
|
|
pub message: String,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
pub struct ProjectRepoMergeResult {
|
|
pub message: String,
|
|
pub merge_commit: String,
|
|
pub status_event: String,
|
|
pub status_publication_error: Option<String>,
|
|
}
|
|
|
|
/// Machine-readable pull-request merge failure types live in
|
|
/// [`super::project_git_merge_error`]; re-imported here for the merge
|
|
/// workflow below.
|
|
use super::project_git_merge_error::{classify_merge_error, ProjectPullRequestMergeError};
|
|
|
|
struct ProjectRepoMergeGitResult {
|
|
message: String,
|
|
merge_commit: String,
|
|
}
|
|
|
|
/// Validated repository and pull-request metadata for a native merge.
|
|
#[derive(Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct ProjectPullRequestMergeInput {
|
|
target_clone_url: String,
|
|
source_clone_url: String,
|
|
target_owner: String,
|
|
repo_address: String,
|
|
pull_request_id: String,
|
|
pull_request_author: String,
|
|
status_created_at: u64,
|
|
target_branch: String,
|
|
source_branch: String,
|
|
expected_commit: String,
|
|
}
|
|
|
|
/// Repository-scoped metadata for an agent-signed review request.
|
|
#[derive(Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct ProjectPullRequestReviewRequestInput {
|
|
target_owner: String,
|
|
repo_address: String,
|
|
pull_request_id: String,
|
|
reviewers: Vec<String>,
|
|
reviewer_label: String,
|
|
}
|
|
|
|
/// Repository-scoped metadata for an agent-signed lifecycle status.
|
|
#[derive(Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct ProjectPullRequestStatusInput {
|
|
target_owner: String,
|
|
repo_address: String,
|
|
pull_request_id: String,
|
|
pull_request_author: String,
|
|
status: String,
|
|
created_at: u64,
|
|
}
|
|
|
|
/// A previously signed merged-status event that needs publishing again.
|
|
#[derive(Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct ProjectPullRequestMergedStatusInput {
|
|
target_owner: String,
|
|
status_event: String,
|
|
}
|
|
|
|
fn normalize_commit(value: &str) -> Option<String> {
|
|
clean_commit(Some(value.trim().to_ascii_lowercase()))
|
|
}
|
|
|
|
fn normalize_event_id(value: &str) -> Option<String> {
|
|
let value = value.trim().to_ascii_lowercase();
|
|
(value.len() == 64 && value.chars().all(|c| c.is_ascii_hexdigit())).then_some(value)
|
|
}
|
|
|
|
struct ProjectOwnerIdentity {
|
|
keys: Keys,
|
|
auth_tag: Option<String>,
|
|
}
|
|
|
|
fn project_owner_identity(
|
|
app: &AppHandle,
|
|
state: &AppState,
|
|
target_owner: &str,
|
|
) -> Result<ProjectOwnerIdentity, String> {
|
|
let viewer_keys = state.signing_keys()?;
|
|
if viewer_keys.public_key().to_hex() == target_owner {
|
|
return Ok(ProjectOwnerIdentity {
|
|
keys: viewer_keys,
|
|
auth_tag: None,
|
|
});
|
|
}
|
|
|
|
let _store_guard = state
|
|
.managed_agents_store_lock
|
|
.lock()
|
|
.map_err(|error| error.to_string())?;
|
|
let records = load_managed_agents(app)?;
|
|
let record = records
|
|
.iter()
|
|
.find(|record| record.pubkey.eq_ignore_ascii_case(target_owner))
|
|
.ok_or_else(|| {
|
|
"Only the repository owner or the owner of its managed agent can merge pull requests."
|
|
.to_string()
|
|
})?;
|
|
if let Some(error) = spawn_key_refusal(record) {
|
|
return Err(error);
|
|
}
|
|
let keys = Keys::parse(&record.private_key_nsec)
|
|
.map_err(|error| format!("managed agent signing key is invalid: {error}"))?;
|
|
if keys.public_key().to_hex() != target_owner {
|
|
return Err("Managed agent key does not match the repository owner.".to_string());
|
|
}
|
|
Ok(ProjectOwnerIdentity {
|
|
keys,
|
|
auth_tag: record.auth_tag.clone(),
|
|
})
|
|
}
|
|
|
|
fn validate_repo_address(repo_address: &str, owner: &str) -> Result<(), String> {
|
|
let prefix = format!("30617:{owner}:");
|
|
if repo_address.strip_prefix(&prefix).is_none_or(str::is_empty) {
|
|
return Err("Repository address does not match the repository owner.".to_string());
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn validate_merge_status_metadata(
|
|
repo_address: &str,
|
|
owner: &str,
|
|
pull_request_id: &str,
|
|
pull_request_author: &str,
|
|
) -> Result<(String, String), String> {
|
|
validate_repo_address(repo_address, owner)?;
|
|
let pull_request_id = normalize_event_id(pull_request_id)
|
|
.ok_or_else(|| "Invalid pull request event ID.".to_string())?;
|
|
let pull_request_author = normalize_event_id(pull_request_author)
|
|
.ok_or_else(|| "Invalid pull request author.".to_string())?;
|
|
Ok((pull_request_id, pull_request_author))
|
|
}
|
|
|
|
fn build_merged_status_event(
|
|
keys: &Keys,
|
|
repo_address: &str,
|
|
pull_request_id: &str,
|
|
pull_request_author: &str,
|
|
merge_commit: &str,
|
|
created_at: u64,
|
|
) -> Result<String, String> {
|
|
let owner = keys.public_key().to_hex();
|
|
let (pull_request_id, pull_request_author) =
|
|
validate_merge_status_metadata(repo_address, &owner, pull_request_id, pull_request_author)?;
|
|
let merge_commit =
|
|
normalize_commit(merge_commit).ok_or_else(|| "Invalid merge commit.".to_string())?;
|
|
let created_at = created_at.max(Timestamp::now().as_secs());
|
|
|
|
let mut raw_tags = vec![
|
|
vec!["e", pull_request_id.as_str(), "", "root"],
|
|
vec!["a", repo_address],
|
|
vec!["p", owner.as_str()],
|
|
];
|
|
if pull_request_author != owner {
|
|
raw_tags.push(vec!["p", pull_request_author.as_str()]);
|
|
}
|
|
raw_tags.extend([
|
|
vec!["merge-commit", merge_commit.as_str()],
|
|
vec!["r", merge_commit.as_str()],
|
|
]);
|
|
let tags = raw_tags
|
|
.into_iter()
|
|
.map(Tag::parse)
|
|
.collect::<Result<Vec<_>, _>>()
|
|
.map_err(|error| format!("build merged status tags: {error}"))?;
|
|
EventBuilder::new(Kind::Custom(1631), "")
|
|
.tags(tags)
|
|
.custom_created_at(Timestamp::from(created_at))
|
|
.sign_with_keys(keys)
|
|
.map(|event| event.as_json())
|
|
.map_err(|error| format!("sign merged pull request status: {error}"))
|
|
}
|
|
|
|
fn build_pull_request_status_event(
|
|
keys: &Keys,
|
|
repo_address: &str,
|
|
pull_request_id: &str,
|
|
pull_request_author: &str,
|
|
status: &str,
|
|
created_at: u64,
|
|
) -> Result<String, String> {
|
|
let owner = keys.public_key().to_hex();
|
|
let (pull_request_id, pull_request_author) =
|
|
validate_merge_status_metadata(repo_address, &owner, pull_request_id, pull_request_author)?;
|
|
let kind = match status {
|
|
"open" => Kind::Custom(1630),
|
|
"closed" => Kind::Custom(1632),
|
|
"draft" => Kind::Custom(1633),
|
|
_ => return Err("Invalid pull request lifecycle status.".to_string()),
|
|
};
|
|
let mut raw_tags = vec![
|
|
vec!["e", pull_request_id.as_str(), "", "root"],
|
|
vec!["a", repo_address],
|
|
vec!["p", owner.as_str()],
|
|
];
|
|
if pull_request_author != owner {
|
|
raw_tags.push(vec!["p", pull_request_author.as_str()]);
|
|
}
|
|
let tags = raw_tags
|
|
.into_iter()
|
|
.map(Tag::parse)
|
|
.collect::<Result<Vec<_>, _>>()
|
|
.map_err(|error| format!("build pull request status tags: {error}"))?;
|
|
EventBuilder::new(kind, "")
|
|
.tags(tags)
|
|
.custom_created_at(Timestamp::from(created_at.max(Timestamp::now().as_secs())))
|
|
.sign_with_keys(keys)
|
|
.map(|event| event.as_json())
|
|
.map_err(|error| format!("sign pull request status: {error}"))
|
|
}
|
|
|
|
fn build_review_request_event(
|
|
keys: &Keys,
|
|
repo_address: &str,
|
|
pull_request_id: &str,
|
|
reviewers: &[String],
|
|
reviewer_label: &str,
|
|
) -> Result<String, String> {
|
|
let owner = keys.public_key().to_hex();
|
|
validate_repo_address(repo_address, &owner)?;
|
|
let pull_request_id = normalize_event_id(pull_request_id)
|
|
.ok_or_else(|| "Invalid pull request event ID.".to_string())?;
|
|
if reviewers.is_empty() || reviewers.len() > 50 {
|
|
return Err("Select between 1 and 50 reviewers.".to_string());
|
|
}
|
|
let mut reviewers = reviewers
|
|
.iter()
|
|
.map(|reviewer| {
|
|
normalize_event_id(reviewer).ok_or_else(|| "Invalid reviewer pubkey.".to_string())
|
|
})
|
|
.collect::<Result<Vec<_>, _>>()?;
|
|
reviewers.sort();
|
|
reviewers.dedup();
|
|
let reviewer_label = reviewer_label.trim();
|
|
if reviewer_label.is_empty() || reviewer_label.chars().count() > 128 {
|
|
return Err("Reviewer label must be between 1 and 128 characters.".to_string());
|
|
}
|
|
|
|
let mut raw_tags = vec![
|
|
vec![
|
|
"e".to_string(),
|
|
pull_request_id,
|
|
String::new(),
|
|
"root".to_string(),
|
|
],
|
|
vec!["a".to_string(), repo_address.to_string()],
|
|
];
|
|
raw_tags.extend(
|
|
reviewers
|
|
.into_iter()
|
|
.map(|reviewer| vec!["p".to_string(), reviewer]),
|
|
);
|
|
raw_tags.push(vec!["t".to_string(), "review-request".to_string()]);
|
|
let tags = raw_tags
|
|
.into_iter()
|
|
.map(Tag::parse)
|
|
.collect::<Result<Vec<_>, _>>()
|
|
.map_err(|error| format!("build review request tags: {error}"))?;
|
|
EventBuilder::new(
|
|
Kind::TextNote,
|
|
format!("Requested a review from {reviewer_label}"),
|
|
)
|
|
.tags(tags)
|
|
.sign_with_keys(keys)
|
|
.map(|event| event.as_json())
|
|
.map_err(|error| format!("sign pull request review request: {error}"))
|
|
}
|
|
|
|
fn same_repository(left: &str, right: &str) -> bool {
|
|
left.trim()
|
|
.trim_end_matches('/')
|
|
.trim_end_matches(".git")
|
|
.eq_ignore_ascii_case(right.trim().trim_end_matches('/').trim_end_matches(".git"))
|
|
}
|
|
|
|
fn clone_destination_root(repos_dir: Option<&str>) -> Result<std::path::PathBuf, String> {
|
|
match canonical_repos_roots(repos_dir) {
|
|
Ok(roots) => roots
|
|
.into_iter()
|
|
.next()
|
|
.ok_or_else(|| "reposDir is not accessible".to_string()),
|
|
Err(error) => {
|
|
if repos_dir.is_some() {
|
|
return Err(error);
|
|
}
|
|
let root = default_repos_root_candidates()
|
|
.into_iter()
|
|
.next()
|
|
.ok_or(error)?;
|
|
std::fs::create_dir_all(&root).map_err(|error| format!("create repos dir: {error}"))?;
|
|
canonicalize_repos_root(root)
|
|
}
|
|
}
|
|
}
|
|
|
|
fn align_unborn_head_branch(
|
|
repo_dir: &std::path::Path,
|
|
branch: Option<&str>,
|
|
auth: &GitAuthConfig,
|
|
) -> Result<(), String> {
|
|
let Some(branch) = branch else {
|
|
return Ok(());
|
|
};
|
|
if run_git(&["rev-parse", "--verify", "HEAD"], Some(repo_dir), auth).is_ok() {
|
|
return Ok(());
|
|
}
|
|
let target = format!("refs/heads/{branch}");
|
|
run_git(
|
|
&["symbolic-ref", "HEAD", target.as_str()],
|
|
Some(repo_dir),
|
|
auth,
|
|
)
|
|
.map(|_| ())
|
|
}
|
|
|
|
pub(crate) fn clone_project_repository_blocking(
|
|
repos_dir: Option<&str>,
|
|
project_dtag: &str,
|
|
clone_url: &str,
|
|
default_branch: Option<&str>,
|
|
auth: &GitAuthConfig,
|
|
) -> Result<ProjectRepoCloneResult, String> {
|
|
validate_local_clone_url(clone_url)?;
|
|
let branch = normalize_branch_option(default_branch);
|
|
if let Some(repo_dir) = find_local_repo_dir(repos_dir, project_dtag, Some(clone_url))? {
|
|
return Ok(ProjectRepoCloneResult {
|
|
path: repo_dir.display().to_string(),
|
|
cloned: false,
|
|
message: "Repository is already cloned.".to_string(),
|
|
});
|
|
}
|
|
|
|
let repos_root = clone_destination_root(repos_dir)?;
|
|
let repo_name = local_repo_candidates(project_dtag, Some(clone_url))
|
|
.into_iter()
|
|
.next()
|
|
.ok_or_else(|| "Could not derive a directory name for the repository.".to_string())?;
|
|
let repo_dir = repos_root.join(repo_name);
|
|
if repo_dir.exists() {
|
|
return Err(format!(
|
|
"{} already exists but is not a git checkout.",
|
|
repo_dir.display()
|
|
));
|
|
}
|
|
let repo_path = repo_dir
|
|
.to_str()
|
|
.ok_or_else(|| "repository path is not UTF-8".to_string())?;
|
|
|
|
let mut clone_args = vec!["clone"];
|
|
if let Some(ref branch) = branch {
|
|
clone_args.extend(["--branch", branch.as_str()]);
|
|
}
|
|
clone_args.extend(["--end-of-options", clone_url, repo_path]);
|
|
if let Err(error) = run_git(&clone_args, None, auth) {
|
|
if branch.is_none() {
|
|
return Err(error);
|
|
}
|
|
run_git(
|
|
&["clone", "--end-of-options", clone_url, repo_path],
|
|
None,
|
|
auth,
|
|
)?;
|
|
}
|
|
align_unborn_head_branch(&repo_dir, branch.as_deref(), auth)?;
|
|
|
|
Ok(ProjectRepoCloneResult {
|
|
path: repo_dir.display().to_string(),
|
|
cloned: true,
|
|
message: format!("Cloned repository to {}.", repo_dir.display()),
|
|
})
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn clone_project_repository(
|
|
repos_dir: Option<String>,
|
|
project_dtag: String,
|
|
clone_url: String,
|
|
default_branch: Option<String>,
|
|
state: State<'_, AppState>,
|
|
) -> Result<ProjectRepoCloneResult, String> {
|
|
validate_local_clone_url_for_workspace(&clone_url, &state)?;
|
|
let auth = build_git_clone_auth_config(&clone_url, &state)?;
|
|
tauri::async_runtime::spawn_blocking(move || {
|
|
clone_project_repository_blocking(
|
|
repos_dir.as_deref(),
|
|
&project_dtag,
|
|
&clone_url,
|
|
default_branch.as_deref(),
|
|
&auth,
|
|
)
|
|
})
|
|
.await
|
|
.map_err(|error| format!("repo clone task failed: {error}"))?
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn sign_project_pull_request_status(
|
|
input: ProjectPullRequestStatusInput,
|
|
app: AppHandle,
|
|
state: State<'_, AppState>,
|
|
) -> Result<(), String> {
|
|
let target_owner = input.target_owner.trim().to_ascii_lowercase();
|
|
if normalize_event_id(&target_owner).is_none() {
|
|
return Err("Invalid target repository owner.".to_string());
|
|
}
|
|
let identity = project_owner_identity(&app, &state, &target_owner)?;
|
|
let event = Event::from_json(build_pull_request_status_event(
|
|
&identity.keys,
|
|
&input.repo_address,
|
|
&input.pull_request_id,
|
|
&input.pull_request_author,
|
|
&input.status,
|
|
input.created_at,
|
|
)?)
|
|
.map_err(|error| format!("parse signed pull request status: {error}"))?;
|
|
submit_signed_event_with_keys(&event, &state, &identity.keys, identity.auth_tag.as_deref())
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn sign_project_pull_request_review_request(
|
|
input: ProjectPullRequestReviewRequestInput,
|
|
app: AppHandle,
|
|
state: State<'_, AppState>,
|
|
) -> Result<(), String> {
|
|
let target_owner = input.target_owner.trim().to_ascii_lowercase();
|
|
if normalize_event_id(&target_owner).is_none() {
|
|
return Err("Invalid target repository owner.".to_string());
|
|
}
|
|
let identity = project_owner_identity(&app, &state, &target_owner)?;
|
|
let event = Event::from_json(build_review_request_event(
|
|
&identity.keys,
|
|
&input.repo_address,
|
|
&input.pull_request_id,
|
|
&input.reviewers,
|
|
&input.reviewer_label,
|
|
)?)
|
|
.map_err(|error| format!("parse signed review request: {error}"))?;
|
|
submit_signed_event_with_keys(&event, &state, &identity.keys, identity.auth_tag.as_deref())
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn publish_project_pull_request_merged_status(
|
|
input: ProjectPullRequestMergedStatusInput,
|
|
app: AppHandle,
|
|
state: State<'_, AppState>,
|
|
) -> Result<(), String> {
|
|
let target_owner = input.target_owner.trim().to_ascii_lowercase();
|
|
if normalize_event_id(&target_owner).is_none() {
|
|
return Err("Invalid target repository owner.".to_string());
|
|
}
|
|
let event = Event::from_json(input.status_event)
|
|
.map_err(|error| format!("parse merged status event: {error}"))?;
|
|
if event.kind != Kind::Custom(1631)
|
|
|| event.pubkey.to_hex() != target_owner
|
|
|| event.verify().is_err()
|
|
{
|
|
return Err("Invalid merged pull request status event.".to_string());
|
|
}
|
|
let identity = project_owner_identity(&app, &state, &target_owner)?;
|
|
submit_signed_event_with_keys(&event, &state, &identity.keys, identity.auth_tag.as_deref())
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn merge_project_pull_request(
|
|
input: ProjectPullRequestMergeInput,
|
|
app: AppHandle,
|
|
state: State<'_, AppState>,
|
|
) -> Result<ProjectRepoMergeResult, ProjectPullRequestMergeError> {
|
|
let ProjectPullRequestMergeInput {
|
|
target_clone_url,
|
|
source_clone_url,
|
|
target_owner,
|
|
repo_address,
|
|
pull_request_id,
|
|
pull_request_author,
|
|
status_created_at,
|
|
target_branch,
|
|
source_branch,
|
|
expected_commit,
|
|
} = input;
|
|
validate_workspace_clone_url(&target_clone_url, &state)?;
|
|
validate_workspace_clone_url(&source_clone_url, &state)?;
|
|
let target_owner = target_owner.trim().to_ascii_lowercase();
|
|
if target_owner.len() != 64 || !target_owner.chars().all(|c| c.is_ascii_hexdigit()) {
|
|
return Err("Invalid target repository owner.".to_string().into());
|
|
}
|
|
if clone_url_owner(&target_clone_url).as_deref() != Some(target_owner.as_str()) {
|
|
return Err("Target clone URL does not match the repository owner."
|
|
.to_string()
|
|
.into());
|
|
}
|
|
let owner_identity = project_owner_identity(&app, &state, &target_owner)?;
|
|
let merger_pubkey = owner_identity.keys.public_key().to_hex();
|
|
let target_branch = normalize_branch_option(Some(&target_branch))
|
|
.ok_or_else(|| "Invalid target branch.".to_string())?;
|
|
let source_branch = normalize_branch_option(Some(&source_branch))
|
|
.ok_or_else(|| "Invalid source branch.".to_string())?;
|
|
if target_branch == source_branch && same_repository(&target_clone_url, &source_clone_url) {
|
|
return Err("Source and target branches must be different."
|
|
.to_string()
|
|
.into());
|
|
}
|
|
let expected_commit = normalize_commit(&expected_commit)
|
|
.ok_or_else(|| "Invalid pull request commit.".to_string())?;
|
|
let (pull_request_id, pull_request_author) = validate_merge_status_metadata(
|
|
&repo_address,
|
|
&merger_pubkey,
|
|
&pull_request_id,
|
|
&pull_request_author,
|
|
)?;
|
|
let auth = build_git_auth_config_for_keys(&owner_identity.keys)?;
|
|
|
|
let git_result = tauri::async_runtime::spawn_blocking(
|
|
move || -> Result<ProjectRepoMergeGitResult, ProjectPullRequestMergeError> {
|
|
let temp_dir =
|
|
tempfile::tempdir().map_err(|error| format!("create temp dir: {error}"))?;
|
|
let repo_dir = temp_dir.path().join("repo");
|
|
let repo_path = repo_dir
|
|
.to_str()
|
|
.ok_or_else(|| "temporary repository path is not UTF-8".to_string())?;
|
|
run_git(
|
|
&[
|
|
"clone",
|
|
"--filter=blob:none",
|
|
"--no-tags",
|
|
"--branch",
|
|
target_branch.as_str(),
|
|
"--single-branch",
|
|
"--end-of-options",
|
|
target_clone_url.as_str(),
|
|
repo_path,
|
|
],
|
|
None,
|
|
&auth,
|
|
)?;
|
|
run_git(
|
|
&[
|
|
"fetch",
|
|
"--quiet",
|
|
"--end-of-options",
|
|
source_clone_url.as_str(),
|
|
source_branch.as_str(),
|
|
],
|
|
Some(&repo_dir),
|
|
&auth,
|
|
)?;
|
|
let source_head = run_git(&["rev-parse", "FETCH_HEAD"], Some(&repo_dir), &auth)
|
|
.ok()
|
|
.and_then(|output| first_output_line(&output))
|
|
.ok_or_else(|| "Could not resolve the pull request branch.".to_string())?;
|
|
if source_head.to_ascii_lowercase() != expected_commit {
|
|
return Err(ProjectPullRequestMergeError::new(
|
|
"branch_changed",
|
|
"The pull request branch changed. Refresh the pull request before merging."
|
|
.to_string(),
|
|
));
|
|
}
|
|
|
|
let merge_email = format!("{merger_pubkey}@users.noreply.buzz");
|
|
let merge_result = run_git(
|
|
&[
|
|
"-c",
|
|
"user.name=Buzz User",
|
|
"-c",
|
|
format!("user.email={merge_email}").as_str(),
|
|
"merge",
|
|
"--no-edit",
|
|
"--end-of-options",
|
|
expected_commit.as_str(),
|
|
],
|
|
Some(&repo_dir),
|
|
&auth,
|
|
);
|
|
if let Err(error) = merge_result {
|
|
let has_conflicts = run_git(
|
|
&["diff", "--name-only", "--diff-filter=U"],
|
|
Some(&repo_dir),
|
|
&auth,
|
|
)
|
|
.is_ok_and(|output| !output.trim().is_empty());
|
|
return Err(classify_merge_error(
|
|
error,
|
|
has_conflicts,
|
|
&target_branch,
|
|
&source_branch,
|
|
));
|
|
}
|
|
let merge_commit = run_git(&["rev-parse", "HEAD"], Some(&repo_dir), &auth)
|
|
.ok()
|
|
.and_then(|output| first_output_line(&output))
|
|
.ok_or_else(|| "Could not resolve the merge commit.".to_string())?;
|
|
run_git(
|
|
&[
|
|
"push",
|
|
"--end-of-options",
|
|
"origin",
|
|
format!("HEAD:{target_branch}").as_str(),
|
|
],
|
|
Some(&repo_dir),
|
|
&auth,
|
|
)?;
|
|
|
|
Ok(ProjectRepoMergeGitResult {
|
|
message: format!("Merged {source_branch} into {target_branch}."),
|
|
merge_commit,
|
|
})
|
|
},
|
|
)
|
|
.await
|
|
.map_err(|error| {
|
|
ProjectPullRequestMergeError::new(
|
|
"merge_task_failed",
|
|
format!("pull request merge task failed: {error}"),
|
|
)
|
|
})??;
|
|
let status_event = build_merged_status_event(
|
|
&owner_identity.keys,
|
|
&repo_address,
|
|
&pull_request_id,
|
|
&pull_request_author,
|
|
&git_result.merge_commit,
|
|
status_created_at,
|
|
)?;
|
|
let signed_status = Event::from_json(&status_event)
|
|
.map_err(|error| format!("parse signed merged status: {error}"))?;
|
|
let status_publication_error = submit_signed_event_with_keys(
|
|
&signed_status,
|
|
&state,
|
|
&owner_identity.keys,
|
|
owner_identity.auth_tag.as_deref(),
|
|
)
|
|
.await
|
|
.err();
|
|
Ok(ProjectRepoMergeResult {
|
|
message: git_result.message,
|
|
merge_commit: git_result.merge_commit,
|
|
status_event,
|
|
status_publication_error,
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{
|
|
align_unborn_head_branch, build_merged_status_event, build_pull_request_status_event,
|
|
build_review_request_event, normalize_commit, same_repository,
|
|
validate_merge_status_metadata,
|
|
};
|
|
use crate::commands::project_git_exec::{build_test_git_auth_config, run_git};
|
|
use nostr::{Event, JsonUtil, Keys, Timestamp};
|
|
|
|
#[test]
|
|
fn empty_clone_uses_requested_default_branch() {
|
|
let auth = build_test_git_auth_config().expect("build test git config");
|
|
let repo = tempfile::tempdir().expect("create repository");
|
|
run_git(&["init"], Some(repo.path()), &auth).expect("initialize repository");
|
|
|
|
align_unborn_head_branch(repo.path(), Some("main"), &auth).expect("align unborn HEAD");
|
|
|
|
assert_eq!(
|
|
std::fs::read_to_string(repo.path().join(".git/HEAD"))
|
|
.expect("read HEAD")
|
|
.trim(),
|
|
"ref: refs/heads/main"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn normalize_commit_accepts_sha1_and_sha256_hex() {
|
|
assert_eq!(normalize_commit(&"A".repeat(40)), Some("a".repeat(40)));
|
|
assert_eq!(normalize_commit(&"B".repeat(64)), Some("b".repeat(64)));
|
|
}
|
|
|
|
#[test]
|
|
fn normalize_commit_rejects_invalid_values() {
|
|
assert_eq!(normalize_commit("abc"), None);
|
|
assert_eq!(normalize_commit(&"z".repeat(40)), None);
|
|
}
|
|
|
|
#[test]
|
|
fn repository_comparison_normalizes_git_suffix_and_trailing_slash() {
|
|
assert!(same_repository(
|
|
"https://relay.example/git/owner/repo.git",
|
|
"https://relay.example/git/owner/repo/"
|
|
));
|
|
assert!(!same_repository(
|
|
"https://relay.example/git/owner/repo",
|
|
"https://relay.example/git/fork/repo"
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn merged_status_is_signed_by_repository_owner() {
|
|
let keys = Keys::generate();
|
|
let owner = keys.public_key().to_hex();
|
|
let pull_request_id = "d".repeat(64);
|
|
let pull_request_author = "b".repeat(64);
|
|
let merge_commit = "e".repeat(40);
|
|
let repo_address = format!("30617:{owner}:buzz");
|
|
let before = Timestamp::now().as_secs();
|
|
let event = Event::from_json(
|
|
build_merged_status_event(
|
|
&keys,
|
|
&repo_address,
|
|
&pull_request_id,
|
|
&pull_request_author,
|
|
&merge_commit,
|
|
123,
|
|
)
|
|
.unwrap(),
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(event.pubkey, keys.public_key());
|
|
assert_eq!(event.kind.as_u16(), 1631);
|
|
assert!(event.created_at.as_secs() >= before);
|
|
assert!(event
|
|
.tags
|
|
.iter()
|
|
.any(|tag| tag.as_slice() == ["merge-commit", merge_commit.as_str()]));
|
|
assert!(event.verify().is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn merged_status_preserves_a_newer_requested_timestamp() {
|
|
let keys = Keys::generate();
|
|
let owner = keys.public_key().to_hex();
|
|
let requested = Timestamp::now().as_secs() + 10;
|
|
let event = Event::from_json(
|
|
build_merged_status_event(
|
|
&keys,
|
|
&format!("30617:{owner}:buzz"),
|
|
&"d".repeat(64),
|
|
&"b".repeat(64),
|
|
&"e".repeat(40),
|
|
requested,
|
|
)
|
|
.unwrap(),
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(event.created_at.as_secs(), requested);
|
|
}
|
|
|
|
#[test]
|
|
fn merge_status_metadata_is_rejected_before_git_work() {
|
|
let owner = "a".repeat(64);
|
|
assert!(validate_merge_status_metadata(
|
|
&format!("30617:{}:buzz", "b".repeat(64)),
|
|
&owner,
|
|
&"d".repeat(64),
|
|
&"e".repeat(64),
|
|
)
|
|
.is_err());
|
|
assert!(validate_merge_status_metadata(
|
|
&format!("30617:{owner}:buzz"),
|
|
&owner,
|
|
"not-an-event-id",
|
|
&"e".repeat(64),
|
|
)
|
|
.is_err());
|
|
assert!(validate_merge_status_metadata(
|
|
&format!("30617:{owner}:buzz"),
|
|
&owner,
|
|
&"d".repeat(64),
|
|
"not-an-author",
|
|
)
|
|
.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn lifecycle_status_is_signed_by_repository_owner() {
|
|
let keys = Keys::generate();
|
|
let owner = keys.public_key().to_hex();
|
|
let author = "b".repeat(64);
|
|
let event = Event::from_json(
|
|
build_pull_request_status_event(
|
|
&keys,
|
|
&format!("30617:{owner}:buzz"),
|
|
&"d".repeat(64),
|
|
&author,
|
|
"closed",
|
|
Timestamp::now().as_secs(),
|
|
)
|
|
.unwrap(),
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(event.pubkey, keys.public_key());
|
|
assert_eq!(event.kind.as_u16(), 1632);
|
|
assert!(event
|
|
.tags
|
|
.iter()
|
|
.any(|tag| tag.as_slice() == ["p", author.as_str()]));
|
|
assert!(event.verify().is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn lifecycle_status_rejects_merged_alias() {
|
|
let keys = Keys::generate();
|
|
let owner = keys.public_key().to_hex();
|
|
|
|
assert!(build_pull_request_status_event(
|
|
&keys,
|
|
&format!("30617:{owner}:buzz"),
|
|
&"d".repeat(64),
|
|
&"b".repeat(64),
|
|
"merged",
|
|
Timestamp::now().as_secs(),
|
|
)
|
|
.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn review_request_is_signed_by_repository_owner() {
|
|
let keys = Keys::generate();
|
|
let owner = keys.public_key().to_hex();
|
|
let reviewer = "b".repeat(64);
|
|
let repo_address = format!("30617:{owner}:buzz");
|
|
let event = Event::from_json(
|
|
build_review_request_event(
|
|
&keys,
|
|
&repo_address,
|
|
&"d".repeat(64),
|
|
std::slice::from_ref(&reviewer),
|
|
"Bob",
|
|
)
|
|
.unwrap(),
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(event.pubkey, keys.public_key());
|
|
assert_eq!(event.kind, nostr::Kind::TextNote);
|
|
assert_eq!(event.content, "Requested a review from Bob");
|
|
assert!(event
|
|
.tags
|
|
.iter()
|
|
.any(|tag| tag.as_slice() == ["p", reviewer.as_str()]));
|
|
assert!(event
|
|
.tags
|
|
.iter()
|
|
.any(|tag| tag.as_slice() == ["t", "review-request"]));
|
|
assert!(event.verify().is_ok());
|
|
}
|
|
}
|