Skip to content

Commit d893732

Browse files
Copilotlpcox
andauthored
Implement built-in promotion and demotion labels in GitHub guard
Agent-Logs-Url: https://github.com/github/gh-aw-mcpg/sessions/c19d9291-dcb0-41e7-9ecb-1ba67bde60ee Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com>
1 parent 65a071a commit d893732

3 files changed

Lines changed: 387 additions & 3 deletions

File tree

guards/github-guard/rust-guard/src/labels/helpers.rs

Lines changed: 105 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -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

150159
fn 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)
14371535
fn 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

Comments
 (0)