Rework GH Project status logic to reflect triage runbook (#55845)

Lucas White created

Self-Review Checklist:

- [ x] I've reviewed my own diff for quality, security, and reliability
- [ x] Unsafe blocks (if any) have justifying comments
- [ n/a] The content is consistent with the [UI/UX
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
- [ n/a] Tests cover the new/changed behavior
- [ n/a] Performance impact has been considered and is acceptable

Closes #ISSUE

Release Notes:

- N/A

Change summary

script/triage_project_sync.py | 60 +++++++++++++++++++++++++++---------
1 file changed, 45 insertions(+), 15 deletions(-)

Detailed changes

script/triage_project_sync.py 🔗

@@ -72,6 +72,7 @@ STAFF_TEAM_SLUG = "staff"
 # (Casing matters — GH Projects single-select option matching is case-sensitive.)
 STATUS_NEEDS_LABELS = "Needs labels"
 STATUS_NEEDS_REPRO_ATTEMPT = "Needs repro attempt"
+STATUS_NEEDS_ASK = "Needs ask"
 STATUS_USER_REPLIED = "User replied (review)"
 STATUS_AWAITING_USER = "Awaiting user"
 STATUS_RESPONDED_NO_REPRO = "Responded, no repro"
@@ -90,6 +91,8 @@ AGE_THRESHOLDS_DAYS = {
     STATUS_NEEDS_REPRO_ATTEMPT: 7,
     STATUS_AWAITING_USER: 14,
     STATUS_USER_REPLIED: 3,
+    # Needs ask is handled explicitly in derive_aged (always flagged), so
+    # it doesn't need a threshold here.
 }
 
 TERMINAL_OR_RESTING_STATUSES = {
@@ -370,34 +373,46 @@ def derive_status(issue: IssueData, staff: set[str]) -> tuple[str, str, str]:
             "reproducible, no assignee, no substantive staff comment — close the loop",
         )
 
-    if "state:needs triage" in L:
-        return STATUS_NEEDS_LABELS, "R3", "state:needs triage label present"
-
+    # R4 (state:needs info) and R5 (state:needs repro) intentionally come
+    # before R3 (state:needs triage). Per the team's actual practice,
+    # state:needs triage is often left on while triage is in progress; only
+    # when no other state label is more specific should we treat the issue
+    # as "needs initial labels."
     if "state:needs info" in L:
-        last_staff = None
+        # R4 splits into three sub-cases based on whether we've actually
+        # asked anything (substantive staff comment) and whether the reporter
+        # or a third-party has responded.
+        substantive_staff = None
         for c in issue.comments:
-            if c["user"]["login"] in staff and not is_bot(c["user"]):
-                last_staff = c
-        if last_staff is None:
-            return STATUS_AWAITING_USER, "R4a", "needs info, no staff comment yet"
+            if is_substantive_staff_comment(c, staff):
+                substantive_staff = c
+        if substantive_staff is None:
+            # state:needs info applied without an actual question to the user.
+            # Runbook violation — we owe the reporter a comment explaining
+            # what info we need.
+            return (
+                STATUS_NEEDS_ASK,
+                "R4c",
+                "state:needs info present but no substantive staff comment exists — we haven't asked anything",
+            )
         last_comment = issue.comments[-1] if issue.comments else None
         if last_comment is not None:
             author = last_comment["user"]["login"]
             non_staff = author not in staff and not is_bot(last_comment["user"])
             if non_staff:
                 ct = parse_dt(last_comment["created_at"])
-                st = parse_dt(last_staff["created_at"])
+                st = parse_dt(substantive_staff["created_at"])
                 if ct and st and ct > st:
                     relation = "reporter" if author == issue.reporter else "third-party"
                     return (
                         STATUS_USER_REPLIED,
                         "R4b",
-                        f"{relation} (@{author}) replied {ct.isoformat()} after staff @ {st.isoformat()}",
+                        f"{relation} (@{author}) replied {ct.isoformat()} after substantive staff @ {st.isoformat()}",
                     )
         return (
             STATUS_AWAITING_USER,
             "R4a",
-            f"last staff comment @ {last_staff['created_at']}, no non-staff reply since",
+            f"substantive staff comment @ {substantive_staff['created_at']}, no non-staff reply since",
         )
 
     if "state:needs repro" in L:
@@ -412,6 +427,13 @@ def derive_status(issue: IssueData, staff: set[str]) -> tuple[str, str, str]:
                 )
         return STATUS_NEEDS_REPRO_ATTEMPT, "R5a", "no substantive staff comment after reporter's last activity"
 
+    # R3 (state:needs triage) is checked LAST among recognized state labels.
+    # If state:needs triage is the only state label, the issue genuinely needs
+    # initial labeling. If any other state label is also present, that state
+    # has already been matched above and won.
+    if "state:needs triage" in L:
+        return STATUS_NEEDS_LABELS, "R3", "state:needs triage label present (no other state:* matched)"
+
     return STATUS_UNKNOWN, "R6", f"open with no recognized state label (labels: {sorted(L) or '<none>'})"
 
 
@@ -425,12 +447,18 @@ def derive_stale_since(
         return issue.created_at
     if status == STATUS_NEEDS_REPRO_ATTEMPT:
         return latest_reporter_activity(issue)
+    if status == STATUS_NEEDS_ASK:
+        # Anchor on issue creation — measures how long the runbook violation
+        # has gone unaddressed. Aging threshold is 0 (always flagged).
+        return issue.created_at
     if status == STATUS_AWAITING_USER:
-        last_staff = None
+        # Anchor on the most recent SUBSTANTIVE staff comment (the actual
+        # "ask"), consistent with R4's substantive-comment requirement.
+        substantive_staff = None
         for c in issue.comments:
-            if c["user"]["login"] in staff and not is_bot(c["user"]):
-                last_staff = c
-        return parse_dt(last_staff["created_at"]) if last_staff else issue.created_at
+            if is_substantive_staff_comment(c, staff):
+                substantive_staff = c
+        return parse_dt(substantive_staff["created_at"]) if substantive_staff else issue.created_at
     if status == STATUS_USER_REPLIED:
         last_non_staff = None
         for c in issue.comments:
@@ -450,6 +478,8 @@ def derive_aged(status: str, stale_since: datetime | None) -> tuple[str, str]:
     """Returns ('Yes' | 'No', why)."""
     if status == STATUS_HANDOFF_INCOMPLETE:
         return "Yes", "always-flagged for loop closure"
+    if status == STATUS_NEEDS_ASK:
+        return "Yes", "always-flagged: state:needs info applied without a substantive staff comment"
     if status in TERMINAL_OR_RESTING_STATUSES or status == STATUS_UNKNOWN:
         return "No", "terminal/resting"
     if not stale_since: