@@ -31,11 +31,41 @@ internal static class BundleDescriptionSubstitution
3131 /// <summary>
3232 /// Substitutes placeholders in a description string with provided values
3333 /// </summary>
34- public static string SubstitutePlaceholders ( string description , string ? version , string ? lifecycle , string ? owner , string ? repo )
34+ public static string SubstitutePlaceholders ( string description , string ? version , string ? lifecycle , string ? owner , string ? repo ) =>
35+ SubstitutePlaceholders ( description , version , lifecycle , owner , repo , validateResolvable : false ) ;
36+
37+ /// <summary>
38+ /// Substitutes placeholders in a description string with provided values
39+ /// </summary>
40+ /// <param name="description">The description string containing placeholders</param>
41+ /// <param name="version">Version value for {version} placeholder</param>
42+ /// <param name="lifecycle">Lifecycle value for {lifecycle} placeholder</param>
43+ /// <param name="owner">Owner value for {owner} placeholder</param>
44+ /// <param name="repo">Repository value for {repo} placeholder</param>
45+ /// <param name="validateResolvable">If true, validates that all used placeholders can be resolved</param>
46+ /// <returns>Description with placeholders substituted</returns>
47+ /// <exception cref="InvalidOperationException">When validateResolvable is true and placeholders cannot be resolved</exception>
48+ public static string SubstitutePlaceholders ( string description , string ? version , string ? lifecycle , string ? owner , string ? repo , bool validateResolvable )
3549 {
3650 if ( string . IsNullOrEmpty ( description ) )
3751 return description ;
3852
53+ if ( validateResolvable )
54+ {
55+ var missingValues = new List < string > ( ) ;
56+ if ( description . Contains ( "{version}" ) && string . IsNullOrEmpty ( version ) )
57+ missingValues . Add ( "version" ) ;
58+ if ( description . Contains ( "{lifecycle}" ) && string . IsNullOrEmpty ( lifecycle ) )
59+ missingValues . Add ( "lifecycle" ) ;
60+ if ( description . Contains ( "{owner}" ) && string . IsNullOrEmpty ( owner ) )
61+ missingValues . Add ( "owner" ) ;
62+ if ( description . Contains ( "{repo}" ) && string . IsNullOrEmpty ( repo ) )
63+ missingValues . Add ( "repo" ) ;
64+
65+ if ( missingValues . Count > 0 )
66+ throw new InvalidOperationException ( $ "Cannot resolve placeholders: { string . Join ( ", " , missingValues ) } ") ;
67+ }
68+
3969 return description
4070 . Replace ( "{version}" , version ?? string . Empty )
4171 . Replace ( "{lifecycle}" , lifecycle ?? string . Empty )
@@ -205,6 +235,9 @@ public async Task<bool> BundleChangelogs(IDiagnosticsCollector collector, Bundle
205235 if ( ! ValidateInput ( collector , input ) )
206236 return false ;
207237
238+ if ( ! ValidatePlaceholderUsage ( collector , input ) )
239+ return false ;
240+
208241 if ( ! ValidateLinkAllowlist ( collector , input ) )
209242 return false ;
210243
@@ -455,16 +488,27 @@ public async Task<bool> BundleChangelogs(IDiagnosticsCollector collector, Bundle
455488 {
456489 // Validate placeholder usage in profile mode
457490 var hasVersionPlaceholder = descriptionTemplate . Contains ( "{version}" ) || descriptionTemplate . Contains ( "{lifecycle}" ) ;
491+ var hasOwnerRepoPlaceholder = descriptionTemplate . Contains ( "{owner}" ) || descriptionTemplate . Contains ( "{repo}" ) ;
492+
458493 if ( hasVersionPlaceholder &&
459494 filterResult . Version == "unknown" &&
460495 string . IsNullOrEmpty ( profile . OutputProducts ) )
461496 {
462497 collector . EmitError ( string . Empty ,
463- $ "Profile '{ input . Profile } ' uses placeholders in description but no version is available for substitution. " +
498+ $ "Profile '{ input . Profile } ' uses {{version}} or {{lifecycle}} placeholders in description but no version is available for substitution. " +
464499 "Either provide a version argument, or add 'output_products' pattern to the profile configuration." ) ;
465500 return null ;
466501 }
467502
503+ if ( hasOwnerRepoPlaceholder &&
504+ ( string . IsNullOrEmpty ( owner ) || string . IsNullOrEmpty ( repo ) ) )
505+ {
506+ collector . EmitError ( string . Empty ,
507+ $ "Profile '{ input . Profile } ' uses {{owner}} or {{repo}} placeholders in description but values are not resolvable. " +
508+ "Ensure repository metadata is available in the configuration." ) ;
509+ return null ;
510+ }
511+
468512 profileDescription = BundleDescriptionSubstitution . SubstitutePlaceholders (
469513 descriptionTemplate , filterResult . Version , resolvedLifecycle , owner , repo ) ;
470514 }
@@ -628,6 +672,31 @@ private bool ValidateInput(IDiagnosticsCollector collector, BundleChangelogsArgu
628672 return true ;
629673 }
630674
675+ private static bool ValidatePlaceholderUsage ( IDiagnosticsCollector collector , BundleChangelogsArguments input )
676+ {
677+ // Only validate in option-based mode (profile mode has separate validation)
678+ if ( ! string . IsNullOrEmpty ( input . Profile ) )
679+ return true ;
680+
681+ if ( string . IsNullOrEmpty ( input . Description ) )
682+ return true ;
683+
684+ var hasPlaceholders = input . Description . Contains ( "{version}" ) ||
685+ input . Description . Contains ( "{lifecycle}" ) ||
686+ input . Description . Contains ( "{owner}" ) ||
687+ input . Description . Contains ( "{repo}" ) ;
688+
689+ if ( hasPlaceholders && ( input . OutputProducts == null || input . OutputProducts . Count == 0 ) )
690+ {
691+ collector . EmitError ( string . Empty ,
692+ "When using placeholders in bundle description in option-based mode, " +
693+ "--output-products must be explicitly specified to ensure predictable substitution values." ) ;
694+ return false ;
695+ }
696+
697+ return true ;
698+ }
699+
631700 private static bool ValidateLinkAllowlist ( IDiagnosticsCollector collector , BundleChangelogsArguments input )
632701 {
633702 if ( input . LinkAllowRepos == null )
0 commit comments