Skip to content

Commit 3a7cce1

Browse files
Merge pull request #33 from Penn-Electric-Racing/ops-tasks-digest
Add Sunday Ops-tasks digest DM
2 parents b83c107 + 22e602d commit 3a7cce1

5 files changed

Lines changed: 303 additions & 0 deletions

File tree

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
name: Ops Tasks — Sunday Digest DM
2+
3+
# Sunday ~10am ET: DM each Ops member their own open tasks from the Ops Tasks Notion
4+
# database (🔴 past due, "carried N wks" on rollovers). DMs only — never a channel post.
5+
# Two cron triggers cover the EDT/EST offset; the script's isSunday10amET() time-check
6+
# makes double-fires safe. Members with nothing open get no DM (silence is valid).
7+
on:
8+
schedule:
9+
- cron: '0 14 * * 0' # 10am EDT
10+
- cron: '0 15 * * 0' # 10am EST
11+
workflow_dispatch:
12+
inputs:
13+
force:
14+
description: 'Run even if it is not Sunday 10am ET'
15+
type: boolean
16+
default: false
17+
dry_run:
18+
description: 'Print the DMs to the log instead of sending them'
19+
type: boolean
20+
default: false
21+
22+
jobs:
23+
ops-digest:
24+
runs-on: ubuntu-latest
25+
concurrency:
26+
group: ops-tasks-digest
27+
cancel-in-progress: false
28+
steps:
29+
- uses: actions/checkout@v4
30+
31+
- uses: actions/setup-node@v4
32+
with:
33+
node-version: '20'
34+
cache: 'npm'
35+
cache-dependency-path: PERBot/package-lock.json
36+
37+
- name: Install dependencies
38+
working-directory: PERBot
39+
run: npm ci
40+
41+
- name: DM members their open Ops tasks
42+
working-directory: PERBot
43+
run: npm run job:ops-digest
44+
env:
45+
SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
46+
NOTION_API_KEY: ${{ secrets.NOTION_API_KEY }}
47+
FORCE_OPS_DIGEST: ${{ inputs.force }}
48+
OPS_DIGEST_DRY_RUN: ${{ inputs.dry_run }}

