use std::collections::BTreeSet;
use knot_cob::WriteContext;
use knot_cobs::{
Artifact, Comment, Editing, LabelDef, LabelDefChange, Opening, Reaction, SocialChange, Ticket,
TicketChange,
};
use knot_record::artifact::{ArtifactRecord, artifact_collection};
use knot_record::comment::{CommentRecord, comment_collection};
use knot_record::label::{LabelDefRecord, LabelToken, label_def_collection};
use knot_record::reaction::{ReactionRecord, reaction_collection};
use knot_record::ticket::{TargetBranch, TicketState, ticket_collection};
use knot_types::{AccountDid, CobId, RecordAddress, RecordRkey};
use super::drafts::{
ArtifactDraft, CommentDraft, LabelDefDraft, ReactionDraft, SwapInput, TicketDraft, typed,
};
use super::error::WriteError;
use super::ledger::{ready, ticket_body_of};
use super::session::wrote;
use super::{Ledger, NOT_ASSIGN_AUTHORITY, NOT_LABEL_AUTHORITY, NOT_STATE_AUTHORITY, Written};
impl Ledger<'_> {
fn opened_comment(&self, object: CobId) -> Result<Comment, WriteError> {
match self.change_of::<Comment>(
object,
"A comment object's root change went missing",
true,
)? {
SocialChange::Open { body, .. } => Ok(body),
_ => Err(WriteError::internal(
"A comment object's root change isn't the change that created it",
)),
}
}
fn opened_ticket(&self, object: CobId) -> Result<Ticket, WriteError> {
match self.change_of::<Ticket>(object, "A ticket object's changes went missing", false)? {
SocialChange::Open { body, .. } | SocialChange::Edit { body, .. } => Ok(body),
SocialChange::Erase { .. } => Err(WriteError::internal(
"A live ticket's latest change is an erasure",
)),
}
}
fn require_label_defs(&self, tokens: &[LabelToken]) -> Result<(), WriteError> {
tokens.iter().try_for_each(|token| {
let key = token.key();
let address =
RecordAddress::new(label_def_collection(), RecordRkey::composed(key.clone()));
let object = ready(self.index.social_object_at(self.repo, &address), "social")?
.ok_or_else(|| {
WriteError::invalid_request(format!(
"tangledLabels cites {key}, which isn't a label def on this repository"
))
})?;
self.serving_object(&address, object)?.ok_or_else(|| {
WriteError::invalid_request(format!(
"tangledLabels cites {key}, whose label def was deleted"
))
})?;
Ok(())
})
}
fn vet_ticket_scope(
&self,
target_branch: Option<&TargetBranch>,
tokens: Option<&[LabelToken]>,
) -> Result<(), WriteError> {
if let Some(branch) = target_branch
&& self.git.find_ref(&branch.head_ref())?.is_none()
{
return Err(WriteError::invalid_request(format!(
"a ticket's targetBranch opens onto a branch in this repository, and {branch} \
isn't one of them"
)));
}
tokens.map_or(Ok(()), |tokens| self.require_label_defs(tokens))
}
pub(super) fn ticket_open(
&self,
cx: &WriteContext<'_>,
record: serde_json::Value,
) -> Result<Written, WriteError> {
let collection = ticket_collection();
let parsed = typed::<TicketDraft>(record, &collection)?.parse(self.repo, &collection)?;
let wanted = parsed.state.unwrap_or(TicketState::Open);
if wanted != TicketState::Open {
return Err(WriteError::invalid_request(
"a ticket opens in the open state; close or complete it with putRecord",
));
}
self.cite_attachments(parsed.blobs.as_deref().unwrap_or_default());
if parsed.assignees.is_some() && !cx.permission().moderates().is_allowed() {
return Err(WriteError::forbidden(NOT_ASSIGN_AUTHORITY));
}
self.vet_ticket_scope(
parsed.target_branch.as_ref(),
parsed.tangled_labels.as_deref(),
)?;
let (record, ticket) = ticket_body_of(
parsed.prose,
wanted,
parsed.tangled_labels,
parsed.assignees,
parsed.target_branch,
parsed.blobs,
self.now,
cx.actor().clone(),
)?;
self.open_keyed(
cx,
&collection.clone(),
|number| {
RecordRkey::new(number.get().to_string())
.map(|rkey| RecordAddress::new(collection, rkey))
.map_err(|error| {
WriteError::internal(format!("a ticket number isn't a record key: {error}"))
})
},
None,
&record,
ticket,
)
}
pub(super) fn comment_open(
&self,
cx: &WriteContext<'_>,
record: serde_json::Value,
) -> Result<Written, WriteError> {
let collection = comment_collection();
let draft = typed::<CommentDraft>(record, &collection)?.parse(self.repo)?;
self.cite_attachments(draft.markup.attachments());
self.standing_version(&draft.subject, "subject")?;
draft
.reply_to
.as_ref()
.map(|reply| self.standing_version(reply, "reply"))
.transpose()?;
let subject = draft.subject.clone();
let reply = draft.reply_to.clone();
let record = CommentRecord::new(
self.repo.clone(),
draft.subject,
draft.markup,
draft.reply_to,
draft.embed,
self.now,
cx.actor().clone(),
)?
.body()?;
let body = Comment {
subject: subject.cid(),
reply,
};
self.open_keyed(
cx,
&collection,
|_| Ok(RecordAddress::new(comment_collection(), self.mint())),
Some(subject.address().clone()),
&record,
body,
)
}
pub(super) fn artifact_open(
&self,
cx: &WriteContext<'_>,
record: serde_json::Value,
) -> Result<Written, WriteError> {
let collection = artifact_collection();
let (oid, name, artifact) = typed::<ArtifactDraft>(record, &collection)?.parse()?;
self.cite(artifact.cid(), artifact.mime(), artifact.size());
let record =
ArtifactRecord::new(oid, name, artifact, self.now, cx.actor().clone())?.body()?;
self.open_keyed(
cx,
&collection,
|_| Ok(RecordAddress::new(artifact_collection(), self.mint())),
None,
&record,
Artifact,
)
}
pub(super) fn reaction_open(
&self,
cx: &WriteContext<'_>,
record: serde_json::Value,
) -> Result<Written, WriteError> {
let collection = reaction_collection();
let (subject, reaction) = typed::<ReactionDraft>(record, &collection)?.parse(self.repo)?;
self.standing_version(&subject, "subject")?;
if cx.permission().contributes().is_allowed()
&& let Some(object) = ready(
self.index
.social_live_reaction(self.repo, subject.address(), cx.actor(), reaction),
"social",
)?
{
let address = ready(self.index.social_address_of(self.repo, object), "social")?
.ok_or_else(|| {
WriteError::internal("The matching reaction is missing its address")
})?;
let version = self.live_record_at(&address)?.version().ok_or_else(|| {
WriteError::internal("The matching reaction should have a version and doesn't")
})?;
return Ok(wrote(self.repo, &address, version));
}
let record = ReactionRecord::new(
self.repo.clone(),
subject.clone(),
reaction,
self.now,
cx.actor().clone(),
)?
.body()?;
self.open_keyed(
cx,
&collection,
|_| Ok(RecordAddress::new(reaction_collection(), self.mint())),
Some(subject.address().clone()),
&record,
Reaction {
subject: subject.cid(),
kind: reaction,
},
)
}
pub(super) fn label_def_open(
&self,
cx: &WriteContext<'_>,
rkey: Option<RecordRkey>,
record: serde_json::Value,
) -> Result<Written, WriteError> {
let collection = label_def_collection();
let slug = rkey.and_then(|rkey| rkey.as_label_key()).ok_or_else(|| {
WriteError::invalid_request(
"a label def's record key is the slug the owner chooses; send that slug \
as the rkey",
)
})?;
let def = typed::<LabelDefDraft>(record, &collection)?.parse(&collection)?;
if !Opening::Ownership.decide(cx.permission()).is_allowed() {
return Err(WriteError::forbidden(NOT_LABEL_AUTHORITY));
}
let record = LabelDefRecord::new(def, self.now, cx.actor().clone())?.body()?;
let address = RecordAddress::new(collection.clone(), RecordRkey::composed(slug));
match self.object_at(&address) {
Ok(object) => {
return Err(match self.serving_object(&address, object)? {
Some(_) => WriteError::conflict(format!(
"a label def already stands at {}",
address.at_uri(self.repo)
)),
None => WriteError::conflict(format!(
"a label def was erased at {}, and an erased def's slug stays \
retired while the versions stay in the change graph",
address.at_uri(self.repo)
)),
});
}
Err(missing) if missing.status() == http::StatusCode::NOT_FOUND => {}
Err(other) => return Err(other),
}
self.open_keyed(cx, &collection, |_| Ok(address), None, &record, LabelDef)
}
pub(super) fn ticket_put(
&self,
cx: &WriteContext<'_>,
swap: SwapInput,
record: serde_json::Value,
) -> Result<Written, WriteError> {
let (_, rkey, from) = swap.edit_of(&ticket_collection())?;
let collection = ticket_collection();
let parsed = typed::<TicketDraft>(record, &collection)?.parse(self.repo, &collection)?;
self.cite_attachments(parsed.blobs.as_deref().unwrap_or_default());
let address = RecordAddress::new(collection, rkey);
let (object, live) = self.standing(&address)?;
let opened = self.opened_ticket(object)?;
let wanted = parsed.state.unwrap_or(opened.state);
if parsed.state.is_some_and(|sent| sent != opened.state)
&& live.author() != cx.actor()
&& !cx.permission().moderates().is_allowed()
{
return Err(WriteError::forbidden(NOT_STATE_AUTHORITY));
}
let assigned = |assignees: &Option<Vec<AccountDid>>| {
assignees.iter().flatten().cloned().collect::<BTreeSet<_>>()
};
if assigned(&parsed.assignees) != assigned(&opened.assignees)
&& !cx.permission().moderates().is_allowed()
{
return Err(WriteError::forbidden(NOT_ASSIGN_AUTHORITY));
}
self.vet_ticket_scope(
parsed.target_branch.as_ref(),
parsed.tangled_labels.as_deref(),
)?;
let (record, body) = ticket_body_of(
parsed.prose,
wanted,
parsed.tangled_labels,
parsed.assignees,
parsed.target_branch,
parsed.blobs,
live.created_at(),
cx.actor().clone(),
)?;
let produces = record.cid();
if live.version() == Some(produces) {
if parsed.state.is_some_and(|sent| sent == opened.state) {
return Err(WriteError::conflict(format!(
"ticket at {} is already {wanted}",
address.at_uri(self.repo)
)));
}
if from == produces {
return Ok(wrote(self.repo, &address, produces));
}
}
let change: TicketChange = SocialChange::Edit {
editing: Editing {
author: cx.actor().clone(),
from,
produces,
edited_at: self.now,
},
body,
};
self.edit(cx, &address, change, object, &record)
}
pub(super) fn comment_put(
&self,
cx: &WriteContext<'_>,
swap: SwapInput,
record: serde_json::Value,
) -> Result<Written, WriteError> {
let (_, rkey, from) = swap.edit_of(&comment_collection())?;
let collection = comment_collection();
let draft = typed::<CommentDraft>(record, &collection)?.parse(self.repo)?;
self.cite_attachments(draft.markup.attachments());
let address = RecordAddress::new(collection, rkey);
let (object, live) = self.standing(&address)?;
if live.parent() != Some(draft.subject.address()) {
let opened_on = live.parent().map_or_else(
|| "wasn't created on a subject".to_string(),
|parent| format!("was created on {}", parent.at_uri(self.repo)),
);
return Err(WriteError::invalid_request(format!(
"a comment's subject is set at creation: {} {}, and the edit's subject is {}",
address.at_uri(self.repo),
opened_on,
draft.subject.address().at_uri(self.repo),
)));
}
let opened = self.opened_comment(object)?;
if opened.subject != draft.subject.cid() {
return Err(WriteError::invalid_request(format!(
"the edit's subject is {}, but this comment was created on {}; subject is set \
at creation",
draft.subject.cid(),
opened.subject,
)));
}
if opened.reply != draft.reply_to {
let describe = |reply: Option<&knot_record::comment::CommentRef>| {
reply.map_or_else(
|| "without a reply".to_string(),
|reply| format!("{} at {}", reply.cid(), reply.address().at_uri(self.repo)),
)
};
return Err(WriteError::invalid_request(format!(
"reply is set at creation: the comment was created {}, the edit's reply is {}",
describe(opened.reply.as_ref()),
describe(draft.reply_to.as_ref()),
)));
}
let record = CommentRecord::new(
self.repo.clone(),
draft.subject,
draft.markup,
draft.reply_to,
draft.embed,
live.created_at(),
cx.actor().clone(),
)?
.body()?;
let change: knot_cobs::CommentChange = SocialChange::Edit {
editing: Editing {
author: cx.actor().clone(),
from,
produces: record.cid(),
edited_at: self.now,
},
body: opened,
};
self.edit(cx, &address, change, object, &record)
}
pub(super) fn label_def_put(
&self,
cx: &WriteContext<'_>,
swap: SwapInput,
record: serde_json::Value,
) -> Result<Written, WriteError> {
let collection = label_def_collection();
let (_, rkey, from) = swap.edit_of(&collection)?;
let def = typed::<LabelDefDraft>(record, &collection)?.parse(&collection)?;
let address = RecordAddress::new(collection, rkey);
let (object, live) = self.standing(&address)?;
let record = LabelDefRecord::new(def, live.created_at(), cx.actor().clone())?.body()?;
let produces = record.cid();
if live.version() == Some(produces) && from == produces {
return Ok(wrote(self.repo, &address, produces));
}
let change: LabelDefChange = SocialChange::Edit {
editing: Editing {
author: cx.actor().clone(),
from,
produces,
edited_at: self.now,
},
body: LabelDef,
};
self.edit(cx, &address, change, object, &record)
}
}