Skip to main content

roder_core/review/
publish.rs

1//! Handing a finished review to a [`ReviewPublisher`].
2//!
3//! Core knows nothing about GitHub or any other platform: a publisher is an
4//! extension service resolved by id, so a third party ships a `roder-ext-*`
5//! crate and everything below keeps working unchanged.
6
7use std::sync::Arc;
8
9use roder_api::events::{ReviewPublished, RoderEvent};
10use roder_api::review::{
11    ReviewDestination, ReviewId, ReviewPublishError, ReviewPublishRequest, ReviewPublishResult,
12    ReviewPublisher,
13};
14use time::OffsetDateTime;
15
16use crate::runtime::Runtime;
17
18/// `[review]` settings the runtime itself acts on.
19#[derive(Debug, Clone, Default)]
20pub struct RuntimeReviewConfig {
21    /// Publisher used when a publish request names none. `"none"` and the
22    /// empty string mean "always ask", matching the config file.
23    pub default_publisher: Option<String>,
24    /// Model override for review turns.
25    pub model: Option<String>,
26}
27
28impl RuntimeReviewConfig {
29    fn default_publisher(&self) -> Option<&str> {
30        self.default_publisher
31            .as_deref()
32            .map(str::trim)
33            .filter(|id| !id.is_empty() && !id.eq_ignore_ascii_case("none"))
34    }
35}
36
37#[derive(Debug, Clone)]
38pub struct PublishReviewRequest {
39    pub review_id: ReviewId,
40    pub destination: ReviewDestination,
41    /// Indices into the review's findings; `None` publishes all of them.
42    pub selected: Option<Vec<usize>>,
43    pub dry_run: bool,
44}
45
46/// Separates "the caller asked for something impossible" from "the remote said
47/// no", so a transport can map the first to an invalid-params error.
48#[derive(Debug)]
49pub enum PublishReviewError {
50    InvalidRequest(String),
51    Publish(ReviewPublishError),
52}
53
54impl std::fmt::Display for PublishReviewError {
55    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
56        match self {
57            Self::InvalidRequest(message) => f.write_str(message),
58            Self::Publish(error) => write!(f, "{error}"),
59        }
60    }
61}
62
63impl std::error::Error for PublishReviewError {
64    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
65        match self {
66            Self::Publish(error) => Some(error),
67            Self::InvalidRequest(_) => None,
68        }
69    }
70}
71
72impl From<ReviewPublishError> for PublishReviewError {
73    fn from(error: ReviewPublishError) -> Self {
74        Self::Publish(error)
75    }
76}
77
78impl Runtime {
79    /// The live `[review]` settings.
80    pub async fn review_config(&self) -> RuntimeReviewConfig {
81        self.status().await.review
82    }
83
84    /// Publishes a completed review's findings and emits
85    /// [`RoderEvent::ReviewPublished`].
86    pub async fn publish_review(
87        &self,
88        request: PublishReviewRequest,
89    ) -> Result<ReviewPublishResult, PublishReviewError> {
90        // Review history lives in memory, so an id from an earlier process is a
91        // caller mistake rather than a server fault.
92        let record = self.review_record(&request.review_id).await.ok_or_else(|| {
93            PublishReviewError::InvalidRequest(format!(
94                "review {} is not in this session's history; re-run the review before publishing",
95                request.review_id
96            ))
97        })?;
98
99        let findings = record.output.findings.len();
100        if let Some(index) = request
101            .selected
102            .iter()
103            .flatten()
104            .find(|index| **index >= findings)
105        {
106            return Err(PublishReviewError::InvalidRequest(format!(
107                "finding index {index} is out of range; the review produced {findings} findings"
108            )));
109        }
110
111        let publisher = self
112            .resolve_review_publisher(request.destination.publisher_id.as_deref())
113            .await?;
114
115        let result = publisher
116            .publish(ReviewPublishRequest {
117                review_id: record.review_id.clone(),
118                workspace_root: record.workspace_root.clone(),
119                request: record.request.clone(),
120                output: record.output.clone(),
121                selected: request.selected,
122                destination: request.destination,
123                dry_run: request.dry_run,
124            })
125            .await?;
126
127        self.emit(RoderEvent::ReviewPublished(ReviewPublished {
128            thread_id: Some(record.parent_thread_id.clone()),
129            review_id: record.review_id.clone(),
130            publisher_id: result.publisher_id.clone(),
131            url: result.url.clone(),
132            published_findings: result.published_findings,
133            skipped: result.skipped.clone(),
134            dry_run: request.dry_run,
135            timestamp: OffsetDateTime::now_utc(),
136        }))
137        .await;
138        Ok(result)
139    }
140
141    /// Explicit id, then `[review].default_publisher`, then the only installed
142    /// publisher. Anything else is ambiguous and has to be spelled out.
143    async fn resolve_review_publisher(
144        &self,
145        publisher_id: Option<&str>,
146    ) -> Result<Arc<dyn ReviewPublisher>, PublishReviewError> {
147        let publishers = self.registry.review_publishers();
148        let configured = self.review_config().await;
149        let requested = publisher_id
150            .map(str::trim)
151            .filter(|id| !id.is_empty())
152            .or_else(|| configured.default_publisher());
153
154        let Some(requested) = requested else {
155            return match publishers {
156                [] => Err(PublishReviewError::InvalidRequest(
157                    "no review publisher is installed; add a review publisher extension before publishing findings".to_string(),
158                )),
159                [only] => Ok(Arc::clone(only)),
160                _ => Err(PublishReviewError::InvalidRequest(format!(
161                    "several review publishers are installed ({}); pass publisherId or set [review].default_publisher",
162                    publisher_ids(publishers)
163                ))),
164            };
165        };
166
167        self.registry.review_publisher(requested).ok_or_else(|| {
168            PublishReviewError::InvalidRequest(format!(
169                "review publisher '{requested}' is not installed; available publishers: {}",
170                publisher_ids(publishers)
171            ))
172        })
173    }
174}
175
176fn publisher_ids(publishers: &[Arc<dyn ReviewPublisher>]) -> String {
177    if publishers.is_empty() {
178        return "none".to_string();
179    }
180    publishers
181        .iter()
182        .map(|publisher| publisher.descriptor().id)
183        .collect::<Vec<_>>()
184        .join(", ")
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190
191    #[test]
192    fn none_and_blank_default_publishers_mean_ask_explicitly() {
193        for value in [None, Some(""), Some("  "), Some("none"), Some("None")] {
194            let config = RuntimeReviewConfig {
195                default_publisher: value.map(str::to_string),
196                model: None,
197            };
198            assert_eq!(config.default_publisher(), None, "value: {value:?}");
199        }
200        let config = RuntimeReviewConfig {
201            default_publisher: Some(" github ".to_string()),
202            model: None,
203        };
204        assert_eq!(config.default_publisher(), Some("github"));
205    }
206}