‎PERBot/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
"job:sponsor-digest": "tsx src/sponsorship/jobs/weeklyDigest.ts",
2020
"job:sponsor-quota": "tsx src/sponsorship/jobs/quotaAudit.ts",
2121
"job:sponsor-dri-sync": "tsx src/sponsorship/jobs/driSync.ts",
22+
"job:ops-digest": "tsx src/opsTasks/digest.ts",
2223
"check": "tsc --noEmit"
2324
},
2425
"dependencies": {

‎PERBot/src/config.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,16 @@ export const config = {
9494
quotaChannel: optionalString('SPONSOR_QUOTA_CHANNEL', 'perbot_spam'),
9595
weeklyQuota: optionalNumber('SPONSOR_WEEKLY_QUOTA', 3),
9696
},
97+
opsTasks: {
98+
// Ops Tasks database under REV12 Operations (see CLAUDE.md "Notion IDs"): one row per
99+
// action item a member types under their name on the Saturday meeting page.
100+
dataSourceId: optionalString('OPS_TASKS_DS_ID', 'a8afc6ec-29d6-4308-b779-ad4235b35e80'),
101+
// The database's "My open" view (Owner = me, Status != Done) — linked from the digest DM.
102+
myOpenViewUrl: optionalString(
103+
'OPS_TASKS_MY_OPEN_URL',
104+
'https://www.notion.so/20cbf9381bcb45f99125e1468de321a3?v=3d8560bcc0398150b213000c6479f533'
105+
),
106+
},
97107
github: {
98108
token: process.env.GITHUB_TOKEN?.trim() || '',
99109
repo: optionalString('GITHUB_REPO', 'Penn-Electric-Racing/PERBot'),

‎PERBot/src/opsTasks/digest.ts‎

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import { config } from '../config.js';
2+
import { logger } from '../utils/logger.js';
3+
import { daysUntil, todayIsoET } from '../sponsorship/dates.js';
4+
import { fetchSlackDirectory, notionUserToSlackId } from '../sponsorship/identity.js';
5+
import { makeSlackClient } from '../sponsorship/jobs/shared.js';
6+
import { OpsTask, OpsTasksNotion } from './notion.js';
7+
8+
/**
9+
* Sunday 10 AM ET Ops-tasks digest — DMs ONLY, never a channel post (Arjun's call,
10+
* 2026-09-11). Each Ops member with an open task in the Ops Tasks database gets one
11+
* short DM listing exactly their own open tasks: 🔴 on anything past Due, and a
12+
* "carried N wks" tag on anything assigned before the most recent Saturday. Members
13+
* with nothing open get nothing (silence is valid).
14+
*
15+
* Why "everything open" rather than "assigned this week": a Status-based query can't go
16+
* silent if a meeting page is created late or a task never gets linked to a meeting, and
17+
* it is the honest picture anyway.
18+
*
19+
* Idempotency: two DST cron triggers, and only the one landing on 10am ET does the work
20+
* (same pattern as the sponsorship digest). DMs post straight to the user ID.
21+
*/
22+
23+
/** Seed rows created so Notion would show every member's group; skipped until renamed. */
24+
export const PLACEHOLDER_RE = /^rename me/i;
25+
26+
function isSunday10amET(): boolean {
27+
const now = new Date();
28+
const weekday = now.toLocaleDateString('en-US', { weekday: 'long', timeZone: 'America/New_York' });
29+
const hour = Number(now.toLocaleString('en-US', { hour: '2-digit', hour12: false, timeZone: 'America/New_York' }));
30+
return weekday === 'Sunday' && hour === 10;
31+
}
32+
33+
export function isPlaceholder(task: OpsTask): boolean {
34+
return task.title === '' || PLACEHOLDER_RE.test(task.title);
35+
}
36+
37+
/** Whole Saturday meetings that have passed since the task was assigned (0 = fresh). */
38+
export function carriedWeeks(task: OpsTask): number {
39+
if (!task.week) return 0;
40+
const age = -daysUntil(task.week);
41+
return age > 0 ? Math.floor(age / 7) : 0;
42+
}
43+
44+
export function isOverdue(task: OpsTask): boolean {
45+
return task.due !== null && daysUntil(task.due) < 0;
46+
}
47+
48+
/** "9/19" — matches how the meeting pages are named. */
49+
function shortDate(iso: string): string {
50+
const [, m, d] = iso.split('-');
51+
return `${Number(m)}/${Number(d)}`;
52+
}
53+
54+
/** Overdue first (oldest due first), then by due date, undated last. */
55+
export function sortTasks(tasks: OpsTask[]): OpsTask[] {
56+
return [...tasks].sort((a, b) => {
57+
const ao = isOverdue(a) ? 0 : 1;
58+
const bo = isOverdue(b) ? 0 : 1;
59+
if (ao !== bo) return ao - bo;
60+
return (a.due ?? '9999').localeCompare(b.due ?? '9999');
61+
});
62+
}
63+
64+
export function taskLine(task: OpsTask): string {
65+
const link = `<${task.url}|${task.title}>`;
66+
const parts: string[] = [];
67+
if (task.due) parts.push(`due ${shortDate(task.due)}${isOverdue(task) ? ' 🔴 overdue' : ''}`);
68+
else parts.push('no due date');
69+
const carried = carriedWeeks(task);
70+
if (carried >= 1) parts.push(`carried ${carried} wk${carried === 1 ? '' : 's'}`);
71+
return `• ${link} — ${parts.join(' · ')}`;
72+
}
73+
74+
export function buildDigest(tasks: OpsTask[]): string {
75+
const sorted = sortTasks(tasks);
76+
const overdue = sorted.filter(isOverdue).length;
77+
const header = `:clipboard: *Your open Ops tasks (${sorted.length}${overdue > 0 ? `, ${overdue} overdue` : ''})*`;
78+
const footer = `_Set Status → Done in Notion as you finish — Saturday's walkthrough runs off this list. <${config.opsTasks.myOpenViewUrl}|My open>_`;
79+
return [header, ...sorted.map(taskLine), footer].join('\n');
80+
}
81+
82+
export async function runOpsDigest(force = process.env.FORCE_OPS_DIGEST?.toLowerCase() === 'true'): Promise<void> {
83+
if (!force && !isSunday10amET()) {
84+
logger.info('Ops digest: not Sunday 10am (ET) and not forced — skipping.');
85+
return;
86+
}
87+
const dryRun = process.env.OPS_DIGEST_DRY_RUN?.toLowerCase() === 'true';
88+
89+
const notion = new OpsTasksNotion();
90+
const open = await notion.queryOpenTasks();
91+
const real = open.filter((t) => !isPlaceholder(t));
92+
logger.info(`Ops digest (${todayIsoET()}): ${open.length} open rows, ${open.length - real.length} placeholders skipped.`);
93+
94+
// One list per owner; a co-owned task appears in every owner's DM.
95+
const byOwner = new Map<string, { owner: OpsTask['owners'][number]; tasks: OpsTask[] }>();
96+
for (const task of real) {
97+
if (task.owners.length === 0) {
98+
logger.warn(`Ops digest: "${task.title}" has no Owner — nobody to DM.`);
99+
continue;
100+
}
101+
for (const owner of task.owners) {
102+
const entry = byOwner.get(owner.id) ?? { owner, tasks: [] };
103+
entry.tasks.push(task);
104+
byOwner.set(owner.id, entry);
105+
}
106+
}
107+
if (byOwner.size === 0) {
108+
logger.info('Ops digest: nobody has open tasks — nothing to send.');
109+
return;
110+
}
111+
112+
const client = makeSlackClient();
113+
const slackDir = await fetchSlackDirectory(client);
114+
115+
for (const { owner, tasks } of byOwner.values()) {
116+
const slackUserId = await notionUserToSlackId(client, owner, slackDir);
117+
const label = owner.name || owner.email || owner.id;
118+
if (!slackUserId) {
119+
logger.warn(`Ops digest: no Slack match for ${label} — skipping ${tasks.length} task(s).`);
120+
continue;
121+
}
122+
const text = buildDigest(tasks);
123+
if (dryRun) {
124+
logger.info(`Ops digest (dry run) → ${label} (${slackUserId}):\n${text}`);
125+
continue;
126+
}
127+
await client.chat.postMessage({ channel: slackUserId, text, unfurl_links: false });
128+
logger.info(`Ops digest: sent ${tasks.length} task(s) to ${label}.`);
129+
}
130+
}
131+
132+
// Entrypoint when run directly (GitHub Actions).
133+
const isMain = process.argv[1] && import.meta.url === `file://${process.argv[1]}`;
134+
if (isMain) {
135+
runOpsDigest().catch((err) => {
136+
logger.error('Ops digest job failed.', err);
137+
process.exitCode = 1;
138+
});
139+
}

‎PERBot/src/opsTasks/notion.ts‎

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import { Client } from '@notionhq/client';
2+
import { config } from '../config.js';
3+
4+
/**
5+
* Read-only Notion access for the Ops Tasks database (child of "REV12 Operations").
6+
* One row = one action item a member typed under their own name on a Saturday meeting
7+
* page. Schema: Task (title) · Owner (person) · Status (Not started / In progress /
8+
* Done) · Due (date, convention = next Saturday) · Week (date, the Saturday it was
9+
* assigned) · Created. Only Status matters for "open": anything not Done is open.
10+
*/
11+
12+
export interface OpsTaskOwner {
13+
id: string;
14+
name: string;
15+
email: string | null;
16+
}
17+
18+
export interface OpsTask {
19+
id: string;
20+
url: string;
21+
title: string;
22+
status: string;
23+
/** YYYY-MM-DD or null when the member left Due blank. */
24+
due: string | null;
25+
/**
26+
* YYYY-MM-DD the task was assigned at. Prefers the "Meeting date" rollup (from the
27+
* Meeting relation set automatically inside a meeting page), then the legacy hand-set
28+
* Week date, then the row's Created time — so carried-week counts never go blank.
29+
*/
30+
week: string | null;
31+
owners: OpsTaskOwner[];
32+
}
33+
34+
function readTitle(prop: any): string {
35+
return (prop?.title ?? []).map((t: any) => t?.plain_text ?? '').join('').trim();
36+
}
37+
function readDate(prop: any): string | null {
38+
const start = prop?.date?.start;
39+
return typeof start === 'string' ? start.slice(0, 10) : null;
40+
}
41+
/** A date rollup ("show original") arrives as rollup.date or rollup.array[0].date. */
42+
function readRollupDate(prop: any): string | null {
43+
const r = prop?.rollup;
44+
if (!r) return null;
45+
if (r.type === 'date') return readDate(r);
46+
if (r.type === 'array') {
47+
for (const item of r.array ?? []) {
48+
const d = readDate(item);
49+
if (d) return d;
50+
}
51+
}
52+
return null;
53+
}
54+
function readCreated(page: any): string | null {
55+
const t = page?.created_time;
56+
return typeof t === 'string' ? t.slice(0, 10) : null;
57+
}
58+
function readOwners(prop: any): OpsTaskOwner[] {
59+
return (prop?.people ?? [])
60+
.filter((p: any) => typeof p?.id === 'string')
61+
.map((p: any) => ({
62+
id: p.id as string,
63+
name: typeof p.name === 'string' ? p.name : '',
64+
// Present when the integration has the "read user emails" capability (it does).
65+
email: typeof p?.person?.email === 'string' ? p.person.email : null,
66+
}));
67+
}
68+
69+
export function parseOpsTask(page: any): OpsTask {
70+
const p = page?.properties ?? {};
71+
return {
72+
id: page.id,
73+
url: page.url ?? '',
74+
title: readTitle(p['Task']),
75+
status: p['Status']?.status?.name ?? '',
76+
due: readDate(p['Due']),
77+
week: readRollupDate(p['Meeting date']) ?? readDate(p['Week']) ?? readCreated(page),
78+
owners: readOwners(p['Owner']),
79+
};
80+
}
81+
82+
export class OpsTasksNotion {
83+
private client: Client;
84+
85+
constructor(token: string = config.notion.token) {
86+
this.client = new Client({ auth: token, notionVersion: config.notion.apiVersion });
87+
}
88+
89+
/** Every row whose Status is not Done (paginated). */
90+
async queryOpenTasks(): Promise<OpsTask[]> {
91+
const tasks: OpsTask[] = [];
92+
let cursor: string | undefined;
93+
do {
94+
const response: any = await this.client.dataSources.query({
95+
data_source_id: config.opsTasks.dataSourceId,
96+
filter: { property: 'Status', status: { does_not_equal: 'Done' } },
97+
page_size: 100,
98+
start_cursor: cursor,
99+
});
100+
for (const page of response.results ?? []) tasks.push(parseOpsTask(page));
101+
cursor = response.has_more ? response.next_cursor ?? undefined : undefined;
102+
} while (cursor);
103+
return tasks;
104+
}
105+
}

0 commit comments

Comments
 (0)