Skip to content

Commit c782382

Browse files
committed
fix(scripts): target live production convex scans
1 parent 53dfaa9 commit c782382

4 files changed

Lines changed: 320 additions & 48 deletions

File tree

package.json

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,16 @@
1212
"typecheck": "turbo run typecheck",
1313
"check:web": "pnpm --filter @stackmatch/web check",
1414
"check:sentry": "pnpm --filter @stackmatch/web check:sentry",
15-
"check:production-scan-readiness": "node scripts/convex-production.mjs check-scan-readiness",
15+
"check:production-scan-readiness": "tsx scripts/convex-production.mjs check-scan-readiness",
1616
"check:commit-msg": "node scripts/check-commit-msg.mjs",
1717
"check:constants": "node scripts/check-centralized-constants.mjs",
1818
"check:data-boundary": "node scripts/check-data-boundary.mjs",
1919
"check:no-magic-changed": "node scripts/check-no-magic-numbers-changed.mjs --base=origin/main",
2020
"check:no-magic-staged": "node scripts/check-no-magic-numbers-changed.mjs --staged",
2121
"check:folders": "bash scripts/check-folder-size.sh",
22-
"convex:prod": "node scripts/convex-production.mjs",
23-
"backfill:auth-profiles:prod": "node scripts/convex-production.mjs backfill-auth-profiles",
22+
"convex:prod": "tsx scripts/convex-production.mjs",
23+
"queue-owner-scan:prod": "tsx scripts/convex-production.mjs queue-owner-scan",
24+
"backfill:auth-profiles:prod": "tsx scripts/convex-production.mjs backfill-auth-profiles",
2425
"verify": "bash scripts/verify.sh",
2526
"verify:quick": "bash scripts/verify.sh --no-build",
2627
"clean": "bash scripts/clean.sh",

scripts/RESYNC-GUIDE.md

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,13 @@ by fetching public GitHub repos and reusing the standard scan request mutation.
3737
- Convex CLI available through `pnpm --filter @stackmatch/web exec convex`
3838
- `adminResyncOwner` deployed to the target environment
3939
- `adminQueueOwnerScan` deployed to bootstrap owners with no cached rows
40+
- Production commands target the deployment in Vercel's production env file, not the
41+
Convex CLI's `--prod` alias. Pull the env file first:
42+
43+
```bash
44+
vercel env pull /private/tmp/stackmatch-vercel-prod.env --environment=production --yes
45+
```
46+
4047
- Production scan readiness passes:
4148

4249
```bash
@@ -72,15 +79,14 @@ the first scan batch.
7279
### Bootstrap a brand-new owner directly
7380

7481
```bash
75-
pnpm --filter @stackmatch/web exec convex run --prod \
76-
github/admin_queue_owner_scan:adminQueueOwnerScan \
77-
'{"owner":"toksdotdev","dryRun":true}'
82+
pnpm queue-owner-scan:prod -- toksdotdev --dry-run
7883

