@@ -43,18 +43,39 @@ pub fn get_upgrade_version(config: &Config) -> Option<String> {
4343 upgrade_version_from_info ( info)
4444}
4545
46+ #[ derive( Debug , Clone , PartialEq , Eq ) ]
47+ pub ( crate ) struct UpgradeInfo {
48+ pub ( crate ) latest_version : String ,
49+ pub ( crate ) release_notes : Vec < String > ,
50+ pub ( crate ) release_notes_url : String ,
51+ }
52+
4653#[ derive( Serialize , Deserialize , Debug , Clone ) ]
4754struct VersionInfo {
4855 latest_version : String ,
56+ #[ serde( default ) ]
57+ release_notes : Vec < String > ,
58+ #[ serde( default ) ]
59+ release_notes_url : Option < String > ,
4960 // ISO-8601 timestamp (RFC3339)
5061 last_checked_at : DateTime < Utc > ,
5162 #[ serde( default ) ]
5263 dismissed_version : Option < String > ,
5364}
5465
66+ #[ derive( Deserialize , Debug ) ]
67+ struct GithubReleaseInfo {
68+ html_url : Option < String > ,
69+ body : Option < String > ,
70+ }
71+
5572const VERSION_FILENAME : & str = "version.json" ;
5673const CACHE_REFRESH_INTERVAL : Duration = Duration :: hours ( 20 ) ;
5774const STARTUP_UPDATE_CHECK_TIMEOUT : StdDuration = StdDuration :: from_secs ( 3 ) ;
75+ const RELEASE_NOTES_LIMIT : usize = 5 ;
76+ const OPEN_CODEX_RELEASES_URL : & str = "https://github.com/LEON-gittech/Open-Codex-CLI/releases" ;
77+ const OPEN_CODEX_RELEASE_API_URL_PREFIX : & str =
78+ "https://api.github.com/repos/LEON-gittech/Open-Codex-CLI/releases/tags/rust-v" ;
5879
5980fn version_filepath ( config : & Config ) -> PathBuf {
6081 config. codex_home . join ( VERSION_FILENAME ) . into_path_buf ( )
@@ -74,12 +95,21 @@ fn should_refresh_version_info(info: Option<&VersionInfo>, now: DateTime<Utc>) -
7495}
7596
7697fn upgrade_version_from_info ( info : Option < VersionInfo > ) -> Option < String > {
77- info. and_then ( |info| {
78- if is_newer ( & info. latest_version , CODEX_CLI_VERSION ) . unwrap_or ( false ) {
79- Some ( info. latest_version )
80- } else {
81- None
82- }
98+ upgrade_info_from_info ( info) . map ( |info| info. latest_version )
99+ }
100+
101+ fn upgrade_info_from_info ( info : Option < VersionInfo > ) -> Option < UpgradeInfo > {
102+ let info = info?;
103+ if !is_newer ( & info. latest_version , CODEX_CLI_VERSION ) . unwrap_or ( false ) {
104+ return None ;
105+ }
106+
107+ Some ( UpgradeInfo {
108+ release_notes_url : info
109+ . release_notes_url
110+ . unwrap_or_else ( || fallback_release_notes_url ( & info. latest_version ) ) ,
111+ latest_version : info. latest_version ,
112+ release_notes : info. release_notes ,
83113 } )
84114}
85115
@@ -95,11 +125,22 @@ async fn check_for_update(
95125 . json :: < NpmPackageInfo > ( )
96126 . await ?;
97127 let latest_version = npm_registry:: latest_version ( & package_info) ?. to_string ( ) ;
128+ let release_notes = fetch_release_notes ( & latest_version)
129+ . await
130+ . unwrap_or_else ( |err| {
131+ tracing:: error!( "Failed to fetch release notes for version {latest_version}: {err}" ) ;
132+ ReleaseNotes {
133+ url : fallback_release_notes_url ( & latest_version) ,
134+ notes : Vec :: new ( ) ,
135+ }
136+ } ) ;
98137
99138 // Preserve any previously dismissed version if present.
100139 let prev_info = read_version_info ( version_file) . ok ( ) ;
101140 let info = VersionInfo {
102141 latest_version,
142+ release_notes : release_notes. notes ,
143+ release_notes_url : Some ( release_notes. url ) ,
103144 last_checked_at : Utc :: now ( ) ,
104145 dismissed_version : prev_info. and_then ( |p| p. dismissed_version ) ,
105146 } ;
@@ -112,9 +153,77 @@ async fn check_for_update(
112153 Ok ( ( ) )
113154}
114155
156+ struct ReleaseNotes {
157+ url : String ,
158+ notes : Vec < String > ,
159+ }
160+
161+ async fn fetch_release_notes ( version : & str ) -> anyhow:: Result < ReleaseNotes > {
162+ let url = format ! ( "{OPEN_CODEX_RELEASE_API_URL_PREFIX}{version}" ) ;
163+ let release = create_client ( )
164+ . get ( & url)
165+ . header ( reqwest:: header:: USER_AGENT , "open-codex-update-check" )
166+ . send ( )
167+ . await ?
168+ . error_for_status ( ) ?
169+ . json :: < GithubReleaseInfo > ( )
170+ . await ?;
171+
172+ let notes = release
173+ . body
174+ . as_deref ( )
175+ . map ( release_note_items_from_body)
176+ . unwrap_or_default ( ) ;
177+
178+ Ok ( ReleaseNotes {
179+ url : release
180+ . html_url
181+ . unwrap_or_else ( || fallback_release_notes_url ( version) ) ,
182+ notes,
183+ } )
184+ }
185+
186+ fn fallback_release_notes_url ( version : & str ) -> String {
187+ format ! ( "{OPEN_CODEX_RELEASES_URL}/tag/rust-v{version}" )
188+ }
189+
190+ fn release_note_items_from_body ( body : & str ) -> Vec < String > {
191+ body. lines ( )
192+ . filter_map ( |line| {
193+ let trimmed = line. trim ( ) ;
194+ trimmed
195+ . strip_prefix ( "- " )
196+ . or_else ( || trimmed. strip_prefix ( "* " ) )
197+ } )
198+ . map ( clean_release_note_item)
199+ . filter ( |line| !line. is_empty ( ) )
200+ . take ( RELEASE_NOTES_LIMIT )
201+ . collect ( )
202+ }
203+
204+ fn clean_release_note_item ( item : & str ) -> String {
205+ let mut cleaned = String :: with_capacity ( item. len ( ) ) ;
206+ let mut previous_was_space = false ;
207+ for ch in item. chars ( ) {
208+ if ch == '`' {
209+ continue ;
210+ }
211+ if ch. is_whitespace ( ) {
212+ if !previous_was_space {
213+ cleaned. push ( ' ' ) ;
214+ }
215+ previous_was_space = true ;
216+ } else {
217+ cleaned. push ( ch) ;
218+ previous_was_space = false ;
219+ }
220+ }
221+ cleaned. trim ( ) . to_string ( )
222+ }
223+
115224/// Returns the latest version to show in a popup, if it should be shown.
116225/// This respects the user's dismissal choice for the current latest version.
117- pub async fn get_upgrade_version_for_popup ( config : & Config ) -> Option < String > {
226+ pub async fn get_upgrade_info_for_popup ( config : & Config ) -> Option < UpgradeInfo > {
118227 if !config. check_for_update_on_startup || is_source_build_version ( CODEX_CLI_VERSION ) {
119228 return None ;
120229 }
@@ -141,14 +250,14 @@ pub async fn get_upgrade_version_for_popup(config: &Config) -> Option<String> {
141250 }
142251 }
143252
144- let latest = upgrade_version_from_info ( info) ?;
253+ let upgrade_info = upgrade_info_from_info ( info) ?;
145254 // If the user dismissed this exact version previously, do not show the popup.
146255 if let Ok ( info) = read_version_info ( & version_file)
147- && info. dismissed_version . as_deref ( ) == Some ( latest . as_str ( ) )
256+ && info. dismissed_version . as_deref ( ) == Some ( upgrade_info . latest_version . as_str ( ) )
148257 {
149258 return None ;
150259 }
151- Some ( latest )
260+ Some ( upgrade_info )
152261}
153262
154263/// Persist a dismissal for the current latest version so we don't show
@@ -167,3 +276,56 @@ pub async fn dismiss_version(config: &Config, version: &str) -> anyhow::Result<(
167276 tokio:: fs:: write ( version_file, json_line) . await ?;
168277 Ok ( ( ) )
169278}
279+
280+ #[ cfg( test) ]
281+ mod tests {
282+ use super :: * ;
283+ use pretty_assertions:: assert_eq;
284+
285+ #[ test]
286+ fn release_notes_body_extracts_top_bullets ( ) {
287+ let body = r#"
288+ ## 0.130.11
289+
290+ - Fix revoke conversation-only restores so they do not touch files.
291+ - Add inline release notes to the update prompt.
292+ - Collapse extra spacing.
293+ - Fourth item.
294+ - Fifth item.
295+ - Sixth item is ignored.
296+
297+ Details after the list are ignored.
298+ "# ;
299+
300+ assert_eq ! (
301+ release_note_items_from_body( body) ,
302+ vec![
303+ "Fix revoke conversation-only restores so they do not touch files." . to_string( ) ,
304+ "Add inline release notes to the update prompt." . to_string( ) ,
305+ "Collapse extra spacing." . to_string( ) ,
306+ "Fourth item." . to_string( ) ,
307+ "Fifth item." . to_string( ) ,
308+ ]
309+ ) ;
310+ }
311+
312+ #[ test]
313+ fn upgrade_info_preserves_cached_release_notes ( ) {
314+ let info = VersionInfo {
315+ latest_version : "999.0.0" . to_string ( ) ,
316+ release_notes : vec ! [ "One concrete change." . to_string( ) ] ,
317+ release_notes_url : Some ( "https://example.test/release" . to_string ( ) ) ,
318+ last_checked_at : Utc :: now ( ) ,
319+ dismissed_version : None ,
320+ } ;
321+
322+ assert_eq ! (
323+ upgrade_info_from_info( Some ( info) ) ,
324+ Some ( UpgradeInfo {
325+ latest_version: "999.0.0" . to_string( ) ,
326+ release_notes: vec![ "One concrete change." . to_string( ) ] ,
327+ release_notes_url: "https://example.test/release" . to_string( ) ,
328+ } )
329+ ) ;
330+ }
331+ }
0 commit comments