ricecoder_github/managers/
release_manager.rs

1//! Release Manager - Handles GitHub release creation and management
2
3use crate::errors::{GitHubError, Result};
4use crate::models::Release;
5use chrono::Utc;
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8use tracing::{debug, info};
9
10/// Semantic version
11#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
12pub struct SemanticVersion {
13    /// Major version
14    pub major: u32,
15    /// Minor version
16    pub minor: u32,
17    /// Patch version
18    pub patch: u32,
19    /// Pre-release identifier (e.g., "alpha", "beta")
20    pub prerelease: Option<String>,
21    /// Build metadata
22    pub build: Option<String>,
23}
24
25impl SemanticVersion {
26    /// Create a new semantic version
27    pub fn new(major: u32, minor: u32, patch: u32) -> Self {
28        Self {
29            major,
30            minor,
31            patch,
32            prerelease: None,
33            build: None,
34        }
35    }
36
37    /// Parse a semantic version from a string
38    pub fn parse(version_str: &str) -> Result<Self> {
39        let version_str = version_str.trim_start_matches('v');
40
41        // Split by + for build metadata
42        let (version_part, build) = if let Some(pos) = version_str.find('+') {
43            (
44                &version_str[..pos],
45                Some(version_str[pos + 1..].to_string()),
46            )
47        } else {
48            (version_str, None)
49        };
50
51        // Split by - for prerelease
52        let (version_part, prerelease) = if let Some(pos) = version_part.find('-') {
53            (
54                &version_part[..pos],
55                Some(version_part[pos + 1..].to_string()),
56            )
57        } else {
58            (version_part, None)
59        };
60
61        // Parse major.minor.patch
62        let parts: Vec<&str> = version_part.split('.').collect();
63        if parts.len() != 3 {
64            return Err(GitHubError::invalid_input(
65                "Invalid semantic version format. Expected major.minor.patch",
66            ));
67        }
68
69        let major = parts[0]
70            .parse::<u32>()
71            .map_err(|_| GitHubError::invalid_input("Invalid major version"))?;
72        let minor = parts[1]
73            .parse::<u32>()
74            .map_err(|_| GitHubError::invalid_input("Invalid minor version"))?;
75        let patch = parts[2]
76            .parse::<u32>()
77            .map_err(|_| GitHubError::invalid_input("Invalid patch version"))?;
78
79        Ok(Self {
80            major,
81            minor,
82            patch,
83            prerelease,
84            build,
85        })
86    }
87
88    /// Convert to tag format (with 'v' prefix)
89    pub fn to_tag(&self) -> String {
90        format!("v{}", self)
91    }
92
93    /// Increment major version
94    pub fn bump_major(&self) -> Self {
95        Self {
96            major: self.major + 1,
97            minor: 0,
98            patch: 0,
99            prerelease: None,
100            build: None,
101        }
102    }
103
104    /// Increment minor version
105    pub fn bump_minor(&self) -> Self {
106        Self {
107            major: self.major,
108            minor: self.minor + 1,
109            patch: 0,
110            prerelease: None,
111            build: None,
112        }
113    }
114
115    /// Increment patch version
116    pub fn bump_patch(&self) -> Self {
117        Self {
118            major: self.major,
119            minor: self.minor,
120            patch: self.patch + 1,
121            prerelease: None,
122            build: None,
123        }
124    }
125}
126
127impl std::fmt::Display for SemanticVersion {
128    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
129        let mut version = format!("{}.{}.{}", self.major, self.minor, self.patch);
130        if let Some(prerelease) = &self.prerelease {
131            version.push('-');
132            version.push_str(prerelease);
133        }
134        if let Some(build) = &self.build {
135            version.push('+');
136            version.push_str(build);
137        }
138        write!(f, "{}", version)
139    }
140}
141
142/// Release creation options
143#[derive(Debug, Clone, Serialize, Deserialize)]
144pub struct ReleaseOptions {
145    /// Release tag name
146    pub tag_name: String,
147    /// Release name
148    pub name: String,
149    /// Release notes/body
150    pub body: String,
151    /// Is draft release
152    pub draft: bool,
153    /// Is prerelease
154    pub prerelease: bool,
155}
156
157/// Release notes generation options
158#[derive(Debug, Clone, Serialize, Deserialize)]
159pub struct ReleaseNotesOptions {
160    /// Previous tag for comparison
161    pub previous_tag: Option<String>,
162    /// Include commit messages
163    pub include_commits: bool,
164    /// Include PR information
165    pub include_prs: bool,
166    /// Include contributors
167    pub include_contributors: bool,
168}
169
170/// Release history entry
171#[derive(Debug, Clone, Serialize, Deserialize)]
172pub struct ReleaseHistoryEntry {
173    /// Version
174    pub version: String,
175    /// Release date
176    pub date: chrono::DateTime<Utc>,
177    /// Release notes
178    pub notes: String,
179    /// Is prerelease
180    pub prerelease: bool,
181}
182
183/// Release Manager
184#[derive(Debug, Clone)]
185pub struct ReleaseManager {
186    /// Release history cache
187    history: HashMap<String, ReleaseHistoryEntry>,
188}
189
190impl ReleaseManager {
191    /// Create a new release manager
192    pub fn new() -> Self {
193        Self {
194            history: HashMap::new(),
195        }
196    }
197
198    /// Create a GitHub release
199    pub async fn create_release(&mut self, options: ReleaseOptions) -> Result<Release> {
200        debug!("Creating release: {}", options.tag_name);
201
202        // Validate tag name format
203        if !options.tag_name.starts_with('v') {
204            return Err(GitHubError::invalid_input(
205                "Tag name must start with 'v'",
206            ));
207        }
208
209        // Parse version to validate semantic versioning
210        let version_str = options.tag_name.trim_start_matches('v');
211        let _version = SemanticVersion::parse(version_str)?;
212
213        // Create release
214        let release = Release {
215            id: 0, // Would be set by GitHub API
216            tag_name: options.tag_name.clone(),
217            name: options.name.clone(),
218            body: options.body.clone(),
219            draft: options.draft,
220            prerelease: options.prerelease,
221            created_at: Utc::now(),
222        };
223
224        // Store in history
225        self.history.insert(
226            options.tag_name.clone(),
227            ReleaseHistoryEntry {
228                version: version_str.to_string(),
229                date: Utc::now(),
230                notes: options.body.clone(),
231                prerelease: options.prerelease,
232            },
233        );
234
235        info!("Release created: {}", options.tag_name);
236        Ok(release)
237    }
238
239    /// Generate release notes from commits and PRs
240    pub async fn generate_release_notes(
241        &self,
242        options: ReleaseNotesOptions,
243    ) -> Result<String> {
244        debug!("Generating release notes");
245
246        let mut notes = String::new();
247
248        if options.include_commits {
249            notes.push_str("## Commits\n\n");
250            notes.push_str("- Commits since previous release\n\n");
251        }
252
253        if options.include_prs {
254            notes.push_str("## Pull Requests\n\n");
255            notes.push_str("- PRs merged since previous release\n\n");
256        }
257
258        if options.include_contributors {
259            notes.push_str("## Contributors\n\n");
260            notes.push_str("- Contributors to this release\n\n");
261        }
262
263        if notes.is_empty() {
264            notes.push_str("## Release Notes\n\nNo changes documented.\n");
265        }
266
267        info!("Release notes generated");
268        Ok(notes)
269    }
270
271    /// Publish a release to GitHub
272    pub async fn publish_release(&mut self, release: Release) -> Result<Release> {
273        debug!("Publishing release: {}", release.tag_name);
274
275        // Validate release
276        if release.tag_name.is_empty() {
277            return Err(GitHubError::invalid_input("Release tag name cannot be empty"));
278        }
279
280        if release.name.is_empty() {
281            return Err(GitHubError::invalid_input("Release name cannot be empty"));
282        }
283
284        // Update history
285        let version_str = release.tag_name.trim_start_matches('v');
286        self.history.insert(
287            release.tag_name.clone(),
288            ReleaseHistoryEntry {
289                version: version_str.to_string(),
290                date: release.created_at,
291                notes: release.body.clone(),
292                prerelease: release.prerelease,
293            },
294        );
295
296        info!("Release published: {}", release.tag_name);
297        Ok(release)
298    }
299
300    /// Get release history
301    pub fn get_release_history(&self) -> Vec<ReleaseHistoryEntry> {
302        let mut entries: Vec<_> = self.history.values().cloned().collect();
303        entries.sort_by(|a, b| b.date.cmp(&a.date));
304        entries
305    }
306
307    /// Get a specific release from history
308    pub fn get_release(&self, tag_name: &str) -> Option<ReleaseHistoryEntry> {
309        self.history.get(tag_name).cloned()
310    }
311
312    /// Maintain changelog
313    pub fn generate_changelog(&self) -> String {
314        let mut changelog = String::from("# Changelog\n\n");
315
316        let mut entries: Vec<_> = self.history.values().cloned().collect();
317        entries.sort_by(|a, b| b.date.cmp(&a.date));
318
319        for entry in entries {
320            changelog.push_str(&format!("## [{}] - {}\n\n", entry.version, entry.date.format("%Y-%m-%d")));
321            changelog.push_str(&entry.notes);
322            changelog.push_str("\n\n");
323        }
324
325        changelog
326    }
327
328    /// Validate semantic version tag
329    pub fn validate_version_tag(tag: &str) -> Result<SemanticVersion> {
330        if !tag.starts_with('v') {
331            return Err(GitHubError::invalid_input(
332                "Version tag must start with 'v'",
333            ));
334        }
335
336        let version_str = tag.trim_start_matches('v');
337        SemanticVersion::parse(version_str)
338    }
339
340    /// Check if version already exists in history
341    pub fn version_exists(&self, tag_name: &str) -> bool {
342        self.history.contains_key(tag_name)
343    }
344
345    /// Get latest release
346    pub fn get_latest_release(&self) -> Option<ReleaseHistoryEntry> {
347        let mut entries: Vec<_> = self.history.values().cloned().collect();
348        entries.sort_by(|a, b| b.date.cmp(&a.date));
349        entries.first().cloned()
350    }
351}
352
353impl Default for ReleaseManager {
354    fn default() -> Self {
355        Self::new()
356    }
357}