79-
pnpm --filter @stackmatch/web exec convex run --prod \
80-
github/admin_queue_owner_scan:adminQueueOwnerScan \
81-
'{"owner":"toksdotdev"}'
84+
pnpm queue-owner-scan:prod -- toksdotdev --write
8285
```
8386

87+
The wrapper reads `NEXT_PUBLIC_CONVEX_URL` from the pulled Vercel production env file
88+
and queues through that exact Convex deployment.
89+
8490
### Full production resync
8591

8692
```bash
@@ -101,6 +107,9 @@ pnpm tsx scripts/resync-all-users.ts --prod --delay 30 --batch-size 5
101107
pnpm tsx scripts/resync-all-users.ts --owner thedaviddias
102108
```
103109

110+
Do not use raw `convex run --prod` for production scans. It can point at a Convex
111+
deployment that is no longer the one serving `stackmatch.dev`.
112+
104113
---
105114

106115
## CLI Options
@@ -175,7 +184,7 @@ Duration: 6m 12s
175184
### "0 repos found for owner X"
176185

177186
The owner has no repos in the database yet. Use `--owner <login>` so the script can call
178-
`adminQueueOwnerScan`, or run `github/admin_queue_owner_scan:adminQueueOwnerScan` directly.
187+
`adminQueueOwnerScan`, or run `pnpm queue-owner-scan:prod -- <owner> --write` directly.
179188

180189
### Repos fail with "GITHUB_TOKEN not configured"
181190

scripts/convex-production.mjs

Lines changed: 192 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,35 @@
1-
#!/usr/bin/env node
1+
#!/usr/bin/env -S pnpm exec tsx
22
import { existsSync, readFileSync } from "node:fs";
3+
import { createRequire } from "node:module";
34
import { resolve } from "node:path";
45
import { spawnSync } from "node:child_process";
6+
import { pathToFileURL } from "node:url";
7+
import { api } from "../apps/web/convex/_generated/api.js";
58

69
const DEFAULT_ENV_FILE = "/private/tmp/stackmatch-vercel-prod.env";
710
const CONVEX_CLOUD_PATTERN = /^https:\/\/([a-z0-9-]+)\.convex\.cloud\/?$/;
811
const CONVEX_SITE_PATTERN = /^https:\/\/([a-z0-9-]+)\.convex\.site\/?$/;
12+
const GITHUB_API_BASE_URL = "https://api.github.com";
13+
const GITHUB_JSON_ACCEPT_HEADER = "application/vnd.github.v3+json";
14+
const GITHUB_OWNER_REPOS_FETCH_PAGE_SIZE = 100;
15+
const GITHUB_NOT_FOUND_STATUS = 404;
16+
const GITHUB_RATE_LIMIT_STATUS = 429;
17+
const WEB_REQUIRE = createRequire(new URL("../apps/web/package.json", import.meta.url));
18+
const { normalizeGitHubOwnerType } = await import(
19+
pathToFileURL(WEB_REQUIRE.resolve("@stackmatch/constants/owner")).href
20+
);
21+
const { GITHUB_PUBLIC_REPOS_SCAN_LIMIT } = await import(
22+
pathToFileURL(WEB_REQUIRE.resolve("@stackmatch/constants/sync")).href
23+
);
924

1025
function usage() {
1126
console.error(`Usage:
1227
pnpm convex:prod --env-file <file> deploy [convex deploy args...]
1328
pnpm convex:prod --env-file <file> run <functionName> [jsonArgs]
1429
pnpm convex:prod --env-file <file> check-scan-readiness
30+
pnpm convex:prod --env-file <file> queue-owner-scan <githubOwner> [--write]
1531
pnpm backfill:auth-profiles:prod --env-file <file> [--limit 10] [--cursor CURSOR] [--write]
32+
pnpm queue-owner-scan:prod -- <githubOwner> [--write]
1633
1734
Env file must be pulled from Vercel production, for example:
1835
vercel env pull /private/tmp/stackmatch-vercel-prod.env --environment=production --yes
@@ -80,6 +97,10 @@ function resolveDeployment(env) {
8097
return deployment;
8198
}
8299

