ricecoder_github/managers/
issue_operations.rs1use crate::errors::{GitHubError, Result};
6use crate::models::IssueStatus;
7use serde::{Deserialize, Serialize};
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct IssueComment {
12 pub body: String,
14 pub id: Option<u64>,
16}
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
20pub enum StatusChange {
21 Open,
23 InProgress,
25 Close,
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct PrLink {
32 pub pr_number: u32,
34 pub pr_title: String,
36 pub link_type: String,
38}
39
40#[derive(Debug, Clone)]
42#[allow(dead_code)]
43pub struct IssueOperations {
44 token: String,
46 owner: String,
48 repo: String,
50}
51
52impl IssueOperations {
53 pub fn new(token: String, owner: String, repo: String) -> Self {
55 IssueOperations { token, owner, repo }
56 }
57
58 pub fn create_comment(&self, body: String) -> IssueComment {
60 IssueComment { body, id: None }
61 }
62
63 pub fn format_progress_comment(
65 &self,
66 current_step: &str,
67 total_steps: u32,
68 completed_steps: u32,
69 details: &str,
70 ) -> IssueComment {
71 let progress_percentage = if total_steps > 0 {
72 (completed_steps as f32 / total_steps as f32 * 100.0) as u32
73 } else {
74 0
75 };
76
77 let progress_bar = self.create_progress_bar(progress_percentage);
78
79 let body = format!(
80 "## 🔄 Progress Update\n\n\
81 **Current Step:** {}\n\
82 **Progress:** {} ({}/{})\n\
83 **Status Bar:** {}\n\n\
84 ### Details\n\
85 {}\n\n\
86 _Last updated: {}_",
87 current_step,
88 progress_percentage,
89 completed_steps,
90 total_steps,
91 progress_bar,
92 details,
93 chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC")
94 );
95
96 self.create_comment(body)
97 }
98
99 pub fn format_status_change_comment(&self, old_status: IssueStatus, new_status: IssueStatus) -> IssueComment {
101 let status_emoji = match new_status {
102 IssueStatus::Open => "🔴",
103 IssueStatus::InProgress => "🟡",
104 IssueStatus::Closed => "🟢",
105 };
106
107 let body = format!(
108 "{} **Status Changed**\n\n\
109 **From:** {:?}\n\
110 **To:** {:?}\n\n\
111 _Updated at: {}_",
112 status_emoji,
113 old_status,
114 new_status,
115 chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC")
116 );
117
118 self.create_comment(body)
119 }
120
121 pub fn format_pr_link_comment(&self, pr_link: &PrLink) -> IssueComment {
123 let body = format!(
124 "## 🔗 PR Linked\n\n\
125 **PR:** #{} - {}\n\
126 **Link Type:** {}\n\n\
127 This issue is now being addressed by the linked pull request.\n\n\
128 _Linked at: {}_",
129 pr_link.pr_number,
130 pr_link.pr_title,
131 pr_link.link_type,
132 chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC")
133 );
134
135 self.create_comment(body)
136 }
137
138 pub fn create_pr_closure_link(&self, pr_number: u32, pr_title: String) -> PrLink {
140 PrLink {
141 pr_number,
142 pr_title,
143 link_type: "closes".to_string(),
144 }
145 }
146
147 pub fn create_pr_relation_link(&self, pr_number: u32, pr_title: String) -> PrLink {
149 PrLink {
150 pr_number,
151 pr_title,
152 link_type: "relates to".to_string(),
153 }
154 }
155
156 pub fn format_closure_message(&self, issue_number: u32) -> String {
158 format!(
159 "Closes #{}\n\nThis PR resolves the issue by implementing the required changes.",
160 issue_number
161 )
162 }
163
164 pub fn validate_comment(&self, comment: &IssueComment) -> Result<()> {
166 if comment.body.is_empty() {
167 return Err(GitHubError::invalid_input("Comment body cannot be empty"));
168 }
169
170 if comment.body.len() > 65536 {
171 return Err(GitHubError::invalid_input(
172 "Comment body exceeds maximum length of 65536 characters",
173 ));
174 }
175
176 Ok(())
177 }
178
179 fn create_progress_bar(&self, percentage: u32) -> String {
181 let filled = (percentage / 10) as usize;
182 let empty = 10 - filled;
183 format!(
184 "[{}{}] {}%",
185 "â–ˆ".repeat(filled),
186 "â–‘".repeat(empty),
187 percentage
188 )
189 }
190
191 pub fn extract_issue_number_from_closure(&self, message: &str) -> Result<u32> {
193 use regex::Regex;
194
195 let pattern = Regex::new(r"[Cc]loses\s+#(\d+)")
196 .map_err(|e| GitHubError::invalid_input(format!("Regex error: {}", e)))?;
197
198 pattern
199 .captures(message)
200 .and_then(|cap| cap.get(1))
201 .and_then(|m| m.as_str().parse::<u32>().ok())
202 .ok_or_else(|| {
203 GitHubError::invalid_input("No issue number found in closure message")
204 })
205 }
206
207 pub async fn post_comment_to_issue(
209 &self,
210 issue_number: u32,
211 comment: &IssueComment,
212 ) -> Result<u64> {
213 self.validate_comment(comment)?;
214
215 let client = octocrab::OctocrabBuilder::new()
216 .personal_token(self.token.clone())
217 .build()
218 .map_err(|e| GitHubError::api_error(format!("Failed to create client: {}", e)))?;
219
220 let response = client
221 .issues(&self.owner, &self.repo)
222 .create_comment(issue_number as u64, &comment.body)
223 .await
224 .map_err(|e| GitHubError::api_error(format!("Failed to post comment: {}", e)))?;
225
226 Ok(response.id.0)
227 }
228
229 pub async fn update_comment_on_issue(
231 &self,
232 comment_id: u64,
233 new_body: &str,
234 ) -> Result<()> {
235 if new_body.is_empty() {
236 return Err(GitHubError::invalid_input("Comment body cannot be empty"));
237 }
238
239 let client = octocrab::OctocrabBuilder::new()
240 .personal_token(self.token.clone())
241 .build()
242 .map_err(|e| GitHubError::api_error(format!("Failed to create client: {}", e)))?;
243
244 client
245 .issues(&self.owner, &self.repo)
246 .update_comment(octocrab::models::CommentId(comment_id), new_body)
247 .await
248 .map_err(|e| GitHubError::api_error(format!("Failed to update comment: {}", e)))?;
249
250 Ok(())
251 }
252
253 pub async fn link_pr_to_close_issue(
255 &self,
256 issue_number: u32,
257 pr_number: u32,
258 pr_title: &str,
259 ) -> Result<u64> {
260 let link = self.create_pr_closure_link(pr_number, pr_title.to_string());
261 let comment = self.format_pr_link_comment(&link);
262 self.post_comment_to_issue(issue_number, &comment).await
263 }
264
265 pub async fn link_pr_to_relate_issue(
267 &self,
268 issue_number: u32,
269 pr_number: u32,
270 pr_title: &str,
271 ) -> Result<u64> {
272 let link = self.create_pr_relation_link(pr_number, pr_title.to_string());
273 let comment = self.format_pr_link_comment(&link);
274 self.post_comment_to_issue(issue_number, &comment).await
275 }
276
277 pub async fn post_progress_update(
279 &self,
280 issue_number: u32,
281 current_step: &str,
282 total_steps: u32,
283 completed_steps: u32,
284 details: &str,
285 ) -> Result<u64> {
286 let comment = self.format_progress_comment(current_step, total_steps, completed_steps, details);
287 self.post_comment_to_issue(issue_number, &comment).await
288 }
289
290 pub async fn post_status_change(
292 &self,
293 issue_number: u32,
294 old_status: IssueStatus,
295 new_status: IssueStatus,
296 ) -> Result<u64> {
297 let comment = self.format_status_change_comment(old_status, new_status);
298 self.post_comment_to_issue(issue_number, &comment).await
299 }
300
301 pub async fn update_issue_status(
303 &self,
304 issue_number: u32,
305 new_status: IssueStatus,
306 ) -> Result<()> {
307 let client = octocrab::OctocrabBuilder::new()
308 .personal_token(self.token.clone())
309 .build()
310 .map_err(|e| GitHubError::api_error(format!("Failed to create client: {}", e)))?;
311
312 let state = match new_status {
313 IssueStatus::Open => octocrab::models::IssueState::Open,
314 IssueStatus::InProgress => octocrab::models::IssueState::Open, IssueStatus::Closed => octocrab::models::IssueState::Closed,
316 };
317
318 client
319 .issues(&self.owner, &self.repo)
320 .update(issue_number as u64)
321 .state(state)
322 .send()
323 .await
324 .map_err(|e| GitHubError::api_error(format!("Failed to update issue status: {}", e)))?;
325
326 Ok(())
327 }
328}
329
330#[cfg(test)]
331mod tests {
332 use super::*;
333
334 fn create_test_operations() -> IssueOperations {
335 IssueOperations::new(
336 "test_token".to_string(),
337 "test_owner".to_string(),
338 "test_repo".to_string(),
339 )
340 }
341
342 #[test]
343 fn test_create_comment() {
344 let ops = create_test_operations();
345 let comment = ops.create_comment("Test comment".to_string());
346 assert_eq!(comment.body, "Test comment");
347 assert_eq!(comment.id, None);
348 }
349
350 #[test]
351 fn test_format_progress_comment() {
352 let ops = create_test_operations();
353 let comment = ops.format_progress_comment("Step 1", 5, 2, "Working on implementation");
354 assert!(comment.body.contains("Progress Update"));
355 assert!(comment.body.contains("Step 1"));
356 assert!(comment.body.contains("2/5"));
357 }
358
359 #[test]
360 fn test_format_status_change_comment() {
361 let ops = create_test_operations();
362 let comment = ops.format_status_change_comment(IssueStatus::Open, IssueStatus::InProgress);
363 assert!(comment.body.contains("Status Changed"));
364 assert!(comment.body.contains("InProgress"));
365 }
366
367 #[test]
368 fn test_format_pr_link_comment() {
369 let ops = create_test_operations();
370 let pr_link = PrLink {
371 pr_number: 42,
372 pr_title: "Implement feature".to_string(),
373 link_type: "closes".to_string(),
374 };
375 let comment = ops.format_pr_link_comment(&pr_link);
376 assert!(comment.body.contains("PR Linked"));
377 assert!(comment.body.contains("#42"));
378 }
379
380 #[test]
381 fn test_create_pr_closure_link() {
382 let ops = create_test_operations();
383 let link = ops.create_pr_closure_link(42, "Implement feature".to_string());
384 assert_eq!(link.pr_number, 42);
385 assert_eq!(link.link_type, "closes");
386 }
387
388 #[test]
389 fn test_validate_comment_valid() {
390 let ops = create_test_operations();
391 let comment = IssueComment {
392 body: "Valid comment".to_string(),
393 id: None,
394 };
395 assert!(ops.validate_comment(&comment).is_ok());
396 }
397
398 #[test]
399 fn test_validate_comment_empty() {
400 let ops = create_test_operations();
401 let comment = IssueComment {
402 body: "".to_string(),
403 id: None,
404 };
405 assert!(ops.validate_comment(&comment).is_err());
406 }
407
408 #[test]
409 fn test_extract_issue_number_from_closure() {
410 let ops = create_test_operations();
411 let message = "Closes #123";
412 assert_eq!(ops.extract_issue_number_from_closure(message).unwrap(), 123);
413 }
414
415 #[test]
416 fn test_extract_issue_number_case_insensitive() {
417 let ops = create_test_operations();
418 let message = "closes #456";
419 assert_eq!(ops.extract_issue_number_from_closure(message).unwrap(), 456);
420 }
421
422 #[test]
423 fn test_link_pr_closure_link_format() {
424 let ops = create_test_operations();
425 let link = ops.create_pr_closure_link(42, "Implement feature".to_string());
426 assert_eq!(link.pr_number, 42);
427 assert_eq!(link.link_type, "closes");
428 assert_eq!(link.pr_title, "Implement feature");
429 }
430
431 #[test]
432 fn test_link_pr_relation_link_format() {
433 let ops = create_test_operations();
434 let link = ops.create_pr_relation_link(42, "Related work".to_string());
435 assert_eq!(link.pr_number, 42);
436 assert_eq!(link.link_type, "relates to");
437 assert_eq!(link.pr_title, "Related work");
438 }
439
440 #[test]
441 fn test_format_closure_message() {
442 let ops = create_test_operations();
443 let message = ops.format_closure_message(123);
444 assert!(message.contains("Closes #123"));
445 assert!(message.contains("resolves the issue"));
446 }
447}