Skip to content
Merged
11 changes: 7 additions & 4 deletions .cursor/skills/_lib/pr-skills/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ This directory does not contain a `SKILL.md`; it is not a Cursor skill itself. T

| Mode | Pod scope | What it shows | Used by |
|---|---|---|---|
| `team` | required (`--pod`) | All open PRs touching the pod's `ownedPaths` that still need reviews. Three sections: needs-your-re-review, stale (>3d), needs-review. PRs with `mergeable: CONFLICTING` are flagged with `⚠️ MERGE CONFLICTS!`. Pass `--authors pod` to additionally scope the dashboard to PRs authored by pod-roster members; non-roster authors touching pod paths are surfaced in a separate "Excluded" section. | `<pod>-pr-status` |
| `team` | required (`--pod`) | All open PRs touching the pod's `ownedPaths` (in the primary repo) plus **every open PR** in the pod's `extraRepos` (pod is treated as sole owner there) that still need reviews. Three sections: needs-your-re-review, stale (>3d), needs-review. PRs with `mergeable: CONFLICTING` are flagged with `⚠️ MERGE CONFLICTS!`. Pass `--authors pod` to additionally scope the dashboard to PRs authored by pod-roster members; non-roster authors are surfaced in a separate "Excluded" section. When `extraRepos` resolve, the first output line is a `Repos:` summary and extra-repo PRs render as `owner/repo#<num>`. | `<pod>-pr-status` |
| `review` | required (`--pod`) | The current user's personal review queue: PRs needing their first review, plus PRs where their review was dismissed. | (currently unused; available for a future skill) |
| `my` | optional (`--pod`); cross-pod by default | The current user's own open PRs grouped by merge readiness. Per-PR pod resolution drives ping logic. Emits copy-paste Slack ping messages for missing reviewers. | `qv-pr-mine` |

Expand All @@ -54,7 +54,7 @@ node .cursor/skills/_lib/pr-skills/pr-status.mjs --pod <name> --mode team --json
`--authors` accepts:

- `any` (default) — original behavior, all PRs touching pod paths are listed regardless of author.
- `pod` — only PRs authored by `leads ∪ members` from the pod's team JSON. PRs touching pod paths but authored outside the roster are surfaced as a separate "Excluded" section so the pod can still see them for context. Only honored with `--mode team`; ignored (with a warning on stderr) for other modes.
- `pod` — only PRs authored by `leads ∪ members` from the pod's team JSON. PRs touching pod paths (or, for `extraRepos`, any open PR) but authored outside the roster are surfaced as a separate "Excluded" section so the pod can still see them for context. The Excluded section is capped at 10 rendered PRs per repo (a `… +N more in <repo>` line summarizes the rest) so a busy `extraRepos` entry can't bury the dashboard; `--json` always carries the complete list. Only honored with `--mode team`; ignored (with a warning on stderr) for other modes.

`pr-status.mjs` reads `~/.config/qvac-pr-skills/config.json` when present for
GitHub repo and stale-day settings. If config is missing, the repo is inferred
Expand All @@ -69,11 +69,14 @@ from the local `upstream` remote.
"name": "<Display Name>",
"leads": ["<github-login>", "..."],
"members": ["<github-login>", "..."],
"ownedPaths": ["packages/<pkg-a>/", "packages/<pkg-b>/"]
"ownedPaths": ["packages/<pkg-a>/", "packages/<pkg-b>/"],
"extraRepos": ["owner/other-repo", "owner/prefix-*"]
}
```

`ownedPaths` are prefix-matched against changed-file paths to decide whether a PR is "owned" by this pod. Use trailing slashes.
`ownedPaths` are prefix-matched against changed-file paths to decide whether a PR is "owned" by this pod in the primary repo. Use trailing slashes.

`extraRepos` (optional, `--mode team` only) is a list of additional `owner/name` repos the pod owns wholesale: **every** open PR there is in-scope regardless of touched paths. Plain `owner/name` entries are used as-is; an entry whose name segment contains `*` (e.g. `owner/prefix-*`) is treated as a glob and resolved per run against the org's non-archived repos via `gh repo list`. Repos the caller cannot read are skipped with a one-line stderr warning. The primary repo (`config.github.repo`) stays path-filtered — do not duplicate it under `extraRepos`. PRs from extra repos carry a `prRef` of `owner/repo#<num>` (primary-repo PRs keep `#<num>`).

