Skip to main content

roder_api/
review.rs

1//! Read-only code review.
2//!
3//! A **review** is a detached, read-only sub-turn that inspects a diff and
4//! emits structured [`ReviewFinding`]s instead of editing the workspace. The
5//! findings are surfaced in the TUI and can optionally be handed to a
6//! [`ReviewPublisher`] — a pluggable extension service that submits them to an
7//! external platform (GitHub pull-request reviews, an issue tracker, chat).
8//!
9//! Core code only sees these types: publishers are registered on the extension
10//! registry like any other service and resolved by id.
11
12use std::path::{Path, PathBuf};
13
14use serde::{Deserialize, Serialize};
15
16pub type ReviewId = String;
17pub type ReviewPublisherId = String;
18
19/// What the reviewer should look at. Resolved against the workspace's version
20/// control provider to produce a [`ReviewRequest`].
21#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
22#[serde(rename_all = "camelCase", tag = "kind")]
23pub enum ReviewTarget {
24    /// Everything not yet committed in the workspace.
25    #[default]
26    UncommittedChanges,
27    /// Everything on the current line since it diverged from `branch`.
28    BaseBranch { branch: String },
29    /// A single commit.
30    Commit {
31        sha: String,
32        #[serde(default, skip_serializing_if = "Option::is_none")]
33        title: Option<String>,
34    },
35    /// Free-form scope described in natural language.
36    Custom { instructions: String },
37}
38
39/// Resolved, model-facing review request: the product of a [`ReviewTarget`]
40/// and the current version-control state.
41#[derive(Debug, Clone, Serialize, Deserialize)]
42#[serde(rename_all = "camelCase")]
43pub struct ReviewRequest {
44    pub target: ReviewTarget,
45    /// Human-readable one-liner shown in the TUI ("review vs main").
46    pub label: String,
47    /// Natural-language prompt handed to the review turn.
48    pub prompt: String,
49    /// Merge-base revision when the target implies one; used by publishers to
50    /// anchor comments.
51    #[serde(default, skip_serializing_if = "Option::is_none")]
52    pub base_sha: Option<String>,
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub head_sha: Option<String>,
55    pub workspace_root: PathBuf,
56}
57
58/// Whether the caller waits for the review turn or is notified later.
59#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
60#[serde(rename_all = "camelCase")]
61pub enum ReviewDelivery {
62    /// Await the review turn and return its findings to the caller.
63    #[default]
64    Inline,
65    /// Start the review turn and return immediately; findings arrive as
66    /// events.
67    Detached,
68}
69
70/// Severity of a finding. `P0` is the most severe; `P2` is the default when
71/// the model omits one.
72#[derive(
73    Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash,
74)]
75#[serde(rename_all = "snake_case")]
76pub enum ReviewPriority {
77    P0,
78    P1,
79    #[default]
80    P2,
81    P3,
82}
83
84impl ReviewPriority {
85    pub const ALL: [ReviewPriority; 4] = [
86        ReviewPriority::P0,
87        ReviewPriority::P1,
88        ReviewPriority::P2,
89        ReviewPriority::P3,
90    ];
91
92    pub fn as_str(self) -> &'static str {
93        match self {
94            ReviewPriority::P0 => "p0",
95            ReviewPriority::P1 => "p1",
96            ReviewPriority::P2 => "p2",
97            ReviewPriority::P3 => "p3",
98        }
99    }
100
101    /// Case-insensitive parse of `p0`..`p3`; `None` for anything else so
102    /// callers can fall back to [`ReviewPriority::default`].
103    pub fn parse(value: &str) -> Option<Self> {
104        match value.trim().to_ascii_lowercase().as_str() {
105            "p0" => Some(ReviewPriority::P0),
106            "p1" => Some(ReviewPriority::P1),
107            "p2" => Some(ReviewPriority::P2),
108            "p3" => Some(ReviewPriority::P3),
109            _ => None,
110        }
111    }
112}
113
114impl std::fmt::Display for ReviewPriority {
115    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
116        f.write_str(self.as_str())
117    }
118}
119
120impl std::str::FromStr for ReviewPriority {
121    type Err = String;
122
123    fn from_str(value: &str) -> Result<Self, Self::Err> {
124        Self::parse(value)
125            .ok_or_else(|| format!("unknown review priority {value}; expected p0..p3"))
126    }
127}
128
129/// Inclusive 1-based line range inside a file.
130#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
131#[serde(rename_all = "camelCase")]
132pub struct ReviewLineRange {
133    pub start: u32,
134    pub end: u32,
135}
136
137#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
138#[serde(rename_all = "camelCase")]
139pub struct ReviewCodeLocation {
140    pub absolute_file_path: PathBuf,
141    pub line_range: ReviewLineRange,
142}
143
144#[derive(Debug, Clone, Serialize, Deserialize)]
145#[serde(rename_all = "camelCase")]
146pub struct ReviewFinding {
147    pub title: String,
148    pub body: String,
149    #[serde(default, skip_serializing_if = "Option::is_none")]
150    pub confidence_score: Option<f32>,
151    #[serde(default)]
152    pub priority: ReviewPriority,
153    pub code_location: ReviewCodeLocation,
154}
155
156impl ReviewFinding {
157    /// The title with any leading `[P0]`..`[P3]` tag removed.
158    ///
159    /// The rubric asks the model to prefix each title with its priority, and
160    /// every surface renders the priority itself, so surfaces must strip the
161    /// model's tag or it shows up twice (`[P3] [P3] …`). Tolerates lowercase
162    /// and missing tags alike.
163    pub fn display_title(&self) -> &str {
164        let title = self.title.trim();
165        let Some(rest) = title.strip_prefix('[') else {
166            return title;
167        };
168        let Some((tag, rest)) = rest.split_once(']') else {
169            return title;
170        };
171        let tag = tag.trim();
172        let is_priority_tag = tag.len() == 2
173            && tag.starts_with(['p', 'P'])
174            && tag[1..].chars().all(|digit| ('0'..='3').contains(&digit));
175        if is_priority_tag {
176            rest.trim_start()
177        } else {
178            title
179        }
180    }
181}
182
183/// Structured result of a review turn. Every field is optional so a partially
184/// parsed model response still round-trips.
185#[derive(Debug, Clone, Default, Serialize, Deserialize)]
186#[serde(rename_all = "camelCase")]
187pub struct ReviewOutput {
188    #[serde(default)]
189    pub findings: Vec<ReviewFinding>,
190    #[serde(default, skip_serializing_if = "Option::is_none")]
191    pub overall_correctness: Option<String>,
192    #[serde(default, skip_serializing_if = "Option::is_none")]
193    pub overall_explanation: Option<String>,
194    #[serde(default, skip_serializing_if = "Option::is_none")]
195    pub overall_confidence_score: Option<f32>,
196}
197
198#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
199#[serde(rename_all = "camelCase")]
200pub struct ReviewPublisherDescriptor {
201    pub id: ReviewPublisherId,
202    pub display_name: String,
203    pub capabilities: ReviewPublisherCapabilities,
204}
205
206#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
207#[serde(rename_all = "camelCase")]
208pub struct ReviewPublisherCapabilities {
209    /// Can attach comments to specific file/line locations.
210    pub inline_comments: bool,
211    /// Can post an overall summary alongside the comments.
212    pub summary_body: bool,
213    /// Can infer the destination (PR number, repo) from the workspace alone.
214    pub autodetect_destination: bool,
215    /// Can render the payload without performing any remote call.
216    pub dry_run: bool,
217}
218
219/// Free-form publish destination; each publisher interprets `target` and
220/// `options` in its own terms.
221#[derive(Debug, Clone, Default, Serialize, Deserialize)]
222#[serde(rename_all = "camelCase")]
223pub struct ReviewDestination {
224    #[serde(default, skip_serializing_if = "Option::is_none")]
225    pub publisher_id: Option<ReviewPublisherId>,
226    /// e.g. `owner/repo#123`, a Linear issue id, a chat channel.
227    #[serde(default, skip_serializing_if = "Option::is_none")]
228    pub target: Option<String>,
229    #[serde(default)]
230    pub options: serde_json::Map<String, serde_json::Value>,
231}
232
233#[derive(Debug, Clone)]
234pub struct ReviewPublishRequest {
235    pub review_id: ReviewId,
236    pub workspace_root: PathBuf,
237    /// Carries `base_sha` / `head_sha` for anchoring comments.
238    pub request: ReviewRequest,
239    pub output: ReviewOutput,
240    /// Indices into `output.findings`; `None` publishes all of them.
241    pub selected: Option<Vec<usize>>,
242    pub destination: ReviewDestination,
243    pub dry_run: bool,
244}
245
246/// A finding the publisher could not place (e.g. outside the reviewed diff).
247#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
248#[serde(rename_all = "camelCase")]
249pub struct ReviewPublishSkip {
250    pub finding_index: usize,
251    pub reason: String,
252}
253
254#[derive(Debug, Clone, Serialize, Deserialize)]
255#[serde(rename_all = "camelCase")]
256pub struct ReviewPublishResult {
257    pub publisher_id: ReviewPublisherId,
258    #[serde(default, skip_serializing_if = "Option::is_none")]
259    pub url: Option<String>,
260    pub published_findings: usize,
261    #[serde(default)]
262    pub skipped: Vec<ReviewPublishSkip>,
263    /// Populated instead of a remote call when `dry_run` is set.
264    #[serde(default, skip_serializing_if = "Option::is_none")]
265    pub payload_preview: Option<serde_json::Value>,
266}
267
268#[derive(Debug)]
269pub enum ReviewPublishError {
270    /// The publisher could not work out where to publish.
271    DestinationUnresolved(String),
272    /// The publisher exists but has no usable credentials/binary.
273    NotConfigured(String),
274    /// The remote refused the review.
275    Remote(String),
276    Other(anyhow::Error),
277}
278
279impl std::fmt::Display for ReviewPublishError {
280    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
281        match self {
282            Self::DestinationUnresolved(message) => {
283                write!(f, "destination could not be resolved: {message}")
284            }
285            Self::NotConfigured(message) => write!(f, "publisher not configured: {message}"),
286            Self::Remote(message) => write!(f, "remote rejected the review: {message}"),
287            Self::Other(error) => write!(f, "{error}"),
288        }
289    }
290}
291
292impl std::error::Error for ReviewPublishError {
293    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
294        match self {
295            Self::Other(error) => Some(error.as_ref()),
296            _ => None,
297        }
298    }
299}
300
301impl From<anyhow::Error> for ReviewPublishError {
302    fn from(error: anyhow::Error) -> Self {
303        Self::Other(error)
304    }
305}
306
307/// Submits review findings to an external platform. Implemented by
308/// `roder-ext-*` crates and registered on the extension registry; nothing in
309/// core knows about any concrete platform.
310#[async_trait::async_trait]
311pub trait ReviewPublisher: Send + Sync + 'static {
312    fn descriptor(&self) -> ReviewPublisherDescriptor;
313
314    /// Cheap availability probe (binary on `PATH`, token present). Advisory:
315    /// a `false` answer should not be treated as a hard failure.
316    async fn is_available(&self, workspace_root: &Path) -> bool {
317        let _ = workspace_root;
318        true
319    }
320
321    async fn publish(
322        &self,
323        request: ReviewPublishRequest,
324    ) -> Result<ReviewPublishResult, ReviewPublishError>;
325}
326
327#[cfg(test)]
328mod tests {
329    use super::*;
330
331    #[test]
332    fn display_title_strips_only_a_leading_priority_tag() {
333        let finding = |title: &str| ReviewFinding {
334            title: title.to_string(),
335            body: String::new(),
336            confidence_score: None,
337            priority: ReviewPriority::P1,
338            code_location: ReviewCodeLocation {
339                absolute_file_path: PathBuf::from("/tmp/a.rs"),
340                line_range: ReviewLineRange { start: 1, end: 1 },
341            },
342        };
343
344        assert_eq!(
345            finding("[P1] Guard the divisor").display_title(),
346            "Guard the divisor"
347        );
348        assert_eq!(finding("[p0]  Guard").display_title(), "Guard");
349        assert_eq!(finding("  [P3] Guard  ").display_title(), "Guard");
350        // No tag, an unrelated bracket, and an out-of-range tag all survive.
351        assert_eq!(
352            finding("Guard the divisor").display_title(),
353            "Guard the divisor"
354        );
355        assert_eq!(finding("[BUG] Guard").display_title(), "[BUG] Guard");
356        assert_eq!(finding("[P9] Guard").display_title(), "[P9] Guard");
357        assert_eq!(finding("[P1 Guard").display_title(), "[P1 Guard");
358    }
359
360    #[test]
361    fn review_target_is_tagged_and_camel_case() {
362        let target = ReviewTarget::BaseBranch {
363            branch: "main".to_string(),
364        };
365        let value = serde_json::to_value(&target).expect("serialize target");
366        assert_eq!(value["kind"], "baseBranch");
367        assert_eq!(value["branch"], "main");
368
369        let round_trip: ReviewTarget = serde_json::from_value(value).expect("deserialize target");
370        assert_eq!(round_trip, target);
371        assert_eq!(ReviewTarget::default(), ReviewTarget::UncommittedChanges);
372    }
373
374    #[test]
375    fn review_priority_defaults_to_p2_and_parses() {
376        assert_eq!(ReviewPriority::default(), ReviewPriority::P2);
377        assert_eq!(ReviewPriority::parse("P1"), Some(ReviewPriority::P1));
378        assert_eq!(ReviewPriority::parse(" p3 "), Some(ReviewPriority::P3));
379        assert_eq!(ReviewPriority::parse("urgent"), None);
380        assert_eq!(ReviewPriority::P0.to_string(), "p0");
381        assert!(ReviewPriority::P0 < ReviewPriority::P3);
382        assert_eq!(
383            serde_json::to_value(ReviewPriority::P1).expect("serialize priority"),
384            serde_json::json!("p1")
385        );
386    }
387
388    #[test]
389    fn review_finding_defaults_missing_priority() {
390        let finding: ReviewFinding = serde_json::from_value(serde_json::json!({
391            "title": "Off-by-one",
392            "body": "The loop overruns.",
393            "codeLocation": {
394                "absoluteFilePath": "/repo/src/lib.rs",
395                "lineRange": { "start": 10, "end": 12 }
396            }
397        }))
398        .expect("deserialize finding");
399
400        assert_eq!(finding.priority, ReviewPriority::P2);
401        assert!(finding.confidence_score.is_none());
402        assert_eq!(finding.code_location.line_range.end, 12);
403    }
404
405    #[test]
406    fn review_output_tolerates_an_empty_object() {
407        let output: ReviewOutput =
408            serde_json::from_str("{}").expect("deserialize empty review output");
409        assert!(output.findings.is_empty());
410        assert!(output.overall_correctness.is_none());
411    }
412
413    #[test]
414    fn publish_error_renders_context() {
415        let error = ReviewPublishError::NotConfigured("gh not on PATH".to_string());
416        assert_eq!(
417            error.to_string(),
418            "publisher not configured: gh not on PATH"
419        );
420    }
421}