ricecoder_github/managers/
issue_operations.rs

1//! Issue Operations
2//!
3//! Handles issue tracking, updates, and PR linking
4
5use crate::errors::{GitHubError, Result};
6use crate::models::IssueStatus;
7use serde::{Deserialize, Serialize};
8
9/// Comment to post on an issue
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct IssueComment {
12    /// Comment body
13    pub body: String,
14    /// Comment ID (if existing)
15    pub id: Option<u64>,
16}
17
18/// Status change for an issue
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
20pub enum StatusChange {
21    /// Open the issue
22    Open,
23    /// Mark as in progress
24    InProgress,
25    /// Close the issue
26    Close,
27}
28
29/// PR linking information
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct PrLink {
32    /// PR number
33    pub pr_number: u32,
34    /// PR title
35    pub pr_title: String,
36    /// Link type (closes, relates to, etc.)
37    pub link_type: String,
38}
39
40/// Issue Operations for tracking and updates
41#[derive(Debug, Clone)]
42#[allow(dead_code)]
43pub struct IssueOperations {
44    /// GitHub token for API access
45    token: String,
46    /// Owner of the repository
47    owner: String,
48    /// Repository name
49    repo: String,
50}
51
52impl IssueOperations {
53    /// Create a new IssueOperations
54    pub fn new(token: String, owner: String, repo: String) -> Self {
55        IssueOperations { token, owner, repo }
56    }
57
58    /// Create a comment for posting to an issue
59    pub fn create_comment(&self, body: String) -> IssueComment {
60        IssueComment { body, id: None }
61    }
62
63    /// Format a progress comment
64    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    /// Format a status change comment
100    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    /// Format a PR linking comment
122    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    /// Create a PR closure link
139    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    /// Create a PR relation link
148    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    /// Format a closure message for PR body
157    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    /// Validate a comment
165    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    /// Create a progress bar string
180    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    /// Extract issue number from a closure message
192    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    /// Post a comment to an issue using the GitHub API
208    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    /// Update an existing comment on an issue
230    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    /// Link a PR to close an issue
254    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    /// Link a PR to relate to an issue
266    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    /// Post a progress update comment to an issue
278    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    /// Post a status change comment to an issue
291    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    /// Update issue status (open, in progress, closed)
302    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, // GitHub doesn't have "in progress" state, use open with label
315            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}