@@ -145,6 +145,15 @@ pub struct PolicyContext {
145145 /// endorsement or disapproval. Defaults to "approved" when empty. Options:
146146 /// "none", "unapproved", "approved", "merged".
147147 pub endorser_min_integrity : String ,
148+ /// A single GitHub label name that promotes a content item's effective integrity to
149+ /// "approved" when present. Disabled when empty. Case-insensitive. Composes with
150+ /// `approval-labels`; both can promote to approved.
151+ pub promotion_label : String ,
152+ /// A single GitHub label name that demotes a content item's effective integrity to
153+ /// "none" when present. Disabled when empty. Case-insensitive. Overrides promotion
154+ /// label, approval-labels, trusted-users, and endorsement reactions. Only
155+ /// `blocked-users` takes precedence over demotion label.
156+ pub demotion_label : String ,
148157}
149158
150159fn normalize_scope ( scope : & str , ctx : & PolicyContext ) -> String {
@@ -352,6 +361,93 @@ fn apply_approval_label_promotion(
352361 }
353362}
354363
364+ // ============================================================================
365+ // Built-in promotion/demotion label helpers
366+ // ============================================================================
367+
368+ /// Check whether a content item carries the configured built-in promotion label
369+ /// (case-insensitive). Returns `false` when `promotion_label` is empty (feature disabled).
370+ #[ cfg( test) ]
371+ pub fn has_promotion_label ( item : & Value , ctx : & PolicyContext ) -> bool {
372+ if ctx. promotion_label . is_empty ( ) {
373+ return false ;
374+ }
375+ let label_names = extract_github_label_names ( item) ;
376+ label_names
377+ . iter ( )
378+ . any ( |name| ctx. promotion_label . eq_ignore_ascii_case ( name) )
379+ }
380+
381+ /// Check whether a content item carries the configured built-in demotion label
382+ /// (case-insensitive). Returns `false` when `demotion_label` is empty (feature disabled).
383+ #[ cfg( test) ]
384+ pub fn has_demotion_label ( item : & Value , ctx : & PolicyContext ) -> bool {
385+ if ctx. demotion_label . is_empty ( ) {
386+ return false ;
387+ }
388+ let label_names = extract_github_label_names ( item) ;
389+ label_names
390+ . iter ( )
391+ . any ( |name| ctx. demotion_label . eq_ignore_ascii_case ( name) )
392+ }
393+
394+ /// Apply built-in promotion label: if the item carries the configured promotion label,
395+ /// raise integrity to at least writer (approved) level.
396+ fn apply_promotion_label_promotion (
397+ item : & Value ,
398+ resource_type : & str ,
399+ repo_full_name : & str ,
400+ integrity : Vec < String > ,
401+ ctx : & PolicyContext ,
402+ ) -> Vec < String > {
403+ if ctx. promotion_label . is_empty ( ) {
404+ return integrity;
405+ }
406+ let label_names = extract_github_label_names ( item) ;
407+ if label_names
408+ . iter ( )
409+ . any ( |name| ctx. promotion_label . eq_ignore_ascii_case ( name) )
410+ {
411+ let number = item. get ( field_names:: NUMBER ) . and_then ( |v| v. as_u64 ( ) ) . unwrap_or ( 0 ) ;
412+ crate :: log_info ( & format ! (
413+ "[integrity] {}:{}#{} promoted to approved (built-in promotion-label '{}')" ,
414+ resource_type, repo_full_name, number, ctx. promotion_label
415+ ) ) ;
416+ max_integrity ( repo_full_name, integrity, writer_integrity ( repo_full_name, ctx) , ctx)
417+ } else {
418+ integrity
419+ }
420+ }
421+
422+ /// Apply built-in demotion label: if the item carries the configured demotion label,
423+ /// cap integrity at none. Overrides promotion label, approval-labels, trusted-users,
424+ /// and endorsement reactions. Only `blocked-users` takes absolute precedence.
425+ fn apply_demotion_label_demotion (
426+ item : & Value ,
427+ resource_type : & str ,
428+ repo_full_name : & str ,
429+ integrity : Vec < String > ,
430+ ctx : & PolicyContext ,
431+ ) -> Vec < String > {
432+ if ctx. demotion_label . is_empty ( ) {
433+ return integrity;
434+ }
435+ let label_names = extract_github_label_names ( item) ;
436+ if label_names
437+ . iter ( )
438+ . any ( |name| ctx. demotion_label . eq_ignore_ascii_case ( name) )
439+ {
440+ let number = item. get ( field_names:: NUMBER ) . and_then ( |v| v. as_u64 ( ) ) . unwrap_or ( 0 ) ;
441+ crate :: log_info ( & format ! (
442+ "[integrity] {}:{}#{} demoted to none (built-in demotion-label '{}')" ,
443+ resource_type, repo_full_name, number, ctx. demotion_label
444+ ) ) ;
445+ cap_integrity ( repo_full_name, integrity, none_integrity ( repo_full_name, ctx) , ctx)
446+ } else {
447+ integrity
448+ }
449+ }
450+
355451// ============================================================================
356452// Reaction-based endorsement and disapproval helpers
357453// ============================================================================
@@ -1431,9 +1527,11 @@ pub fn is_default_branch_commit_context(tool_name: &str, sha_or_ref: &str) -> bo
14311527
14321528/// Apply the standard post-integrity adjustment pipeline to a content item after
14331529/// baseline integrity calculation:
1434- /// 1. Approval-label promotion → raise to at least approved
1435- /// 2. Endorsement promotion → raise to at least approved on maintainer reaction
1436- /// 3. Disapproval demotion → cap at configured level on maintainer reaction (wins last)
1530+ /// 1. Approval-label promotion → raise to at least approved
1531+ /// 2. Built-in promotion label → raise to at least approved (new)
1532+ /// 3. Endorsement promotion → raise to at least approved on maintainer reaction
1533+ /// 4. Built-in demotion label → cap at none (new; overrides steps 1–3)
1534+ /// 5. Disapproval demotion → cap at configured level on maintainer reaction (wins last)
14371535fn apply_post_integrity_adjustments (
14381536 item : & Value ,
14391537 resource_type : & str ,
@@ -1443,8 +1541,12 @@ fn apply_post_integrity_adjustments(
14431541) -> Vec < String > {
14441542 let integrity =
14451543 apply_approval_label_promotion ( item, resource_type, repo_full_name, integrity, ctx) ;
1544+ let integrity =
1545+ apply_promotion_label_promotion ( item, resource_type, repo_full_name, integrity, ctx) ;
14461546 let integrity =
14471547 apply_endorsement_promotion ( item, resource_type, repo_full_name, integrity, ctx) ;
1548+ let integrity =
1549+ apply_demotion_label_demotion ( item, resource_type, repo_full_name, integrity, ctx) ;
14481550 apply_disapproval_demotion ( item, resource_type, repo_full_name, integrity, ctx)
14491551}
14501552
0 commit comments