From de3a0e5ff4de2aca7c86215649a00f8cab2ddb60 Mon Sep 17 00:00:00 2001 From: "apollos-medic[bot]" <340157229+apollos-medic[bot]@users.noreply.github.com> Date: Mon, 26 Oct 2026 14:39:48 +0000 Subject: [PATCH] List only top-level issues in the stale Slack digest --- lib/linear.ts | 3 ++- lib/notifications.ts | 2 ++ lib/reports.ts | 2 +- lib/types.ts | 1 + tests/notifications.test.ts | 8 +++++--- 5 files changed, 11 insertions(+), 5 deletions(-) diff --git a/lib/linear.ts b/lib/linear.ts index 3b33039..d2297eb 100644 --- a/lib/linear.ts +++ b/lib/linear.ts @@ -107,8 +107,9 @@ export async function fetchIssues( return issues; } const team = { team: { key: { eq: config.linear_team_key } } }; +// parent lets the Slack stale digest list only top-level issues. export const openIssues = cache(() => - fetchIssues({ ...team, state: { type: { in: openTypes } } }, true, "open"), + fetchIssues({ ...team, state: { type: { in: openTypes } } }, true, "open", `${fields} parent { id }`), ); const completedFilter = (after: string, before: string) => ({ ...team, diff --git a/lib/notifications.ts b/lib/notifications.ts index 1b7eaed..5cd55c7 100644 --- a/lib/notifications.ts +++ b/lib/notifications.ts @@ -182,8 +182,10 @@ export async function staleMessage() { personMetrics(slug, report.completed, report.prs, report.projects, report.window), ])) : {}; const reviews = reviewReminders(queue, Date.now(), metrics); + // Sub-issues roll up to their parent, so the digest lists only top-level issues. const issues = open.filter( (i) => + !i.parent && daysSince(i.updatedAt) > 21 && engineers.includes( slugFor(i.assignee?.name, i.assignee?.displayName) || "", diff --git a/lib/reports.ts b/lib/reports.ts index fa4893d..2265283 100644 --- a/lib/reports.ts +++ b/lib/reports.ts @@ -112,7 +112,7 @@ export const openWork = cache(async () => { ); } return unstable_cache(async () => compactOpenIssues(await openIssues()), - ["open-work-v4", process.env.VERCEL_URL || "local"], { revalidate: 60 })(); + ["open-work-v5", process.env.VERCEL_URL || "local"], { revalidate: 60 })(); }); export function sortedTeam(rows: TeamRow[], search: Search) { const everyone = param(search, "everyone") === "1"; diff --git a/lib/types.ts b/lib/types.ts index 025d2f5..fea128e 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -32,6 +32,7 @@ export type Issue = { slaStartedAt?: string; assignee: Identity | null; project: { id: string; name: string } | null; + parent?: { id: string } | null; state: IssueState; labels: { nodes: { name: string }[] }; attachments?: { diff --git a/tests/notifications.test.ts b/tests/notifications.test.ts index 4098db9..b783d7b 100644 --- a/tests/notifications.test.ts +++ b/tests/notifications.test.ts @@ -137,7 +137,7 @@ test("Slack digests preserve complete lines and claim delivery per part", async assert.deepEqual(await notify("stale", "short"), { status: "already-claimed" }); assert.equal(posts.length, 1); }); - await t.test("stale issues include projects without changing age or assignee gates", async () => { + await t.test("stale issues include projects, skip sub-issues, and keep age or assignee gates", async () => { const project = { id: "project", name: "Backlog cleanup" }; issues = [ issue(2, "Standalone stale"), @@ -147,13 +147,15 @@ test("Slack digests preserve complete lines and claim delivery per part", async { ...issue(6, "Project recent"), project, updatedAt: new Date(Date.now() - 21 * 86_400_000).toISOString() }, { ...issue(7, "Project unassigned"), project, assignee: null }, { ...issue(8, "Project outside engineering"), project, assignee: { name: "vincent", displayName: "vincent" } }, + { ...issue(10, "Stale sub-issue"), parent: { id: "2" } }, + { ...issue(11, "Top-level null parent"), parent: null }, ]; const message = (await staleMessage())!; assert.match(message, /\*Stale Open Issues\*/); assert.match(message, /<@U0BLNNPAB> \(avg completion: n\/a, last 30d\):/); - for (const title of ["Standalone stale", "Project stale", "Project merged", "Project parked"]) + for (const title of ["Standalone stale", "Project stale", "Project merged", "Project parked", "Top-level null parent"]) assert.ok(message.includes(`|${title}>`), title); - for (const title of ["Project recent", "Project unassigned", "Project outside engineering"]) + for (const title of ["Project recent", "Project unassigned", "Project outside engineering", "Stale sub-issue"]) assert.ok(!message.includes(title), title); }); await t.test("person headings reuse the metrics snapshot for both 30-day averages", async () => {