Skip to content

Commit 574a002

Browse files
committed
refactor: resolve code review findings including dialect sniffing, standalone migrations, SQLite transactions, ORM parameterization, request timeouts, PAT redaction, and Postgres CI testing
1 parent 16d3eb0 commit 574a002

17 files changed

Lines changed: 395 additions & 118 deletions

File tree

.github/workflows/ci.yml

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,28 @@ on:
66
pull_request:
77
branches: [main]
88

9+
permissions:
10+
contents: read
11+
912
jobs:
1013
test:
1114
runs-on: ubuntu-latest
1215

16+
services:
17+
postgres:
18+
image: postgres:16
19+
env:
20+
POSTGRES_USER: postgres
21+
POSTGRES_PASSWORD: postgres
22+
POSTGRES_DB: postgres
23+
ports:
24+
- 5432:5432
25+
options: >-
26+
--health-cmd pg_isready
27+
--health-interval 10s
28+
--health-timeout 5s
29+
--health-retries 5
30+
1331
steps:
1432
- uses: actions/checkout@v4
1533

@@ -22,4 +40,7 @@ jobs:
2240

2341
- run: npx tsc --noEmit
2442

25-
- run: npm test
43+
- name: Run tests
44+
run: npm test
45+
env:
46+
TEST_POSTGRES_URL: postgres://postgres:postgres@localhost:5432/postgres

.github/workflows/daily-budget-check.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@ on:
33
schedule:
44
- cron: '0 9 * * *'
55
workflow_dispatch: {}
6+
permissions:
7+
contents: read
8+
69
jobs:
710
budget-sync:
811
runs-on: ubuntu-latest

.github/workflows/daily-forecast.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@ on:
33
schedule:
44
- cron: '0 8 * * *'
55
workflow_dispatch: {}
6+
permissions:
7+
contents: read
8+
69
jobs:
710
forecast:
811
runs-on: ubuntu-latest

.github/workflows/nightly-etl.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@ on:
33
schedule:
44
- cron: '0 1 * * *'
55
workflow_dispatch: {}
6+
permissions:
7+
contents: read
8+
69
jobs:
710
etl:
811
runs-on: ubuntu-latest

.github/workflows/weekly-classify.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@ on:
33
schedule:
44
- cron: '0 6 * * 1'
55
workflow_dispatch: {}
6+
permissions:
7+
contents: read
8+
69
jobs:
710
classify:
811
runs-on: ubuntu-latest

src/budget/budget_sync.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ function computeAlertLevel(pctOfBudget7d: number | null, pctOfBudget30d: number
6565
}
6666

