onevcs/review.rs
1//! A change request's review feedback, read, and a reply posted where it belongs.
2//!
3//! Two operations over the host. [`review_comments`] reads every review-thread
4//! comment, review summary and conversation comment a change request carries — or
5//! only what changed since a [`ReadMarker`] an earlier read handed back — and
6//! [`reply_to_comment`] answers one of them: in its thread where the host has one,
7//! and as a new conversation comment linking it where it does not.
8//!
9//! **The change request on the host is the record.** Every reply this crate posts
10//! ends with one hidden line, the reply marker, naming the comment it answers, the
11//! caller's idempotency key and an optional label. A read parses it back into
12//! [`ReviewComment::reply_marker`] and sets [`ReviewComment::ours`] from it and from
13//! nothing else — never from who wrote the comment, because a reviewer and the
14//! credential replying for them are often one account. So a consumer derives from
15//! the host alone which feedback it has answered and how, and a caller that lost the
16//! answer to a reply it posted asks again with the same key and is handed the reply
17//! that is already there rather than posting a second.
18//!
19//! Both are recorded on an event stream in the review phase: a session's own stream
20//! when the change request is named by its session, and otherwise a stream of the
21//! change request's own, `change-<digest>` — the first twelve hex characters of the
22//! SHA-256 of its canonical URL.
23
24use serde::{Deserialize, Serialize};
25use serde_json::json;
26use url::Url;
27
28use crate::boundary::{Surface, TermScope};
29use crate::error::{invalid, Result};
30use crate::event::EventKind;
31use crate::host::{ChangeId, RemoteHost};
32use crate::providers::Providers;
33use crate::session::SessionToken;
34use crate::stream::Stream;
35use crate::workspace::object;
36use crate::{change, ids, lock, publish};
37
38pub(crate) mod github;
39
40/// A change request, named by the session that published it or by its URL.
41///
42/// A URL needs no session and no registry: it names the repository and the change
43/// request on the host directly, so a caller on a host that has never seen the
44/// change can still read and answer its feedback.
45#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
46#[serde(rename_all = "kebab-case")]
47pub enum ChangeRef {
48 /// The open change request of this session — the one `publish` opened for it.
49 Session(SessionToken),
50 /// A change request's URL, as the host prints it.
51 // llmlint: ignore[invalid_states_unrepresentable] the contract declares this variant
52 // as `Url(String)`; the text is parsed — host, repository and number — where a call
53 // resolves it, and refused there by name when it is not a change request's URL.
54 Url(String),
55}
56
57/// The host's own stable id for a comment (GitHub's node id).
58// llmlint: ignore[invalid_states_unrepresentable] the contract declares this as
59// `CommentId(pub String)` and fixes nothing about its content, which is the host's to
60// spell. Every one this crate reads is taken from a host response, and one a caller
61// hands in is refused before it reaches a reply marker or a host route when it could
62// not be one.
63#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
64#[serde(transparent)]
65pub struct CommentId(pub String);
66
67/// What kind of comment a [`ReviewComment`] is, and where it sits.
68#[non_exhaustive]
69#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
70#[serde(tag = "type", rename_all = "kebab-case")]
71pub enum CommentKind {
72 /// A comment in a review thread on the diff.
73 ReviewThread {
74 /// The host's id for the thread.
75 thread: String,
76 /// The file the thread is on.
77 path: String,
78 /// The line it is on, where the host still places it on one.
79 line: Option<u32>,
80 /// Whether the diff has moved on from the line the thread was left on.
81 outdated: bool,
82 /// Whether somebody resolved the thread.
83 resolved: bool,
84 },
85 /// A review's summary body; `state` is the host's word (commented,
86 /// changes_requested, approved).
87 Review {
88 /// The review's state, in the host's own word, lower case.
89 // llmlint: ignore[invalid_states_unrepresentable] the contract fixes this as
90 // the host's own word, passed through; the vocabulary is the host's.
91 state: String,
92 },
93 /// A top-level conversation comment.
94 Conversation,
95}
96
97/// One comment on a change request, as the host holds it.
98// llmlint: ignore[invalid_states_unrepresentable] every text field is the host's own,
99// read out of its response: the contract declares them `String`, and `ours` and
100// `reply_marker` are derived together, in one place, from the body.
101#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
102pub struct ReviewComment {
103 /// The host's id for it.
104 pub id: CommentId,
105 /// What kind of comment it is, and where it sits.
106 pub kind: CommentKind,
107 /// Who wrote it.
108 pub author: String,
109 /// What it says, verbatim.
110 pub body: String,
111 /// Where a person reads it.
112 pub url: String,
113 /// When it was written, RFC 3339.
114 pub created_at: String,
115 /// When it last changed, RFC 3339; differs from `created_at` once edited.
116 pub updated_at: String,
117 /// The comment it replies to in its thread, where the host links one.
118 #[serde(default, skip_serializing_if = "Option::is_none")]
119 pub in_reply_to: Option<CommentId>,
120 /// True for a reply onevcs itself posted (it carries the reply marker).
121 pub ours: bool,
122 /// The reply marker parsed, exactly when `ours`.
123 #[serde(default, skip_serializing_if = "Option::is_none")]
124 pub reply_marker: Option<ReplyMarker>,
125}
126
127/// The hidden last line of every reply this crate posts, parsed.
128///
129/// Written as `<!-- onevcs:reply in-reply-to=<comment id> key=<key> [label=<label>] -->`.
130// llmlint: ignore[invalid_states_unrepresentable] the contract declares these fields as
131// strings; a key and a label are held to their shape where a reply is composed
132// (`checked_key`, `checked_label`), and a marker a read could not hold to it is not a
133// marker at all.
134#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
135pub struct ReplyMarker {
136 /// The comment the reply answers.
137 pub in_reply_to: CommentId,
138 /// The caller's idempotency key.
139 pub key: String,
140 /// The caller's label for the reply, where it gave one.
141 #[serde(default, skip_serializing_if = "Option::is_none")]
142 pub label: Option<String>,
143}
144
145/// Opaque to callers; carries what the transport needs to read only what changed.
146// llmlint: ignore[invalid_states_unrepresentable] opaque by contract: only the
147// transport that wrote one can read it, and it refuses one it did not write by name.
148#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
149#[serde(transparent)]
150pub struct ReadMarker(pub String);
151
152/// What the host charged for one read, from the responses' own allowance figures.
153#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
154pub struct ReadCost {
155 /// GraphQL points, summed from every response's own `rateLimit { cost }`.
156 pub graphql_points: u32,
157 /// REST requests the host charged for, from each response's own allowance header.
158 pub rest_requests: u32,
159}
160
161/// What one read of a change request's review feedback found.
162#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
163pub struct ReviewRead {
164 /// The change request, as the host addresses it.
165 pub change_url: String,
166 /// Every comment created or edited after `since` (all of them when `since` is
167 /// None), and every comment whose thread's resolved or outdated flag changed
168 /// since `since`.
169 pub comments: Vec<ReviewComment>,
170 /// What to pass as `since` next time.
171 pub marker: ReadMarker,
172 /// True when nothing new was read since `since`.
173 pub unchanged: bool,
174 /// What the host charged for this read.
175 pub cost: ReadCost,
176}
177
178/// One reply to post.
179///
180/// `key` is the caller's idempotency key: calls on one host post at most one reply
181/// carrying it. `label` is written into the marker (`label=<label>`);
182/// `verified_absent` skips the read of the thread before posting, for a caller that
183/// has just read it and found no reply with `key`.
184// llmlint: ignore[invalid_states_unrepresentable] the contract declares these fields;
185// `reply_to_comment` refuses an empty body, an empty or unmarkable key, a comment id
186// that could not be one and a label that is not a single token, before the host is asked.
187#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
188pub struct ReplyRequest {
189 /// The change request the comment is on.
190 pub change: ChangeRef,
191 /// The comment to answer.
192 pub comment: CommentId,
193 /// What to say. The reply marker is added after it.
194 pub body: String,
195 /// The caller's idempotency key.
196 pub key: String,
197 /// A single token of letters, digits and hyphens, written into the marker.
198 #[serde(default, skip_serializing_if = "Option::is_none")]
199 pub label: Option<String>,
200 /// Post without reading the thread first: the caller has just read it and found
201 /// no reply carrying `key`.
202 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
203 pub verified_absent: bool,
204 /// Which private repositories the public boundary check derives its terms from,
205 /// should the change request's repository be public. Unset is every registered
206 /// private one.
207 #[serde(default, skip_serializing_if = "TermScope::is_registry")]
208 pub term_scope: TermScope,
209}
210
211/// What a reply did. `existing` is true when a reply carrying the key was already on
212/// the host and nothing was posted.
213#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
214pub struct PostedReply {
215 /// The reply's id on the host.
216 pub id: CommentId,
217 /// Where a person reads it.
218 pub url: String,
219 /// Whether it sits in the answered comment's thread, rather than in the
220 /// conversation.
221 pub threaded: bool,
222 /// Whether it was already on the host, so nothing was posted.
223 pub existing: bool,
224}
225
226/// Read a change request's review feedback: every comment created or edited after
227/// `since`, and every comment whose thread was resolved, unresolved or outdated since
228/// it, with the marker to pass next time.
229///
230/// The library form of `onevcs change comments`. Records one `review-comments-read`
231/// event.
232pub fn review_comments(
233 providers: &Providers<'_>,
234 change: &ChangeRef,
235 since: Option<&ReadMarker>,
236) -> Result<ReviewRead> {
237 let addressed = Addressed::of(providers, change)?;
238 let read = addressed.host.review_comments(&addressed.id, since)?;
239 addressed.record(EventKind::ReviewCommentsRead, read_payload(&read));
240 Ok(read)
241}
242
243/// What a `review-comments-read` event says about one read — one spelling, whichever
244/// operation read it.
245fn read_payload(read: &ReviewRead) -> serde_json::Value {
246 json!({
247 "change_url": read.change_url,
248 "count": read.comments.len(),
249 "unchanged": read.unchanged,
250 // A digest rather than the marker: a marker grows with the change request
251 // and the envelope bounds payload text, while a digest is fixed in size and
252 // still says exactly which marker the call returned.
253 "marker_digest": ids::digest(&read.marker.0),
254 "cost": {
255 "graphql_points": read.cost.graphql_points,
256 "rest_requests": read.cost.rest_requests,
257 },
258 })
259}
260
261/// Record one read of a change request named by its URL, on that change request's own
262/// stream, exactly as [`review_comments`] records one — what a review-and-standings read
263/// records for each change request it read. Best effort.
264pub(crate) fn record_url_read(identity: &str, read: &ReviewRead) {
265 record_on_change_stream(
266 &read.change_url,
267 Some(identity),
268 EventKind::ReviewCommentsRead,
269 read_payload(read),
270 );
271}
272
273/// Record one event on the stream a change request named by URL records on.
274fn record_on_change_stream(
275 change_url: &str,
276 identity: Option<&str>,
277 kind: EventKind,
278 payload: serde_json::Value,
279) {
280 match Stream::open(&change_stream(change_url)) {
281 Ok(mut stream) => {
282 stream.label("change_url", change_url);
283 if let Some(identity) = identity {
284 stream.label("identity", identity);
285 }
286 stream.emit(kind, object(payload));
287 }
288 Err(error) => {
289 eprintln!("onevcs: warning: what was done on {change_url} is not recorded: {error}")
290 }
291 }
292}
293
294/// Answer one comment: in its thread where it has one, and otherwise as a new
295/// conversation comment whose first line links it. Never resolves a thread.
296///
297/// The library form of `onevcs change reply`. The body is held to the public-remote
298/// boundary before anything is posted, and every call with the same change request
299/// and key on this host is serialized under one lock from its read of the replies to
300/// its post, so a call that finds the keyed reply already there posts nothing and
301/// answers it with `existing: true`. Records one `review-reply-posted` event.
302pub fn reply_to_comment(providers: &Providers<'_>, request: &ReplyRequest) -> Result<PostedReply> {
303 if request.body.trim().is_empty() {
304 return Err(invalid(
305 "a reply needs a body, and this one is empty: say what the reply answers with",
306 ));
307 }
308 let key = checked_key(&request.key)?;
309 let comment = checked_comment(&request.comment)?;
310 let label = request.label.as_deref().map(checked_label).transpose()?;
311 let addressed = Addressed::of(providers, &request.change)?;
312 let marker = ReplyMarker {
313 in_reply_to: comment.clone(),
314 key: key.to_owned(),
315 label: label.map(str::to_owned),
316 };
317 let body = compose(&request.body, &marker);
318 // A reply is written to the host as it is, so it is held to the boundary before
319 // the host is asked anything that could write.
320 crate::boundary::evidence::guard_fields(
321 providers.hosting,
322 &addressed.identity,
323 &[(Surface::Body, body.as_str())],
324 &request.term_scope,
325 )?;
326 let _turn = lock::exclusive(&format!("review-reply:{}:{key}", addressed.change_url))?;
327 let reply = if request.verified_absent {
328 addressed
329 .host
330 .reply_to_comment(&addressed.id, comment, &body, key)?
331 } else {
332 let read = addressed.host.review_comments(&addressed.id, None)?;
333 match keyed_reply(&read.comments, key) {
334 Some(existing) => existing,
335 None => {
336 let target = thread_root(&read, comment)?;
337 addressed
338 .host
339 .reply_to_comment(&addressed.id, &target, &body, key)?
340 }
341 }
342 };
343 addressed.record(
344 EventKind::ReviewReplyPosted,
345 json!({
346 "change_url": addressed.change_url,
347 "comment": comment.0,
348 "reply": reply.id.0,
349 "url": reply.url,
350 "threaded": reply.threaded,
351 "key": key,
352 "existing": reply.existing,
353 }),
354 );
355 Ok(reply)
356}
357
358/// The reply already on the host under `key`, if there is one: anywhere in the change
359/// request and whichever comment it answers, since a key stands for one reply.
360fn keyed_reply(comments: &[ReviewComment], key: &str) -> Option<PostedReply> {
361 comments
362 .iter()
363 .find(|candidate| {
364 candidate
365 .reply_marker
366 .as_ref()
367 .is_some_and(|marker| marker.key == key)
368 })
369 .map(|found| PostedReply {
370 id: found.id.clone(),
371 url: found.url.clone(),
372 threaded: matches!(found.kind, CommentKind::ReviewThread { .. }),
373 existing: true,
374 })
375}
376
377/// The comment a reply to `comment` is posted against: its thread's first comment
378/// where it is in a thread, because a host's thread-reply route answers the thread
379/// rather than one reply in it, and the comment itself otherwise. Refused when the
380/// change request carries no such comment, since nothing could be answered.
381fn thread_root(read: &ReviewRead, comment: &CommentId) -> Result<CommentId> {
382 let asked = read
383 .comments
384 .iter()
385 .find(|candidate| candidate.id == *comment)
386 .ok_or_else(|| {
387 invalid(format!(
388 "{} carries no comment {:?}, so there is nothing to reply to; `onevcs change \
389 comments` lists the comments it carries",
390 read.change_url, comment.0
391 ))
392 })?;
393 let CommentKind::ReviewThread { thread, .. } = &asked.kind else {
394 return Ok(comment.clone());
395 };
396 Ok(read
397 .comments
398 .iter()
399 .find(|candidate| {
400 candidate.in_reply_to.is_none()
401 && matches!(&candidate.kind, CommentKind::ReviewThread { thread: other, .. } if other == thread)
402 })
403 .map_or_else(|| comment.clone(), |root| root.id.clone()))
404}
405
406/// The marker's own spelling: the hidden line a reply ends with.
407pub(crate) fn render_marker(marker: &ReplyMarker) -> String {
408 let label = marker
409 .label
410 .as_deref()
411 .map(|label| format!(" label={label}"))
412 .unwrap_or_default();
413 format!(
414 "<!-- onevcs:reply in-reply-to={} key={}{label} -->",
415 marker.in_reply_to.0, marker.key
416 )
417}
418
419/// A reply's whole body: what the caller said, then the marker on its own last line.
420pub(crate) fn compose(body: &str, marker: &ReplyMarker) -> String {
421 format!("{}\n\n{}", body.trim_end(), render_marker(marker))
422}
423
424/// The reply marker a comment's body ends with, where its last line is one.
425///
426/// The one place `ours` is decided: a comment is this crate's reply exactly when this
427/// answers `Some`, whoever wrote it.
428pub(crate) fn parse_marker(body: &str) -> Option<ReplyMarker> {
429 let last = body.lines().map(str::trim).rfind(|line| !line.is_empty())?;
430 let inner = last
431 .strip_prefix("<!-- onevcs:reply ")?
432 .strip_suffix("-->")?
433 .trim();
434 let (mut in_reply_to, mut key, mut label) = (None, None, None);
435 for field in inner.split_whitespace() {
436 let (name, value) = field.split_once('=')?;
437 let slot = match name {
438 "in-reply-to" => &mut in_reply_to,
439 "key" => &mut key,
440 "label" => &mut label,
441 // A later build may write more; what this one reads is still its reply.
442 _ => continue,
443 };
444 if value.is_empty() || slot.replace(value.to_owned()).is_some() {
445 return None;
446 }
447 }
448 if label.as_deref().is_some_and(|label| !is_token(label)) {
449 return None;
450 }
451 Some(ReplyMarker {
452 in_reply_to: CommentId(in_reply_to?),
453 key: key?,
454 label,
455 })
456}
457
458/// A single token of letters, digits and hyphens.
459fn is_token(value: &str) -> bool {
460 !value.is_empty() && value.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
461}
462
463/// Text a marker can carry as one field: something, with no whitespace, no control
464/// character, and no angle bracket that could close the hidden line early.
465fn is_markable(value: &str) -> bool {
466 !value.is_empty()
467 && !value
468 .chars()
469 .any(|c| c.is_whitespace() || c.is_control() || matches!(c, '<' | '>'))
470}
471
472fn checked_key(key: &str) -> Result<&str> {
473 if key.is_empty() {
474 return Err(invalid(
475 "a reply needs an idempotency key, and this one is empty: pass the same key \
476 every time this reply is asked for, so a repeated call finds the first",
477 ));
478 }
479 if !is_markable(key) {
480 return Err(invalid(format!(
481 "the key {key:?} cannot be written into a reply marker: use one with no \
482 whitespace and no angle bracket"
483 )));
484 }
485 Ok(key)
486}
487
488fn checked_label(label: &str) -> Result<&str> {
489 if !is_token(label) {
490 return Err(invalid(format!(
491 "the label {label:?} is not a single token of letters, digits and hyphens"
492 )));
493 }
494 Ok(label)
495}
496
497fn checked_comment(comment: &CommentId) -> Result<&CommentId> {
498 if !is_markable(&comment.0) {
499 return Err(invalid(format!(
500 "{:?} is not a comment id: pass the id `onevcs change comments` reports",
501 comment.0
502 )));
503 }
504 Ok(comment)
505}
506
507/// One change request, resolved as far as the host that answers for it.
508struct Addressed {
509 host: Box<dyn RemoteHost>,
510 id: ChangeId,
511 /// Its canonical URL, which keys the reply lock and names it in every record.
512 change_url: String,
513 /// The repository's identity key, which the boundary check is made for.
514 identity: String,
515 /// The stream its events go on: the session's own, or the change request's.
516 stream: StreamOf,
517}
518
519enum StreamOf {
520 Session { token: String, identity: String },
521 Change,
522}
523
524impl Addressed {
525 fn of(providers: &Providers<'_>, change: &ChangeRef) -> Result<Self> {
526 match change {
527 ChangeRef::Session(token) => {
528 let session = change::Addressed::of(providers, token)?;
529 let request = session.require()?;
530 Ok(Self {
531 id: request.id,
532 change_url: request.url.to_string(),
533 identity: session.record.identity.clone(),
534 stream: StreamOf::Session {
535 token: token.0.clone(),
536 identity: session.record.identity.clone(),
537 },
538 host: session.host,
539 })
540 }
541 ChangeRef::Url(url) => {
542 let parsed = ChangeUrl::parse(url)?;
543 let slug = publish::change_host(&parsed.identity)?;
544 Ok(Self {
545 host: providers.hosting.for_repo(&slug)?,
546 id: ChangeId(parsed.number.clone()),
547 change_url: parsed.canonical(),
548 identity: parsed.identity,
549 stream: StreamOf::Change,
550 })
551 }
552 }
553 }
554
555 /// Record one event, best effort: the read or the post has already happened, and
556 /// reporting it as failed because its record could not be stored would send
557 /// somebody to do it again.
558 fn record(&self, kind: EventKind, payload: serde_json::Value) {
559 let StreamOf::Session { token, identity } = &self.stream else {
560 return record_on_change_stream(&self.change_url, None, kind, payload);
561 };
562 match Stream::open(token) {
563 Ok(mut stream) => {
564 stream.label("identity", identity);
565 stream.emit(kind, object(payload));
566 }
567 Err(error) => eprintln!(
568 "onevcs: warning: what was done on {} is not recorded: {error}",
569 self.change_url
570 ),
571 }
572 }
573}
574
575/// The stream a change request named by URL records on.
576fn change_stream(change_url: &str) -> String {
577 crate::standings::change_stream(change_url)
578}
579
580/// A change request's URL, taken apart: `https://<host>/<owner>/<name>/pull/<number>`,
581/// with anything after the number — a tab of the page, a fragment — ignored.
582pub(crate) struct ChangeUrl {
583 host: String,
584 owner: String,
585 name: String,
586 /// The host's number for it.
587 pub(crate) number: String,
588 /// The identity key of the repository it belongs to: `<host>/<owner>/<name>`.
589 pub(crate) identity: String,
590}
591
592impl ChangeUrl {
593 pub(crate) fn parse(text: &str) -> Result<Self> {
594 let refused = || {
595 invalid(format!(
596 "{text:?} is neither a session token nor a change request's URL; a URL names \
597 one as https://<host>/<owner>/<name>/pull/<number>"
598 ))
599 };
600 let url = Url::parse(text.trim()).map_err(|_| refused())?;
601 if url.scheme() != "https" {
602 return Err(refused());
603 }
604 let host = url.host_str().ok_or_else(refused)?.to_ascii_lowercase();
605 let segments: Vec<&str> = url.path_segments().ok_or_else(refused)?.collect();
606 let [owner, name, "pull", number, ..] = segments[..] else {
607 return Err(refused());
608 };
609 let addressable = |part: &str| {
610 !part.is_empty()
611 && !part.starts_with('-')
612 && part
613 .chars()
614 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
615 };
616 if !addressable(owner)
617 || !addressable(name)
618 || number.is_empty()
619 || !number.chars().all(|c| c.is_ascii_digit())
620 {
621 return Err(refused());
622 }
623 Ok(Self {
624 identity: format!("{host}/{owner}/{name}"),
625 host,
626 owner: owner.to_owned(),
627 name: name.to_owned(),
628 number: number.to_owned(),
629 })
630 }
631
632 pub(crate) fn canonical(&self) -> String {
633 format!(
634 "https://{}/{}/{}/pull/{}",
635 self.host, self.owner, self.name, self.number
636 )
637 }
638}
639
640/// Whether `text` names a change request by URL rather than a session by token —
641/// which is how the command line reads its `<SESSION|URL>` argument.
642pub(crate) fn change_ref(text: &str) -> ChangeRef {
643 if text.contains("://") {
644 ChangeRef::Url(text.to_owned())
645 } else {
646 ChangeRef::Session(SessionToken(text.to_owned()))
647 }
648}