2. Create the per-pod dashboard skill by copying `.cursor/skills/qv-sdk-pr-status/` to `.cursor/skills/qv-<pod>-pr-status/`. Inside the copy, update the SKILL.md frontmatter (`name:`, `description:`) and the script invocation in the `## Usage` block to swap `--pod sdk` for `--pod <pod>`. No other changes required.

Expand Down
139 changes: 133 additions & 6 deletions .cursor/skills/_lib/pr-skills/pr-activity.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,25 @@ export const STATE_ICONS = {
};

function gh(args) {
// stderr is piped (not inherited) so it does not leak to the user's terminal
// on success, but is captured on the thrown error so callers can surface a
// meaningful reason (e.g. "Could not resolve to a Repository").
return execFileSync("gh", args, {
encoding: "utf-8",
maxBuffer: 10 * 1024 * 1024,
stdio: ["ignore", "pipe", "ignore"],
stdio: ["ignore", "pipe", "pipe"],
}).trim();
}

function ghErrorReason(error) {
const stderr = error?.stderr ? error.stderr.toString().trim() : "";
const firstLine = (stderr || error?.message || "unknown error")
.split("\n")
.map((line) => line.trim())
.find((line) => line.length > 0);
return firstLine || "unknown error";
}

function ghGraphQL(query, jq, vars = {}) {
const args = ["api", "graphql", "--raw-field", `query=${query}`];
for (const [k, v] of Object.entries(vars)) {
Expand Down Expand Up @@ -179,6 +191,75 @@ export function fetchOpenPRs(repoConfig) {
return { allPRs, pageNum };
}

const ORG_REPO_LIST_LIMIT = 1000;

function listOrgRepos(owner) {
const raw = gh([
"repo",
"list",
owner,
"--no-archived",
"--limit",
String(ORG_REPO_LIST_LIMIT),
"--json",
"name",
]);
const parsed = raw ? JSON.parse(raw) : [];
const names = parsed.map((entry) => entry.name);
// gh caps the response at --limit with no cursor we can follow here, so a
// full page means the org has at least that many repos and a glob may have
// silently missed some. Surface it instead of resolving an incomplete set.
return { names, truncated: names.length >= ORG_REPO_LIST_LIMIT };
}

// Resolve an `extraRepos` spec list into concrete `owner/name` strings.
// Plain `owner/name` entries pass through unchanged. Entries whose name
// segment contains `*` are treated as globs and resolved against the org's
// non-archived repos via `gh repo list` (each org listed at most once).
// Returns { repos, warnings } — warnings are emitted for malformed entries
// or orgs that cannot be listed, so the caller can surface them on stderr.
export function resolveExtraRepos(specs) {
const resolved = new Set();
const warnings = [];
const orgCache = new Map();
for (const spec of specs) {
// Require exactly two non-empty segments. Splitting with a limit of 2 would
// silently truncate "owner/group/name" to "owner/group"; reject it instead.
const parts = typeof spec === "string" ? spec.split("/") : [];
if (parts.length !== 2 || !parts[0] || !parts[1]) {
warnings.push(`Ignoring extraRepos entry "${spec}" (must be owner/name).`);
continue;
}
const [owner, name] = parts;
if (!name.includes("*")) {
resolved.add(`${owner}/${name}`);
continue;
}
if (!orgCache.has(owner)) {
try {
const { names, truncated } = listOrgRepos(owner);
orgCache.set(owner, names);
if (truncated) {
warnings.push(
`Repo list for "${owner}" hit the ${ORG_REPO_LIST_LIMIT}-repo cap; some glob matches may be missing.`,
);
}
} catch (e) {
warnings.push(`Could not list repos for "${owner}": ${ghErrorReason(e)}`);
orgCache.set(owner, []);
}
}
const pattern = name
.replace(/[.+?^${}()|[\]\\]/g, "\\$&")
.replace(/\*/g, ".*");
const re = new RegExp(`^${pattern}$`);
for (const repoName of orgCache.get(owner)) {
if (re.test(repoName)) resolved.add(`${owner}/${repoName}`);
}
}
return { repos: [...resolved], warnings };
}

function loadPods(mode, pod) {
return mode === "my"
? (pod ? [loadTeam(pod)] : discoverPods())
Expand Down Expand Up @@ -220,7 +301,43 @@ export function collectPRActivity({ mode = "team", pod = null, authorScope = "an
const rosterLogins = enforceAuthorScope
? new Set(pods.flatMap((p) => [...p.leads, ...p.members]))
: null;
const { allPRs, pageNum } = fetchOpenPRs(repoConfig);

// extraRepos are honored only in team mode. There the pod is treated as the
// sole owner of each extra repo, so every open PR is in scope regardless of
// touched paths. review/my modes stay on the primary repo only.
const extraRepoSpecs =
mode === "team"
? [...new Set(pods.flatMap((p) => p.extraRepos || []))]
: [];
const { repos: extraRepoList, warnings: repoWarnings } = resolveExtraRepos(extraRepoSpecs);
for (const warning of repoWarnings) console.error(warning);

const repoTargets = [{ ...repoConfig, soleOwner: false, isPrimary: true }];
for (const full of extraRepoList) {
if (full === repoConfig.repo) continue;
const { owner, name, repo } = splitRepo(full);
repoTargets.push({ owner, name, repo, soleOwner: true, isPrimary: false });
}

const allPRs = [];
const scannedRepos = [];
let pageNum = 0;
for (const target of repoTargets) {
try {
const { allPRs: prs, pageNum: pages } = fetchOpenPRs(target);
pageNum += pages;
for (const pr of prs) {
pr.repo = target.repo;
pr.isPrimaryRepo = target.isPrimary;
pr.soleOwner = target.soleOwner;
}
allPRs.push(...prs);
scannedRepos.push(target.repo);
} catch (e) {
console.error(`Skipping ${target.repo}: ${ghErrorReason(e)}`);
}
}

const isCrossPodMy = mode === "my" && pod === null;
const relevantPRs = [];
const excludedPRs = [];
Expand All @@ -230,16 +347,19 @@ export function collectPRActivity({ mode = "team", pod = null, authorScope = "an
if (!pr.author?.login) continue;
if (mode === "my" && pr.author.login !== currentUser) continue;
const files = pr.files?.nodes || [];
if (!isCrossPodMy && !touchesOwnedPaths(files, ownedPaths)) continue;
if (!isCrossPodMy && !pr.soleOwner && !touchesOwnedPaths(files, ownedPaths)) continue;
const reviews = pr.reviews?.nodes || [];
const reviewState = getReviewState(reviews);
const ready = readySince(pr);
const prRef = pr.isPrimaryRepo === false ? `${pr.repo}#${pr.number}` : `#${pr.number}`;
const enriched = {
...pr,
files,
reviewState,
ready,
stale: now - new Date(ready).getTime() > staleMs,
repo: pr.repo,
prRef,
};
if (enforceAuthorScope && !rosterLogins.has(pr.author.login)) {
excludedPRs.push(enriched);
Expand All @@ -253,6 +373,7 @@ export function collectPRActivity({ mode = "team", pod = null, authorScope = "an
return {
config,
repo: repoConfig.repo,
repos: scannedRepos,
staleDays,
currentUser,
pods,
Expand All @@ -272,12 +393,16 @@ export function classifyTeamPRs(state) {
(pr) => !isFullyApprovedInPod(pr, state.roles),
);
const reReviewPRs = needsAction.filter((pr) => needsMyReReview(pr, me));
const reReviewSet = new Set(reReviewPRs.map((pr) => pr.number));
// Key on the repo-qualified prRef, not the bare number: PR numbers are not
// unique once the dashboard spans multiple repos (extraRepos), so a bare
// number would let a re-review PR mask a same-numbered stale/active PR in a
// different repo and silently drop it from every section.
const reReviewSet = new Set(reReviewPRs.map((pr) => pr.prRef ?? `#${pr.number}`));
const stalePRs = needsAction.filter(
(pr) => pr.stale && !reReviewSet.has(pr.number),
(pr) => pr.stale && !reReviewSet.has(pr.prRef ?? `#${pr.number}`),
);
const activePRs = needsAction.filter(
(pr) => !pr.stale && !reReviewSet.has(pr.number),
(pr) => !pr.stale && !reReviewSet.has(pr.prRef ?? `#${pr.number}`),
);
const conflictCount = needsAction.filter(
(pr) => pr.mergeable === "CONFLICTING",
Expand Down Expand Up @@ -365,6 +490,8 @@ export function classifyMyPRs(state) {
export function toJsonablePR(pr) {
return {
number: pr.number,
repo: pr.repo,
prRef: pr.prRef,
title: pr.title,
url: pr.url,
author: pr.author,
Expand Down
47 changes: 40 additions & 7 deletions .cursor/skills/_lib/pr-skills/pr-status.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ function formatTarget(target) {
function renderPRLine(pr, podRoles = state.roles, extras = []) {
const extraList = Array.isArray(extras) ? extras : extras ? [extras] : [];
const lines = [
`#${pr.number} ${pr.title}`,
`${pr.prRef ?? `#${pr.number}`} ${pr.title}`,
pr.url,
`by ${pr.author.name || pr.author.login} · ${formatAge(pr.ready)} old`,
];
Expand Down Expand Up @@ -134,7 +134,37 @@ function jsonPRs(prs) {

function renderExcludedLine(pr) {
const author = pr.author.name || pr.author.login;
return ` #${pr.number} — ${pr.title}\n ${pr.url}\n by ${author} (@${pr.author.login}) · ${formatAge(pr.ready)} old`;
const ref = pr.prRef ?? `#${pr.number}`;
return ` ${ref} — ${pr.title}\n ${pr.url}\n by ${author} (@${pr.author.login}) · ${formatAge(pr.ready)} old`;
}

// Per-repo render cap for the Excluded section. With sole-owner extraRepos,
// every non-roster (incl. bot) PR is excluded, so a busy extra repo could
// otherwise bury the rest. The cap is display-only — --json always carries
// the complete list.
const EXCLUDED_RENDER_CAP_PER_REPO = 10;

function printExcludedSection(excludedPRs, primaryRepo) {
console.log("⏭️ EXCLUDED (author outside roster)");
console.log("─".repeat(60));
const byRepo = new Map();
for (const pr of excludedPRs) {
const key = pr.repo ?? primaryRepo;
if (!byRepo.has(key)) byRepo.set(key, []);
byRepo.get(key).push(pr);
}
for (const [repo, prs] of byRepo) {
for (const pr of prs.slice(0, EXCLUDED_RENDER_CAP_PER_REPO)) {
console.log("");
console.log(renderExcludedLine(pr));
}
const hidden = prs.length - EXCLUDED_RENDER_CAP_PER_REPO;
if (hidden > 0) {
console.log("");
console.log(` … +${hidden} more in ${repo} — use --json for the full list`);
}
}
console.log("");
}

function modeTeam() {
Expand All @@ -144,6 +174,7 @@ function modeTeam() {
console.log(JSON.stringify({
mode,
repo: state.repo,
repos: state.repos,
currentUser: state.currentUser,
staleDays: state.staleDays,
authorScope: state.authorScope,
Expand All @@ -164,6 +195,12 @@ function modeTeam() {
}, null, 2));
return;
}
const extraRepos = (state.repos ?? []).filter((r) => r !== state.repo);
if (extraRepos.length > 0) {
console.log(
`Repos: ${state.repo} (primary) + ${extraRepos.length} extra: ${extraRepos.join(", ")}\n`,
);
}
const conflictNote = groups.conflictCount > 0
? ` · ${groups.conflictCount} ⚠️ merge conflicts`
: "";
Expand All @@ -177,11 +214,7 @@ function modeTeam() {
printSection(`🔴 STALE (>${state.staleDays}d)`, groups.stalePRs, renderPRLine);
printSection("🟡 NEEDS REVIEW", groups.activePRs, renderPRLine);
if (state.authorScope === "pod" && excludedPRs.length > 0) {
printSection(
"⏭️ EXCLUDED (touches pod paths · author outside roster)",
excludedPRs,
renderExcludedLine,
);
printExcludedSection(excludedPRs, state.repo);
}
if (groups.needsAction.length === 0) {
console.log("All clear — every PR has team + lead approval.");
Expand Down
4 changes: 4 additions & 0 deletions .cursor/skills/_lib/pr-skills/team.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ export function loadTeam(pod) {
assertStringArray(parsed.leads, "leads", teamFile);
assertStringArray(parsed.members, "members", teamFile);
assertStringArray(parsed.ownedPaths, "ownedPaths", teamFile);
if (parsed.extraRepos !== undefined) {
assertStringArray(parsed.extraRepos, "extraRepos", teamFile);
}
if (parsed.leads.length === 0 && parsed.members.length === 0) {
console.error(`Warning: ${teamFile} has no leads or members`);
}
Expand All @@ -59,6 +62,7 @@ export function loadTeam(pod) {
leads: parsed.leads,
members: parsed.members,
ownedPaths: parsed.ownedPaths,
extraRepos: Array.isArray(parsed.extraRepos) ? parsed.extraRepos : [],
teamFile,
};
}
Expand Down
Loading
Loading