88import subprocess
99import sys
1010from collections .abc import Sequence
11+ from dataclasses import dataclass
1112from typing import Any , Final
1213
1314ALLOWED_LABELS : Final [set [str ]] = {
5758 "src/agents/models/" ,
5859)
5960
61+ PR_CONTEXT_DEFAULT_PATH = ".tmp/pr-labels/pr-context.json"
62+
63+
64+ @dataclass (frozen = True )
65+ class PRContext :
66+ title : str = ""
67+ body : str = ""
68+
6069
6170def read_file_at (commit : str | None , path : str ) -> str | None :
6271 if not commit :
@@ -214,6 +223,28 @@ def load_json(path: pathlib.Path) -> Any:
214223 return json .loads (path .read_text ())
215224
216225
226+ def load_pr_context (path : pathlib .Path ) -> PRContext :
227+ if not path .exists ():
228+ return PRContext ()
229+
230+ try :
231+ payload = load_json (path )
232+ except json .JSONDecodeError :
233+ return PRContext ()
234+
235+ if not isinstance (payload , dict ):
236+ return PRContext ()
237+
238+ title = payload .get ("title" , "" )
239+ body = payload .get ("body" , "" )
240+ if not isinstance (title , str ):
241+ title = ""
242+ if not isinstance (body , str ):
243+ body = ""
244+
245+ return PRContext (title = title , body = body )
246+
247+
217248def load_codex_labels (path : pathlib .Path ) -> tuple [list [str ], bool ]:
218249 if not path .exists ():
219250 return [], False
@@ -248,8 +279,22 @@ def fetch_existing_labels(pr_number: str) -> set[str]:
248279 return {label for label in result .splitlines () if label }
249280
250281
282+ def infer_title_intent_labels (pr_context : PRContext ) -> set [str ]:
283+ normalized_title = pr_context .title .strip ().lower ()
284+
285+ bug_prefixes = ("fix:" , "fix(" , "bug:" , "bugfix:" , "hotfix:" , "regression:" )
286+ enhancement_prefixes = ("feat:" , "feat(" , "feature:" , "enhancement:" )
287+
288+ if normalized_title .startswith (bug_prefixes ):
289+ return {"bug" }
290+ if normalized_title .startswith (enhancement_prefixes ):
291+ return {"enhancement" }
292+ return set ()
293+
294+
251295def compute_desired_labels (
252296 * ,
297+ pr_context : PRContext ,
253298 changed_files : Sequence [str ],
254299 diff_text : str ,
255300 codex_ran : bool ,
@@ -259,6 +304,11 @@ def compute_desired_labels(
259304 head_sha : str | None ,
260305) -> set [str ]:
261306 desired : set [str ] = set ()
307+ codex_label_set = {label for label in codex_labels if label in ALLOWED_LABELS }
308+ codex_feature_labels = codex_label_set & FEATURE_LABELS
309+ codex_model_only_labels = codex_label_set & MODEL_ONLY_LABELS
310+ fallback_feature_labels = infer_fallback_labels (changed_files )
311+ title_intent_labels = infer_title_intent_labels (pr_context )
262312
263313 if "pyproject.toml" in changed_files :
264314 desired .add ("project" )
@@ -274,21 +324,30 @@ def compute_desired_labels(
274324 if dependencies_allowed :
275325 desired .add ("dependencies" )
276326
277- if codex_ran and codex_output_valid :
278- for label in codex_labels :
279- if label == "dependencies" and not dependencies_allowed :
280- continue
281- if label in ALLOWED_LABELS :
282- desired .add (label )
283- return desired
327+ if codex_ran and codex_output_valid and codex_feature_labels :
328+ desired .update (codex_feature_labels )
329+ else :
330+ desired .update (fallback_feature_labels )
331+
332+ if title_intent_labels :
333+ desired .update (title_intent_labels )
334+ elif codex_ran and codex_output_valid :
335+ desired .update (codex_model_only_labels )
284336
285- desired .update (infer_fallback_labels (changed_files ))
286337 return desired
287338
288339
289- def compute_managed_labels (* , codex_ran : bool , codex_output_valid : bool ) -> set [str ]:
340+ def compute_managed_labels (
341+ * ,
342+ pr_context : PRContext ,
343+ codex_ran : bool ,
344+ codex_output_valid : bool ,
345+ codex_labels : Sequence [str ],
346+ ) -> set [str ]:
290347 managed = DETERMINISTIC_LABELS | FEATURE_LABELS
291- if codex_ran and codex_output_valid :
348+ title_intent_labels = infer_title_intent_labels (pr_context )
349+ codex_label_set = {label for label in codex_labels if label in MODEL_ONLY_LABELS }
350+ if title_intent_labels or (codex_ran and codex_output_valid and codex_label_set ):
292351 managed |= MODEL_ONLY_LABELS
293352 return managed
294353
@@ -303,6 +362,10 @@ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
303362 default = os .environ .get ("CODEX_OUTPUT_PATH" , ".tmp/codex/outputs/pr-labels.json" ),
304363 )
305364 parser .add_argument ("--codex-conclusion" , default = os .environ .get ("CODEX_CONCLUSION" , "" ))
365+ parser .add_argument (
366+ "--pr-context-path" ,
367+ default = os .environ .get ("PR_CONTEXT_PATH" , PR_CONTEXT_DEFAULT_PATH ),
368+ )
306369 parser .add_argument (
307370 "--changed-files-path" ,
308371 default = os .environ .get ("CHANGED_FILES_PATH" , ".tmp/pr-labels/changed-files.txt" ),
@@ -322,8 +385,10 @@ def main(argv: Sequence[str] | None = None) -> int:
322385 changed_files_path = pathlib .Path (args .changed_files_path )
323386 changes_diff_path = pathlib .Path (args .changes_diff_path )
324387 codex_output_path = pathlib .Path (args .codex_output_path )
388+ pr_context_path = pathlib .Path (args .pr_context_path )
325389 codex_conclusion = args .codex_conclusion .strip ().lower ()
326390 codex_ran = bool (codex_conclusion ) and codex_conclusion != "skipped"
391+ pr_context = load_pr_context (pr_context_path )
327392
328393 changed_files = []
329394 if changed_files_path .exists ():
@@ -339,6 +404,7 @@ def main(argv: Sequence[str] | None = None) -> int:
339404 "model-only labels."
340405 )
341406 desired = compute_desired_labels (
407+ pr_context = pr_context ,
342408 changed_files = changed_files ,
343409 diff_text = diff_text ,
344410 codex_ran = codex_ran ,
@@ -350,8 +416,10 @@ def main(argv: Sequence[str] | None = None) -> int:
350416
351417 existing = fetch_existing_labels (args .pr_number )
352418 managed_labels = compute_managed_labels (
419+ pr_context = pr_context ,
353420 codex_ran = codex_ran ,
354421 codex_output_valid = codex_output_valid ,
422+ codex_labels = codex_labels ,
355423 )
356424 to_add = sorted (desired - existing )
357425 to_remove = sorted ((existing & managed_labels ) - desired )
0 commit comments