Skip to main content

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}