@@ -1822,12 +1822,10 @@ def should_ignore_issue(repo_config: types.ModuleType, repo: Any, issue: Any) ->
18221822 if issue .title and re .match (BUILD_REL , issue .title ):
18231823 return True
18241824
1825- # Check if body has ignore marker on first line
1825+ # Check if body has ignore marker anywhere
18261826 if issue .body :
1827- # Get first non-blank line
18281827 try :
1829- first_line = issue .body .split ("\n " , 1 )[0 ].strip ()
1830- if first_line and RE_CMS_BOT_IGNORE .search (first_line ):
1828+ if RE_CMS_BOT_IGNORE .search (issue .body ):
18311829 return True
18321830 except (AttributeError , TypeError ):
18331831 # issue.body may be None or not a string
@@ -1836,27 +1834,14 @@ def should_ignore_issue(repo_config: types.ModuleType, repo: Any, issue: Any) ->
18361834 return False
18371835
18381836
1839- def should_ignore_pr_body (pr_body : str ) -> bool :
1840- """
1841- Check if PR should be ignored based on description only.
1842-
1843- Returns True if first non-blank line matches <cms-bot></cms-bot>.
1844-
1845- Note: For full ignore checking including IGNORE_ISSUES and BUILD_REL,
1846- use should_ignore_issue() instead.
1847- """
1848- first_line , _ = extract_command_line (pr_body or "" )
1849- if not first_line :
1850- return False
1851- return bool (RE_CMS_BOT_IGNORE .match (first_line ))
1852-
1853-
18541837def should_notify_without_at (pr_body : str ) -> bool :
18551838 """
18561839 Check if notifications should omit @ symbol.
18571840
1858- Returns True if <notify></notify> is found anywhere in issue body .
1841+ Returns True if body contains <notify></notify> anywhere.
18591842 """
1843+ if not pr_body :
1844+ return False
18601845 return bool (RE_NOTIFY_NO_AT .search (pr_body ))
18611846
18621847
@@ -1914,6 +1899,9 @@ class PRContext:
19141899 pending_reactions : Dict [int , str ] = field (default_factory = dict ) # comment_id -> reaction
19151900 holds : List [Hold ] = field (default_factory = list ) # Active holds on the PR
19161901 pending_labels : Set [str ] = field (default_factory = set ) # Labels to add
1902+ pending_labels_to_remove : Set [str ] = field (
1903+ default_factory = set
1904+ ) # Labels to remove (from type command)
19171905 signing_categories : Set [str ] = field (default_factory = set ) # Categories requiring signatures
19181906 manually_assigned_categories : Set [str ] = field (
19191907 default_factory = set
@@ -3229,60 +3217,108 @@ def handle_file_count_override(context: PRContext, match: re.Match, comment: Any
32293217
32303218@command (
32313219 "type" ,
3232- r"^type (?P<label>[ \w-]+)$" ,
3233- description = "Add a type label to the PR/Issue" ,
3220+ r"^type (?P<labels>[-+]?[ \w-]+(,[-+]?[\w-]+)* )$" ,
3221+ description = "Add/remove type labels on the PR/Issue" ,
32343222)
32353223def handle_type (context : PRContext , match : re .Match , comment : Any ) -> bool :
32363224 """
3237- Handle type <label > command.
3225+ Handle type <labels > command.
32383226
3239- Adds a non-blocking label to the PR/Issue. The label must be defined
3227+ Adds or removes non-blocking labels on the PR/Issue. Labels must be defined
32403228 in TYPE_COMMANDS to be valid.
32413229
32423230 Labels have two modes:
32433231 - 'type': Only the last one applies (replaces previous type labels)
32443232 - 'mtype': Accumulates (multiple can coexist)
32453233
3234+ Prefixes:
3235+ - No prefix or '+': Add the label
3236+ - '-': Remove the label
3237+
32463238 Syntax:
3247- type <label>
3239+ type <label>[,<label>...]
32483240
32493241 Examples:
32503242 type bug-fix
3251- type new-feature
3252- type documentation
3243+ type new-feature,urgent
3244+ type +bug-fix,-documentation
3245+ type -obsolete
32533246 """
32543247 user = comment .user .login
3255- label = match .group ("label" )
3248+ labels_str = match .group ("labels" )
3249+ labels_raw = [l .strip () for l in labels_str .split ("," )]
3250+
3251+ errors = []
3252+ labels_to_add = []
3253+ labels_to_remove = []
3254+
3255+ for label_raw in labels_raw :
3256+ if not label_raw :
3257+ continue
32563258
3257- # Validate label is in TYPE_COMMANDS
3258- if label not in TYPE_COMMANDS :
3259+ # Parse prefix
3260+ if label_raw .startswith ("+" ):
3261+ label = label_raw [1 :]
3262+ action = "add"
3263+ elif label_raw .startswith ("-" ):
3264+ label = label_raw [1 :]
3265+ action = "remove"
3266+ else :
3267+ label = label_raw
3268+ action = "add"
3269+
3270+ # Validate label is in TYPE_COMMANDS
3271+ if label not in TYPE_COMMANDS :
3272+ errors .append (label )
3273+ continue
3274+
3275+ if action == "add" :
3276+ labels_to_add .append (label )
3277+ else :
3278+ labels_to_remove .append (label )
3279+
3280+ # Report errors if any
3281+ if errors :
32593282 valid_labels = ", " .join (sorted (TYPE_COMMANDS .keys ()))
3260- context .messages .append (f"Invalid type label '{ label } '. Valid labels: { valid_labels } " )
3261- logger .warning (f"Invalid type label: { label } " )
3283+ context .messages .append (
3284+ f"Invalid type label(s): { ', ' .join (errors )} . Valid labels: { valid_labels } "
3285+ )
3286+ logger .warning (f"Invalid type labels: { errors } " )
32623287 return False
32633288
3264- # Get label type (type or mtype)
3265- # TYPE_COMMANDS[label] = [color, regexp, label_type]
3266- label_info = TYPE_COMMANDS [label ]
3267- label_type = label_info [2 ] if len (label_info ) > 2 else "mtype"
3268-
3269- if label_type == "type" :
3270- # 'type' labels replace previous - remove other 'type' labels
3271- type_labels_to_remove = set ()
3272- for existing_label in context .pending_labels :
3273- if existing_label in TYPE_COMMANDS :
3274- existing_info = TYPE_COMMANDS [existing_label ]
3275- existing_type = existing_info [2 ] if len (existing_info ) > 2 else "mtype"
3276- if existing_type == "type" :
3277- type_labels_to_remove .add (existing_label )
3278-
3279- context .pending_labels -= type_labels_to_remove
3280- if type_labels_to_remove :
3281- logger .debug (f"Removed previous type labels: { type_labels_to_remove } " )
3282-
3283- # Add the new label
3284- context .pending_labels .add (label )
3285- logger .info (f"Type label '{ label } ' ({ label_type } ) added by { user } " )
3289+ # Process labels to add
3290+ for label in labels_to_add :
3291+ # Get label type (type or mtype)
3292+ # TYPE_COMMANDS[label] = [color, regexp, label_type]
3293+ label_info = TYPE_COMMANDS [label ]
3294+ label_type = label_info [2 ] if len (label_info ) > 2 else "mtype"
3295+
3296+ if label_type == "type" :
3297+ # 'type' labels replace previous - remove other 'type' labels
3298+ type_labels_to_remove = set ()
3299+ for existing_label in context .pending_labels :
3300+ if existing_label in TYPE_COMMANDS :
3301+ existing_info = TYPE_COMMANDS [existing_label ]
3302+ existing_type = existing_info [2 ] if len (existing_info ) > 2 else "mtype"
3303+ if existing_type == "type" :
3304+ type_labels_to_remove .add (existing_label )
3305+
3306+ context .pending_labels -= type_labels_to_remove
3307+ if type_labels_to_remove :
3308+ logger .debug (f"Removed previous type labels: { type_labels_to_remove } " )
3309+
3310+ # Add the new label (also remove from pending removal if it was there)
3311+ context .pending_labels .add (label )
3312+ context .pending_labels_to_remove .discard (label )
3313+ logger .info (f"Type label '{ label } ' ({ label_type } ) added by { user } " )
3314+
3315+ # Process labels to remove
3316+ for label in labels_to_remove :
3317+ # Remove from pending adds if it was there
3318+ context .pending_labels .discard (label )
3319+ # Mark for removal from existing labels
3320+ context .pending_labels_to_remove .add (label )
3321+ logger .info (f"Type label '{ label } ' marked for removal by { user } " )
32863322
32873323 # Note: type commands are not cached - only signatures (+1/-1) are cached
32883324 return True
@@ -5751,6 +5787,11 @@ def update_pr_status(context: PRContext) -> Tuple[Set[str], Set[str]]:
57515787 if label not in old_labels :
57525788 labels_to_add .add (label )
57535789
5790+ # Remove labels marked for removal via 'type -label' command
5791+ for label in context .pending_labels_to_remove :
5792+ if label in old_labels :
5793+ labels_to_remove .add (label )
5794+
57545795 # Keep labels that aren't being removed
57555796 for label in old_labels :
57565797 if label not in labels_to_remove :
@@ -6757,6 +6798,7 @@ def process_pr(
67576798 "pr_state" : None ,
67586799 "categories" : {},
67596800 "holds" : [],
6801+ "labels" : [],
67606802 "messages" : [],
67616803 "tests_triggered" : [],
67626804 }
@@ -6776,6 +6818,7 @@ def process_pr(
67766818 "pr_state" : None ,
67776819 "categories" : {},
67786820 "holds" : [],
6821+ "labels" : [],
67796822 "messages" : [],
67806823 "tests_triggered" : [],
67816824 }
@@ -6881,6 +6924,7 @@ def process_pr(
68816924 "pr_state" : None ,
68826925 "categories" : {},
68836926 "holds" : [],
6927+ "labels" : [],
68846928 "messages" : [],
68856929 "tests_triggered" : [],
68866930 }
@@ -6937,6 +6981,7 @@ def process_pr(
69376981 "pr_state" : None ,
69386982 "categories" : {},
69396983 "holds" : [],
6984+ "labels" : [],
69406985 "messages" : [],
69416986 "tests_triggered" : [],
69426987 }
@@ -7123,9 +7168,6 @@ def process_pr(
71237168 pre_checks = signing_checks .pre_checks
71247169 extra_checks = signing_checks .extra_checks
71257170
7126- # Collect all labels
7127- all_labels = set (context .pending_labels )
7128-
71297171 # Return results
71307172 return {
71317173 "pr_number" : issue .number ,
@@ -7144,7 +7186,7 @@ def process_pr(
71447186 for name , state in category_states .items ()
71457187 },
71467188 "holds" : [{"category" : h .category , "user" : h .user } for h in context .holds ],
7147- "labels" : sorted (all_labels ),
7189+ "labels" : sorted (new_labels ),
71487190 "messages" : context .messages ,
71497191 "status_message" : status_message ,
71507192 "test_params" : context .test_params ,
0 commit comments