use serde::{Deserialize, Serialize};
use serde_json::json;
use url::Url;
use crate::boundary::{Surface, TermScope};
use crate::error::{Error, Result};
use crate::event::{ArtifactRef, EventKind};
use crate::host::{ChangeId, ChangeRequest, RemoteHost};
use crate::providers::Providers;
use crate::publish::{self, Subject};
use crate::session::{SessionRecord, SessionToken};
use crate::stream::{self, Stream};
use crate::workspace::{self, object};
use crate::{git, guidance};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChangeDescription {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<Subject>,
pub body: String,
#[serde(default, skip_serializing_if = "TermScope::is_registry")]
pub term_scope: TermScope,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SessionChange {
pub url: Url,
pub id: ChangeId,
pub base: String,
pub draft: bool,
pub title: String,
pub body: String,
}
pub fn session_change(
providers: &Providers<'_>,
token: &SessionToken,
) -> Result<Option<SessionChange>> {
let session = Addressed::of(providers, token)?;
let Some(change) = session.find()? else {
return Ok(None);
};
session.read(&change).map(Some)
}
pub fn describe_change(
providers: &Providers<'_>,
token: &SessionToken,
description: &ChangeDescription,
) -> Result<SessionChange> {
let session = Addressed::of(providers, token)?;
let change = session.require()?;
if let Some(title) = &description.title {
session.hold_to_repository_policy(title)?;
}
let mut fields = vec![(Surface::Body, description.body.as_str())];
if let Some(title) = &description.title {
fields.push((Surface::Title, title));
}
crate::boundary::evidence::guard_fields(
providers.hosting,
&session.record.identity,
&fields,
&description.term_scope,
)?;
session
.host
.describe_change(&change, description.title.as_deref(), &description.body)?;
record_description(&session, &change, description);
session.read(&change)
}
fn record_description(
session: &Addressed<'_>,
change: &ChangeRequest,
description: &ChangeDescription,
) {
let mut stream = match session.stream() {
Ok(stream) => stream,
Err(error) => {
eprintln!(
"onevcs: warning: the description of {} was written and is not recorded: {error}",
change.url
);
return;
}
};
let mut payload = object(json!({
"url": change.url.to_string(),
"id": change.id.0,
"base": change.base,
}));
if let Some(title) = &description.title {
payload.insert("title".to_owned(), json!(title.to_string()));
}
let artifacts: Vec<ArtifactRef> = match stream::store_artifact("body", &description.body) {
Ok(artifact) => {
payload.insert("artifact".to_owned(), json!(artifact.id));
vec![artifact]
}
Err(error) => {
eprintln!(
"onevcs: warning: the description of {} is recorded without its body: {error}",
change.url
);
Vec::new()
}
};
stream.emit_with(EventKind::ChangeDescribed, payload, artifacts);
}
pub fn ready_change(providers: &Providers<'_>, token: &SessionToken) -> Result<SessionChange> {
let session = Addressed::of(providers, token)?;
let change = session.require()?;
let mut stream = session.stream()?;
publish::lift_any_draft(session.host.as_ref(), &change, &mut stream)?;
session.read(&change)
}
pub(crate) struct Addressed<'a> {
token: &'a SessionToken,
pub(crate) record: SessionRecord,
base: String,
pub(crate) host: Box<dyn RemoteHost>,
}
impl<'a> Addressed<'a> {
pub(crate) fn of(providers: &Providers<'_>, token: &'a SessionToken) -> Result<Self> {
let record = providers.vcs.session(token)?;
let slug = publish::change_host(&record.identity)?;
let host = providers.hosting.for_repo(&slug)?;
let base = publication_base(token, &record)?;
Ok(Self {
token,
record,
base,
host,
})
}
fn find(&self) -> Result<Option<ChangeRequest>> {
Ok(self
.host
.find_changes(&self.record.session.branch, &self.base)?
.into_iter()
.next())
}
pub(crate) fn require(&self) -> Result<ChangeRequest> {
self.find()?.ok_or_else(|| Error::Invalid {
reason: format!(
"session {token} has no open change request from {branch:?} into {base:?} on the \
host, so there is nothing to act on. Nothing here opens one: publish the \
session first — `{publish}` opens it as a draft the session holds while its \
work is still being made",
token = self.token.0,
branch = self.record.session.branch,
base = self.base,
publish = guidance::command(["onevcs", "publish", &self.token.0, "--draft"]),
),
})
}
fn read(&self, change: &ChangeRequest) -> Result<SessionChange> {
let description = self.host.change_description(change)?;
Ok(SessionChange {
url: change.url.clone(),
id: change.id.clone(),
base: change.base.clone(),
draft: self.host.is_draft(change)?,
title: description.title,
body: description.body,
})
}
fn hold_to_repository_policy(&self, title: &Subject) -> Result<()> {
let worktree = &self.record.session.worktree;
if !git::is_repo(worktree) {
return Ok(());
}
publish::hold_to_repository_policy(worktree, &self.record.session.branch, title)
}
fn stream(&self) -> Result<Stream> {
let mut stream = Stream::open(&self.token.0)?;
stream.label("identity", &self.record.identity);
Ok(stream)
}
}
fn publication_base(token: &SessionToken, record: &SessionRecord) -> Result<String> {
match workspace::load(&token.0) {
Ok(stored) if stored.session() == record.session => {
Ok(publish::session_target(&stored)?.base().to_string())
}
_ => Ok(record.session.base.clone()),
}
}