Skip to content

Commit b83c107

Browse files
Merge pull request #32 from Penn-Electric-Racing/sponsor-quota-audit
Saturday 10am weekly sponsor-quota audit (#perbot_spam + Notion record)
2 parents 56285de + 78f81c2 commit b83c107

10 files changed

Lines changed: 663 additions & 1 deletion

File tree

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
name: Sponsorship — Hourly DRI Sync
2+
3+
# Keeps PERBot's DRI ledger (`DRI assigned` on each Pipeline deal) in step with the DRI
4+
# people property: stamps newly-added DRIs with the time they were noticed, drops removed
5+
# ones, backfills pre-ledger deals with their creation time. This is what lets the weekly
6+
# quota audit count re-assignments onto older deals. Idempotent — only changed deals are
7+
# written — so late/double cron fires are harmless.
8+
on:
9+
schedule:
10+
- cron: '30 * * * *' # hourly, offset from the win-post job
11+
workflow_dispatch:
12+
13+
jobs:
14+
dri-sync:
15+
runs-on: ubuntu-latest
16+
concurrency:
17+
group: sponsor-dri-sync
18+
cancel-in-progress: false
19+
steps:
20+
- uses: actions/checkout@v4
21+
22+
- uses: actions/setup-node@v4
23+
with:
24+
node-version: '20'
25+
cache: 'npm'
26+
cache-dependency-path: PERBot/package-lock.json
27+
28+
- name: Install dependencies
29+
working-directory: PERBot
30+
run: npm ci
31+
32+
- name: Sync the DRI ledger
33+
working-directory: PERBot
34+
run: npm run job:sponsor-dri-sync
35+
env:
36+
SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
37+
NOTION_API_KEY: ${{ secrets.NOTION_API_KEY }}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
name: Sponsorship — Saturday Quota Audit
2+
3+
# Saturday ~10am ET: check every active member of the 👥 Ops Quota Roster against the
4+
# weekly sponsor-assignment quota (Pipeline deals opened this week with them as DRI),
5+
# record the result per member in 📋 Weekly Quota Audit, and post one message to
6+
# #perbot_spam that @-mentions only the people below quota. Two cron triggers cover the
7+
# EDT/EST offset; the script's isSaturday10amET() time gate makes double-fires safe, and a
8+
# per-week Slack metadata marker blocks a duplicate post on manual re-runs.
9+
on:
10+
schedule:
11+
- cron: '0 14 * * 6' # 10am EDT
12+
- cron: '0 15 * * 6' # 10am EST
13+
workflow_dispatch:
14+
inputs:
15+
force:
16+
description: 'Run even if it is not Saturday 10am ET'
17+
type: boolean
18+
default: false
19+
dry_run:
20+
description: 'Print the post and skip all writes (Notion + Slack)'
21+
type: boolean
22+
default: false
23+
24+
jobs:
25+
quota-audit:
26+
runs-on: ubuntu-latest
27+
concurrency:
28+
group: sponsor-quota-audit
29+
cancel-in-progress: false
30+
steps:
31+
- uses: actions/checkout@v4
32+
33+
- uses: actions/setup-node@v4
34+
with:
35+
node-version: '20'
36+
cache: 'npm'
37+
cache-dependency-path: PERBot/package-lock.json
38+
39+
- name: Install dependencies
40+
working-directory: PERBot
41+
run: npm ci
42+
43+
- name: Audit the weekly sponsor quota
44+
working-directory: PERBot
45+
run: npm run job:sponsor-quota
46+
env:
47+
SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
48+
NOTION_API_KEY: ${{ secrets.NOTION_API_KEY }}
49+
SPONSOR_QUOTA_CHANNEL: ${{ vars.SPONSOR_QUOTA_CHANNEL || 'perbot_spam' }}
50+
SPONSOR_WEEKLY_QUOTA: ${{ vars.SPONSOR_WEEKLY_QUOTA || '3' }}
51+
FORCE_QUOTA_AUDIT: ${{ inputs.force }}
52+
QUOTA_DRY_RUN: ${{ inputs.dry_run }}

‎PERBot/package.json‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
"job:sponsor-stale": "tsx src/sponsorship/jobs/staleDm.ts",
1818
"job:sponsor-reply": "tsx src/sponsorship/jobs/replyDm.ts",
1919
"job:sponsor-digest": "tsx src/sponsorship/jobs/weeklyDigest.ts",
20+
"job:sponsor-quota": "tsx src/sponsorship/jobs/quotaAudit.ts",
21+
"job:sponsor-dri-sync": "tsx src/sponsorship/jobs/driSync.ts",
2022
"check": "tsc --noEmit"
2123
},
2224
"dependencies": {

‎PERBot/src/config.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,13 @@ export const config = {
8686
// The team's outreach template page ("Sponsorship Email Template" under REV12
8787
// Operations) — fetched live by /sponsor email so ops can edit it without a deploy.
8888
emailTemplatePageId: optionalString('SPONSOR_EMAIL_TEMPLATE_PAGE_ID', '393560bc-c039-8097-be19-fdc8876bef9b'),
89+
// Weekly quota audit (Saturday 10am ET): who's on the hook (👥 Ops Quota Roster), the
90+
// per-week record (📋 Weekly Quota Audit), where the call-out lands, and the bar.
91+
quotaRosterDataSourceId: optionalString('SPONSOR_QUOTA_ROSTER_DS_ID', '62729c68-535c-4dd0-a633-2feaf6e2b853'),
92+
quotaAuditDataSourceId: optionalString('SPONSOR_QUOTA_AUDIT_DS_ID', '784770f3-8276-4347-89e0-d677c53576a2'),
93+
quotaAuditUrl: optionalString('SPONSOR_QUOTA_AUDIT_URL', 'https://www.notion.so/acc29c47cfd44d02b5e5835d5ed6a090'),
94+
quotaChannel: optionalString('SPONSOR_QUOTA_CHANNEL', 'perbot_spam'),
95+
weeklyQuota: optionalNumber('SPONSOR_WEEKLY_QUOTA', 3),
8996
},
9097
github: {
9198
token: process.env.GITHUB_TOKEN?.trim() || '',

‎PERBot/src/sponsorship/dates.ts‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,28 @@ export function isoDaysFromNowET(days: number): string {
1717
d.setDate(d.getDate() + days);
1818
return d.toLocaleDateString('en-CA');
1919
}
20+
21+
/** YYYY-MM-DD `days` after a given YYYY-MM-DD date (calendar arithmetic, no timezone). */
22+
export function isoAddDays(dateIso: string, days: number): string {
23+
const d = new Date(`${dateIso.slice(0, 10)}T00:00:00Z`);
24+
d.setUTCDate(d.getUTCDate() + days);
25+
return d.toISOString().slice(0, 10);
26+
}
27+
28+
/**
29+
* The UTC instant of `hour`:00 wall-clock time in America/New_York on `dateIso`.
30+
* DST-aware via Intl (EDT = GMT-4, EST = GMT-5), so jobs can define windows in ET
31+
* without depending on the runner's local timezone (GitHub runners are UTC).
32+
*/
33+
export function etWallTimeToUtc(dateIso: string, hour: number): Date {
34+
const naive = new Date(`${dateIso.slice(0, 10)}T${String(hour).padStart(2, '0')}:00:00Z`);
35+
const label =
36+
new Intl.DateTimeFormat('en-US', { timeZone: 'America/New_York', timeZoneName: 'shortOffset' })
37+
.formatToParts(naive)
38+
.find((p) => p.type === 'timeZoneName')?.value ?? 'GMT-5';
39+
const m = label.match(/GMT([+-]\d+)(?::(\d+))?/);
40+
const offsetHours = m ? Number(m[1]) : -5;
41+
const offsetMinutes = m?.[2] ? Number(m[2]) : 0;
42+
const offsetTotalMin = offsetHours * 60 + Math.sign(offsetHours) * offsetMinutes;
43+
return new Date(naive.getTime() - offsetTotalMin * 60_000);
44+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { logger } from '../../utils/logger.js';
2+
import { SponsorNotion } from '../notion.js';
3+
import { PipelineRow } from '../types.js';
4+
5+
/**
6+
* Hourly DRI sync: Notion doesn't record WHEN a person was added to a people property,
7+
* so PERBot keeps its own ledger on each Pipeline deal (`DRI assigned`: one
8+
* "<user id> <ISO>" line per current DRI). Each run diffs every deal's DRI set against
9+
* its ledger — new DRIs are stamped, removed DRIs are dropped, deals whose ledger is
10+
* empty are backfilled with the deal's creation time (the best evidence for assignments
11+
* that predate the ledger). The quota audit + `/sponsor quota` count by these stamps, so
12+
* a re-assignment onto an older deal counts in the week it happened (Arjun's rule,
13+
* 2026-09-11). Granularity is the sync cadence: an assign→unassign inside one hour is
14+
* invisible, which is fine.
15+
*
16+
* Idempotent by construction: only deals whose ledger would change are written.
17+
*/
18+
19+
/** The ledger a deal should have now, or null if it's already correct. */
20+
export function reconcileLedger(deal: PipelineRow, nowIso: string): Record<string, string> | null {
21+
const current = new Set(deal.driUserIds.map((id) => id.toLowerCase()));
22+
const next: Record<string, string> = {};
23+
const ledgerEmpty = Object.keys(deal.driAssignedAt).length === 0;
24+
for (const id of current) {
25+
// First-ever stamp on a deal → the deal's creation time (pre-ledger assignments);
26+
// later additions → now.
27+
next[id] = deal.driAssignedAt[id] ?? (ledgerEmpty && deal.createdTime ? deal.createdTime : nowIso);
28+
}
29+
const before = Object.entries(deal.driAssignedAt).sort().join('|');
30+
const after = Object.entries(next).sort().join('|');
31+
return before === after ? null : next;
32+
}
33+
34+
export async function syncDriLedger(notion: SponsorNotion = new SponsorNotion()): Promise<{ scanned: number; updated: number }> {
35+
const deals = await notion.queryAllDeals();
36+
const nowIso = new Date().toISOString();
37+
let updated = 0;
38+
for (const deal of deals) {
39+
const next = reconcileLedger(deal, nowIso);
40+
if (!next) continue;
41+
await notion.writeDriLedger(deal.id, next);
42+
updated += 1;
43+
logger.info(`DRI sync: ${deal.company || deal.id} → ${Object.keys(next).length} DRI(s) stamped.`);
44+
}
45+
logger.info(`DRI sync: scanned ${deals.length} deals, updated ${updated}.`);
46+
return { scanned: deals.length, updated };
47+
}
48+
49+
// Entrypoint when run directly (GitHub Actions).
50+
const isMain = process.argv[1] && import.meta.url === `file://${process.argv[1]}`;
51+
if (isMain) {
52+
syncDriLedger().catch((err) => {
53+
logger.error('DRI sync job failed.', err);
54+
process.exitCode = 1;
55+
});
56+
}

0 commit comments

Comments
 (0)