6767
async function getLatestPoolSnapshot(db: DbClient): Promise<PoolSnapshot | null> {
68-
const isPg = db.constructor.name.toLowerCase().includes('postgres');
68+
const isPg = typeof db.run !== 'function' && !db.constructor?.name?.toLowerCase().includes('sqlite');
6969

7070
if (isPg) {
7171
const results = await db
@@ -93,7 +93,7 @@ async function getLatestPoolSnapshot(db: DbClient): Promise<PoolSnapshot | null>
9393
}
9494

9595
async function getYesterdaySnapshot(db: DbClient, date: string): Promise<BudgetSnapshot | null> {
96-
const isPg = db.constructor.name.toLowerCase().includes('postgres');
96+
const isPg = typeof db.run !== 'function' && !db.constructor?.name?.toLowerCase().includes('sqlite');
9797

9898
if (isPg) {
9999
const results = await db
@@ -138,7 +138,7 @@ async function upsertBudgetSnapshot(
138138
note: string | null;
139139
},
140140
): Promise<void> {
141-
const isPg = db.constructor.name.toLowerCase().includes('postgres');
141+
const isPg = typeof db.run !== 'function' && !db.constructor?.name?.toLowerCase().includes('sqlite');
142142

143143
if (isPg) {
144144
await db

src/budget/notifications.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@ import { notificationLogPg, notificationLogSq } from '../db/schema.js';
88
export function sanitizeErrorMessage(error: unknown): string {
99
const message = error instanceof Error ? error.message : String(error);
1010

11-
// Strip GitHub PAT patterns (ghp_, gho_, ghu_, ghs_, ghr_)
12-
const sanitized = message.replace(/gh[pousr]_[a-zA-Z0-9]{36,}/g, '[REDACTED]');
11+
// Strip GitHub PAT patterns (classic ghp_ etc and fine-grained github_pat_)
12+
const sanitized = message
13+
.replace(/gh[pousr]_[a-zA-Z0-9]{36,}/g, '[REDACTED]')
14+
.replace(/github_pat_[a-zA-Z0-9_]{82}/g, '[REDACTED]');
1315

1416
// Truncate very long error bodies (likely API response bodies)
1517
if (sanitized.length > 500) {
@@ -71,6 +73,7 @@ export async function sendSlackNotification(
7173
method: 'POST',
7274
headers: { 'Content-Type': 'application/json' },
7375
body: JSON.stringify(payload),
76+
signal: AbortSignal.timeout(15000),
7477
});
7578

7679
if (!response.ok) {
@@ -152,6 +155,7 @@ export async function sendGitHubIssue(
152155
'Accept': 'application/vnd.github+json',
153156
},
154157
body: JSON.stringify(payload),
158+
signal: AbortSignal.timeout(15000),
155159
});
156160

157161
if (!response.ok) {
@@ -209,7 +213,7 @@ async function logNotification(
209213
errorMessage?: string;
210214
},
211215
): Promise<void> {
212-
const isPg = db.constructor.name.toLowerCase().includes('postgres');
216+
const isPg = typeof db.run !== 'function' && !db.constructor?.name?.toLowerCase().includes('sqlite');
213217

214218
if (isPg) {
215219
await db.insert(notificationLogPg).values({

src/classify/engine.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,13 +40,35 @@ export type ClassifyResult = {
4040
};
4141
};
4242

43+
/**
44+
* Map credit consumption percentile to a consumption tier.
45+
* Percentile thresholds:
46+
* - >= 85%: extreme
47+
* - >= 60% and < 85%: high
48+
* - >= 25% and < 60%: medium
49+
* - < 25%: low
50+
*
51+
* @param percentile The calculated percentile (value between 0 and 1)
52+
*/
4353
function assignConsumptionTier(percentile: number): ConsumptionTier {
4454
if (percentile >= 0.85) return 'extreme';
4555
if (percentile >= 0.60) return 'high';
4656
if (percentile >= 0.25) return 'medium';
4757
return 'low';
4858
}
4959

60+
/**
61+
* Classify users based on their credit usage relative to the organization.
62+
* Calculates credit consumption percentiles and maps them to consumption tiers.
63+
* Maps team assignments to business value tiers based on the config.
64+
*
65+
* If total users < 4, falls back to assigning all users to the 'medium' consumption tier.
66+
*
67+
* @param userCredits List of user GitHub logins and total credits used over 30 days
68+
* @param currentUsers Current users database records
69+
* @param config Team resolving config mapping teams to business value tiers
70+
* @param reason Reason for running the classification (e.g., weekly_recalc, manual)
71+
*/
5072
export function classifyUsers(
5173
userCredits: UserCredits[],
5274
currentUsers: CurrentUser[],

src/classify/runner.ts

Lines changed: 68 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import { loadValueConfig, resolveValueTier as resolveValueTierFn } from './value_config.js';
22
import { classifyUsers } from './engine.js';
33
import type { DbClient } from '../db/client.js';
4-
import { sql } from 'drizzle-orm';
5-
import { usersPg, usersSq, classificationHistoryPg, classificationHistorySq } from '../db/schema.js';
4+
import { sql, gte } from 'drizzle-orm';
5+
import { usersPg, usersSq, classificationHistoryPg, classificationHistorySq, dailyUsagePg, dailyUsageSq } from '../db/schema.js';
66

77
export type ClassifyOptions = {
88
valueConfigPath: string;
@@ -35,69 +35,59 @@ export async function runClassify(
3535
const valueConfig = loadValueConfig(options.valueConfigPath);
3636
const resolveValueTier = (team: string | null) => resolveValueTierFn(team, valueConfig);
3737

38-
// Read phase: get 30 days of daily_usage aggregates
39-
const usageQuery = isSqlite
40-
? `SELECT github_login, SUM(credits) as credits
41-
FROM daily_usage
42-
WHERE usage_date >= date('now', '-30 days')
43-
GROUP BY github_login`
44-
: `SELECT github_login, SUM(credits::numeric) as credits
45-
FROM daily_usage
46-
WHERE usage_date >= CURRENT_DATE - INTERVAL '30 days'
47-
GROUP BY github_login`;
48-
49-
const usageRows = await (isSqlite
50-
? Promise.resolve(db.all(sql.raw(usageQuery)) as Array<{ github_login: string; credits: string }>)
51-
: db.execute(sql.raw(usageQuery)).then((r: any) => r.rows as Array<{ github_login: string; credits: string }>));
38+
const dailyUsageTable = isSqlite ? dailyUsageSq : dailyUsagePg;
39+
const usersTable = isSqlite ? usersSq : usersPg;
40+
41+
// Calculate threshold date (30 days ago) in JS as YYYY-MM-DD
42+
const thresholdDate = new Date();
43+
thresholdDate.setDate(thresholdDate.getDate() - 30);
44+
const dateString = thresholdDate.toISOString().slice(0, 10);
45+
46+
// Read phase: get 30 days of daily_usage aggregates using Drizzle
47+
const usageRows = await db
48+
.select({
49+
github_login: dailyUsageTable.githubLogin,
50+
credits: sql<number>`SUM(${dailyUsageTable.credits})`.mapWith(Number),
51+
})
52+
.from(dailyUsageTable)
53+
.where(gte(dailyUsageTable.usageDate, dateString))
54+
.groupBy(dailyUsageTable.githubLogin);
5255

5356
if (usageRows.length === 0) {
5457
throw new Error('No daily_usage data found. Run `burnrate etl` first.');
5558
}
5659

57-
// Check for 30 distinct days
58-
const distinctDaysQuery = isSqlite
59-
? `SELECT COUNT(DISTINCT usage_date) as days FROM daily_usage WHERE usage_date >= date('now', '-30 days')`
60-
: `SELECT COUNT(DISTINCT usage_date) as days FROM daily_usage WHERE usage_date >= CURRENT_DATE - INTERVAL '30 days'`;
61-
62-
const daysResult = await (isSqlite
63-
? Promise.resolve(db.all(sql.raw(distinctDaysQuery)) as Array<{ days: number }>)
64-
: db.execute(sql.raw(distinctDaysQuery)).then((r: any) => r.rows as Array<{ days: number }>));
65-
66-
if (daysResult[0].days < 30) {
67-
throw new Error(`Insufficient data: only ${daysResult[0].days} distinct days found, need 30.`);
60+
// Check for 30 distinct days using Drizzle
61+
const daysResult = await db
62+
.select({
63+
days: sql<number>`COUNT(DISTINCT ${dailyUsageTable.usageDate})`.mapWith(Number),
64+
})
65+
.from(dailyUsageTable)
66+
.where(gte(dailyUsageTable.usageDate, dateString));
67+
68+
const distinctDays = daysResult[0]?.days ?? 0;
69+
if (distinctDays < 30) {
70+
throw new Error(`Insufficient data: only ${distinctDays} distinct days found, need 30.`);
6871
}
6972

70-
// Read current users
71-
const usersQuery = `SELECT github_login, team, consumption_tier, value_tier, bucket_updated_at FROM users`;
72-
const usersRows = await (isSqlite
73-
? Promise.resolve(db.all(sql.raw(usersQuery)) as Array<{
74-
github_login: string;
75-
team: string | null;
76-
consumption_tier: string | null;
77-
value_tier: string | null;
78-
bucket_updated_at: string | null;
79-
}>)
80-
: db.execute(sql.raw(usersQuery)).then((r: any) => r.rows as Array<{
81-
github_login: string;
82-
team: string | null;
83-
consumption_tier: string | null;
84-
value_tier: string | null;
85-
bucket_updated_at: string | null;
86-
}>));
73+
// Read current users using Drizzle
74+
const usersRows = await db
75+
.select({
76+
github_login: usersTable.githubLogin,
77+
team: usersTable.team,
78+
consumption_tier: usersTable.consumptionTier,
79+
value_tier: usersTable.valueTier,
80+
bucket_updated_at: usersTable.bucketUpdatedAt,
81+
})
82+
.from(usersTable);
8783

8884
// Classify
89-
const userCredits = usageRows.map((r: { github_login: string; credits: string }) => ({
85+
const userCredits = usageRows.map((r: any) => ({
9086
githubLogin: r.github_login,
9187
totalCredits: Number(r.credits),
9288
}));
9389

94-
const currentUsers = usersRows.map((r: {
95-
github_login: string;
96-
team: string | null;
97-
consumption_tier: string | null;
98-
value_tier: string | null;
99-
bucket_updated_at: string | null;
100-
}) => ({
90+
const currentUsers = usersRows.map((r: any) => ({
10191
githubLogin: r.github_login,
10292
team: r.team,
10393
consumptionTier: r.consumption_tier,
@@ -108,33 +98,36 @@ export async function runClassify(
10898
const effectiveDate = new Date().toISOString().slice(0, 10);
10999
const result = classifyUsers(userCredits, currentUsers, { resolveValueTier }, options.reason);
110100

111-
// Write phase: use transactions for PostgreSQL, batch for SQLite
101+
// Write phase: use transaction for SQLite and PostgreSQL
112102
if (result.changes.length > 0) {
113103
const now = new Date().toISOString();
114104

115105
if (isSqlite) {
116-
// SQLite: batch updates (better-sqlite3 transactions require different setup)
117-
for (const change of result.changes) {
118-
await db.update(usersSq)
119-
.set({
120-
consumptionTier: change.consumptionTierNew,
121-
valueTier: change.valueTierNew,
122-
bucketUpdatedAt: now,
123-
updatedAt: sql`CURRENT_TIMESTAMP`,
124-
})
125-
.where(sql`${usersSq.githubLogin} = ${change.githubLogin}`);
126-
127-
await db.insert(classificationHistorySq)
128-
.values({
129-
effectiveDate,
130-
githubLogin: change.githubLogin,
131-
consumptionTierOld: change.consumptionTierOld,
132-
consumptionTierNew: change.consumptionTierNew,
133-
valueTier: change.valueTierNew,
134-
reason: change.reason,
135-
})
136-
.onConflictDoNothing();
137-
}
106+
db.transaction((tx: any) => {
107+
for (const change of result.changes) {
108+
tx.update(usersSq)
109+
.set({
110+
consumptionTier: change.consumptionTierNew,
111+
valueTier: change.valueTierNew,
112+
bucketUpdatedAt: now,
113+
updatedAt: sql`CURRENT_TIMESTAMP`,
114+
})
115+
.where(sql`${usersSq.githubLogin} = ${change.githubLogin}`)
116+
.run();
117+
118+
tx.insert(classificationHistorySq)
119+
.values({
120+
effectiveDate,
121+
githubLogin: change.githubLogin,
122+
consumptionTierOld: change.consumptionTierOld,
123+
consumptionTierNew: change.consumptionTierNew,
124+
valueTier: change.valueTierNew,
125+
reason: change.reason,
126+
})
127+
.onConflictDoNothing()
128+
.run();
129+
}
130+
});
138131
} else {
139132
await db.transaction(async (tx: any) => {
140133
for (const change of result.changes) {

src/db/migrate.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { sql } from 'drizzle-orm';
2+
import { fileURLToPath } from 'node:url';
23

34
export const pgSchemaStatements = [
45
`CREATE TABLE IF NOT EXISTS raw_reports (
@@ -214,3 +215,22 @@ export async function runMigrations(db: any): Promise<void> {
214215
}
215216
}
216217
}
218+
219+
if (process.argv[1] === fileURLToPath(import.meta.url)) {
220+
const url = process.env.DATABASE_URL;
221+
if (!url) {
222+
console.error('Error: DATABASE_URL environment variable is required.');
223+
process.exit(1);
224+
}
225+
const { initDb, closeDb } = await import('./client.js');
226+
const db = initDb(url);
227+
try {
228+
await runMigrations(db);
229+
console.log('Migrations completed successfully.');
230+
} catch (err) {
231+
console.error('Migrations failed:', err);
232+
process.exit(1);
233+
} finally {
234+
await closeDb();
235+
}
236+
}

0 commit comments

Comments
 (0)