use serde::{Deserialize, Serialize};
use serde_json::json;
use url::Url;
use crate::boundary::{Surface, TermScope};
use crate::error::{invalid, Result};
use crate::event::EventKind;
use crate::host::{ChangeId, RemoteHost};
use crate::providers::Providers;
use crate::session::SessionToken;
use crate::stream::Stream;
use crate::workspace::object;
use crate::{change, ids, lock, publish};
pub(crate) mod github;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ChangeRef {
Session(SessionToken),
Url(String),
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct CommentId(pub String);
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "kebab-case")]
pub enum CommentKind {
ReviewThread {
thread: String,
path: String,
line: Option<u32>,
outdated: bool,
resolved: bool,
},
Review {
state: String,
},
Conversation,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ReviewComment {
pub id: CommentId,
pub kind: CommentKind,
pub author: String,
pub body: String,
pub url: String,
pub created_at: String,
pub updated_at: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub in_reply_to: Option<CommentId>,
pub ours: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reply_marker: Option<ReplyMarker>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ReplyMarker {
pub in_reply_to: CommentId,
pub key: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct ReadMarker(pub String);
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ReadCost {
pub graphql_points: u32,
pub rest_requests: u32,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ReviewRead {
pub change_url: String,
pub comments: Vec<ReviewComment>,
pub marker: ReadMarker,
pub unchanged: bool,
pub cost: ReadCost,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ReplyRequest {
pub change: ChangeRef,
pub comment: CommentId,
pub body: String,
pub key: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub verified_absent: bool,
#[serde(default, skip_serializing_if = "TermScope::is_registry")]
pub term_scope: TermScope,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PostedReply {
pub id: CommentId,
pub url: String,
pub threaded: bool,
pub existing: bool,
}
pub fn review_comments(
providers: &Providers<'_>,
change: &ChangeRef,
since: Option<&ReadMarker>,
) -> Result<ReviewRead> {
let addressed = Addressed::of(providers, change)?;
let read = addressed.host.review_comments(&addressed.id, since)?;
addressed.record(EventKind::ReviewCommentsRead, read_payload(&read));
Ok(read)
}
fn read_payload(read: &ReviewRead) -> serde_json::Value {
json!({
"change_url": read.change_url,
"count": read.comments.len(),
"unchanged": read.unchanged,
"marker_digest": ids::digest(&read.marker.0),
"cost": {
"graphql_points": read.cost.graphql_points,
"rest_requests": read.cost.rest_requests,
},
})
}
pub(crate) fn record_url_read(identity: &str, read: &ReviewRead) {
record_on_change_stream(
&read.change_url,
Some(identity),
EventKind::ReviewCommentsRead,
read_payload(read),
);
}
fn record_on_change_stream(
change_url: &str,
identity: Option<&str>,
kind: EventKind,
payload: serde_json::Value,
) {
match Stream::open(&change_stream(change_url)) {
Ok(mut stream) => {
stream.label("change_url", change_url);
if let Some(identity) = identity {
stream.label("identity", identity);
}
stream.emit(kind, object(payload));
}
Err(error) => {
eprintln!("onevcs: warning: what was done on {change_url} is not recorded: {error}")
}
}
}
pub fn reply_to_comment(providers: &Providers<'_>, request: &ReplyRequest) -> Result<PostedReply> {
if request.body.trim().is_empty() {
return Err(invalid(
"a reply needs a body, and this one is empty: say what the reply answers with",
));
}
let key = checked_key(&request.key)?;
let comment = checked_comment(&request.comment)?;
let label = request.label.as_deref().map(checked_label).transpose()?;
let addressed = Addressed::of(providers, &request.change)?;
let marker = ReplyMarker {
in_reply_to: comment.clone(),
key: key.to_owned(),
label: label.map(str::to_owned),
};
let body = compose(&request.body, &marker);
crate::boundary::evidence::guard_fields(
providers.hosting,
&addressed.identity,
&[(Surface::Body, body.as_str())],
&request.term_scope,
)?;
let _turn = lock::exclusive(&format!("review-reply:{}:{key}", addressed.change_url))?;
let reply = if request.verified_absent {
addressed
.host
.reply_to_comment(&addressed.id, comment, &body, key)?
} else {
let read = addressed.host.review_comments(&addressed.id, None)?;
match keyed_reply(&read.comments, key) {
Some(existing) => existing,
None => {
let target = thread_root(&read, comment)?;
addressed
.host
.reply_to_comment(&addressed.id, &target, &body, key)?
}
}
};
addressed.record(
EventKind::ReviewReplyPosted,
json!({
"change_url": addressed.change_url,
"comment": comment.0,
"reply": reply.id.0,
"url": reply.url,
"threaded": reply.threaded,
"key": key,
"existing": reply.existing,
}),
);
Ok(reply)
}
fn keyed_reply(comments: &[ReviewComment], key: &str) -> Option<PostedReply> {
comments
.iter()
.find(|candidate| {
candidate
.reply_marker
.as_ref()
.is_some_and(|marker| marker.key == key)
})
.map(|found| PostedReply {
id: found.id.clone(),
url: found.url.clone(),
threaded: matches!(found.kind, CommentKind::ReviewThread { .. }),
existing: true,
})
}
fn thread_root(read: &ReviewRead, comment: &CommentId) -> Result<CommentId> {
let asked = read
.comments
.iter()
.find(|candidate| candidate.id == *comment)
.ok_or_else(|| {
invalid(format!(
"{} carries no comment {:?}, so there is nothing to reply to; `onevcs change \
comments` lists the comments it carries",
read.change_url, comment.0
))
})?;
let CommentKind::ReviewThread { thread, .. } = &asked.kind else {
return Ok(comment.clone());
};
Ok(read
.comments
.iter()
.find(|candidate| {
candidate.in_reply_to.is_none()
&& matches!(&candidate.kind, CommentKind::ReviewThread { thread: other, .. } if other == thread)
})
.map_or_else(|| comment.clone(), |root| root.id.clone()))
}
pub(crate) fn render_marker(marker: &ReplyMarker) -> String {
let label = marker
.label
.as_deref()
.map(|label| format!(" label={label}"))
.unwrap_or_default();
format!(
"<!-- onevcs:reply in-reply-to={} key={}{label} -->",
marker.in_reply_to.0, marker.key
)
}
pub(crate) fn compose(body: &str, marker: &ReplyMarker) -> String {
format!("{}\n\n{}", body.trim_end(), render_marker(marker))
}
pub(crate) fn parse_marker(body: &str) -> Option<ReplyMarker> {
let last = body.lines().map(str::trim).rfind(|line| !line.is_empty())?;
let inner = last
.strip_prefix("<!-- onevcs:reply ")?
.strip_suffix("-->")?
.trim();
let (mut in_reply_to, mut key, mut label) = (None, None, None);
for field in inner.split_whitespace() {
let (name, value) = field.split_once('=')?;
let slot = match name {
"in-reply-to" => &mut in_reply_to,
"key" => &mut key,
"label" => &mut label,
_ => continue,
};
if value.is_empty() || slot.replace(value.to_owned()).is_some() {
return None;
}
}
if label.as_deref().is_some_and(|label| !is_token(label)) {
return None;
}
Some(ReplyMarker {
in_reply_to: CommentId(in_reply_to?),
key: key?,
label,
})
}
fn is_token(value: &str) -> bool {
!value.is_empty() && value.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
}
fn is_markable(value: &str) -> bool {
!value.is_empty()
&& !value
.chars()
.any(|c| c.is_whitespace() || c.is_control() || matches!(c, '<' | '>'))
}
fn checked_key(key: &str) -> Result<&str> {
if key.is_empty() {
return Err(invalid(
"a reply needs an idempotency key, and this one is empty: pass the same key \
every time this reply is asked for, so a repeated call finds the first",
));
}
if !is_markable(key) {
return Err(invalid(format!(
"the key {key:?} cannot be written into a reply marker: use one with no \
whitespace and no angle bracket"
)));
}
Ok(key)
}
fn checked_label(label: &str) -> Result<&str> {
if !is_token(label) {
return Err(invalid(format!(
"the label {label:?} is not a single token of letters, digits and hyphens"
)));
}
Ok(label)
}
fn checked_comment(comment: &CommentId) -> Result<&CommentId> {
if !is_markable(&comment.0) {
return Err(invalid(format!(
"{:?} is not a comment id: pass the id `onevcs change comments` reports",
comment.0
)));
}
Ok(comment)
}
struct Addressed {
host: Box<dyn RemoteHost>,
id: ChangeId,
change_url: String,
identity: String,
stream: StreamOf,
}
enum StreamOf {
Session { token: String, identity: String },
Change,
}
impl Addressed {
fn of(providers: &Providers<'_>, change: &ChangeRef) -> Result<Self> {
match change {
ChangeRef::Session(token) => {
let session = change::Addressed::of(providers, token)?;
let request = session.require()?;
Ok(Self {
id: request.id,
change_url: request.url.to_string(),
identity: session.record.identity.clone(),
stream: StreamOf::Session {
token: token.0.clone(),
identity: session.record.identity.clone(),
},
host: session.host,
})
}
ChangeRef::Url(url) => {
let parsed = ChangeUrl::parse(url)?;
let slug = publish::change_host(&parsed.identity)?;
Ok(Self {
host: providers.hosting.for_repo(&slug)?,
id: ChangeId(parsed.number.clone()),
change_url: parsed.canonical(),
identity: parsed.identity,
stream: StreamOf::Change,
})
}
}
}
fn record(&self, kind: EventKind, payload: serde_json::Value) {
let StreamOf::Session { token, identity } = &self.stream else {
return record_on_change_stream(&self.change_url, None, kind, payload);
};
match Stream::open(token) {
Ok(mut stream) => {
stream.label("identity", identity);
stream.emit(kind, object(payload));
}
Err(error) => eprintln!(
"onevcs: warning: what was done on {} is not recorded: {error}",
self.change_url
),
}
}
}
fn change_stream(change_url: &str) -> String {
crate::standings::change_stream(change_url)
}
pub(crate) struct ChangeUrl {
host: String,
owner: String,
name: String,
pub(crate) number: String,
pub(crate) identity: String,
}
impl ChangeUrl {
pub(crate) fn parse(text: &str) -> Result<Self> {
let refused = || {
invalid(format!(
"{text:?} is neither a session token nor a change request's URL; a URL names \
one as https://<host>/<owner>/<name>/pull/<number>"
))
};
let url = Url::parse(text.trim()).map_err(|_| refused())?;
if url.scheme() != "https" {
return Err(refused());
}
let host = url.host_str().ok_or_else(refused)?.to_ascii_lowercase();
let segments: Vec<&str> = url.path_segments().ok_or_else(refused)?.collect();
let [owner, name, "pull", number, ..] = segments[..] else {
return Err(refused());
};
let addressable = |part: &str| {
!part.is_empty()
&& !part.starts_with('-')
&& part
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
};
if !addressable(owner)
|| !addressable(name)
|| number.is_empty()
|| !number.chars().all(|c| c.is_ascii_digit())
{
return Err(refused());
}
Ok(Self {
identity: format!("{host}/{owner}/{name}"),
host,
owner: owner.to_owned(),
name: name.to_owned(),
number: number.to_owned(),
})
}
pub(crate) fn canonical(&self) -> String {
format!(
"https://{}/{}/{}/pull/{}",
self.host, self.owner, self.name, self.number
)
}
}
pub(crate) fn change_ref(text: &str) -> ChangeRef {
if text.contains("://") {
ChangeRef::Url(text.to_owned())
} else {
ChangeRef::Session(SessionToken(text.to_owned()))
}
}