Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
87a70c5
feat: first pass at free credits script
iscekic Mar 18, 2026
4a8decd
feat(expire-free-credits): add configurable concurrency with p-limit
iscekic Mar 18, 2026
35d1e90
fix(scripts): exclude categories from expiration to avoid expiring cr…
iscekic Mar 18, 2026
ae0feb5
refactor(expire-free-credits): require --category param and query by …
iscekic Mar 18, 2026
d2448d1
style(scripts): remove eslint-disable-next-line above infinite loop
iscekic Mar 18, 2026
18f2baa
feat(expire-free-credits): switch to dynamic 30-day expiry and catego…
iscekic Mar 18, 2026
dee2668
feat(expire-free-credits): use spreadsheet-driven category/descriptio…
iscekic Mar 18, 2026
e29e2a3
fix(expire-free-credits): correct empty description label in startup log
iscekic Mar 18, 2026
3f43dda
fix(expire-free-credits): log next_credit_expiration_at for rollback …
iscekic Mar 18, 2026
06639ef
fix(expire-free-credits): empty description matches any description f…
iscekic Mar 18, 2026
73ba056
chore(prettier): add .kilo to ignore list
iscekic Mar 19, 2026
39596b7
test(expire-free-credits): add integration test script
iscekic Mar 19, 2026
491fafa
docs(expire-free-credits): replace hardcoded date with EXPIRY_DATE in…
iscekic Mar 19, 2026
93d3695
test(expire-free-credits): add buy-use-free vs free-use-buy ordering …
iscekic Mar 19, 2026
49fc5af
fix(expire-free-credits): paginate by user ID to avoid splitting cred…
iscekic Mar 19, 2026
6ff6482
chore(scripts): add .gitignore to ignore *.jsonl in output dir
iscekic Mar 19, 2026
cd3542b
fix(expire-free-credits): floor projected balance at zero to prevent …
iscekic Mar 19, 2026
6353835
Merge remote-tracking branch 'origin/main' into fix/expire-credits
iscekic Mar 19, 2026
2d8c60e
fix(ci): exclude src/scripts/ from Jest and fix lint errors
iscekic Mar 19, 2026
1b4de9e
fix(expire-free-credits): skip credits that would push balance negati…
iscekic Mar 19, 2026
869ea0c
fix(expire-free-credits): split projected balance into no-script vs a…
iscekic Mar 20, 2026
ee4175b
feat(expire-free-credits): add mutations log and revert script
iscekic Mar 20, 2026
8266911
test(expire-free-credits): add integration test for revert script
iscekic Mar 20, 2026
ba1f294
fix(expire-free-credits): log mutations after commit, revert using ol…
iscekic Mar 20, 2026
556c354
style: format revert test file
iscekic Mar 20, 2026
b8664a8
fix(expire-free-credits): log mutations in dry run mode too
iscekic Mar 20, 2026
91e1c10
feat(expire-free-credits): add confirmation prompt with DB host display
iscekic Mar 20, 2026
d48f4e2
Merge branch 'main' into fix/expire-credits
iscekic Mar 31, 2026
d1b1096
refactor(expire-credits): replace hardcoded data with CSV parser and …
iscekic Mar 31, 2026
65dcecb
test: update expire-credits test to use generated CSV input
iscekic Mar 31, 2026
b27d606
test: update revert test to use generated CSV input
iscekic Mar 31, 2026
b05dc90
chore: add csv-parse dependency for expire-credits script
iscekic Mar 31, 2026
16b4335
fix: cursor advancement, add FALSE-override test, add sheet URL comment
iscekic Mar 31, 2026
dae924e
fix: add comment in empty catch blocks to satisfy linter
iscekic Mar 31, 2026
5da5372
fix: sort credits by expiry date before headroom selection
iscekic Mar 31, 2026
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
1 change: 1 addition & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ pnpm-lock.yaml

# Kilo agent runtime artifacts
.kilocode/
.kilo

# Misc
.DS_Store
Expand Down
1 change: 1 addition & 0 deletions jest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ const config: Config = {
'<rootDir>/kiloclaw/',
'<rootDir>/packages/encryption/',
'<rootDir>/packages/worker-utils/',
'<rootDir>/src/scripts/',
'<rootDir>/.worktrees/',
],
modulePathIgnorePatterns: ['<rootDir>/.worktrees/'],
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"csv-parse": "^6.2.1",
"date-fns": "^4.1.0",
"dayjs": "^1.11.20",
"discord-api-types": "^0.38.42",
Expand Down
10 changes: 9 additions & 1 deletion pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

