diff --git a/docs/setup.md b/docs/setup.md index 128bf5e..3879e98 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -214,7 +214,7 @@ Before starting: steps 1–7 done for staging, `npm run verify:github -- staging **Scheduled work** - [ ] The hourly sweep runs at the top of the hour: cards' ages update, and any PR that changed while webhooks were off is corrected (try one with the Worker's GitHub webhook temporarily deactivated). -- [ ] Reminders: a PR that sits past its threshold (24 working hours, 4 with `urgent`) gets a reminder reply in its card's thread. To see one sooner, deploy staging with a lowered `reminders.thresholdHours` for a day. +- [ ] Reminders: a PR that sits past its threshold (24 working hours; 4 with `urgent`, 120 for a WIP title) gets a reminder reply in its card's thread. To see one sooner, deploy staging with a lowered `reminders.thresholdHours` for a day. - [ ] Drafts older than 14 days produce `[dry-run → DM …]` nudges. - [ ] The daily report posts at 09:30 ET on a weekday, with the Trends and People sections on Monday, and its **Request PR** button opens the form. - [ ] `/nathan-staging prs` and the Home tab show "Waiting on you" and "Your open PRs" for someone in `users`, and instructions for someone who isn't. @@ -247,7 +247,7 @@ Production launches in two moves: first deployed with `pr_management` off, to ch - [ ] **The first sweep backfills cards** at the next top of the hour: one card for every open, non-draft PR with reviewers requested, including PRs the old workflow already posted. Expect that burst in `#prs`. - [ ] **Old drafts get one DM each:** drafts older than 14 days are nudged on the first sweep (once, however old). -- [ ] Reminders don't start yet: staleness is measured from when Nathan first saw each PR, so the first reminders come one threshold (24 working hours) after launch. +- [ ] Reminders don't start yet: staleness is measured from when Nathan first saw each PR, so the first reminders come one threshold (24 working hours; a week for WIP titles) after launch. - [ ] The first daily report, the next weekday at 09:30 ET, has no previous report to compare against, so it shows no change in the open count. The Needs attention section fills in once reminders start. - [ ] Request one real review through the form and watch its card and handoffs. - [ ] The admin channel stays quiet, and `nathan-jobs-production-dlq` stays empty. diff --git a/specs/projects/nathan/functional_spec.md b/specs/projects/nathan/functional_spec.md index 5e99284..92a84cf 100644 --- a/specs/projects/nathan/functional_spec.md +++ b/specs/projects/nathan/functional_spec.md @@ -150,7 +150,7 @@ There are two equivalent entry points, and both end in the same state: reviewers ### 4.6 Stale reminders - **Staleness clock:** hours elapsed since the PR entered its current state and owner set. Weekend hours (Saturday and Sunday in the **owner's** Slack time zone) don't count, so tone doesn't escalate over a weekend nobody worked. Any state or owner change resets the clock and the escalation level. -- **Threshold:** 24 hours (1 day) for all states. The `urgent` modifier (label) shortens this to 4 hours. Thresholds are configurable per state. +- **Threshold:** 24 hours (1 day) for all states except `wip_title`, which gets 120 hours (a working week): a WIP-titled PR is still being worked on, so a daily nudge would be noise. The `urgent` modifier (label) shortens this to 4 hours. Thresholds are configurable per state. - **Delivery:** an hourly sweep posts a threaded reply on the card, @-tagging each overdue owner. Reminders are sent as soon as they're due, at any time of day. Team policy is "send anytime, read when you're working", so there are no quiet hours. - **Repeat:** after the first reminder, the next is due after another full threshold interval, at an escalation level one higher. - **Tone escalation:** configurable message templates grouped by level (1, 2, 3, 4+). Each level has several variants, picked at random (avoiding the variant used last time on that PR) so it stays fresh. Level 1 is friendly, and later levels get progressively more pointed and humorous. The highest level repeats indefinitely. Every reminder states the next step and age. diff --git a/src/features/pr_management/config.ts b/src/features/pr_management/config.ts index 56c115d..7b313ec 100644 --- a/src/features/pr_management/config.ts +++ b/src/features/pr_management/config.ts @@ -25,6 +25,12 @@ export function isReminded(state: PRStatusState): state is RemindedState { return (REMINDED_STATES as readonly PRStatusState[]).includes(state); } +/** + * A WIP-titled PR is still being worked on, so its author is reminded weekly (5 working days) + * rather than daily. + */ +export const DEFAULT_THRESHOLD_HOURS_BY_STATE: Partial> = { wip_title: 120 }; + /** * Reminder lines by escalation level: levels 1, 2, 3, then 4 and up (the last group repeats). * Each is mrkdwn, posted after the owner tags and followed by the next step and age. @@ -57,8 +63,11 @@ const positiveHours = z.number().positive(); const remindersSchema = z.strictObject({ /** Working hours (weekends excluded) a PR may sit in one state before its owners are reminded. */ thresholdHours: positiveHours.default(24), - /** Overrides `thresholdHours` for particular states. */ - thresholdHoursByState: z.partialRecord(z.enum(REMINDED_STATES), positiveHours).default({}), + /** Overrides `thresholdHours` for particular states, on top of DEFAULT_THRESHOLD_HOURS_BY_STATE. */ + thresholdHoursByState: z + .partialRecord(z.enum(REMINDED_STATES), positiveHours) + .default({}) + .transform((byState) => ({ ...DEFAULT_THRESHOLD_HOURS_BY_STATE, ...byState })), /** The threshold for PRs requested with the `urgent` modifier (never longer than the state's own). */ urgentThresholdHours: positiveHours.default(4), /** Reminder lines grouped by level; see DEFAULT_REMINDER_TEMPLATES. */ diff --git a/test/features/pr_management/config.test.ts b/test/features/pr_management/config.test.ts index 3dee168..5b707f1 100644 --- a/test/features/pr_management/config.test.ts +++ b/test/features/pr_management/config.test.ts @@ -6,6 +6,7 @@ import { prManagement } from "../../../src/features/pr_management"; import { DEFAULT_BOT_AUTHORS, DEFAULT_REMINDER_TEMPLATES, + DEFAULT_THRESHOLD_HOURS_BY_STATE, DEFAULT_WIP_TITLE_PATTERN, } from "../../../src/features/pr_management/config"; import { prApp, prConfig } from "../../helpers/pr"; @@ -23,7 +24,7 @@ describe("pr_management config", () => { wipTitlePattern: DEFAULT_WIP_TITLE_PATTERN, reminders: { thresholdHours: 24, - thresholdHoursByState: {}, + thresholdHoursByState: DEFAULT_THRESHOLD_HOURS_BY_STATE, urgentThresholdHours: 4, templates: DEFAULT_REMINDER_TEMPLATES, }, @@ -45,11 +46,21 @@ describe("pr_management config", () => { drafts: { nudgeEveryDays: 3 }, }), ).toMatchObject({ - reminders: { thresholdHours: 24, thresholdHoursByState: { awaiting_review: 8 }, templates: [["Ping"]] }, + reminders: { + thresholdHours: 24, + thresholdHoursByState: { wip_title: 120, awaiting_review: 8 }, + templates: [["Ping"]], + }, drafts: { nudgeAfterDays: 14, nudgeEveryDays: 3 }, }); }); + it("lets a per-state threshold replace the WIP default", () => { + expect(load({ reminders: { thresholdHoursByState: { wip_title: 24 } } })).toMatchObject({ + reminders: { thresholdHoursByState: { wip_title: 24 } }, + }); + }); + it("ships at least two variants for each of levels 1, 2, 3 and 4+", () => { expect(DEFAULT_REMINDER_TEMPLATES).toHaveLength(4); for (const group of DEFAULT_REMINDER_TEMPLATES) expect(group.length).toBeGreaterThanOrEqual(2); diff --git a/test/features/pr_management/reminders_sweep.test.ts b/test/features/pr_management/reminders_sweep.test.ts index 6cf9998..ea33952 100644 --- a/test/features/pr_management/reminders_sweep.test.ts +++ b/test/features/pr_management/reminders_sweep.test.ts @@ -192,6 +192,30 @@ describe("stale reminders", () => { expect(threadReplies(h)).toHaveLength(2); }); + it("reminds about a WIP title after a working week, then on the default schedule once the title changes", async () => { + const h = prApp(); + await seenAt(h, "2026-10-05T14:00:00Z", { title: "WIP: Add the thing" }); + await sweepAt(h, "2026-10-06T14:00:00Z"); + await sweepAt(h, "2026-10-12T13:00:00Z"); + expect(threadReplies(h)).toEqual([]); + + await sweepAt(h, "2026-10-12T14:00:00Z"); + expect(threadReplies(h).map((post) => post.text)).toEqual([ + `<@UALICE> — ${LEVEL_1[0]}\n*Next step:* Convert to draft, or drop "WIP" from the title · waiting 1w · opened 1w 3d ago`, + ]); + + // Dropping WIP changes the state, which restarts the clock under the default threshold. + h.github.upsert(aPR({ title: "Add the thing" })); + await sweepAt(h, "2026-10-12T15:00:00Z"); + await sweepAt(h, "2026-10-13T14:00:00Z"); + expect(threadReplies(h)).toHaveLength(1); + + await sweepAt(h, "2026-10-13T15:00:00Z"); + expect(threadReplies(h).at(-1)?.text).toBe( + `<@UALICE> — ${LEVEL_1[1]}\n*Next step:* Request a reviewer · waiting 1d · opened 1w 5d ago`, + ); + }); + it("never reminds about drafts", async () => { const h = prApp(); await seenAt(h, "2026-10-05T14:00:00Z", { isDraft: true });