diff --git a/AGENTS.md b/AGENTS.md index 855b2da..a0e41e9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,7 +20,7 @@ Before making any changes: | Command definitions | `/commands/.md` | | Agent definitions | `/agents//AGENT.md` | | Hook definitions | `/hooks/` | -| PR approval ownership | `/OWNERS` (k8s-style) | +| PR approval ownership | `/OWNERS` (k8s-style, references aliases from the root `OWNERS_ALIASES`; both are generated from `hyperfleet/docs/owners.yaml` in the architecture repository, so ownership changes are a PR to that file; `hyperfleet/docs/owners.md` there explains the roles and criteria) | | Contributor guide | `CONTRIBUTING.md` | | Skill reference data | `/skills//references/` | | CI review container | `hyperfleet-code-review/ci/` | @@ -35,7 +35,7 @@ Before making any changes: | `hyperfleet-standards` | 1 | - | - | - | 1.1.0 | | `hyperfleet-operational-readiness` | 1 | - | - | - | 0.2.0 | | `hyperfleet-devtools` | 6 (`architecture-impact`, `e2e-test-automation`, `e2e-test-design`, `e2e-debug`, `renovate-prs`, `next-ticket`) | 1 | 1 | - | 0.8.2 | -| `hyperfleet-work-triage` | 2 (`bugs-triage`, `open-prs`) | - | - | - | 0.3.0 | +| `hyperfleet-work-triage` | 2 (`bugs-triage`, `open-prs`) | - | - | - | 0.4.0 | | `hyperfleet-hooks` | - | - | - | 1 | 0.2.0 | | `hyperfleet-adapter-authoring` | 1 | - | - | - | N/A | | `hyperfleet-release` | 1 (`release-notes`) | - | - | - | 0.1.0 | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4ea066a..91b66a6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -104,6 +104,10 @@ Team members get updates by running `/plugin marketplace update hyperfleet-claud This repository utilizes k8s-style OWNERS files. Each plugin is expected to define an OWNERS file with a list of approvers and (optionally) reviewers. PRs making changes to a plugin must be reviewed by the reviewers/approvers listed in that plugin's OWNERS file. +OWNERS files reference aliases from the root `OWNERS_ALIASES` rather than listing logins directly, and both are generated from `hyperfleet/docs/owners.yaml` in the architecture repository. To change who owns a plugin, open a PR to that file; a maintainer then runs `make owners` there and `hack/owners/open_prs.sh` opens the regenerated OWNERS PR here. The roles, the criteria and the area table are explained in `hyperfleet/docs/owners.md` next to it. Do not hand-edit OWNERS or `OWNERS_ALIASES`, as the next regeneration would overwrite the change. + +Prow applies `area/*` labels from OWNERS files and requests reviewers from the area's pool, so the ownership document also decides who is asked to review. + See [k8s OWNERS documentation](https://www.kubernetes.dev/docs/guide/owners/) for more information. ### Submitting Changes @@ -111,7 +115,7 @@ See [k8s OWNERS documentation](https://www.kubernetes.dev/docs/guide/owners/) fo 1. Follow the plugin structure outlined in "Adding a Plugin" above 2. Bump the version number according to semantic versioning 3. Submit a PR with your changes -4. Request review from reviewers/approvers listed in the OWNERS file +4. Prow requests reviewers from the plugin's OWNERS pool; if you need a specific reviewer, pick one from the aliases the OWNERS file references ## Security Guidelines diff --git a/hyperfleet-work-triage/.claude-plugin/plugin.json b/hyperfleet-work-triage/.claude-plugin/plugin.json index b29e0c2..7fa1058 100644 --- a/hyperfleet-work-triage/.claude-plugin/plugin.json +++ b/hyperfleet-work-triage/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "hyperfleet-work-triage", - "version": "0.3.6", + "version": "0.4.0", "description": "Work triage skills for HyperFleet: /bugs-triage for interactive JIRA bug and GitHub issue triage. /open-prs surfaces and prioritizes open PRs across the org using GitHub + JIRA context with multi-factor scoring and confidence levels.", "author": { "name": "Rafael Benevides", diff --git a/hyperfleet-work-triage/README.md b/hyperfleet-work-triage/README.md index 0499883..8efa9ce 100644 --- a/hyperfleet-work-triage/README.md +++ b/hyperfleet-work-triage/README.md @@ -42,6 +42,7 @@ Work triage skills for HyperFleet: bug/issue triage and PR prioritization. - Detects unresolved reviewer comments and author responsiveness - Applies 8-factor weighted scoring with confidence levels - Groups PRs into 4 tiers: Immediate Attention, Should Review Soon, This Week, Informational +- Groups the digest by `area/*` label and shows the reviewers Prow requested from the area's OWNERS pool with business days waiting; more than one business day is flagged - Works without JIRA CLI (graceful degradation with reduced confidence) ## Prerequisites diff --git a/hyperfleet-work-triage/references/github-repos.md b/hyperfleet-work-triage/references/github-repos.md index f796192..722793e 100644 --- a/hyperfleet-work-triage/references/github-repos.md +++ b/hyperfleet-work-triage/references/github-repos.md @@ -15,37 +15,28 @@ Used by `/bugs-triage` (issue triage) and `/open-prs` (PR prioritization). ## Infrastructure & Deployment - `hyperfleet-infra` -- `oci-infra` - `hyperfleet-credential-provider` - `hyperfleet-logger` - `hyperfleet-release` -- `hypershift` -- `management-cluster-reconciler` -## Testing & CLI +## Testing - `hyperfleet-e2e` -- `maestro-cli` -- `hyperfleet-scripts` ## API & Specifications - `hyperfleet-api-spec` +- `hyperfleet-api-spec-template` ## Documentation & Tooling - `architecture` - `hyperfleet-claude-plugins` - `hyperfleet-hooks` -- `rh-hooks-ai` - `coderabbit` - `renovate-config` - `.github` -## Other Active Projects - -- `kartograph` - ## Additional PR sources These sources are used only by `/open-prs`; they are not GitHub issue-triage diff --git a/hyperfleet-work-triage/skills/bugs-triage/references/owners.csv b/hyperfleet-work-triage/skills/bugs-triage/references/owners.csv index 08b2c8f..88b8d90 100644 --- a/hyperfleet-work-triage/skills/bugs-triage/references/owners.csv +++ b/hyperfleet-work-triage/skills/bugs-triage/references/owners.csv @@ -1,13 +1,23 @@ type,name,primary_owner,secondary_owner -component,Hyperfleet API,Yanming Sun,Angel Marin -component,Broker,Angel Marin,dandan wang -component,Adapter,Xue Li,Angel Marin -component,Sentinel,Rafael Benevides,"Phuongnhat Nguyen, Yanming Sun" -component,CICD,Ying Zhang, -component,Claude Code Plugin,Rafael Benevides,Yanming Sun -domain,Hyperfleet Architecture,Victor Kareh Fabregas,Ciaran Roche -domain,CLM Architecture,Ciaran Roche,Angel Marin -domain,CICD Framework,Xue Li, -domain,Test Automation Framework,Yanming Sun,Ying Zhang -domain,Deployment Strategy,Ciaran Roche,Rafael Benevides -domain,Implementation Standard,"Angel Marin, Rafael Benevides",Ciaran Roche +component,Hyperfleet API,Dmitrii Andreev,Phuongnhat Nguyen +component,Broker,Angel Marin,Dmitrii Andreev +component,Adapter,Angel Marin,"Rafael Benevides, Dmitrii Andreev" +component,Sentinel,Phuongnhat Nguyen,"Dmitrii Andreev, Angel Marin" +component,CICD,Mallory Hill,Dmitrii Andreev +component,Claude Code Plugin,Rafael Benevides,Phuongnhat Nguyen +domain,Hyperfleet Architecture,Ciaran Roche,"Michael, Rafael Benevides, Angel Marin" +domain,CLM Architecture,Ciaran Roche,"Michael, Rafael Benevides, Angel Marin" +domain,CICD Framework,Mallory Hill,Dmitrii Andreev +domain,Test Automation Framework,Dmitrii Andreev,Mallory Hill +domain,Deployment Strategy,Mallory Hill,"Dmitrii Andreev, Rafael Benevides" +domain,Implementation Standard,Ciaran Roche,"Michael, Rafael Benevides, Angel Marin" +domain,Tenancy and Auth,Phuongnhat Nguyen,Martin Liptak +domain,Generic Model and Search,Dmitrii Andreev,Phuongnhat Nguyen +domain,Contracts,Angel Marin,"Martin Liptak, Dmitrii Andreev, Ciaran Roche" +domain,Desire Delivery and Applier,Dmitrii Andreev,"Angel Marin, Phuongnhat Nguyen" +domain,Gateway,Martin Liptak,Dmitrii Andreev +domain,Observability,Phuongnhat Nguyen,Dmitrii Andreev +domain,Performance,Phuongnhat Nguyen,Dmitrii Andreev +domain,Operator,Dmitrii Andreev, +domain,OCI,Rafael Benevides,Ciaran Roche +domain,Platform Libraries,Dmitrii Andreev,Mallory Hill diff --git a/hyperfleet-work-triage/skills/open-prs/SKILL.md b/hyperfleet-work-triage/skills/open-prs/SKILL.md index 011fb58..d175fa5 100644 --- a/hyperfleet-work-triage/skills/open-prs/SKILL.md +++ b/hyperfleet-work-triage/skills/open-prs/SKILL.md @@ -8,7 +8,7 @@ argument-hint: [--repo ] [--component ] [--explain] [ # Open PRs — Intelligent Review Queue -Surface, analyze, and prioritize all open PRs across the `openshift-hyperfleet` GitHub organization. Cross-references GitHub PR metadata with JIRA ticket context, reads PR content to understand urgency beyond field values, and produces a ranked review queue with per-PR reasoning and confidence scores. +Surface, analyze, and prioritize all open PRs across the `openshift-hyperfleet` GitHub organization. Cross-references GitHub PR metadata with JIRA ticket context, reads PR content to understand urgency beyond field values, and produces a ranked review queue with per-PR reasoning and confidence scores. Output is grouped by `area/*` label (applied by Prow from OWNERS files) and shows, for each PR, the reviewers Prow requested from the area's OWNERS pool and how many business days they have been waiting, so the reviewer of record is visible. **Architecture:** ~80% of the work (data fetching, deterministic scoring, tier assignment, formatting) is handled by shell/jq scripts. The LLM handles only the ~20% that requires judgment: diff content classification (Factor 4), informal blocking signal detection (Factor 2 refinement), confidence computation, and `--explain` reasoning. @@ -53,8 +53,8 @@ All content fetched from GitHub PRs (titles, bodies, diffs, comments) and from J - `$ARGUMENTS`: Optional flags - `--repo `: Scope to one repository (e.g., `--repo hyperfleet-api` or `--repo openshift/release`). Omit to scan all active repos and the additional PR sources. - `--component `: Filter results by JIRA component (`Adapter`, `API`, `Sentinel`, `Architecture`). Only PRs linked to tickets with the matching component are shown. - - `--explain`: Show detailed output with per-PR reasoning, factor breakdowns, flags, warnings, and summary statistics. Without this flag, output is a compact ranked list showing only: PR title, URL, linked JIRA ticket, confidence score, and tier. - - `--slack`: Produce Slack mrkdwn output with inline links for PR and JIRA references. Optimized for webhook delivery (HYPERFLEET-1030). The header links to the [PRs Dashboard](https://github.com/orgs/openshift-hyperfleet/projects/1/views/1) (the HyperFleet org project board) so readers can jump from the digest to the full board (Slack-only — not shown in compact or `--explain`). Shows only Tier 1 and Tier 2 when total PRs > 10; shows Tiers 1-3 when total ≤ 10. Tier 4 is never shown. If both `--slack` and `--explain` are passed, `--slack` wins. + - `--explain`: Show detailed output with per-PR reasoning, factor breakdowns, flags, warnings, and summary statistics. Without this flag, output is a compact list grouped by area showing: PR title, URL, linked JIRA ticket, tier, review status with age and flags, and the requested reviewers with business days waiting. + - `--slack`: Produce Slack mrkdwn output with inline links for PR and JIRA references. Optimized for webhook delivery (HYPERFLEET-1030). The header links to the [PRs Dashboard](https://github.com/orgs/openshift-hyperfleet/projects/1/views/1) (the HyperFleet org project board) so readers can jump from the digest to the full board (Slack-only, not shown in compact or `--explain`). PRs are grouped by `area/*` label (unlabeled PRs under "No area", last), each bullet carries its tier emoji, the requested reviewers with business days waiting, and flags (`overdue` when a requested reviewer has waited more than one business day). Shows only Tier 1 and Tier 2 PRs when total PRs > 10; shows Tiers 1-3 when total ≤ 10. Tier 4 is never shown. If both `--slack` and `--explain` are passed, `--slack` wins. ## Scripts @@ -105,6 +105,11 @@ This computes: Plus: override rules (CI failing → T4, waiting on author → T4, conflicts → T4, draft → T4, Blocker/Critical → T1, no ticket → cap T3), provisional tiers, sorting. +It also attaches, without affecting the score: +- `area`: primary area from the first `area/*` label (`"No area"` when unlabeled) plus `others` for any further area labels +- `review_request`: pending requested reviewers (the reviewer of record from the area's OWNERS pool), `requests` with the business days each pending reviewer has waited, `business_days_waiting` as the longest of those, `estimated` when a request time had to be taken from the PR open time, and `overdue` (flag `reviewer_overdue`) when any requested reviewer on a non-draft PR has waited more than one business day; an estimated time is shown with a tilde and never sets `overdue` +- flag `no_area_label` when no `area/*` label is present + **DO NOT re-compute** the deterministic factors — the script output IS the scoring. Your role is only to fill in the LLM-required parts (Step 3). ### Step 3 — LLM analysis (Factor 4 classification + Factor 2 refinement + confidence) @@ -159,7 +164,7 @@ echo 'FINAL_JSON' | jq --arg mode "slack" -rf ${CLAUDE_SKILL_DIR}/scripts/format Or `--arg mode "compact"` for compact mode. -**DO NOT manually format** the output — the script produces the exact format defined in [output-format.md](output-format.md) with correct Unicode emojis, Slack mrkdwn links, and tier visibility rules. Output the formatter result **exactly as-is** — preserve all newlines, do NOT reflow or merge lines. +**DO NOT manually format** the output: the script produces the exact format defined in [output-format.md](output-format.md) with correct Unicode emojis, Slack mrkdwn links, area grouping, requested reviewers, and tier visibility rules. Output the formatter result **exactly as-is**, preserve all newlines, do NOT reflow or merge lines. For **`--slack` mode**: wrap the ENTIRE output in a single code block (triple backticks). This preserves the Slack mrkdwn characters (`*`, `_`, ``) and newlines through the terminal. The user copies the content from inside the code block and pastes it into Slack. @@ -169,15 +174,15 @@ For **compact mode**: output directly without code blocks — the markdown rende Produce the detailed output directly following the format in [output-format.md](output-format.md): 1. Header with metadata -2. Tier 1 table + per-PR detail blocks with 8-factor breakdowns and reasoning +2. Tier 1 table (with Area column) + per-PR detail blocks with area, requested reviewers and business days waiting, 8-factor breakdowns and reasoning 3. Tier 2 table + per-PR detail blocks 4. Tier 3 condensed table 5. Tier 4 grouped by reason -6. Flags & Warnings +6. Flags & Warnings (including "Waiting on requested reviewer" from `review_request.overdue` and "No area label") 7. Summary Statistics 8. Recommendation line -Use the pre-computed scores from the JSON — do not re-score. Add your reasoning text for Tier 1 and Tier 2 PRs explaining WHY they are ranked where they are. +Use the pre-computed scores from the JSON, do not re-score. Add your reasoning text for Tier 1 and Tier 2 PRs explaining WHY they are ranked where they are. Take the area and reviewer fields from `area` and `review_request` in the scored JSON; do not infer them from titles or comments. When a risk label contributed to Factor 4 scoring, mention it in the reasoning (e.g., "Risk label: `risk/high` boosted Factor 4 from LLM-classified 5 to floor 8"). diff --git a/hyperfleet-work-triage/skills/open-prs/output-format.md b/hyperfleet-work-triage/skills/open-prs/output-format.md index d182cf3..683d2c7 100644 --- a/hyperfleet-work-triage/skills/open-prs/output-format.md +++ b/hyperfleet-work-triage/skills/open-prs/output-format.md @@ -2,8 +2,10 @@ This document defines the output format for the `/open-prs` skill. There are three modes: -- **Default (compact):** A ranked list grouped by tier. Shows PR title, URL, linked JIRA ticket, review status with age, and tier. -- **`--slack` (Slack-ready):** Paste-ready output for Slack channels. No markdown tables, Slack-native formatting. Shows Tier 1-2 (or 1-3 if ≤ 10 PRs). Tier 4 never shown. +- **Default (compact):** PRs grouped by area (`area/*` label), ranked by priority score inside each area. Shows PR title, URL, linked JIRA ticket, tier, review status with age and flags, and the requested reviewers with business days waiting. +- **`--slack` (Slack-ready):** Paste-ready output for Slack channels. No markdown tables, Slack-native formatting. Same area grouping; shows Tier 1-2 PRs (or 1-3 if ≤ 10 PRs). Tier 4 never shown. + +**Area grouping (compact and Slack):** Prow applies `area/*` labels from OWNERS files and requests reviewers from the area's OWNERS pool, so the area is the ownership unit. A PR with several area labels appears under its first label with the others shown inline (`+area/contracts`). PRs with no area label go under `No area`, which is always the last group. Areas are ordered by their most urgent PR (best tier, then highest score). Inside an area, PRs keep their priority order. - **`--explain` (detailed):** Full output with per-PR reasoning, factor breakdowns, flags & warnings, and summary statistics. --- @@ -15,7 +17,7 @@ This document defines the output format for the `/open-prs` skill. There are thr ```text ## Open PRs — openshift-hyperfleet -**Generated:** YYYY-MM-DD HH:MM UTC | **N PRs** across M repos | Sorted by priority score | `/open-prs --explain` for full analysis +**Generated:** YYYY-MM-DD HH:MM UTC | **N PRs** across M repos | Grouped by area, ranked by priority score | `/open-prs --explain` for full analysis ``` If `--repo` or `--component` filters were applied, add: @@ -30,47 +32,42 @@ If JIRA is unavailable: **Note:** JIRA unavailable — GitHub-only mode, confidence reduced. ``` -### Tier tables +### Area tables -Show each non-empty tier as a compact table. Omit empty tiers entirely. +Show one table per area. All tiers appear in the compact output; the tier is a column, and Tier 4 rows show the override reason instead of review status. Omit areas with no PRs. ```text -### Tier 1 (N PRs) +### area/api (N PRs) -| # | PR | Status | -|---|----|--------| -| 1 | [repo#number](url) — [TICKET-KEY](jira-url) - type: PR title | Approved, 2d | -| 2 | [repo#number](url) — [TICKET-KEY](jira-url) - type: PR title | No reviews, 5d | +| # | PR | Tier | Status | Reviewers | +|---|----|------|--------|-----------| +| 1 | [repo#number](url) — [TICKET-KEY](jira-url) - type: PR title | 🚨 T1 | No reviews, 5d ⚠️ overdue, SLA | @reviewer1, @reviewer2 (3bd) | +| 4 | [repo#number](url) — [TICKET-KEY](jira-url) - type: PR title (+area/auth) | 🟡 T2 | In review, 3d | @reviewer1 (1bd) | +| - | [repo#number](url) — [TICKET-KEY](jira-url) - type: PR title | 🔵 T4 | Draft | none requested | -### Tier 2 (N PRs) +### area/sentinel (N PRs) -| # | PR | Status | -|---|----|--------| -| 3 | [repo#number](url) — [TICKET-KEY](jira-url) - type: PR title | In review, 3d | - -### Tier 3 (N PRs) +| # | PR | Tier | Status | Reviewers | +|---|----|------|--------|-----------| +| 2 | [repo#number](url) — [TICKET-KEY](jira-url) - type: PR title | 🚨 T1 | Approved, 2d | none requested | -| # | PR | Status | -|---|----|--------| -| 6 | [repo#number](url) — PR title without ticket | No reviews, 1d | +### No area (N PRs) -### Tier 4 (N PRs) - -| PR | Status | -|----|--------| -| [repo#number](url) — [TICKET-KEY](jira-url) - type: PR title | Draft | -| [repo#number](url) — [TICKET-KEY](jira-url) - type: PR title | Waiting on author | -| [repo#number](url) — PR title without ticket | CI failing | -| [repo#number](url) — [TICKET-KEY](jira-url) - type: PR title | Merge conflicts | +| # | PR | Tier | Status | Reviewers | +|---|----|------|--------|-----------| +| 6 | [repo#number](url) — PR title without ticket | 🟢 T3 | No reviews, 1d | @reviewer2 (1bd) | +| - | [repo#number](url) — PR title without ticket | 🔵 T4 | CI failing | none requested | ``` **Column definitions (compact):** | Column | Content | Example | |--------|---------|---------| -| # | Rank position (continuous across tiers 1-3) | `1` | -| PR | `[repo#number](url) — title`. The repo#number links to the GitHub PR. If a JIRA ticket key appears in the title, it is replaced with a link to the JIRA ticket. | `[hyperfleet-api#115](https://...) — [HYPERFLEET-856](https://redhat.atlassian.net/browse/HYPERFLEET-856) - fix: Deletion observability metrics` | -| Status | Review state and age. Tiers 1-3: `Approved, 2d`, `No reviews, 5d`, `In review, 3d`. Tier 4: reason for informational status (`Draft`, `Waiting on author`, `CI failing`, `Merge conflicts`) | `No reviews, 5d` | +| # | Rank position by priority score across all areas (tiers 1-3 only; `-` for Tier 4) | `1` | +| PR | `[repo#number](url) — title`. The repo#number links to the GitHub PR. If a JIRA ticket key appears in the title, it is replaced with a link to the JIRA ticket. Extra area labels follow the title as `(+area/x, area/y)`. | `[hyperfleet-api#115](https://...) — [HYPERFLEET-856](https://redhat.atlassian.net/browse/HYPERFLEET-856) - fix: Deletion observability metrics (+area/auth)` | +| Tier | Tier emoji and number | `🚨 T1`, `🟡 T2`, `🟢 T3`, `🔵 T4` | +| Status | Review state and age, followed by flags when present (`⚠️ overdue, SLA, stale`). Tier 4: reason for informational status (`Draft`, `Waiting on author`, `CI failing`, `Merge conflicts`) | `No reviews, 5d ⚠️ overdue, SLA` | +| Reviewers | Requested reviewers still pending (the reviewer of record, requested from the area's OWNERS pool) and business days waiting, one figure when they match and one per reviewer when they differ, or `none requested`. A tilde marks a wait estimated from the PR open time because the request event could not be fetched; estimates never raise `overdue` | `@kuudori, @pnguyen44 (3bd)`, `@kuudori (3bd), @pnguyen44 (0bd)` or `@ma-hill (~2bd)` | ### Recommendation @@ -111,10 +108,10 @@ When the user passes `--slack`, produce Slack mrkdwn output with inline links fo - If total PRs ≤ 10: show Tiers 1, 2, and 3 - NEVER show Tier 4 — not actionable, adds noise to the channel -**Tier emojis and labels:** -- Tier 1: `🚨 *Tier 1 — Immediate Attention (N PRs)*` -- Tier 2: `🟡 *Tier 2 — Should Review Soon (N PRs)*` -- Tier 3: `🟢 *Tier 3 — This Week (N PRs)*` +**Area sections and tier emojis:** +- One section per area: `EMOJI *area/name (N PRs)*` where EMOJI is the best tier in that section (🚨 Tier 1, 🟡 Tier 2, 🟢 Tier 3). Unlabeled PRs go under `*No area (N PRs)*`, last. +- Each PR bullet starts with its own tier emoji (🚨, 🟡, 🟢) since tiers are no longer section headers. +- Tier visibility rules above decide which PRs are included; an area with no visible PRs is omitted. **Header emoji** — varies based on the highest tier with PRs: - 🚨 if Tier 1 PRs exist @@ -132,18 +129,19 @@ This link is Slack-only — it is not shown in compact or `--explain` output. It ```text HEADER_EMOJI *Open PRs — openshift-hyperfleet* -_YYYY-MM-DD HH:MM UTC | N PRs across M repos_ +_YYYY-MM-DD HH:MM UTC | N PRs across M repos | grouped by area_ 📋 -🟡 *Tier 2 — Should Review Soon (N PRs)* +🚨 *area/api (2 PRs)* -• : - feat: Add PUT for internal status endpoints — _In review, 3d_ +• 🚨 : - feat: Add PUT for internal status endpoints — _No reviews, 5d_ · 👀 @kuudori, @pnguyen44 (3bd) ⚠️ overdue, SLA +• 🟡 : - fix: Change status endpoint from POST to PUT — _In review, 3d_ · 👀 @mliptak0 (1bd) · _+area/auth_ -• : - fix: Change status endpoint from POST to PUT — _No reviews, 5d_ +🟡 *area/architecture (1 PR)* -• : - feat: Config Driven Generic Resource API Design — _Approved, 2d_ +• 🟡 : - feat: Config Driven Generic Resource API Design — _Approved, 2d_ · 👀 none requested -_N more PRs in Tier 3-4. Run `/open-prs` for full list._ +_N more PRs in Tier 3-4 (not shown). Run `/open-prs` for full list._ ``` ### Per-PR format @@ -151,12 +149,16 @@ _N more PRs in Tier 3-4. Run `/open-prs` for full list._ Each PR is a single bullet line: ```text -• : - type: PR title — _review status, age_ +• TIER_EMOJI : - type: PR title — _review status, age_ · 👀 @reviewer1, @reviewer2 (Nbd) ⚠️ flags · _+area/other_ ``` +- **Tier emoji:** 🚨 Tier 1, 🟡 Tier 2, 🟢 Tier 3 - **PR link:** `` — inline code-styled clickable link - **Title with inline JIRA link:** The JIRA ticket key in the title is replaced with a Slack link to the ticket. PRs without a ticket key show the title as-is. - **Status suffix:** review state and age in italics — e.g., `_No reviews, 5d_`, `_Approved, 2d_`, `_In review, 3d_` +- **Reviewers:** `👀` followed by the requested reviewers still pending (GitHub logins) and business days waiting, one figure when they match and one per reviewer when they differ, e.g. `👀 @kuudori, @pnguyen44 (3bd)` or `👀 @kuudori (3bd), @pnguyen44 (0bd)`, with a tilde (`~2bd`) when the time is estimated from the PR open time; `👀 none requested` when no request is pending +- **Flags:** `⚠️` followed by any of `overdue` (any requested reviewer waiting more than one business day on a request with a known time; estimated times never flag), `SLA` (more than 3 days with no human review), `stale` (more than 7 days). Omitted when there are no flags. +- **Other areas:** `· _+area/x, area/y_` when the PR carries more than one area label. Omitted otherwise. Each PR is one bullet point — no multi-line entries. @@ -251,9 +253,9 @@ If `--repo` or `--component` filters were applied, add: ```text ### Tier 1 (N PRs) -| # | PR | JIRA | Priority | Age | Size | Reviews | CI | Score | Confidence | -|---|----|----- |----------|-----|------|---------|----|-------|------------| -| 1 | [repo#number](url) | TICKET-KEY (Priority) | Priority | Xd | +A/-D (F files) | Status | Status | XX/100 | Label (XX%) | +| # | PR | Area | JIRA | Priority | Age | Size | Reviews | CI | Score | Confidence | +|---|----|------|----- |----------|-----|------|---------|----|-------|------------| +| 1 | [repo#number](url) | area/api | TICKET-KEY (Priority) | Priority | Xd | +A/-D (F files) | Status | Status | XX/100 | Label (XX%) | ``` **Column definitions:** @@ -262,6 +264,7 @@ If `--repo` or `--component` filters were applied, add: |--------|---------|---------| | # | Rank position | `1` | | PR | `[repo#number](url)` — linked to GitHub | `[hyperfleet-api#115](https://...)` | +| Area | First `area/*` label, with extra labels after a `+`; `No area` when unlabeled | `area/api +area/auth` | | JIRA | `TICKET-KEY (Priority)` or `No ticket` | `HYPERFLEET-856 (High)` | | Priority | Derived priority label from scoring | `High` | | Age | Days since PR creation | `12d` or `4h` | @@ -279,7 +282,7 @@ After the table, show a detailed block for each PR: --- #### #1: repo#number — PR title -**Author:** @login | **Assigned reviewers:** @reviewer1, @reviewer2 +**Author:** @login | **Area:** area/api (+area/auth) | **Requested reviewers:** @reviewer1, @reviewer2 (3 business days waiting, overdue) **JIRA:** [TICKET-KEY](jira-url) | **Type:** Bug | **Story Points:** 5 | **Component:** API **Domain:** Security fix | Bug fix @@ -320,9 +323,9 @@ Condensed format — table only, with a brief one-line reasoning per PR instead ```text ### Tier 3 (N PRs) -| # | PR | JIRA | Age | Size | Reviews | Score | Confidence | Reason | -|---|----|----- |-----|------|---------|-------|------------|--------| -| 8 | [repo#42](url) | HYPERFLEET-900 | 2d | +45/-10 | No reviews | 38/100 | High (75%) | Normal-priority feature, recently opened | +| # | PR | Area | JIRA | Age | Size | Reviews | Reviewers | Score | Confidence | Reason | +|---|----|------|----- |-----|------|---------|-----------|-------|------------|--------| +| 8 | [repo#42](url) | area/sentinel | HYPERFLEET-900 | 2d | +45/-10 | No reviews | @pnguyen44 (1bd) | 38/100 | High (75%) | Normal-priority feature, recently opened | ``` --- @@ -360,6 +363,12 @@ If there are no Tier 4 PRs, omit this section entirely. - [repo#XX](url) — Xd without review - [repo#YY](url) — Xd without review +- **Waiting on requested reviewer:** N PRs have a review request pending for more than 1 business day + - [repo#XX](url) — @reviewer1, @reviewer2 requested Xbd ago (area/api) + +- **No area label:** N PRs carry no `area/*` label, so no OWNERS pool was asked to review + - [repo#XX](url) — "PR title" + - **Large PRs (>500 lines):** N PRs may benefit from splitting - [repo#XX](url) — XXXX lines changed across XX files @@ -396,6 +405,8 @@ Only show flag categories that have at least one entry. If no flags, omit the se | PRs with passing CI | N (XX%) | | PRs with failing CI | N (XX%) | | PRs waiting >3d for first review | N (XX%) | +| PRs waiting >1 business day on a requested reviewer | N (XX%) | +| PRs with no area label | N (XX%) | | JIRA-linked PRs | N/N (XX%) | | Avg confidence score | XX% | ``` @@ -435,7 +446,7 @@ If ALL PRs are Tier 4 (no actionable PRs in Tiers 1-3): ### Review status formatting -Note: Reviewers are auto-assigned. Status is based on actual engagement, not assignment. +Note: Review status reflects actual engagement (reviews, comments, approvals). Pending review requests are shown separately (see "Reviewer and flag formatting" below). | State | Display | |-------|---------| @@ -445,6 +456,21 @@ Note: Reviewers are auto-assigned. Status is based on actual engagement, not ass | All required approvals received | `Approved` | | Active discussion between author and reviewer | `In discussion` | +### Reviewer and flag formatting + +Prow requests reviewers from the area's OWNERS pool, so a pending request names the reviewer of record. GitHub drops the request once that reviewer submits a review, so only reviewers who have not acted yet are listed. + +| Item | Display | +|------|---------| +| Pending requested reviewers and business days waiting, per reviewer when they differ, tilde when estimated from the PR open time | `@login1, @login2 (Nbd)`, `@login1 (Nbd), @login2 (Mbd)` or `@login1 (~Nbd)` | +| No pending request | `none requested` | +| Any requested reviewer waiting more than 1 business day (non-draft, request time known) | flag `overdue` | +| More than 3 days with no human review | flag `SLA` | +| More than 7 days old | flag `stale` | +| Extra area labels beyond the first | `+area/x, area/y` | + +Flags are rendered together after the status as `⚠️ overdue, SLA, stale` (only those that apply). Business days exclude Saturday and Sunday. + ### CI status formatting | State | Display | diff --git a/hyperfleet-work-triage/skills/open-prs/prioritization-algorithm.md b/hyperfleet-work-triage/skills/open-prs/prioritization-algorithm.md index a55cba4..0569ade 100644 --- a/hyperfleet-work-triage/skills/open-prs/prioritization-algorithm.md +++ b/hyperfleet-work-triage/skills/open-prs/prioritization-algorithm.md @@ -214,7 +214,7 @@ Measures where the PR is in the review lifecycle and whether it needs reviewer a ### Scoring Rubric -**Note:** Reviewers are auto-assigned in this organization, so `reviewRequests` being populated does NOT mean someone consciously asked for a review. The key signal is whether anyone has actually **engaged** (commented, reviewed, approved) — not whether reviewers are assigned. +**Note:** Prow requests reviewers from the PR's area OWNERS pool (two per PR via Blunderbuss), so `reviewRequests` names the reviewer of record. GitHub drops a request once that reviewer submits a review, so a request still pending means the reviewer has not acted yet. Factor 5 still scores actual engagement (comments, reviews, approvals); the pending request is tracked separately (see "Review request tracking" below) and does not change the Factor 5 score. | Score | Criteria | |-------|----------| @@ -237,7 +237,7 @@ Determine review state from `latestReviews` and `reviewDecision` — these show 1. `latestReviews`: State of each reviewer's latest review (APPROVED, CHANGES_REQUESTED, COMMENTED). This is the most reliable signal. 2. `reviewDecision`: Overall decision (APPROVED, REVIEW_REQUIRED, CHANGES_REQUESTED). This is the aggregate status. -**Do NOT rely on `reviewRequests`** for determining whether a PR needs review attention. Reviewers are auto-assigned in this organization, so this field is always populated for new PRs. It does not indicate a conscious request for review. +**Review request tracking.** `collect-data.sh` carries each pending request in `review_requests` (login, user or team, and the timestamp of the latest `review_requested` event from the issue timeline; when the timeline cannot be fetched the PR `createdAt` is used, since Prow requests reviewers when the PR opens, and `requested_at_source` records which one was used). `score.jq` measures each pending request on its own (`review_request.requests`), reports `review_request.business_days_waiting` as the longest wait, and sets `review_request.overdue` (flag `reviewer_overdue`) when any requested reviewer on a non-draft PR has waited more than one business day, so a reviewer re-requested today does not hide one who has been waiting since last week. When the request time had to be taken from the PR `createdAt` (`requested_at_source: pr_created_at`), the request is marked `estimated`, the wait is shown with a tilde, and it never sets `overdue`, because an older PR may have had its reviewer requested well after it opened. This is a visibility rule for the digest, not a scoring factor: the weighted score is unchanged. The digest groups PRs by `area/*` label and shows the requested reviewers and business days waiting next to each PR so the reviewer of record is visible. **To detect "author addressed changes":** Fetch the latest commit date via: ```bash diff --git a/hyperfleet-work-triage/skills/open-prs/scripts/collect-data.sh b/hyperfleet-work-triage/skills/open-prs/scripts/collect-data.sh index d95f128..33f4e3b 100755 --- a/hyperfleet-work-triage/skills/open-prs/scripts/collect-data.sh +++ b/hyperfleet-work-triage/skills/open-prs/scripts/collect-data.sh @@ -289,6 +289,7 @@ fetch_pr_details() { local review_comments="[]" local issue_comments="[]" + local review_request_events="[]" local latest_commit_date="" local commit_status='{}' local mergeable="" @@ -300,6 +301,24 @@ fetch_pr_details() { issue_comments=$(gh api --paginate "repos/$org/$repo/issues/$number/comments" \ --jq '[.[] | {author: .user.login, created: .created_at}]' 2>/dev/null) || issue_comments="[]" + # Review request timestamps. `gh pr list --json reviewRequests` only returns who is + # currently requested, not when. The issue timeline exposes `review_requested` events + # with `created_at`, so each pending request is matched to its latest event here. + # Pages are merged with `jq -s` because a busy PR can have more than 100 timeline events. + # The gh output is captured first and parsed only on success, so a failure part way + # through pagination cannot leave a partial event list behind. + local timeline_pages + if timeline_pages=$(gh api --paginate "repos/$org/$repo/issues/$number/timeline?per_page=100" 2>/dev/null); then + review_request_events=$(printf '%s' "$timeline_pages" | \ + jq -s '[.[]? | .[]? | select(.event == "review_requested") | + {login: (.requested_reviewer.login // .requested_team.slug // empty), + type: (if .requested_team != null then "team" else "user" end), + requested_at: .created_at}]' 2>/dev/null) || review_request_events="[]" + else + review_request_events="[]" + fi + [ -n "$review_request_events" ] || review_request_events="[]" + latest_commit_date=$(gh api --paginate "repos/$org/$repo/pulls/$number/commits" \ --jq '.[-1].commit.committer.date' 2>/dev/null) || latest_commit_date="" @@ -325,6 +344,7 @@ fetch_pr_details() { --argjson number "$number" \ --argjson review_comments "$review_comments" \ --argjson issue_comments "$issue_comments" \ + --argjson review_request_events "$review_request_events" \ --arg latest_commit_date "$latest_commit_date" \ --argjson commit_status "$commit_status" \ --arg mergeable "$mergeable" \ @@ -334,6 +354,7 @@ fetch_pr_details() { number: $number, review_comments: $review_comments, issue_comments: $issue_comments, + review_request_events: $review_request_events, latest_commit_date: $latest_commit_date, commit_status: $commit_status, mergeable: $mergeable, @@ -398,13 +419,33 @@ assemble_pr() { local risk_label risk_label=$(jq -r '[.labels[]?.name // empty | select(startswith("risk/"))] | first // ""' "$pr_file") + # area/* labels are applied by Prow's owners-label plugin from OWNERS files. + # reviewRequests lists reviewers still pending (GitHub drops a request once the + # reviewer submits a review). Each pending request is paired with the newest + # matching `review_requested` timeline event for that reviewer (a re-request + # restarts their clock); when no event was fetched, the PR createdAt is used as + # the request time because Prow requests reviewers at open; requested_at_source + # says which, and score.jq treats pr_created_at as an estimate that is shown + # but never sets the overdue flag. Across reviewers the oldest pending request + # is the one that counts, so review_requested_at is the minimum and a recent + # re-request of one reviewer cannot mask another's wait. jq -c \ --slurpfile details "$details_file" \ --slurpfile jira_data "$jira_data_file" \ --arg author_login "$author_login" \ --arg risk_label "$risk_label" \ --argjson jira_keys "$(echo "$jira_keys" | jq -R -s 'split("\n") | map(select(. != ""))')" \ - '{ + '($details[0].review_request_events // []) as $rr_events | + .createdAt as $created_at | + ([.reviewRequests[]? | + {login: (.login // .slug // .name // empty), + type: (if .__typename == "Team" then "team" else "user" end)} | + . as $req | + ([$rr_events[] | select(.login == $req.login and .type == $req.type) | .requested_at] | max) as $event_at | + $req + {requested_at: ($event_at // $created_at), + requested_at_source: (if $event_at != null then "timeline" else "pr_created_at" end)} + ]) as $review_requests | + { repo: .repo, number: .number, title: .title, @@ -418,7 +459,10 @@ assemble_pr() { is_draft: .isDraft, head_branch: .headRefName, labels: [.labels[]?.name // empty], + area_labels: [.labels[]?.name // empty | select(startswith("area/"))], risk_label: $risk_label, + review_requests: $review_requests, + review_requested_at: ([$review_requests[].requested_at] | min), review_decision: .reviewDecision, latest_reviews: .latestReviews, status_check_rollup: .statusCheckRollup, diff --git a/hyperfleet-work-triage/skills/open-prs/scripts/format-output.jq b/hyperfleet-work-triage/skills/open-prs/scripts/format-output.jq index 4cfbd10..b4784bf 100644 --- a/hyperfleet-work-triage/skills/open-prs/scripts/format-output.jq +++ b/hyperfleet-work-triage/skills/open-prs/scripts/format-output.jq @@ -2,6 +2,12 @@ # Input: Final scored JSON (after LLM fills Factor 4) via stdin # Args: --arg mode "compact|slack" # Output: Formatted text to stdout. +# +# Both modes group PRs by area (Prow `area/*` label). A PR with several area +# labels is listed under its first label with the others shown inline; PRs with +# no area label are listed under "No area", always last. Tier still decides +# which PRs are shown (Slack hides Tier 4 and, above 10 PRs, Tier 3) and is +# shown per PR as an emoji. def escape_md: gsub("[|]"; "\\|") | gsub("\\["; "\\[") | gsub("\\]"; "\\]"); @@ -67,6 +73,67 @@ def title_with_jira_link_slack: (.title | escape_slack) | gsub("(?
^|\\b)\($key)(?\\b)"; "\(.pre)\(.post)")
   end;
 
+# ===== AREA AND REVIEWER HELPERS =====
+
+def area_name:
+  (.area.primary // "No area") as $p |
+  if $p == "No area" then "No area" else "area/\($p)" end;
+
+def area_heading:
+  if .area == "No area" then "No area" else "area/\(.area)" end;
+
+# Pending review requests with business days waiting. One figure when every
+# pending reviewer has waited the same time with the same certainty, otherwise
+# one per reviewer so a fresh re-request does not hide an older outstanding one
+# and a known time is not shown as an estimate. A tilde marks a wait estimated
+# from the PR open time (timeline unavailable); estimates never flag.
+def bd_text($days; $estimated):
+  "(\(if $estimated then "~" else "" end)\($days // 0)bd)";
+def reviewers_text:
+  (.review_request // {}) as $rr |
+  ($rr.requests // []) as $reqs |
+  if (($rr.reviewers // []) | length) == 0 then "none requested"
+  elif (($reqs | length) == 0) or (([$reqs[] | [.business_days_waiting, (.estimated // false)]] | unique | length) <= 1)
+    then "\($rr.reviewers | map("@" + .) | join(", ")) \(bd_text($rr.business_days_waiting; ($rr.estimated // false)))"
+  else ([$reqs[] | "@\(.login) \(bd_text(.business_days_waiting; (.estimated // false)))"] | join(", ")) end;
+
+# Short flag list: reviewer overdue (>1 business day on a requested reviewer),
+# SLA breach (>3 days with no human review), stale (>7 days).
+def pr_flag_list:
+  (.flags // []) as $f |
+  [ (if (.review_request.overdue // false) then "overdue" else empty end),
+    (if ($f | any(. == "sla_breach")) then "SLA" else empty end),
+    (if ($f | any(. == "stale")) then "stale" else empty end) ];
+
+def flags_text:
+  pr_flag_list | if length == 0 then "" else " ⚠️ \(join(", "))" end;
+
+def other_areas_md:
+  (.area.others // []) | if length == 0 then "" else " (+\(map("area/" + .) | join(", ") | escape_md))" end;
+
+def other_areas_slack:
+  (.area.others // []) | if length == 0 then "" else " · _+\(map("area/" + .) | join(", ") | escape_slack)_" end;
+
+# Group PRs by primary area. Input order (score descending) is kept inside each
+# group. Areas are ordered by best tier, then highest score; "No area" is last.
+def group_by_area:
+  group_by(.area.primary // "No area") |
+  map({
+    area: (.[0].area.primary // "No area"),
+    prs: .,
+    best_tier: ([.[].effective_tier] | min),
+    top_score: ([.[].deterministic_score // 0] | max)
+  }) |
+  sort_by([(if .area == "No area" then 1 else 0 end), .best_tier, (-.top_score), .area]);
+
+# Rank map: position of each Tier 1-3 PR in the score-sorted list.
+def rank_map:
+  [.[] | select(.effective_tier <= 3)] | to_entries |
+  map({key: "\(.value.repo)#\(.value.number)", value: (.key + 1)}) | from_entries;
+
+def pr_count_label:
+  if . == 1 then "1 PR" else "\(.) PRs" end;
+
 # ===== COMPACT MODE =====
 
 def format_compact:
@@ -78,60 +145,32 @@ def format_compact:
     "No open PRs found across the openshift-hyperfleet organization. Nothing to review!"
   else
 
-  ([$prs[] | select(.effective_tier == 1)]) as $t1 |
-  ([$prs[] | select(.effective_tier == 2)]) as $t2 |
-  ([$prs[] | select(.effective_tier == 3)]) as $t3 |
-  ([$prs[] | select(.effective_tier == 4)]) as $t4 |
+  ([$prs[] | select(.effective_tier <= 3)]) as $actionable |
+  ($prs | rank_map) as $ranks |
+  ($prs | group_by_area) as $groups |
 
   # Header
   "## Open PRs — openshift-hyperfleet\n\n" +
-  "**Generated:** \($meta.generated_at // $meta.scored_at) | **\($total) PRs** across \($meta.repos_with_prs) repos | Sorted by priority score | `/open-prs --explain` for full analysis\n" +
+  "**Generated:** \($meta.generated_at // $meta.scored_at) | **\($total) PRs** across \($meta.repos_with_prs) repos | Grouped by area, ranked by priority score | `/open-prs --explain` for full analysis\n" +
 
   (if $meta.component_filter != null then "**Filter:** component=\($meta.component_filter)\n" else "" end) +
   (if ($meta.jira_available | not) then "**Note:** JIRA unavailable — GitHub-only mode, confidence reduced.\n" else "" end) +
 
-  # Tier tables
-  (if ($t1 | length) > 0 then
-    "\n### Tier 1 — Immediate Attention (\($t1 | length) PRs)\n\n" +
-    "| # | PR | Status |\n|---|----|--------|\n" +
-    ([range($t1 | length)] | map(
-      . as $i | $t1[$i] |
-      "| \($i + 1) | [\(.repo)#\(.number)](\(.url)) — \(title_with_jira_link) | \(compact_status) |"
-    ) | join("\n")) + "\n"
-  else "" end) +
-
-  (if ($t2 | length) > 0 then
-    "\n### Tier 2 — Should Review Soon (\($t2 | length) PRs)\n\n" +
-    "| # | PR | Status |\n|---|----|--------|\n" +
-    (($t1 | length) as $offset |
-    [range($t2 | length)] | map(
-      . as $i | $t2[$i] |
-      "| \($offset + $i + 1) | [\(.repo)#\(.number)](\(.url)) — \(title_with_jira_link) | \(compact_status) |"
-    ) | join("\n")) + "\n"
-  else "" end) +
-
-  (if ($t3 | length) > 0 then
-    "\n### Tier 3 — This Week (\($t3 | length) PRs)\n\n" +
-    "| # | PR | Status |\n|---|----|--------|\n" +
-    ((($t1 | length) + ($t2 | length)) as $offset |
-    [range($t3 | length)] | map(
-      . as $i | $t3[$i] |
-      "| \($offset + $i + 1) | [\(.repo)#\(.number)](\(.url)) — \(title_with_jira_link) | \(compact_status) |"
-    ) | join("\n")) + "\n"
-  else "" end) +
-
-  (if ($t4 | length) > 0 then
-    "\n### Tier 4 — Informational (\($t4 | length) PRs)\n\n" +
-    "| PR | Status |\n|----|--------|\n" +
-    ([$t4[] |
-      "| [\(.repo)#\(.number)](\(.url)) — \(title_with_jira_link) | \(override_status) |"
+  # Area tables
+  ([$groups[] |
+    "\n### \(area_heading | escape_md) (\(.prs | length | pr_count_label))\n\n" +
+    "| # | PR | Tier | Status | Reviewers |\n|---|----|------|--------|-----------|\n" +
+    ([.prs[] |
+      ($ranks["\(.repo)#\(.number)"] // "-") as $rank |
+      (if .effective_tier == 4 then override_status else compact_status + flags_text end) as $status |
+      "| \($rank) | [\(.repo)#\(.number)](\(.url)) — \(title_with_jira_link)\(other_areas_md) | \(.effective_tier | tier_emoji) T\(.effective_tier) | \($status) | \(reviewers_text) |"
     ] | join("\n")) + "\n"
-  else "" end) +
+  ] | join("")) +
 
   # Recommendation
   "\n---\n\n" +
-  (if ($t1 + $t2 + $t3 | length) > 0 then
-    ($t1 + $t2 + $t3)[0] as $top |
+  (if ($actionable | length) > 0 then
+    $actionable[0] as $top |
     "**Start with:** [\($top.repo)#\($top.number)](\($top.url)) — \($top.override_info.reason // "highest priority score")"
   else
     "**No actionable PRs right now** — all open PRs are drafts, waiting on author, have failing CI, or have merge conflicts. Check back after authors address feedback."
@@ -157,6 +196,10 @@ def format_slack:
   # Show Tier 3 if total <= 10 OR if no T1/T2 PRs exist
   ($hide_t3 | not or $fallback_to_t3) as $show_t3 |
 
+  # PRs that make it into the message, grouped by area
+  ([$prs[] | select(.effective_tier <= 2 or ($show_t3 and .effective_tier == 3))]) as $shown |
+  ($shown | group_by_area) as $groups |
+
   # Header emoji based on highest tier with PRs
   (if ($t1 | length) > 0 then "🚨"
    elif ($t2 | length) > 0 then "🟡"
@@ -171,35 +214,19 @@ def format_slack:
   else
     # Main header
     "\($hdr_emoji) *Open PRs — openshift-hyperfleet*\n" +
-    "_\($meta.generated_at // $meta.scored_at) | \($total) PRs across \($meta.repos_with_prs) repos_\n" +
+    "_\($meta.generated_at // $meta.scored_at) | \($total) PRs across \($meta.repos_with_prs) repos | grouped by area_\n" +
     "📋 <\(board_url)|PRs Dashboard>\n" +
 
     (if $meta.component_filter != null then "_Filter: component=\($meta.component_filter)_\n" else "" end) +
     (if ($meta.jira_available | not) then "_⚠️ JIRA unavailable — GitHub-only mode, confidence reduced_\n" else "" end) +
 
-    # Tier 1
-    (if ($t1 | length) > 0 then
-      "\n🚨 *Tier 1 — Immediate Attention (\($t1 | length) PRs)*\n\n" +
-      ([$t1[] |
-        "<\(.url)|`\(.repo) #\(.number)`> : \(title_with_jira_link_slack) — _\(compact_status)_"
-      ] | map("• " + .) | join("\n")) + "\n"
-    else "" end) +
-
-    # Tier 2
-    (if ($t2 | length) > 0 then
-      "\n🟡 *Tier 2 — Should Review Soon (\($t2 | length) PRs)*\n\n" +
-      ([$t2[] |
-        "<\(.url)|`\(.repo) #\(.number)`> : \(title_with_jira_link_slack) — _\(compact_status)_"
-      ] | map("• " + .) | join("\n")) + "\n"
-    else "" end) +
-
-    # Tier 3 (conditional)
-    (if $show_t3 and ($t3 | length) > 0 then
-      "\n🟢 *Tier 3 — This Week (\($t3 | length) PRs)*\n\n" +
-      ([$t3[] |
-        "<\(.url)|`\(.repo) #\(.number)`> : \(title_with_jira_link_slack) — _\(compact_status)_"
-      ] | map("• " + .) | join("\n")) + "\n"
-    else "" end) +
+    # One section per area, each PR a single bullet with its tier emoji
+    ([$groups[] |
+      "\n\(.best_tier | tier_emoji) *\(area_heading | escape_slack) (\(.prs | length | pr_count_label))*\n\n" +
+      ([.prs[] |
+        "• \(.effective_tier | tier_emoji) <\(.url)|`\(.repo) #\(.number)`> : \(title_with_jira_link_slack) — _\(compact_status)_ · 👀 \(reviewers_text)\(flags_text)\(other_areas_slack)"
+      ] | join("\n")) + "\n"
+    ] | join("")) +
 
     # Summary line
     (
diff --git a/hyperfleet-work-triage/skills/open-prs/scripts/score.jq b/hyperfleet-work-triage/skills/open-prs/scripts/score.jq
index 716f8ad..ae9515e 100644
--- a/hyperfleet-work-triage/skills/open-prs/scripts/score.jq
+++ b/hyperfleet-work-triage/skills/open-prs/scripts/score.jq
@@ -377,6 +377,65 @@ def score_factor8:
     end
   end;
 
+# --- Area ownership (from Prow area/* labels) ---
+# A PR with several area labels is grouped under its first label; the rest are
+# listed in `others` so the formatter can show them inline. No label -> "No area".
+def area_info:
+  ((.area_labels // [.labels[]? | select(startswith("area/"))]) | map(sub("^area/"; ""))) as $areas |
+  {labels: $areas,
+   primary: ($areas[0] // "No area"),
+   others: ($areas[1:] // []),
+   has_area: ($areas | length > 0)};
+
+# --- Review request tracking ---
+# Prow requests reviewers from the area's OWNERS pool, so a pending request names
+# the reviewer of record. GitHub drops the request once the reviewer submits a
+# review, so anything still listed has not been acted on. Each pending request is
+# measured on its own: a reviewer re-requested this morning does not hide one who
+# has been waiting since last week. The PR is overdue when any requested reviewer
+# has waited more than one business day, and `business_days_waiting` is the
+# longest wait among the pending requests. A request time taken from the PR open
+# time (timeline unavailable) is an estimate: it is reported with `estimated:
+# true` and never sets overdue, since an old PR may have had its reviewer
+# requested long after it opened.
+def review_request_info($now_epoch):
+  (.review_requests // []) as $reqs |
+  (.review_requested_at // null) as $fallback_at |
+  (.is_draft | not) as $not_draft |
+  ([$reqs[] | select(.login != null) |
+     (.requested_at // $fallback_at) as $at |
+     ($at | safe_parse_iso8601) as $epoch |
+     (if $epoch > 0 then business_days_between($epoch; $now_epoch) else 0 end) as $bdays |
+     ((.requested_at_source // "unknown") != "timeline") as $estimated |
+     {login: .login,
+      type: (.type // "user"),
+      requested_at: $at,
+      requested_at_source: (.requested_at_source // null),
+      estimated: $estimated,
+      business_days_waiting: $bdays,
+      overdue: ($not_draft and $epoch > 0 and $bdays > 1 and ($estimated | not))}]) as $pending |
+  ([$pending[] | .login]) as $reviewers |
+  (([$pending[] | .requested_at | select(. != null)] | min) // null) as $requested_at |
+  (([$pending[] | .business_days_waiting] | max) // 0) as $bdays |
+  (any($pending[]; .overdue)) as $overdue |
+  (([$pending[] | .business_days_waiting] | unique | length) <= 1) as $uniform |
+  (any($pending[]; .estimated)) as $estimated |
+  {reviewers: $reviewers,
+   teams: [$pending[] | select(.type == "team") | .login],
+   requests: $pending,
+   requested_at: $requested_at,
+   requested_at_source: ([$pending[] | .requested_at_source] | first // null),
+   estimated: $estimated,
+   business_days_waiting: $bdays,
+   overdue: $overdue,
+   detail: (
+     (if $estimated then " (estimated from the PR open time, not flagged)" else "" end) as $est_note |
+     if ($reviewers | length) == 0 then "No reviewer requested"
+     elif $uniform then "\($reviewers | map("@" + .) | join(", ")) requested \($bdays) business day(s) ago\($est_note)\(if $overdue then ", overdue" else "" end)"
+     else "\([$pending[] | "@\(.login) requested \(.business_days_waiting) business day(s) ago"] | join(", "))\($est_note)\(if $overdue then ", overdue" else "" end)"
+     end
+   )};
+
 # --- Data Completeness (for confidence) ---
 def data_completeness:
   . as $pr |
@@ -413,7 +472,9 @@ def generate_flags($now_epoch):
     (if ($pr.jira_keys | length) == 0 then "no_jira_ticket" else empty end),
     (if $age_days > 7 then "stale" else empty end),
     (if ($pr.labels | any(. == "needs-rebase")) then "needs_rebase" else empty end),
-    (if ($pr.scores.factor8.score // 0) >= 10 then "high_story_points" else empty end)
+    (if ($pr.scores.factor8.score // 0) >= 10 then "high_story_points" else empty end),
+    (if ($pr.review_request.overdue // false) then "reviewer_overdue" else empty end),
+    (if $pr.area.has_area == false then "no_area_label" else empty end)
   ];
 
 # --- Override rules ---
@@ -513,6 +574,10 @@ def assign_tier:
     factor8: score_factor8
   }} |
 
+  # Area ownership and reviewer of record (not part of the weighted score)
+  . + {area: area_info} |
+  . + {review_request: review_request_info($now_epoch)} |
+
   # Apply overrides
   . + {override_info: apply_overrides} |
 
diff --git a/hyperfleet-work-triage/skills/open-prs/scripts/tests/fixtures.json b/hyperfleet-work-triage/skills/open-prs/scripts/tests/fixtures.json
index a5af055..99bd6c0 100644
--- a/hyperfleet-work-triage/skills/open-prs/scripts/tests/fixtures.json
+++ b/hyperfleet-work-triage/skills/open-prs/scripts/tests/fixtures.json
@@ -22,7 +22,10 @@
       "changed_files": 8,
       "is_draft": false,
       "head_branch": "fix/deletion-metrics",
-      "labels": ["bug", "risk/medium"],
+      "labels": ["bug", "risk/medium", "area/api"],
+      "area_labels": ["area/api"],
+      "review_requests": [{"login": "kuudori", "type": "user", "requested_at": "2026-05-25T10:01:00Z", "requested_at_source": "timeline"}, {"login": "pnguyen44", "type": "user", "requested_at": "2026-06-08T09:00:00Z", "requested_at_source": "timeline"}],
+      "review_requested_at": "2026-05-25T10:01:00Z",
       "risk_label": "risk/medium",
       "review_decision": "REVIEW_REQUIRED",
       "latest_reviews": [],
@@ -63,7 +66,10 @@
       "changed_files": 15,
       "is_draft": false,
       "head_branch": "feat/config-api",
-      "labels": ["feature"],
+      "labels": ["feature", "area/sentinel"],
+      "area_labels": ["area/sentinel"],
+      "review_requests": [{"login": "pnguyen44", "type": "user", "requested_at": "2026-06-05T14:00:00Z", "requested_at_source": "timeline"}],
+      "review_requested_at": "2026-06-05T14:00:00Z",
       "risk_label": "",
       "review_decision": "REVIEW_REQUIRED",
       "latest_reviews": [{"author": {"login": "ciaranRoche"}, "state": "COMMENTED", "submittedAt": "2026-06-06T10:00:00Z"}],
@@ -104,7 +110,10 @@
       "changed_files": 3,
       "is_draft": false,
       "head_branch": "fix/auth-rotation",
-      "labels": ["security", "risk/high"],
+      "labels": ["security", "risk/high", "area/adapter", "area/contracts"],
+      "area_labels": ["area/adapter", "area/contracts"],
+      "review_requests": [],
+      "review_requested_at": null,
       "risk_label": "risk/high",
       "review_decision": "APPROVED",
       "latest_reviews": [{"author": {"login": "ciaranRoche"}, "state": "APPROVED", "submittedAt": "2026-06-07T15:00:00Z"}],
@@ -148,6 +157,9 @@
       "is_draft": true,
       "head_branch": "explore/caching",
       "labels": [],
+      "area_labels": [],
+      "review_requests": [],
+      "review_requested_at": null,
       "risk_label": "",
       "review_decision": null,
       "latest_reviews": [],
@@ -175,6 +187,9 @@
       "is_draft": false,
       "head_branch": "chore/bump-chart",
       "labels": ["chore"],
+      "area_labels": [],
+      "review_requests": [],
+      "review_requested_at": null,
       "risk_label": "risk/low",
       "review_decision": "CHANGES_REQUESTED",
       "latest_reviews": [{"author": {"login": "reviewer1"}, "state": "CHANGES_REQUESTED", "submittedAt": "2026-06-07T08:00:00Z"}],
@@ -215,7 +230,10 @@
       "changed_files": 6,
       "is_draft": false,
       "head_branch": "test/deletion-e2e",
-      "labels": ["test"],
+      "labels": ["test", "area/e2e"],
+      "area_labels": ["area/e2e"],
+      "review_requests": [{"login": "ma-hill", "type": "user", "requested_at": "2026-06-04T10:00:00Z", "requested_at_source": "pr_created_at"}],
+      "review_requested_at": "2026-06-04T10:00:00Z",
       "risk_label": "",
       "review_decision": "REVIEW_REQUIRED",
       "latest_reviews": [],
@@ -257,6 +275,9 @@
       "is_draft": true,
       "head_branch": "fix/auth-bypass",
       "labels": ["security", "risk/high"],
+      "area_labels": [],
+      "review_requests": [],
+      "review_requested_at": null,
       "risk_label": "risk/high",
       "review_decision": null,
       "latest_reviews": [],
diff --git a/hyperfleet-work-triage/skills/open-prs/scripts/tests/run-tests.sh b/hyperfleet-work-triage/skills/open-prs/scripts/tests/run-tests.sh
index 6a979c9..0367151 100755
--- a/hyperfleet-work-triage/skills/open-prs/scripts/tests/run-tests.sh
+++ b/hyperfleet-work-triage/skills/open-prs/scripts/tests/run-tests.sh
@@ -151,6 +151,28 @@ echo "--- Test: Flags ---"
 assert_contains "SLA breach flag on 14d PR" "sla_breach" "$(echo "$PR115" | jq -c '.flags')"
 assert_contains "No JIRA ticket flag on draft PR" "no_jira_ticket" "$(echo "$PR120" | jq -c '.flags')"
 assert_contains "Stale flag on 14d PR" "stale" "$(echo "$PR115" | jq -c '.flags')"
+assert_contains "Reviewer overdue flag on PR waiting 10 business days" "reviewer_overdue" "$(echo "$PR115" | jq -c '.flags')"
+assert_not_contains "No reviewer overdue flag on PR requested 1 business day ago" "reviewer_overdue" "$(echo "$PR42" | jq -c '.flags')"
+assert_contains "No area label flag on unlabeled PR" "no_area_label" "$(echo "$PR120" | jq -c '.flags')"
+
+echo ""
+echo "--- Test: Area ownership and review requests ---"
+assert_eq "Primary area from first area/* label" '"adapter"' "$(echo "$PR88" | jq '.area.primary')"
+assert_eq "Other areas listed separately" '["contracts"]' "$(echo "$PR88" | jq -c '.area.others')"
+assert_eq "No area label -> No area" '"No area"' "$(echo "$PR120" | jq '.area.primary')"
+assert_eq "Requested reviewers carried through" '["kuudori","pnguyen44"]' "$(echo "$PR115" | jq -c '.review_request.reviewers')"
+assert_eq "Longest wait wins (kuudori Mon 25 May -> Mon 8 Jun, pnguyen44 re-requested today)" "10" "$(echo "$PR115" | jq '.review_request.business_days_waiting')"
+assert_eq "Per-reviewer wait kept for each pending request" '[["kuudori",10,true],["pnguyen44",0,false]]' "$(echo "$PR115" | jq -c '[.review_request.requests[] | [.login, .business_days_waiting, .overdue]]')"
+assert_eq "requested_at is the oldest pending request" '"2026-05-25T10:01:00Z"' "$(echo "$PR115" | jq '.review_request.requested_at')"
+assert_eq "Overdue when any reviewer has waited >1 business day" "true" "$(echo "$PR115" | jq '.review_request.overdue')"
+assert_eq "Business days since request (Fri 14:00 -> Mon 10:00)" "1" "$(echo "$PR42" | jq '.review_request.business_days_waiting')"
+assert_eq "Not overdue at exactly 1 business day" "false" "$(echo "$PR42" | jq '.review_request.overdue')"
+assert_eq "Approved PR has no pending request" "0" "$(echo "$PR88" | jq '.review_request.reviewers | length')"
+assert_eq "Request time from PR open is an estimate" "true" "$(echo "$PR33" | jq '.review_request.estimated')"
+assert_eq "Estimated wait is still reported (Thu 4 Jun -> Mon 8 Jun)" "2" "$(echo "$PR33" | jq '.review_request.business_days_waiting')"
+assert_eq "Estimated wait never sets overdue" "false" "$(echo "$PR33" | jq '.review_request.overdue')"
+assert_not_contains "No reviewer overdue flag from an estimated request time" "reviewer_overdue" "$(echo "$PR33" | jq -c '.flags')"
+assert_eq "Area derived from labels when area_labels is absent" '"api"' "$(jq 'del(.prs[].area_labels)' "$FIXTURES" | jq --arg now "$NOW" -f "$SCRIPTS_DIR/score.jq" | jq '.scored_prs[] | select(.repo == "hyperfleet-api" and .number == 115) | .area.primary')"
 
 # --- Run format-output.jq ---
 
@@ -166,7 +188,17 @@ if ! SLACK_OUT=$(echo "$SCORED" | jq --arg mode "slack" -rf "$SCRIPTS_DIR/format
 fi
 
 assert_contains "Slack header emoji" "🚨 *Open PRs" "$SLACK_OUT"
-assert_contains "Tier 1 section" "🚨 *Tier 1" "$SLACK_OUT"
+assert_contains "Area section header with best-tier emoji" "🚨 *area/adapter (1 PR)*" "$SLACK_OUT"
+assert_contains "Other area labels shown inline" "_+area/contracts_" "$SLACK_OUT"
+assert_not_contains "No-area section absent when all unlabeled PRs are Tier 4" "*No area (" "$SLACK_OUT"
+NOAREA_SLACK=$(echo "$SCORED" | jq '.scored_prs |= [.[] | select(.number == 120) | .provisional_tier = 2 | .override_info = {override: null, reason: null}]' | \
+  jq --arg mode "slack" -rf "$SCRIPTS_DIR/format-output.jq" 2>&1)
+assert_contains "No-area section shown for actionable unlabeled PR" "🟡 *No area (1 PR)*" "$NOAREA_SLACK"
+assert_contains "Per-PR tier emoji on bullet" "• 🚨 " "$SLACK_OUT"
 assert_not_contains "No separate JIRA pipe in Slack" "|