use knot_cob::{ChangePayload, CobError, WriteContext};
use knot_cobs::SocialBody as _;
use knot_cobs::{Patch, SocialChange};
use knot_git::CommitRange;
use knot_record::pull::{PatchRecord, PatchSource, patch_collection};
use knot_record::ticket::ticket_collection;
use knot_types::{
AccountDid, BranchName, ContributionPermission, Decision, Oid, RecordAddress, RecordRkey,
};
use super::drafts::SubmitPatchInput;
use super::error::WriteError;
use super::keep_ref;
use super::ledger::rejected;
use super::session::wrote;
use super::{Done, Ledger};
pub(super) fn patch_opening(
actor: &AccountDid,
permission: ContributionPermission,
) -> Result<(), WriteError> {
if matches!(Patch::OPENING.decide(permission), Decision::Deny) {
return Err(rejected(CobError::CreateRejected {
actor: actor.clone(),
type_name: <SocialChange<Patch> as ChangePayload>::type_name(),
permission,
}));
}
Ok(())
}
impl Ledger<'_> {
pub(super) fn submit_patch(
&self,
cx: &WriteContext<'_>,
input: SubmitPatchInput,
) -> Result<Done, WriteError> {
let SubmitPatchInput { patch, .. } = input;
patch_opening(cx.actor(), cx.permission())?;
let range = CommitRange {
base: patch.base.oid,
head: patch.head.oid,
};
let ticket_address = RecordAddress::new(ticket_collection(), patch.ticket.clone());
self.patch_ticket(&ticket_address)?;
self.require_commit(range.base, "base")?;
if let Some(branch) = patch
.source_branch
.as_ref()
.filter(|_| patch.source_repo == *self.repo)
{
self.require_on_branch(branch, range.head)?;
}
let source = patch.source_branch.map(|branch| PatchSource {
repo: patch.source_repo,
branch,
});
let record =
PatchRecord::new(patch.ticket, range, source, self.now, cx.actor().clone())?.body()?;
let produces = record.cid();
let key = RecordRkey::new(produces.to_string()).map_err(|error| {
WriteError::internal(format!(
"a patch's record key is its record's CID, and {produces} wouldn't parse as one: \
{error}"
))
})?;
let address = RecordAddress::new(patch_collection(), key);
match self.object_at(&address) {
Ok(object) => {
let Some(live) = self.serving_object(&address, object)? else {
return Err(WriteError::conflict(format!(
"a patch was erased at {}, and an erased patch's key stays retired",
address.at_uri(self.repo)
)));
};
let serving = live.version().ok_or_else(|| {
WriteError::internal("a serving patch is missing its version")
})?;
if serving != produces {
return Err(WriteError::internal(format!(
"the patch at {} serves version {serving} under the key of {produces}, \
and a patch's key is its own record's CID",
address.at_uri(self.repo)
)));
}
return Ok(Done::Written(wrote(self.repo, &address, produces)));
}
Err(missing) if missing.status() == http::StatusCode::NOT_FOUND => {}
Err(other) => return Err(other),
}
self.require_commit(range.head, "head")?;
self.require_ancestor(range.base, range.head)?;
self.pin(&address, range);
let written = self.open_keyed(
cx,
&patch_collection(),
|_| Ok(address),
Some(ticket_address),
&record,
Patch,
)?;
Ok(Done::Written(written))
}
fn require_commit(&self, oid: Oid, what: &str) -> Result<(), WriteError> {
self.git.find_commit(oid).map(|_| ()).map_err(|_| {
WriteError::invalid_request(format!(
"the patch's {what} {oid} isn't a commit in this repository"
))
})
}
fn require_ancestor(&self, base: Oid, head: Oid) -> Result<(), WriteError> {
if self.git.merge_base(base, head)? != Some(base) {
return Err(WriteError::invalid_request(format!(
"a patch is the range {base}..{head}, and its base isn't an ancestor of its head",
)));
}
Ok(())
}
// the branch tip can advance after the patch was opened, only check that head is an ancestor
fn require_on_branch(&self, branch: &BranchName, head: Oid) -> Result<(), WriteError> {
let tip = self.git.find_ref(&branch.head_ref())?.ok_or_else(|| {
WriteError::invalid_request(format!(
"the patch's source branch {branch} isn't a branch of this repository"
))
})?;
self.require_commit(head, "head")?;
if self.git.merge_base(head, tip)? != Some(head) {
return Err(WriteError::invalid_request(format!(
"the patch's head {head} isn't on its source branch {branch}"
)));
}
Ok(())
}
fn pin(&self, address: &RecordAddress, range: CommitRange) {
[range.base, range.head]
.into_iter()
.try_for_each(|commit| {
let name = keep_ref(self.repo, address, commit)?;
if self.git.find_ref(&name)?.is_some_and(|pinned| pinned == commit) {
return Ok(());
}
self.git
.set_reference(&name, commit)
.map_err(WriteError::from)
})
.inspect_err(|error| {
tracing::warn!(repo = %self.repo, %error, "A patch's keep refs wouldn't write after the open");
})
.ok();
}
fn patch_ticket(&self, ticket_address: &RecordAddress) -> Result<(), WriteError> {
if self.live_record_at(ticket_address)?.parent().is_some() {
return Err(WriteError::invalid_request(format!(
"a patch's ticket must be a top-level ticket, and {} isn't",
ticket_address.at_uri(self.repo)
)));
}
Ok(())
}
}