305 changes: 305 additions & 0 deletions src/scripts/d2026-03-18_expire-free-credits-revert.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,305 @@
/**
* Integration test for the expire-free-credits revert script.
*
* 1. Inserts test users + credits
* 2. Runs the expire script with --execute (produces a mutations file)
* 3. Runs the revert script with --execute using that mutations file
* 4. Asserts DB state is back to the original state
*
* Usage:
* pnpm script src/scripts/d2026-03-18_expire-free-credits-revert.test.ts
*/

import '../lib/load-env';

import { execSync } from 'node:child_process';
import { readdirSync, writeFileSync, unlinkSync, mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { db, closeAllDrizzleConnections } from '@/lib/drizzle';
import { credit_transactions, kilocode_users } from '@kilocode/db/schema';
import { inArray } from 'drizzle-orm';
import { defineTestUser } from '@/tests/helpers/user.helper';

// ── Test user IDs ────────────────────────────────────────────────────────────

const TEST_PREFIX = `revert-test-${Date.now()}`;
const USER_PARTIALLY_SPENT = `${TEST_PREFIX}-partially-spent`;
const USER_UNSPENT = `${TEST_PREFIX}-unspent`;
const USER_FULLY_SPENT = `${TEST_PREFIX}-fully-spent`;
const USER_EXISTING_EXPIRY = `${TEST_PREFIX}-existing-expiry`;

const ALL_USER_IDS = [USER_PARTIALLY_SPENT, USER_UNSPENT, USER_FULLY_SPENT, USER_EXISTING_EXPIRY];

// ── Helpers ──────────────────────────────────────────────────────────────────

const MICRODOLLARS = 1_000_000;

function makeUser(id: string, spent: number, acquired: number) {
return defineTestUser({
id,
google_user_email: `${id}@test.local`,
stripe_customer_id: `stripe-${id}`,
microdollars_used: spent * MICRODOLLARS,
total_microdollars_acquired: acquired * MICRODOLLARS,
});
}

function makeCredit(
userId: string,
amount: number,
opts: {
category?: string;
description?: string | null;
expiryDate?: string | null;
} = {}
) {
return {
kilo_user_id: userId,
amount_microdollars: amount * MICRODOLLARS,
is_free: true,
credit_category: opts.category ?? 'automatic-welcome-credits',
description:
opts.description ??
'Free credits for new users, obtained by stych approval, card validation, or maybe some other method',
expiry_date: opts.expiryDate ?? null,
organization_id: null,
original_baseline_microdollars_used: 0,
check_category_uniqueness: false,
};
}

type Snapshot = {
credits: Map<string, { expiry_date: string | null; expiration_baseline: number | null }>;
users: Map<string, { next_credit_expiration_at: string | null }>;
};

async function takeSnapshot(): Promise<Snapshot> {
const credits = await db
.select()
.from(credit_transactions)
.where(inArray(credit_transactions.kilo_user_id, ALL_USER_IDS));

const users = await db
.select()
.from(kilocode_users)
.where(inArray(kilocode_users.id, ALL_USER_IDS));

return {
credits: new Map(
credits.map(c => [
c.id,
{
expiry_date: c.expiry_date,
expiration_baseline: c.expiration_baseline_microdollars_used,
},
])
),
users: new Map(
users.map(u => [u.id, { next_credit_expiration_at: u.next_credit_expiration_at }])
),
};
}

const EXPECTED_LOCAL_DB_URL = 'postgres://postgres:postgres@localhost:5432/postgres';

function assertLocalDatabase() {
const dbUrl = process.env.POSTGRES_SCRIPT_URL ?? process.env.POSTGRES_URL ?? '';
if (dbUrl !== EXPECTED_LOCAL_DB_URL) {
console.error(`ABORT: Expected local database URL but got: ${dbUrl}`);
console.error(`Expected: ${EXPECTED_LOCAL_DB_URL}`);
process.exit(1);
}
}

let testCsvPath = '';

function generateTestCsv(): string {
const dir = mkdtempSync(path.join(tmpdir(), 'expire-test-'));
const csvPath = path.join(dir, 'input.csv');

const csvContent = [
'CREDIT_CATEGORY,SHOULD_EXPIRE,DESCRIPTION,RECORDS,USERS,BLOCKED_USERS,FIRST_ISSUED_AT,LAST_ISSUED_AT,AMOUNT_GRANTED_USD,PCT,EXPIRE_IN_DAYS,REVIEWED_BY',
'automatic-welcome-credits,TRUE,"Free credits for new users, obtained by stych approval, card validation, or maybe some other method",0,0,0,,,,,30,test',
'referral-redeeming-bonus,TRUE,,0,0,0,,,,,30,test',
'card-validation-upgrade,TRUE,Upgrade credits for passing card validation after having already passed Stytch validation.,0,0,0,,,,,30,test',
'card-validation-no-stytch,TRUE,Free credits for passing card validation without prior Stytch validation.,0,0,0,,,,,30,test',
'stytch-validation,TRUE,Free credits for passing Stytch fraud detection.,0,0,0,,,,,30,test',
].join('\n');

writeFileSync(csvPath, csvContent);
return csvPath;
}

let insertedCreditIds: string[] = [];

async function setup() {
testCsvPath = generateTestCsv();
console.log(` Generated test CSV: ${testCsvPath}\n`);
console.log('Setting up test data...\n');

const EARLIER_EXPIRY = new Date(Date.now() + 10 * 24 * 60 * 60 * 1000).toISOString();

await db.insert(kilocode_users).values([
makeUser(USER_PARTIALLY_SPENT, 5, 10),
makeUser(USER_UNSPENT, 0, 10),
makeUser(USER_FULLY_SPENT, 10, 10),
{
...makeUser(USER_EXISTING_EXPIRY, 5, 15),
next_credit_expiration_at: EARLIER_EXPIRY,
},
]);

const credits = await db
.insert(credit_transactions)
.values([
// Partially spent: $10 credit, $5 spent → $5 would expire
makeCredit(USER_PARTIALLY_SPENT, 10),

// Unspent: $10 credit, $0 spent → $10 would expire
makeCredit(USER_UNSPENT, 10),

// Fully spent: $10 credit, $10 spent → $0 would expire
makeCredit(USER_FULLY_SPENT, 10),

// Existing expiry user: has an independent expiring credit + a new one
makeCredit(USER_EXISTING_EXPIRY, 5, { expiryDate: EARLIER_EXPIRY }),
makeCredit(USER_EXISTING_EXPIRY, 10),
])
.returning({ id: credit_transactions.id });

insertedCreditIds = credits.map(c => c.id);
console.log(` Inserted ${ALL_USER_IDS.length} users and ${insertedCreditIds.length} credits\n`);
}

async function cleanup() {
console.log('\nCleaning up test data...');
await db
.delete(credit_transactions)
.where(inArray(credit_transactions.kilo_user_id, ALL_USER_IDS));
await db.delete(kilocode_users).where(inArray(kilocode_users.id, ALL_USER_IDS));
if (testCsvPath) {
try {
unlinkSync(testCsvPath);
} catch {
// ignore — temp file cleanup is best-effort
}
}
console.log(' Done.\n');
}

function findLatestMutationsFile(): string {
const outputDir = path.join(__dirname, 'output');
const files = readdirSync(outputDir)
.filter(f => f.includes('.mutations.jsonl'))
.sort();
if (files.length === 0) throw new Error('No mutations file found');
return path.join(outputDir, files[files.length - 1]);
}

type AssertionResult = { name: string; passed: boolean; detail?: string };

// ── Main ─────────────────────────────────────────────────────────────────────

async function main() {
try {
assertLocalDatabase();
await setup();

// 1. Take snapshot of original state
const before = await takeSnapshot();

// 2. Run the expire script
console.log('Running expire-free-credits script with --execute...\n');
const expireOutput = execSync(
`pnpm script src/scripts/d2026-03-18_expire-free-credits.ts --input=${testCsvPath} --execute --yes --batch-size=1`,
{ cwd: process.cwd(), encoding: 'utf-8', env: { ...process.env }, timeout: 120_000 }
);
console.log(expireOutput);

// 3. Verify something actually changed
const afterExpire = await takeSnapshot();
let creditsChanged = 0;
for (const [id, snap] of afterExpire.credits) {
const orig = before.credits.get(id);
if (orig && orig.expiry_date !== snap.expiry_date) creditsChanged++;
}
console.log(`Credits modified by expire script: ${creditsChanged}\n`);

// 4. Run the revert script
const mutationsFile = findLatestMutationsFile();
console.log(`Running revert script with mutations file: ${mutationsFile}\n`);
const revertOutput = execSync(
`pnpm script src/scripts/d2026-03-18_expire-free-credits-revert.ts ${mutationsFile} --execute --yes`,

Check warning

Code scanning / CodeQL

Shell command built from environment values Medium

This shell command depends on an uncontrolled
file name
.

Copilot Autofix

AI about 1 month ago

In general, the fix is to avoid constructing a shell command string that embeds unescaped, dynamic values (like filenames) and then running it through a shell. Instead, invoke the command with an API that accepts the executable and its arguments separately (for example, execFileSync or spawnSync from node:child_process), passing mutationsFile as a distinct argument so the shell does not interpret it.

Concretely here, we should:

  1. Replace the use of execSync with execFileSync for both command invocations:
    • The “expire-free-credits” script call on lines 214–217.
    • The “revert” script call on lines 232–235, which is where the tainted mutationsFile is interpolated.
  2. Pass "pnpm" as the command, "script" as the first argument, followed by the script path and other flags, and in the second call include mutationsFile as its own argument in the args array.
  3. Import execFileSync from node:child_process instead of execSync.
  4. Preserve the existing options (cwd, encoding, env, timeout) by passing the same options object to execFileSync.

All of these changes must be made within src/scripts/d2026-03-18_expire-free-credits-revert.test.ts:

  • Update the import on line 15 from execSync to execFileSync.
  • Change the expireOutput assignment on lines 214–217 to call execFileSync('pnpm', [...args...], options).
  • Change the revertOutput assignment on lines 232–235 similarly, ensuring mutationsFile is in the args array and not interpolated into a single command string.

No additional helper methods are required; only the import and function calls need updating.

Suggested changeset 1
src/scripts/d2026-03-18_expire-free-credits-revert.test.ts

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/src/scripts/d2026-03-18_expire-free-credits-revert.test.ts b/src/scripts/d2026-03-18_expire-free-credits-revert.test.ts
--- a/src/scripts/d2026-03-18_expire-free-credits-revert.test.ts
+++ b/src/scripts/d2026-03-18_expire-free-credits-revert.test.ts
@@ -12,7 +12,7 @@
 
 import '../lib/load-env';
 
-import { execSync } from 'node:child_process';
+import { execFileSync } from 'node:child_process';
 import { readdirSync, writeFileSync, unlinkSync, mkdtempSync } from 'node:fs';
 import { tmpdir } from 'node:os';
 import path from 'node:path';
@@ -211,8 +211,16 @@
 
     // 2. Run the expire script
     console.log('Running expire-free-credits script with --execute...\n');
-    const expireOutput = execSync(
-      `pnpm script src/scripts/d2026-03-18_expire-free-credits.ts --input=${testCsvPath} --execute --yes --batch-size=1`,
+    const expireOutput = execFileSync(
+      'pnpm',
+      [
+        'script',
+        'src/scripts/d2026-03-18_expire-free-credits.ts',
+        `--input=${testCsvPath}`,
+        '--execute',
+        '--yes',
+        '--batch-size=1',
+      ],
       { cwd: process.cwd(), encoding: 'utf-8', env: { ...process.env }, timeout: 120_000 }
     );
     console.log(expireOutput);
@@ -229,8 +237,15 @@
     // 4. Run the revert script
     const mutationsFile = findLatestMutationsFile();
     console.log(`Running revert script with mutations file: ${mutationsFile}\n`);
-    const revertOutput = execSync(
-      `pnpm script src/scripts/d2026-03-18_expire-free-credits-revert.ts ${mutationsFile} --execute --yes`,
+    const revertOutput = execFileSync(
+      'pnpm',
+      [
+        'script',
+        'src/scripts/d2026-03-18_expire-free-credits-revert.ts',
+        mutationsFile,
+        '--execute',
+        '--yes',
+      ],
       { cwd: process.cwd(), encoding: 'utf-8', env: { ...process.env }, timeout: 120_000 }
     );
     console.log(revertOutput);
EOF
@@ -12,7 +12,7 @@

import '../lib/load-env';

import { execSync } from 'node:child_process';
import { execFileSync } from 'node:child_process';
import { readdirSync, writeFileSync, unlinkSync, mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
@@ -211,8 +211,16 @@

// 2. Run the expire script
console.log('Running expire-free-credits script with --execute...\n');
const expireOutput = execSync(
`pnpm script src/scripts/d2026-03-18_expire-free-credits.ts --input=${testCsvPath} --execute --yes --batch-size=1`,
const expireOutput = execFileSync(
'pnpm',
[
'script',
'src/scripts/d2026-03-18_expire-free-credits.ts',
`--input=${testCsvPath}`,
'--execute',
'--yes',
'--batch-size=1',
],
{ cwd: process.cwd(), encoding: 'utf-8', env: { ...process.env }, timeout: 120_000 }
);
console.log(expireOutput);
@@ -229,8 +237,15 @@
// 4. Run the revert script
const mutationsFile = findLatestMutationsFile();
console.log(`Running revert script with mutations file: ${mutationsFile}\n`);
const revertOutput = execSync(
`pnpm script src/scripts/d2026-03-18_expire-free-credits-revert.ts ${mutationsFile} --execute --yes`,
const revertOutput = execFileSync(
'pnpm',
[
'script',
'src/scripts/d2026-03-18_expire-free-credits-revert.ts',
mutationsFile,
'--execute',
'--yes',
],
{ cwd: process.cwd(), encoding: 'utf-8', env: { ...process.env }, timeout: 120_000 }
);
console.log(revertOutput);
Copilot is powered by AI and may make mistakes. Always verify output.
{ cwd: process.cwd(), encoding: 'utf-8', env: { ...process.env }, timeout: 120_000 }
);
console.log(revertOutput);

// 5. Take snapshot of reverted state and compare to original
const afterRevert = await takeSnapshot();

const results: AssertionResult[] = [];

// Check all credits are back to original
for (const [id, orig] of before.credits) {
const reverted = afterRevert.credits.get(id);
results.push({
name: `Credit ${id.slice(0, 8)}: expiry_date restored`,
passed: orig.expiry_date === (reverted?.expiry_date ?? null),
detail: `before=${orig.expiry_date}, after=${reverted?.expiry_date}`,
});
results.push({
name: `Credit ${id.slice(0, 8)}: baseline restored`,
passed: orig.expiration_baseline === (reverted?.expiration_baseline ?? null),
detail: `before=${orig.expiration_baseline}, after=${reverted?.expiration_baseline}`,
});
}

// Check user next_credit_expiration_at is correct
// For USER_EXISTING_EXPIRY: should still point to the independent credit's expiry
// For others: should be null (no remaining expiring credits)
for (const userId of ALL_USER_IDS) {
const orig = before.users.get(userId);
const reverted = afterRevert.users.get(userId);
results.push({
name: `User ${userId.split('-').pop()}: next_credit_expiration_at restored`,
passed: orig?.next_credit_expiration_at === reverted?.next_credit_expiration_at,
detail: `before=${orig?.next_credit_expiration_at}, after=${reverted?.next_credit_expiration_at}`,
});
}

// Verify at least some credits were actually changed and reverted
results.push({
name: 'Sanity: some credits were modified by expire script',
passed: creditsChanged > 0,
detail: `${creditsChanged} credits changed`,
});

let passed = 0;
let failed = 0;
for (const r of results) {
const icon = r.passed ? 'PASS' : 'FAIL';
console.log(` [${icon}] ${r.name}`);
if (!r.passed) {
console.log(` ${r.detail}`);
failed++;
} else {
passed++;
}
}

console.log(`\n${passed} passed, ${failed} failed out of ${results.length} assertions`);

if (failed > 0) {
process.exitCode = 1;
}
} finally {
await cleanup();
await closeAllDrizzleConnections();
}
}

void main().catch(err => {
console.error('Fatal error:', err);
process.exit(1);
});
Loading
Loading