roder_core/review/
publish.rs1use 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#[derive(Debug, Clone, Default)]
20pub struct RuntimeReviewConfig {
21 pub default_publisher: Option<String>,
24 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 pub selected: Option<Vec<usize>>,
43 pub dry_run: bool,
44}
45
46#[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 pub async fn review_config(&self) -> RuntimeReviewConfig {
81 self.status().await.review
82 }
83
84 pub async fn publish_review(
87 &self,
88 request: PublishReviewRequest,
89 ) -> Result<ReviewPublishResult, PublishReviewError> {
90 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 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}