100+
function getConvexCloudUrl(deployment) {
101+
return `https://${deployment}.convex.cloud`;
102+
}
103+
83104
function runPnpm(commandArgs, env) {
84105
const result = spawnSync("pnpm", commandArgs, {
85106
cwd: process.cwd(),
@@ -143,6 +164,37 @@ function buildRunEnv(env) {
143164
return childEnv;
144165
}
145166

167+
function parseQueueOwnerScanArgs(args) {
168+
const options = {
169+
dryRun: true,
170+
owner: "",
171+
};
172+
173+
while (args.length > 0) {
174+
const arg = args.shift();
175+
if (arg === "--write") {
176+
options.dryRun = false;
177+
continue;
178+
}
179+
if (arg === "--dry-run") {
180+
options.dryRun = true;
181+
continue;
182+
}
183+
if (!options.owner) {
184+
options.owner = arg ?? "";
185+
continue;
186+
}
187+
throw new Error(`Unknown queue-owner-scan argument: ${arg}`);
188+
}
189+
190+
options.owner = options.owner.trim();
191+
if (!options.owner) {
192+
throw new Error("queue-owner-scan requires a GitHub owner");
193+
}
194+
195+
return options;
196+
}
197+
146198
function buildBackfillArgs(env, args) {
147199
const payload = {
148200
apiKey: env.ANALYZE_API_KEY,
@@ -179,6 +231,136 @@ function buildBackfillArgs(env, args) {
179231
return JSON.stringify(payload);
180232
}
181233

234+
function buildGitHubHeaders(env) {
235+
const token = env.GITHUB_TOKEN?.trim();
236+
return {
237+
Accept: GITHUB_JSON_ACCEPT_HEADER,
238+
...(token ? { Authorization: `token ${token}` } : {}),
239+
};
240+
}
241+
242+
async function fetchGitHubJson(path, env) {
243+
const response = await fetch(`${GITHUB_API_BASE_URL}${path}`, {
244+
headers: buildGitHubHeaders(env),
245+
});
246+
247+
if (!response.ok) {
248+
let message = `${response.status} ${response.statusText}`;
249+
try {
250+
const body = await response.clone().json();
251+
if (typeof body.message === "string") {
252+
message = `${message}: ${body.message}`;
253+
}
254+
} catch {
255+
// Keep the status-only message when GitHub returns a non-JSON error body.
256+
}
257+
258+
if (
259+
response.status === GITHUB_RATE_LIMIT_STATUS ||
260+
response.headers.get("x-ratelimit-remaining") === "0"
261+
) {
262+
throw new Error(`GitHub rate limit reached: ${message}`);
263+
}
264+
265+
if (response.status === GITHUB_NOT_FOUND_STATUS) {
266+
throw new Error(`GitHub owner was not found: ${message}`);
267+
}
268+
269+
throw new Error(`GitHub request failed: ${message}`);
270+
}
271+
272+
return response.json();
273+
}
274+
275+
function parseGitHubTimestamp(value) {
276+
if (!value) return undefined;
277+
const timestamp = Date.parse(value);
278+
return Number.isFinite(timestamp) ? timestamp : undefined;
279+
}
280+
281+
function normalizeOwnerProfile(owner, profile) {
282+
return {
283+
...(profile.name ? { name: profile.name } : {}),
284+
avatarUrl: profile.avatar_url ?? `https://github.com/${owner}.png?size=200`,
285+
followers: profile.followers ?? 0,
286+
...(profile.bio ? { bio: profile.bio } : {}),
287+
...(profile.blog ? { website: profile.blog } : {}),
288+
...(profile.twitter_username ? { x: profile.twitter_username } : {}),
289+
...(profile.location ? { location: profile.location } : {}),
290+
...(profile.company ? { company: profile.company } : {}),
291+
ownerType: normalizeGitHubOwnerType(profile.type),
292+
};
293+
}
294+
295+
function normalizeRepos(repos, fallbackOwner) {
296+
return repos
297+
.filter((repo) => !repo.fork && repo.name)
298+
.sort((a, b) => (b.stargazers_count ?? 0) - (a.stargazers_count ?? 0))
299+
.slice(0, GITHUB_PUBLIC_REPOS_SCAN_LIMIT)
300+
.map((repo) => {
301+
const pushedAt = parseGitHubTimestamp(repo.pushed_at);
302+
return {
303+
owner: repo.owner?.login ?? fallbackOwner,
304+
name: repo.name,
305+
...(pushedAt !== undefined ? { pushedAt } : {}),
306+
};
307+
});
308+
}
309+
310+
async function queueOwnerScan(env, deployment, options) {
311+
if (!env.ANALYZE_API_KEY) {
312+
throw new Error("ANALYZE_API_KEY is missing from the production env file");
313+
}
314+
315+
const encodedOwner = encodeURIComponent(options.owner);
316+
const [profile, fetchedRepos] = await Promise.all([
317+
fetchGitHubJson(`/users/${encodedOwner}`, env),
318+
fetchGitHubJson(
319+
`/users/${encodedOwner}/repos?per_page=${GITHUB_OWNER_REPOS_FETCH_PAGE_SIZE}&type=public`,
320+
env
321+
),
322+
]);
323+
324+
const canonicalOwner = profile.login ?? options.owner;
325+
const repos = normalizeRepos(fetchedRepos, canonicalOwner);
326+
const summary = {
327+
deployment,
328+
dryRun: options.dryRun,
329+
owner: canonicalOwner,
330+
totalFetchedRepos: fetchedRepos.length,
331+
queuedCount: repos.length,
332+
repos: repos.map((repo) => `${repo.owner}/${repo.name}`),
333+
};
334+
335+
if (options.dryRun) {
336+
console.log(JSON.stringify(summary, null, 2));
337+
return;
338+
}
339+
340+
const { ConvexHttpClient } = await import(
341+
pathToFileURL(WEB_REQUIRE.resolve("convex/browser")).href
342+
);
343+
const client = new ConvexHttpClient(getConvexCloudUrl(deployment));
344+
const results = await client.mutation(api.mutations.request_user_scan.requestUserScan, {
345+
repos,
346+
apiKey: env.ANALYZE_API_KEY,
347+
ownerProfile: normalizeOwnerProfile(canonicalOwner, profile),
348+
});
349+
350+
console.log(
351+
JSON.stringify(
352+
{
353+
...summary,
354+
queuedCount: results.length,
355+
existingCount: results.filter((repo) => repo.existing).length,
356+
results,
357+
},
358+
null,
359+
2
360+
)
361+
);
362+
}
363+
182364
const { envFile, command, args } = parseCli(process.argv.slice(2));
183365
const env = parseEnvFile(envFile);
184366
const deployment = resolveDeployment(env);
@@ -220,6 +402,15 @@ if (command === "check-scan-readiness") {
220402
process.exit(result.ready ? 0 : 1);
221403
}
222404

405+
if (command === "queue-owner-scan") {
406+
const options = parseQueueOwnerScanArgs(args);
407+
console.error(
408+
`${options.dryRun ? "Previewing" : "Queueing"} owner scan on live Vercel production deployment: ${deployment}`
409+
);
410+
await queueOwnerScan(env, deployment, options);
411+
process.exit(0);
412+
}
413+
223414
if (command === "backfill-auth-profiles") {
224415
const payload = buildBackfillArgs(env, args);
225416
console.error(`Backfilling auth profiles on live Vercel production deployment: ${deployment}`);

0 commit comments

Comments
 (0)