//! Patch proposals: the rules a patch proposal lives by, and the store that
//! keeps its rows.
//!
//! The rules sit here rather than in the handlers because everything that
//! speaks about a patch proposal has to agree on them: what a summary must be,
//! who may revise it, and what statuses mean.
pub mod change_id;
pub mod merge;
pub mod store;
use crate::entities::patch_proposals::{MergePolicy, Model as PatchProposal, Status};
use crate::entities::repositories::MergePolicy as RepoMergePolicy;
/// How long a summary may be, which is the width of the column it is kept in.
pub const MAX_SUMMARY_LEN: usize = 255;
/// The magic ref that triggers patch proposal creation when pushed to.
pub const PATCHES_REF: &str = "refs/patches";
/// The namespace under which individual patch proposals live.
pub const PATCHES_NAMESPACE: &str = "refs/patches/";
/// Computes the diff range for a patch proposal.
/// If the patch has snapshot OIDs (merged or closed), uses those OIDs.
/// Otherwise, uses the live branch references.
pub fn compute_diff_range(patch: &PatchProposal) -> String {
if let (Some(base_oid), Some(head_oid)) = (&patch.snapshot_base_oid, &patch.snapshot_head_oid) {
format!("{}...{}", base_oid, head_oid)
} else {
format!("{}...{}", patch.target_branch, patch.source_ref)
}
}
/// The summary a patch proposal may be saved with: the text with the space
/// around it taken off, or the reason it cannot be one.
pub fn checked_summary(summary: &str) -> Result<&str, String> {
let summary = summary.trim();
if summary.is_empty() {
return Err("a patch proposal needs a summary".to_string());
}
if summary.chars().count() > MAX_SUMMARY_LEN {
return Err(format!(
"a summary may be at most {MAX_SUMMARY_LEN} characters long"
));
}
Ok(summary)
}
/// Whether `pusher` may revise (update or delete) a patch proposal created by
/// `creator` in a repository owned by `owner`. If the patch was created by a
/// remote actor (creator is None), only the repository owner may revise it.
pub fn may_revise(owner: i64, creator: Option<i64>, pusher: i64) -> bool {
creator.is_some_and(|c| pusher == c) || pusher == owner
}
/// Parses a status string into the enum, or returns None if invalid.
pub fn parse_status(s: &str) -> Option<Status> {
match s {
"draft" => Some(Status::Draft),
"open" => Some(Status::Open),
"closed" => Some(Status::Closed),
"merged" => Some(Status::Merged),
_ => None,
}
}
/// Parses a merge policy string into the enum, or returns None if invalid.
pub fn parse_merge_policy(s: &str) -> Option<MergePolicy> {
match s {
"prefer-fast-forward" => Some(MergePolicy::PreferFastForward),
"fast-forward-only" => Some(MergePolicy::FastForwardOnly),
"merge-commit-only" => Some(MergePolicy::MergeCommitOnly),
"rebase-then-fast-forward" => Some(MergePolicy::RebaseThenFastForward),
_ => None,
}
}
/// Converts a repository's merge policy to a patch proposal's merge policy.
pub fn repo_merge_policy_to_pp(policy: RepoMergePolicy) -> MergePolicy {
match policy {
RepoMergePolicy::PreferFastForward => MergePolicy::PreferFastForward,
RepoMergePolicy::FastForwardOnly => MergePolicy::FastForwardOnly,
RepoMergePolicy::MergeCommitOnly => MergePolicy::MergeCommitOnly,
RepoMergePolicy::RebaseThenFastForward => MergePolicy::RebaseThenFastForward,
}
}
/// Whether `user` may change the merge policy of a patch proposal in a
/// repository owned by `owner`. Only the repository owner may change the merge
/// policy.
pub fn may_change_merge_policy(owner: i64, user: i64) -> bool {
user == owner
}
/// Whether a ref is the magic `refs/patches` ref or under `refs/patches/`.
pub fn is_patches_ref(ref_name: &str) -> bool {
ref_name == PATCHES_REF || ref_name.starts_with(PATCHES_NAMESPACE)
}
/// Extracts the patch number from a ref like `refs/patches/123`, or None if
/// the ref is not in that form.
pub fn patch_number_from_ref(ref_name: &str) -> Option<i64> {
ref_name
.strip_prefix(PATCHES_NAMESPACE)?
.parse::<i64>()
.ok()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_summary_is_the_text_with_the_space_around_it_gone() {
assert_eq!(checked_summary(" Add a feature \n"), Ok("Add a feature"));
}
#[test]
fn a_summary_of_nothing_is_refused() {
assert_eq!(
checked_summary(""),
Err("a patch proposal needs a summary".to_string())
);
assert_eq!(
checked_summary(" "),
Err("a patch proposal needs a summary".to_string())
);
}
#[test]
fn a_summary_longer_than_the_column_holds_says_how_long_is_too_long() {
let long = "x".repeat(MAX_SUMMARY_LEN + 1);
assert!(checked_summary(&long).is_err());
}
#[test]
fn may_revise_allows_creator_and_owner() {
let owner = 1;
let creator = 2;
let stranger = 3;
assert!(may_revise(owner, Some(creator), creator));
assert!(may_revise(owner, Some(creator), owner));
assert!(!may_revise(owner, Some(creator), stranger));
// Remote-created patches (creator is None) can only be revised by the
// owner
assert!(
may_revise(owner, None, owner),
"the owner may revise remote-created patches"
);
assert!(
!may_revise(owner, None, stranger),
"non-owners may not revise remote-created patches"
);
}
#[test]
fn status_parsing() {
assert_eq!(parse_status("draft"), Some(Status::Draft));
assert_eq!(parse_status("open"), Some(Status::Open));
assert_eq!(parse_status("closed"), Some(Status::Closed));
assert_eq!(parse_status("merged"), Some(Status::Merged));
assert_eq!(parse_status("invalid"), None);
}
#[test]
fn merge_policy_parsing() {
assert_eq!(
parse_merge_policy("prefer-fast-forward"),
Some(MergePolicy::PreferFastForward)
);
assert_eq!(
parse_merge_policy("fast-forward-only"),
Some(MergePolicy::FastForwardOnly)
);
assert_eq!(
parse_merge_policy("merge-commit-only"),
Some(MergePolicy::MergeCommitOnly)
);
assert_eq!(
parse_merge_policy("rebase-then-fast-forward"),
Some(MergePolicy::RebaseThenFastForward)
);
assert_eq!(parse_merge_policy("invalid"), None);
}
#[test]
fn may_change_merge_policy_allows_owner_only() {
let owner = 1;
let creator = 2;
let stranger = 3;
assert!(may_change_merge_policy(owner, owner));
assert!(!may_change_merge_policy(owner, creator));
assert!(!may_change_merge_policy(owner, stranger));
}
#[test]
fn is_patches_ref_checks_both_shapes() {
assert!(is_patches_ref("refs/patches"));
assert!(is_patches_ref("refs/patches/1"));
assert!(is_patches_ref("refs/patches/123"));
assert!(!is_patches_ref("refs/heads/main"));
assert!(!is_patches_ref("refs/patches-extra"));
}
#[test]
fn patch_number_from_ref_extracts_numbers() {
assert_eq!(patch_number_from_ref("refs/patches/1"), Some(1));
assert_eq!(patch_number_from_ref("refs/patches/123"), Some(123));
assert_eq!(patch_number_from_ref("refs/patches"), None);
assert_eq!(patch_number_from_ref("refs/patches/abc"), None);
}
}