From 538151a55e01ccdac89c3e8bafacafb348606269 Mon Sep 17 00:00:00 2001 From: Lucas White Date: Wed, 6 May 2026 08:22:50 -0700 Subject: [PATCH] Rework GH Project status logic to reflect triage runbook (#55845) 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 --- script/triage_project_sync.py | 60 ++++++++++++++++++++++++++--------- 1 file changed, 45 insertions(+), 15 deletions(-) diff --git a/script/triage_project_sync.py b/script/triage_project_sync.py index 239ae9f7ab482327dbd5997b44862cc66d284564..8c5c75988eed22ccbf1d1b36e2e368393633e48a 100644 --- a/script/triage_project_sync.py +++ b/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 ''})" @@ -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: