Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion specs/projects/nathan/functional_spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
13 changes: 11 additions & 2 deletions src/features/pr_management/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<RemindedState, number>> = { 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.
Expand Down Expand Up @@ -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. */
Expand Down
15 changes: 13 additions & 2 deletions test/features/pr_management/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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,
},
Expand All @@ -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);
Expand Down
24 changes: 24 additions & 0 deletions test/features/pr_management/reminders_sweep.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
Loading