From 14cdc0add044d40f379da8e0d515e116ed5119a7 Mon Sep 17 00:00:00 2001
From: aamoghS
Date: Sun, 25 Oct 2026 17:23:55 -0400
Subject: [PATCH 01/11] feat(db): add judging repo check table and settings
---
packages/db/src/schemas/judging.ts | 56 ++++++++++++++++++++++++++++++
1 file changed, 56 insertions(+)
diff --git a/packages/db/src/schemas/judging.ts b/packages/db/src/schemas/judging.ts
index 96b3d0f1..7cd4d350 100644
--- a/packages/db/src/schemas/judging.ts
+++ b/packages/db/src/schemas/judging.ts
@@ -60,6 +60,14 @@ export const judgingConfigs = pgTable("judging_config", {
// While judging runs, presentations of a judge who left or went quiet move
// to judges who are here.
autoRebalance: boolean("auto_rebalance").notNull().default(true),
+ // Late-push check (judging_repo_check): while on, each entry's GitHub repo is
+ // checked for pushes after the submission deadline plus this grace. The run
+ // time is claimed in the row so only one instance starts a run per interval.
+ repoCheckEnabled: boolean("repo_check_enabled").notNull().default(false),
+ repoCheckGraceMinutes: integer("repo_check_grace_minutes")
+ .notNull()
+ .default(0),
+ repoCheckLastRunAt: timestamp("repo_check_last_run_at"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
});
@@ -157,6 +165,54 @@ export const judgingEntryMembers = pgTable(
],
);
+// What GitHub says about an entry's repo: when it was last pushed to and, if
+// that was after the deadline, the commits and pushes that came with it. Only
+// ever flags; `decision` is the organiser's. `removedEntry` records that
+// disqualifying took the project out of judging, so reinstating knows to put
+// it back.
+export const judgingRepoChecks = pgTable(
+ "judging_repo_check",
+ {
+ entryId: uuid("entry_id")
+ .primaryKey()
+ .references(() => judgingEntries.id, { onDelete: "cascade" }),
+ hackathonId: uuid("hackathon_id")
+ .notNull()
+ .references(() => hackathons.id, { onDelete: "cascade" }),
+ repoOwner: text("repo_owner"),
+ repoName: text("repo_name"),
+ outcome: text("outcome", {
+ enum: ["ok", "late", "no_repo", "unreadable", "error"],
+ }).notNull(),
+ // GitHub's own clock for the last push to any branch.
+ pushedAt: timestamp("pushed_at"),
+ // At most 20 commits and 20 pushes; shown for context, never decided on.
+ evidence: jsonb("evidence").$type<{
+ commits: {
+ sha: string;
+ message: string;
+ author: string | null;
+ committedAt: string | null;
+ url: string;
+ }[];
+ pushes: { at: string; ref: string | null; pusher: string | null }[];
+ }>(),
+ checkedAt: timestamp("checked_at").notNull(),
+ decision: text("decision", {
+ enum: ["pending", "disqualified", "cleared"],
+ })
+ .notNull()
+ .default("pending"),
+ decidedById: text("decided_by_id").references(() => users.id, {
+ onDelete: "set null",
+ }),
+ decidedAt: timestamp("decided_at"),
+ note: text("note"),
+ removedEntry: boolean("removed_entry").notNull().default(false),
+ },
+ (table) => [index("judging_repo_check_hackathon_idx").on(table.hackathonId)],
+);
+
// A one-time claim link for a member whose Devpost email is not the address
// they sign in with. The token is cleared when used.
export const judgingClaimLinks = pgTable(
From dd0b73604bfcf044c9ffbf3e26d2c3c58fec8c57 Mon Sep 17 00:00:00 2001
From: aamoghS
Date: Sun, 25 Oct 2026 17:27:33 -0400
Subject: [PATCH 02/11] feat(api): add late-push repo check service
---
.../src/services/judging/repo-check.test.ts | 737 ++++++++++++++++++
.../api/src/services/judging/repo-check.ts | 433 ++++++++++
2 files changed, 1170 insertions(+)
create mode 100644 packages/api/src/services/judging/repo-check.test.ts
create mode 100644 packages/api/src/services/judging/repo-check.ts
diff --git a/packages/api/src/services/judging/repo-check.test.ts b/packages/api/src/services/judging/repo-check.test.ts
new file mode 100644
index 00000000..6fe15ff1
--- /dev/null
+++ b/packages/api/src/services/judging/repo-check.test.ts
@@ -0,0 +1,737 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { getTableName } from "drizzle-orm";
+import { judgingConfigs, judgingEntries, judgingRepoChecks } from "@query/db";
+import {
+ GithubClient,
+ REPO_CHECK_EVERY_MS,
+ checkRepo,
+ isLate,
+ nextCheck,
+ parseRepoUrl,
+ repoCheckIfDue,
+ repoCheckThrottle,
+ repoOfEntry,
+ runRepoCheck,
+} from "./repo-check";
+import type { RepoCheckRow, RepoResult } from "./repo-check";
+
+const HACK = "hackathon";
+const DEADLINE = new Date("2026-02-08T12:00:00Z");
+const MINUTE = 60 * 1000;
+const ref = { owner: "team", repo: "app" };
+
+type Reply = { status?: number; body?: unknown; headers?: Record };
+
+/** A fetch that answers by URL substring, in the order the rules are given. */
+function fakeFetch(rules: [string, Reply | (() => Reply)][]) {
+ const calls: { url: string; headers: Record }[] = [];
+ const fn = vi.fn(async (input: unknown, init?: RequestInit) => {
+ const url = String(input);
+ calls.push({ url, headers: init?.headers as Record });
+ const rule = rules.find(([part]) => url.includes(part));
+ const reply = rule ? (typeof rule[1] === "function" ? rule[1]() : rule[1]) : {};
+ return new Response(JSON.stringify(reply.body ?? {}), {
+ status: reply.status ?? 200,
+ headers: reply.headers,
+ });
+ });
+ return { fn: fn as unknown as typeof fetch, calls };
+}
+
+const repoReply = (pushedAt: string | null): Reply => ({
+ body: { pushed_at: pushedAt },
+});
+
+describe("parseRepoUrl", () => {
+ it("reads owner and repo from the usual links", () => {
+ expect(parseRepoUrl("https://github.com/team/app")).toEqual(ref);
+ expect(parseRepoUrl("https://www.github.com/team/app/")).toEqual(ref);
+ expect(parseRepoUrl("http://github.com/team/app")).toEqual(ref);
+ expect(parseRepoUrl("github.com/team/app")).toEqual(ref);
+ expect(parseRepoUrl(" https://github.com/team/app ")).toEqual(ref);
+ });
+
+ it("drops .git, trailing paths, queries and hashes", () => {
+ expect(parseRepoUrl("https://github.com/team/app.git")).toEqual(ref);
+ expect(parseRepoUrl("https://github.com/team/app/tree/main/src")).toEqual(ref);
+ expect(parseRepoUrl("https://github.com/team/app/issues/3?x=1#top")).toEqual(
+ ref,
+ );
+ expect(parseRepoUrl("https://github.com/team/my.app-v2_x")).toEqual({
+ owner: "team",
+ repo: "my.app-v2_x",
+ });
+ });
+
+ it("is not fooled by other hosts or look-alikes", () => {
+ expect(parseRepoUrl("https://gitlab.com/team/app")).toBeNull();
+ expect(parseRepoUrl("https://github.com.evil.io/team/app")).toBeNull();
+ expect(parseRepoUrl("https://evil.io/github.com/team/app")).toBeNull();
+ expect(parseRepoUrl("https://gist.github.com/team/abc")).toBeNull();
+ expect(parseRepoUrl("ftp://github.com/team/app")).toBeNull();
+ expect(parseRepoUrl("https://team.github.io/app")).toBeNull();
+ });
+
+ it("refuses links that are not a repo", () => {
+ expect(parseRepoUrl("https://github.com/team")).toBeNull();
+ expect(parseRepoUrl("https://github.com/")).toBeNull();
+ expect(parseRepoUrl("https://github.com/orgs/team/people")).toBeNull();
+ expect(parseRepoUrl("https://github.com/settings/profile")).toBeNull();
+ expect(parseRepoUrl("https://github.com/team/..")).toBeNull();
+ expect(parseRepoUrl("https://github.com/-bad/app")).toBeNull();
+ expect(parseRepoUrl("https://github.com/team/ap p")).toBeNull();
+ expect(parseRepoUrl("not a url")).toBeNull();
+ expect(parseRepoUrl("")).toBeNull();
+ expect(parseRepoUrl(null)).toBeNull();
+ expect(parseRepoUrl(undefined)).toBeNull();
+ });
+});
+
+describe("repoOfEntry", () => {
+ it("prefers the portal project's link", () => {
+ expect(
+ repoOfEntry({
+ githubUrl: "https://github.com/team/app",
+ demoLinks: ["https://github.com/other/thing"],
+ }),
+ ).toEqual(ref);
+ });
+
+ it("falls back to the first repo among the demo links", () => {
+ expect(
+ repoOfEntry({
+ githubUrl: null,
+ demoLinks: [
+ "https://devpost.com/software/app",
+ "https://github.com/team",
+ "https://github.com/team/app/tree/dev",
+ "https://github.com/other/thing",
+ ],
+ }),
+ ).toEqual(ref);
+ });
+
+ it("uses the demo links when the project's own link is not a repo", () => {
+ expect(
+ repoOfEntry({
+ githubUrl: "https://github.com/team",
+ demoLinks: ["https://github.com/team/app"],
+ }),
+ ).toEqual(ref);
+ });
+
+ it("is null with no repo anywhere", () => {
+ expect(repoOfEntry({ githubUrl: null, demoLinks: [] })).toBeNull();
+ expect(
+ repoOfEntry({ githubUrl: "", demoLinks: ["https://example.com"] }),
+ ).toBeNull();
+ });
+});
+
+describe("isLate", () => {
+ it("flags only a push after the deadline", () => {
+ expect(isLate(new Date(DEADLINE.getTime() - 1), DEADLINE, 0)).toBe(false);
+ expect(isLate(DEADLINE, DEADLINE, 0)).toBe(false);
+ expect(isLate(new Date(DEADLINE.getTime() + 1), DEADLINE, 0)).toBe(true);
+ });
+
+ it("adds the grace, and a push on its last millisecond is on time", () => {
+ const edge = new Date(DEADLINE.getTime() + 15 * MINUTE);
+ expect(isLate(edge, DEADLINE, 15)).toBe(false);
+ expect(isLate(new Date(edge.getTime() + 1), DEADLINE, 15)).toBe(true);
+ expect(isLate(new Date(DEADLINE.getTime() + 10 * MINUTE), DEADLINE, 15)).toBe(
+ false,
+ );
+ });
+});
+
+describe("checkRepo", () => {
+ const run = (rules: Parameters[0], grace = 0, token?: string) => {
+ const fetched = fakeFetch(rules);
+ const client = new GithubClient(fetched.fn, token);
+ return { fetched, client, result: checkRepo(client, ref, DEADLINE, grace) };
+ };
+
+ it("is ok when the last push was before the deadline", async () => {
+ const { fetched, result } = run([
+ ["/repos/team/app", repoReply("2026-02-08T11:59:59Z")],
+ ]);
+ await expect(result).resolves.toMatchObject({
+ outcome: "ok",
+ pushedAt: new Date("2026-02-08T11:59:59Z"),
+ evidence: null,
+ });
+ expect(fetched.calls).toHaveLength(1);
+ });
+
+ it("is ok for a push exactly on the deadline, and for an empty repo", async () => {
+ await expect(
+ run([["/repos/team/app", repoReply("2026-02-08T12:00:00Z")]]).result,
+ ).resolves.toMatchObject({ outcome: "ok" });
+ await expect(
+ run([["/repos/team/app", repoReply(null)]]).result,
+ ).resolves.toMatchObject({ outcome: "ok", pushedAt: null });
+ });
+
+ it("is ok inside the grace and late one second past it", async () => {
+ await expect(
+ run([["/repos/team/app", repoReply("2026-02-08T12:15:00Z")]], 15).result,
+ ).resolves.toMatchObject({ outcome: "ok" });
+ await expect(
+ run([["/repos/team/app", repoReply("2026-02-08T12:15:01Z")]], 15).result,
+ ).resolves.toMatchObject({ outcome: "late" });
+ });
+
+ it("gathers commits and pushes after the deadline for a late repo", async () => {
+ const { fetched, result } = run([
+ ["/commits", { body: [
+ {
+ sha: "abc123",
+ html_url: "https://github.com/team/app/commit/abc123",
+ commit: {
+ message: "fix the demo\n\nlong body",
+ author: { name: "Ada" },
+ committer: { date: "1999-01-01T00:00:00Z" },
+ },
+ },
+ ] }],
+ ["/events", { body: [
+ {
+ type: "PushEvent",
+ created_at: "2026-02-08T13:00:00Z",
+ actor: { login: "ada" },
+ payload: { ref: "refs/heads/dev" },
+ },
+ {
+ type: "PushEvent",
+ created_at: "2026-02-08T10:00:00Z",
+ actor: { login: "ada" },
+ payload: { ref: "refs/heads/main" },
+ },
+ { type: "WatchEvent", created_at: "2026-02-08T14:00:00Z" },
+ ] }],
+ ["/repos/team/app", repoReply("2026-02-08T13:00:00Z")],
+ ]);
+ const done = await result;
+ expect(done.outcome).toBe("late");
+ expect(done.evidence).toEqual({
+ commits: [
+ {
+ sha: "abc123",
+ message: "fix the demo",
+ author: "Ada",
+ committedAt: "1999-01-01T00:00:00Z",
+ url: "https://github.com/team/app/commit/abc123",
+ },
+ ],
+ pushes: [{ at: "2026-02-08T13:00:00Z", ref: "refs/heads/dev", pusher: "ada" }],
+ });
+ expect(fetched.calls[1]!.url).toContain(
+ `since=${encodeURIComponent(DEADLINE.toISOString())}&per_page=20`,
+ );
+ expect(fetched.calls[2]!.url).toContain("/events?per_page=100");
+ });
+
+ it("keeps at most 20 commits and 20 pushes", async () => {
+ const commit = (i: number) => ({
+ sha: String(i),
+ html_url: "u",
+ commit: { message: "m", author: { name: "a" }, committer: { date: "d" } },
+ });
+ const push = (i: number) => ({
+ type: "PushEvent",
+ created_at: `2026-02-09T00:00:${String(i).padStart(2, "0")}Z`,
+ actor: { login: "a" },
+ payload: { ref: "r" },
+ });
+ const { result } = run([
+ ["/commits", { body: Array.from({ length: 30 }, (_, i) => commit(i)) }],
+ ["/events", { body: Array.from({ length: 30 }, (_, i) => push(i)) }],
+ ["/repos/team/app", repoReply("2026-02-09T00:00:00Z")],
+ ]);
+ const done = await result;
+ expect(done.evidence!.commits).toHaveLength(20);
+ expect(done.evidence!.pushes).toHaveLength(20);
+ });
+
+ it("is still late when the evidence cannot be fetched", async () => {
+ const { result } = run([
+ ["/commits", { status: 500 }],
+ ["/events", { status: 500 }],
+ ["/repos/team/app", repoReply("2026-02-09T00:00:00Z")],
+ ]);
+ await expect(result).resolves.toMatchObject({
+ outcome: "late",
+ evidence: { commits: [], pushes: [] },
+ });
+ });
+
+ it("calls a missing, private or blocked repo unreadable", async () => {
+ for (const status of [404, 403, 451, 410]) {
+ await expect(
+ run([["/repos/team/app", { status }]]).result,
+ ).resolves.toMatchObject({ outcome: "unreadable", pushedAt: null });
+ }
+ });
+
+ it("calls a server failure or a dropped connection an error", async () => {
+ await expect(
+ run([["/repos/team/app", { status: 502 }]]).result,
+ ).resolves.toMatchObject({ outcome: "error" });
+
+ const client = new GithubClient(
+ (async () => {
+ throw new TypeError("fetch failed");
+ }) as unknown as typeof fetch,
+ undefined,
+ );
+ await expect(checkRepo(client, ref, DEADLINE, 0)).resolves.toMatchObject({
+ outcome: "error",
+ });
+ });
+
+ it("sends a User-Agent, and the token only when there is one", async () => {
+ const without = run([["/repos/team/app", repoReply(null)]]);
+ await without.result;
+ expect(without.fetched.calls[0]!.headers["User-Agent"]).toBeTruthy();
+ expect(without.fetched.calls[0]!.headers.Authorization).toBeUndefined();
+
+ const withToken = run([["/repos/team/app", repoReply(null)]], 0, "ghp_x");
+ await withToken.result;
+ expect(withToken.fetched.calls[0]!.headers.Authorization).toBe("Bearer ghp_x");
+ });
+
+ it("times a request out after 10 seconds", async () => {
+ const signals: AbortSignal[] = [];
+ const client = new GithubClient(
+ (async (_url: unknown, init?: RequestInit) => {
+ signals.push(init!.signal!);
+ return new Response("{}");
+ }) as unknown as typeof fetch,
+ undefined,
+ );
+ await client.get("/repos/team/app");
+ expect(signals[0]).toBeInstanceOf(AbortSignal);
+ });
+
+ describe("rate limits", () => {
+ it("stops on a 403 with no requests left", async () => {
+ await expect(
+ run([
+ ["/repos/team/app", { status: 403, headers: { "x-ratelimit-remaining": "0" } }],
+ ]).result,
+ ).rejects.toThrow("rate limited");
+ });
+
+ it("stops on retry-after and on 429", async () => {
+ await expect(
+ run([["/repos/team/app", { status: 403, headers: { "retry-after": "60" } }]])
+ .result,
+ ).rejects.toThrow("rate limited");
+ await expect(
+ run([["/repos/team/app", { status: 429 }]]).result,
+ ).rejects.toThrow("rate limited");
+ });
+
+ it("does not mistake a private repo's 403 for a limit", async () => {
+ await expect(
+ run([
+ ["/repos/team/app", { status: 403, headers: { "x-ratelimit-remaining": "40" } }],
+ ]).result,
+ ).resolves.toMatchObject({ outcome: "unreadable" });
+ });
+
+ it("makes no more requests once the last one used the budget up", async () => {
+ const fetched = fakeFetch([
+ [
+ "/repos/team/app",
+ { body: { pushed_at: null }, headers: { "x-ratelimit-remaining": "0" } },
+ ],
+ ]);
+ const client = new GithubClient(fetched.fn, undefined);
+ await expect(checkRepo(client, ref, DEADLINE, 0)).resolves.toMatchObject({
+ outcome: "ok",
+ });
+ await expect(checkRepo(client, ref, DEADLINE, 0)).rejects.toThrow(
+ "rate limited",
+ );
+ expect(fetched.calls).toHaveLength(1);
+ });
+ });
+});
+
+const result = (over: Partial): RepoResult => ({
+ outcome: "late",
+ ref,
+ pushedAt: new Date("2026-02-08T13:00:00Z"),
+ evidence: { commits: [], pushes: [] },
+ ...over,
+});
+
+const decided = (over: Partial = {}): RepoCheckRow => ({
+ entryId: "e1",
+ hackathonId: HACK,
+ repoOwner: "team",
+ repoName: "app",
+ outcome: "late",
+ pushedAt: new Date("2026-02-08T13:00:00Z"),
+ evidence: { commits: [], pushes: [] },
+ checkedAt: new Date("2026-02-08T14:00:00Z"),
+ decision: "cleared",
+ decidedById: "admin",
+ decidedAt: new Date("2026-02-08T15:00:00Z"),
+ note: "agreed with the team",
+ removedEntry: false,
+ ...over,
+});
+
+describe("nextCheck", () => {
+ const checkedAt = new Date("2026-02-08T16:00:00Z");
+
+ it("stores a first check as pending", () => {
+ expect(nextCheck(undefined, result({}), checkedAt)).toMatchObject({
+ outcome: "late",
+ decision: "pending",
+ checkedAt,
+ });
+ });
+
+ it("refreshes a pending check", () => {
+ const row = nextCheck(decided({ decision: "pending", decidedAt: null }), result({ outcome: "ok" }), checkedAt);
+ expect(row).toMatchObject({ outcome: "ok", decision: "pending" });
+ });
+
+ it("leaves a decided entry alone when nothing was pushed since", () => {
+ for (const decision of ["cleared", "disqualified"] as const) {
+ expect(
+ nextCheck(decided({ decision }), result({}), checkedAt),
+ ).toBeNull();
+ }
+ });
+
+ it("reopens a decided entry pushed to after the decision", () => {
+ const pushedAt = new Date("2026-02-08T15:00:01Z");
+ for (const decision of ["cleared", "disqualified"] as const) {
+ const row = nextCheck(
+ decided({ decision, removedEntry: decision === "disqualified" }),
+ result({
+ pushedAt,
+ evidence: {
+ commits: [],
+ pushes: [{ at: pushedAt.toISOString(), ref: "r", pusher: "p" }],
+ },
+ }),
+ checkedAt,
+ );
+ expect(row).toMatchObject({
+ decision: "pending",
+ decidedById: null,
+ decidedAt: null,
+ note: null,
+ pushedAt,
+ removedEntry: decision === "disqualified",
+ });
+ expect(row!.evidence!.pushes).toHaveLength(1);
+ }
+ });
+
+ it("does not reopen on a push exactly at the decision", () => {
+ expect(
+ nextCheck(
+ decided(),
+ result({ pushedAt: new Date("2026-02-08T15:00:00Z") }),
+ checkedAt,
+ ),
+ ).toBeNull();
+ });
+
+ it("never undoes a decision because a check could not look", () => {
+ for (const outcome of ["error", "unreadable", "no_repo", "ok"] as const) {
+ expect(
+ nextCheck(decided(), result({ outcome, pushedAt: null }), checkedAt),
+ ).toBeNull();
+ }
+ });
+});
+
+/** A database whose selects answer by table and whose writes are recorded. */
+function fakeDb(opts: {
+ config?: Record | null;
+ claim?: boolean;
+ entries?: Record[];
+ checks?: Record[];
+}) {
+ const writes: { table: string; values: any }[] = [];
+ const claims: unknown[] = [];
+ const select = () => {
+ let table = "";
+ const chain: any = {
+ then: (ok: any, err: any) => {
+ const rows =
+ table === getTableName(judgingConfigs)
+ ? [opts.config === null ? undefined : (opts.config ?? { graceMinutes: 0 })]
+ : table === getTableName(judgingEntries)
+ ? (opts.entries ?? [])
+ : (opts.checks ?? []);
+ return Promise.resolve(rows.filter(Boolean)).then(ok, err);
+ },
+ };
+ chain.from = (t: unknown) => {
+ table = getTableName(t as never);
+ return chain;
+ };
+ chain.leftJoin = () => chain;
+ chain.where = () => chain;
+ chain.limit = () => chain;
+ return chain;
+ };
+ const db: any = {
+ query: {
+ hackathons: {
+ findFirst: async () => ({
+ hackingStartTime: new Date("2026-02-07T00:00:00Z"),
+ startDate: new Date("2026-02-07T00:00:00Z"),
+ }),
+ },
+ },
+ select,
+ update: () => ({
+ set: (values: unknown) => ({
+ where: () => ({
+ returning: async () => {
+ claims.push(values);
+ return opts.claim ? [{ hackathonId: HACK }] : [];
+ },
+ }),
+ }),
+ }),
+ insert: (table: unknown) => ({
+ values: (values: unknown) => ({
+ onConflictDoUpdate: async () => {
+ writes.push({ table: getTableName(table as never), values });
+ },
+ }),
+ }),
+ };
+ return { db, writes, claims };
+}
+
+const entryRow = (id: string, link: string | null) => ({
+ id,
+ deletedAt: null,
+ demoLinks: link ? [link] : [],
+ githubUrl: null,
+});
+
+describe("runRepoCheck", () => {
+ const written = (writes: { table: string; values: any }[]) =>
+ new Map(
+ writes
+ .filter((w) => w.table === getTableName(judgingRepoChecks))
+ .map((w) => [w.values.entryId as string, w.values]),
+ );
+
+ it("records ok, late, no_repo and unreadable per project", async () => {
+ const { db, writes } = fakeDb({
+ entries: [
+ entryRow("a", "https://github.com/t/ok"),
+ entryRow("b", "https://github.com/t/late"),
+ entryRow("c", null),
+ entryRow("d", "https://github.com/t/gone"),
+ ],
+ });
+ const fetched = fakeFetch([
+ ["/repos/t/ok", repoReply("2026-02-08T11:00:00Z")],
+ ["/repos/t/late", repoReply("2026-02-08T12:30:00Z")],
+ ["/repos/t/gone", { status: 404 }],
+ ]);
+ await runRepoCheck(db, HACK, {
+ fetchFn: fetched.fn,
+ now: new Date("2026-02-09T00:00:00Z"),
+ });
+ const rows = written(writes);
+ expect([...rows.keys()].sort()).toEqual(["a", "b", "c", "d"]);
+ expect(rows.get("a")).toMatchObject({ outcome: "ok", decision: "pending" });
+ expect(rows.get("b")).toMatchObject({ outcome: "late", repoName: "late" });
+ expect(rows.get("c")).toMatchObject({ outcome: "no_repo", repoOwner: null });
+ expect(rows.get("d")).toMatchObject({ outcome: "unreadable" });
+ });
+
+ it("uses the submission close time and the edition's grace", async () => {
+ // Hacking starts Feb 7 00:00Z; submissions close 36 hours later.
+ const { db, writes } = fakeDb({
+ config: { graceMinutes: 30 },
+ entries: [entryRow("a", "https://github.com/t/a")],
+ });
+ await runRepoCheck(db, HACK, {
+ fetchFn: fakeFetch([["/repos/t/a", repoReply("2026-02-08T12:30:00Z")]]).fn,
+ });
+ expect(written(writes).get("a")).toMatchObject({ outcome: "ok" });
+ });
+
+ it("never has more than 5 requests in flight", async () => {
+ const entries = Array.from({ length: 23 }, (_, i) =>
+ entryRow(`e${i}`, `https://github.com/t/r${i}`),
+ );
+ const { db } = fakeDb({ entries });
+ let inFlight = 0;
+ let peak = 0;
+ const fetchFn = (async () => {
+ inFlight++;
+ peak = Math.max(peak, inFlight);
+ await new Promise((resolve) => setTimeout(resolve, 2));
+ inFlight--;
+ return new Response(JSON.stringify({ pushed_at: null }));
+ }) as unknown as typeof fetch;
+ await runRepoCheck(db, HACK, { fetchFn });
+ expect(peak).toBe(5);
+ });
+
+ it("marks the rest error once the rate limit is hit, and spares decided ones", async () => {
+ const entries = Array.from({ length: 12 }, (_, i) =>
+ entryRow(`e${i}`, `https://github.com/t/r${i}`),
+ );
+ const { db, writes } = fakeDb({
+ entries,
+ checks: [decided({ entryId: "e11", hackathonId: HACK })],
+ });
+ let served = 0;
+ const fetchFn = vi.fn(async () => {
+ served++;
+ if (served > 3) {
+ return new Response("{}", {
+ status: 403,
+ headers: { "x-ratelimit-remaining": "0" },
+ });
+ }
+ return new Response(JSON.stringify({ pushed_at: null }));
+ }) as unknown as typeof fetch;
+ await runRepoCheck(db, HACK, { fetchFn });
+
+ const rows = written(writes);
+ const outcomes = [...rows.values()].map((row) => row.outcome);
+ expect(outcomes.filter((o) => o === "ok")).toHaveLength(3);
+ expect(outcomes.filter((o) => o === "error").length).toBeGreaterThan(0);
+ expect(rows.has("e11")).toBe(false);
+ // Only requests already in flight when the limit hit were made.
+ expect(served).toBeLessThanOrEqual(3 + 5);
+ });
+
+ it("does not look at removed projects, except disqualified ones", async () => {
+ const removed = { ...entryRow("x", "https://github.com/t/x"), deletedAt: new Date() };
+ const dq = { ...entryRow("y", "https://github.com/t/y"), deletedAt: new Date() };
+ const { db } = fakeDb({
+ entries: [removed, dq],
+ checks: [decided({ entryId: "y", decision: "disqualified" })],
+ });
+ const fetched = fakeFetch([["/repos", repoReply(null)]]);
+ await runRepoCheck(db, HACK, { fetchFn: fetched.fn });
+ expect(fetched.calls.map((c) => c.url)).toEqual([
+ "https://api.github.com/repos/t/y",
+ ]);
+ });
+
+ it("reopens a cleared project that was pushed to after it was cleared", async () => {
+ const { db, writes } = fakeDb({
+ entries: [entryRow("e1", "https://github.com/t/a")],
+ checks: [decided()],
+ });
+ const fetched = fakeFetch([
+ ["/repos/t/a", repoReply("2026-02-08T15:00:01Z")],
+ ]);
+ await runRepoCheck(db, HACK, { fetchFn: fetched.fn });
+ expect(written(writes).get("e1")).toMatchObject({
+ decision: "pending",
+ outcome: "late",
+ });
+ });
+
+ it("does not run twice at once", async () => {
+ const { db } = fakeDb({ entries: [entryRow("a", "https://github.com/t/a")] });
+ const fetched = fakeFetch([["/repos/t/a", repoReply(null)]]);
+ await Promise.all([
+ runRepoCheck(db, HACK, { fetchFn: fetched.fn }),
+ runRepoCheck(db, HACK, { fetchFn: fetched.fn }),
+ ]);
+ expect(fetched.calls).toHaveLength(1);
+ });
+});
+
+describe("repoCheckIfDue", () => {
+ afterEach(() => {
+ repoCheckThrottle.clear();
+ vi.restoreAllMocks();
+ });
+
+ const t0 = new Date("2026-02-08T20:00:00Z");
+ const fetchFn = fakeFetch([["/repos", repoReply(null)]]).fn;
+
+ it("starts a run when the claim on the config row succeeds", async () => {
+ const { db, claims, writes } = fakeDb({
+ claim: true,
+ entries: [entryRow("a", "https://github.com/t/a")],
+ });
+ const started = await repoCheckIfDue(db, HACK, t0, { fetchFn });
+ expect(claims).toEqual([{ repoCheckLastRunAt: t0 }]);
+ await started!.done;
+ expect(writes).toHaveLength(1);
+ });
+
+ it("does not start one when another instance holds the claim, or it is off", async () => {
+ const { db, writes } = fakeDb({
+ claim: false,
+ entries: [entryRow("a", "https://github.com/t/a")],
+ });
+ expect(await repoCheckIfDue(db, HACK, t0, { fetchFn })).toBeUndefined();
+ expect(writes).toHaveLength(0);
+ });
+
+ it("asks at most once every 15 minutes in this process", async () => {
+ const { db, claims } = fakeDb({ claim: true });
+ const started = await repoCheckIfDue(db, HACK, t0, { fetchFn });
+ await started?.done;
+ await repoCheckIfDue(db, HACK, new Date(t0.getTime() + REPO_CHECK_EVERY_MS - 1), { fetchFn });
+ expect(claims).toHaveLength(1);
+ const again = await repoCheckIfDue(
+ db,
+ HACK,
+ new Date(t0.getTime() + REPO_CHECK_EVERY_MS),
+ { fetchFn },
+ );
+ await again?.done;
+ expect(claims).toHaveLength(2);
+ });
+
+ it("keeps the throttle per edition", async () => {
+ const { db, claims } = fakeDb({ claim: true });
+ await (await repoCheckIfDue(db, "one", t0, { fetchFn }))?.done;
+ await (await repoCheckIfDue(db, "two", t0, { fetchFn }))?.done;
+ expect(claims).toHaveLength(2);
+ });
+
+ it("does not wait for GitHub", async () => {
+ const { db } = fakeDb({
+ claim: true,
+ entries: [entryRow("a", "https://github.com/t/a")],
+ });
+ let release!: () => void;
+ const gate = new Promise((resolve) => (release = resolve));
+ const slow = (async () => {
+ await gate;
+ return new Response(JSON.stringify({ pushed_at: null }));
+ }) as unknown as typeof fetch;
+ const started = await repoCheckIfDue(db, HACK, t0, { fetchFn: slow });
+ expect(started).toBeDefined();
+ release();
+ await started!.done;
+ });
+
+ it("never throws", async () => {
+ vi.spyOn(console, "error").mockImplementation(() => undefined);
+ const { db } = fakeDb({ claim: true });
+ db.update = () => {
+ throw new Error("db down");
+ };
+ await expect(repoCheckIfDue(db, HACK, t0, { fetchFn })).resolves.toBeUndefined();
+ });
+});
diff --git a/packages/api/src/services/judging/repo-check.ts b/packages/api/src/services/judging/repo-check.ts
new file mode 100644
index 00000000..38229ad6
--- /dev/null
+++ b/packages/api/src/services/judging/repo-check.ts
@@ -0,0 +1,433 @@
+// Late-push check. When an organiser turns it on for an edition, each project's
+// GitHub repo is looked at and flagged if it was pushed to after the submission
+// deadline. The signal is GitHub's own `pushed_at` (any branch), never commit
+// dates, which come from the hacker's machine and can be set to anything. The
+// commits and pushes after the deadline are kept as evidence for the organiser;
+// they do not decide anything. Nothing is disqualified here: this only raises
+// flags, and a flag waits for an organiser (admin/repo-check.ts).
+//
+// There is no worker. judging.admin.overview calls repoCheckIfDue, which starts
+// a run in the background at most every 15 minutes, so a poll never waits on
+// GitHub and the next one sees the results.
+
+import { and, eq, isNull, lt, or } from "drizzle-orm";
+import {
+ hackathonProjects,
+ judgingConfigs,
+ judgingEntries,
+ judgingRepoChecks,
+} from "@query/db";
+import type { DrizzleDB } from "@query/db";
+import {
+ computeSubmissionWindow,
+ loadHackathonBaseTime,
+} from "../../routers/team/windows";
+
+export type RepoRef = { owner: string; repo: string };
+export type RepoOutcome = "ok" | "late" | "no_repo" | "unreadable" | "error";
+export type RepoEvidence = NonNullable<
+ typeof judgingRepoChecks.$inferSelect.evidence
+>;
+export type RepoCheckRow = typeof judgingRepoChecks.$inferSelect;
+
+export type RepoResult = {
+ outcome: RepoOutcome;
+ ref: RepoRef | null;
+ pushedAt: Date | null;
+ evidence: RepoEvidence | null;
+};
+
+const GITHUB_API = "https://api.github.com";
+const REQUEST_TIMEOUT_MS = 10_000;
+const MAX_IN_FLIGHT = 5;
+const EVIDENCE_LIMIT = 20;
+export const REPO_CHECK_EVERY_MS = 15 * 60 * 1000;
+const MINUTE_MS = 60 * 1000;
+
+// First path segments on github.com that are pages, not accounts.
+const NOT_AN_OWNER = new Set([
+ "about",
+ "apps",
+ "collections",
+ "enterprise",
+ "explore",
+ "features",
+ "login",
+ "marketplace",
+ "notifications",
+ "orgs",
+ "pricing",
+ "search",
+ "settings",
+ "sponsors",
+ "topics",
+ "trending",
+ "users",
+]);
+const OWNER = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/;
+const REPO = /^[A-Za-z0-9._-]{1,100}$/;
+
+/**
+ * `owner/repo` from a github.com link, or null. Anything after the repo
+ * (`/tree/main`, `/issues`, a query or hash) and a trailing `.git` are ignored;
+ * a profile, an organisation page or any other host is not a repo.
+ */
+export function parseRepoUrl(raw: string | null | undefined): RepoRef | null {
+ const text = raw?.trim();
+ if (!text) return null;
+ let url: URL;
+ try {
+ url = new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(text) ? text : `https://${text}`);
+ } catch {
+ return null;
+ }
+ if (url.protocol !== "https:" && url.protocol !== "http:") return null;
+ if (url.hostname !== "github.com" && url.hostname !== "www.github.com") {
+ return null;
+ }
+ const [owner, name] = url.pathname.split("/").filter(Boolean);
+ if (!owner || !name) return null;
+ const repo = name.replace(/\.git$/i, "");
+ if (!OWNER.test(owner) || NOT_AN_OWNER.has(owner.toLowerCase())) return null;
+ if (!REPO.test(repo) || repo === "." || repo === "..") return null;
+ return { owner, repo };
+}
+
+/** The portal project's repo link, else the first repo link among the demo links. */
+export function repoOfEntry(entry: {
+ githubUrl: string | null;
+ demoLinks: readonly string[];
+}): RepoRef | null {
+ const own = parseRepoUrl(entry.githubUrl);
+ if (own) return own;
+ for (const link of entry.demoLinks) {
+ const found = parseRepoUrl(link);
+ if (found) return found;
+ }
+ return null;
+}
+
+/** Later than the deadline plus grace. A push exactly on the line is on time. */
+export function isLate(pushedAt: Date, deadline: Date, graceMinutes: number) {
+ return pushedAt.getTime() > deadline.getTime() + graceMinutes * MINUTE_MS;
+}
+
+/** GitHub said to slow down, or the budget for this run is spent. */
+export class RateLimited extends Error {}
+
+type GithubReply = { status: number; body: unknown };
+
+/**
+ * A GitHub REST client for one run. Once the rate limit is hit or the last
+ * request used it up, every further call throws RateLimited without a request.
+ */
+export class GithubClient {
+ exhausted = false;
+
+ constructor(
+ private readonly fetchFn: typeof fetch,
+ private readonly token: string | undefined,
+ ) {}
+
+ async get(path: string): Promise {
+ if (this.exhausted) throw new RateLimited("rate limited");
+ const headers: Record = {
+ Accept: "application/vnd.github+json",
+ "User-Agent": "query-judging-repo-check",
+ "X-GitHub-Api-Version": "2022-11-28",
+ };
+ if (this.token) headers.Authorization = `Bearer ${this.token}`;
+
+ const response = await this.fetchFn(`${GITHUB_API}${path}`, {
+ headers,
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
+ });
+ const remaining = response.headers.get("x-ratelimit-remaining");
+ if (remaining === "0") this.exhausted = true;
+
+ const limited =
+ response.status === 429 ||
+ (response.status === 403 &&
+ (remaining === "0" || response.headers.has("retry-after")));
+ if (limited) {
+ this.exhausted = true;
+ throw new RateLimited("rate limited");
+ }
+ const body: unknown = await response.json().catch(() => null);
+ return { status: response.status, body };
+ }
+}
+
+type Json = Record | null;
+
+/** Commits and pushes after the deadline. Context only; a failure leaves it short. */
+async function gatherEvidence(
+ client: GithubClient,
+ ref: RepoRef,
+ deadline: Date,
+): Promise {
+ const base = `/repos/${ref.owner}/${ref.repo}`;
+ const evidence: RepoEvidence = { commits: [], pushes: [] };
+ try {
+ const commits = await client.get(
+ `${base}/commits?since=${encodeURIComponent(deadline.toISOString())}&per_page=${EVIDENCE_LIMIT}`,
+ );
+ if (commits.status === 200 && Array.isArray(commits.body)) {
+ evidence.commits = (commits.body as Json[]).slice(0, EVIDENCE_LIMIT).map(
+ (c) => ({
+ sha: String(c?.sha ?? ""),
+ message: String(c?.commit?.message ?? "").split("\n")[0]!.slice(0, 200),
+ author: c?.commit?.author?.name ?? null,
+ committedAt: c?.commit?.committer?.date ?? null,
+ url: String(c?.html_url ?? ""),
+ }),
+ );
+ }
+ } catch {
+ // Evidence is optional: pushed_at has already decided.
+ }
+ try {
+ const events = await client.get(`${base}/events?per_page=100`);
+ if (events.status === 200 && Array.isArray(events.body)) {
+ evidence.pushes = (events.body as Json[])
+ .filter(
+ (e) =>
+ e?.type === "PushEvent" &&
+ typeof e.created_at === "string" &&
+ new Date(e.created_at) > deadline,
+ )
+ .slice(0, EVIDENCE_LIMIT)
+ .map((e) => ({
+ at: e!.created_at as string,
+ ref: e!.payload?.ref ?? null,
+ pusher: e!.actor?.login ?? null,
+ }));
+ }
+ } catch {
+ // Same.
+ }
+ return evidence;
+}
+
+/**
+ * One repo against the deadline. Throws RateLimited when GitHub says to stop;
+ * any other failure to reach it is an `error`, tried again next run.
+ */
+export async function checkRepo(
+ client: GithubClient,
+ ref: RepoRef,
+ deadline: Date,
+ graceMinutes: number,
+): Promise {
+ let reply: GithubReply;
+ try {
+ reply = await client.get(`/repos/${ref.owner}/${ref.repo}`);
+ } catch (error) {
+ if (error instanceof RateLimited) throw error;
+ return { outcome: "error", ref, pushedAt: null, evidence: null };
+ }
+ if ([401, 403, 404, 410, 451].includes(reply.status)) {
+ return { outcome: "unreadable", ref, pushedAt: null, evidence: null };
+ }
+ const pushed = (reply.body as Json)?.pushed_at;
+ if (reply.status !== 200 || (pushed != null && typeof pushed !== "string")) {
+ return { outcome: "error", ref, pushedAt: null, evidence: null };
+ }
+ // An empty repo has never been pushed to.
+ const pushedAt = pushed ? new Date(pushed) : null;
+ if (!pushedAt || !isLate(pushedAt, deadline, graceMinutes)) {
+ return { outcome: "ok", ref, pushedAt, evidence: null };
+ }
+ return {
+ outcome: "late",
+ ref,
+ pushedAt,
+ evidence: await gatherEvidence(client, ref, deadline),
+ };
+}
+
+/**
+ * The row to store after a check, or null to leave what is there. An entry an
+ * organiser has decided stays decided unless the repo was pushed to after the
+ * decision, which reopens it as pending with the new evidence; a check that
+ * could not look (error, unreadable, no repo) never undoes a decision.
+ */
+export function nextCheck(
+ existing: RepoCheckRow | undefined,
+ result: RepoResult,
+ checkedAt: Date,
+): Omit | null {
+ const fresh = {
+ repoOwner: result.ref?.owner ?? null,
+ repoName: result.ref?.repo ?? null,
+ outcome: result.outcome,
+ pushedAt: result.pushedAt,
+ evidence: result.evidence,
+ checkedAt,
+ decision: "pending" as const,
+ decidedById: null,
+ decidedAt: null,
+ note: null,
+ removedEntry: existing?.removedEntry ?? false,
+ };
+ if (!existing || existing.decision === "pending") return fresh;
+ const pushedSince =
+ result.outcome === "late" &&
+ result.pushedAt !== null &&
+ existing.decidedAt !== null &&
+ result.pushedAt > existing.decidedAt;
+ return pushedSince ? fresh : null;
+}
+
+/** `fn` over `items`, at most `limit` at a time. */
+async function mapLimit(
+ items: readonly T[],
+ limit: number,
+ fn: (item: T) => Promise,
+) {
+ let next = 0;
+ const worker = async () => {
+ while (next < items.length) await fn(items[next++]!);
+ };
+ await Promise.all(
+ Array.from({ length: Math.min(limit, items.length) }, worker),
+ );
+}
+
+type RunDeps = { fetchFn?: typeof fetch; token?: string; now?: Date };
+
+/** Editions with a run going in this process, so two never overlap. */
+const running = new Set();
+
+export const repoCheckRunning = (hackathonId: string) =>
+ running.has(hackathonId);
+
+/**
+ * Checks every project of the edition, five repos at a time. When GitHub's rate
+ * limit runs out the rest are recorded as `error` (unless they already carry a
+ * decision) and the next run picks them up.
+ */
+export async function runRepoCheck(
+ db: DrizzleDB,
+ hackathonId: string,
+ deps: RunDeps = {},
+) {
+ if (running.has(hackathonId)) return;
+ running.add(hackathonId);
+ try {
+ const now = deps.now ?? new Date();
+ const [config] = await db
+ .select({ graceMinutes: judgingConfigs.repoCheckGraceMinutes })
+ .from(judgingConfigs)
+ .where(eq(judgingConfigs.hackathonId, hackathonId))
+ .limit(1);
+ if (!config) return;
+ const base = await loadHackathonBaseTime(db, hackathonId);
+ const deadline = computeSubmissionWindow(base, now).closesAt;
+
+ const [entries, existing] = await Promise.all([
+ db
+ .select({
+ id: judgingEntries.id,
+ deletedAt: judgingEntries.deletedAt,
+ demoLinks: judgingEntries.demoLinks,
+ githubUrl: hackathonProjects.githubUrl,
+ })
+ .from(judgingEntries)
+ .leftJoin(
+ hackathonProjects,
+ eq(hackathonProjects.id, judgingEntries.sourceProjectId),
+ )
+ .where(eq(judgingEntries.hackathonId, hackathonId)),
+ db
+ .select()
+ .from(judgingRepoChecks)
+ .where(eq(judgingRepoChecks.hackathonId, hackathonId)),
+ ]);
+ const before = new Map(existing.map((row) => [row.entryId, row]));
+ // A removed project is looked at only if it was disqualified here, to see
+ // whether it was pushed to again.
+ const todo = entries.filter(
+ (entry) =>
+ !entry.deletedAt || before.get(entry.id)?.decision === "disqualified",
+ );
+
+ const client = new GithubClient(
+ deps.fetchFn ?? fetch,
+ deps.token ?? (process.env.GITHUB_TOKEN || undefined),
+ );
+ await mapLimit(todo, MAX_IN_FLIGHT, async (entry) => {
+ const ref = repoOfEntry(entry);
+ let result: RepoResult;
+ if (!ref) {
+ result = { outcome: "no_repo", ref: null, pushedAt: null, evidence: null };
+ } else {
+ try {
+ result = await checkRepo(client, ref, deadline, config.graceMinutes);
+ } catch (error) {
+ if (!(error instanceof RateLimited)) throw error;
+ result = { outcome: "error", ref, pushedAt: null, evidence: null };
+ }
+ }
+ const row = nextCheck(before.get(entry.id), result, now);
+ if (!row) return;
+ await db
+ .insert(judgingRepoChecks)
+ .values({ entryId: entry.id, hackathonId, ...row })
+ .onConflictDoUpdate({ target: judgingRepoChecks.entryId, set: row });
+ });
+ } catch (error) {
+ // Deliberate operational logging: nothing awaits a background run, so this
+ // is the only record that it failed. The next run starts afresh.
+ // eslint-disable-next-line no-console
+ console.error(`[Judging] Repo check for ${hackathonId} failed:`, error);
+ } finally {
+ running.delete(hackathonId);
+ }
+}
+
+/** When each edition's run was last started in this process. */
+export const repoCheckThrottle = new Map();
+
+/**
+ * Starts a run in the background unless one was started less than 15 minutes
+ * ago, here or on another instance (the claim on the config row), the switch
+ * is off, or judging is not open. Resolves once the run is under way, with a
+ * handle to its end for tests; callers on a read do not wait for it. Never
+ * throws.
+ */
+export async function repoCheckIfDue(
+ db: DrizzleDB,
+ hackathonId: string,
+ now: Date = new Date(),
+ deps: RunDeps = {},
+): Promise<{ done: Promise } | undefined> {
+ const last = repoCheckThrottle.get(hackathonId);
+ if (last !== undefined && now.getTime() - last < REPO_CHECK_EVERY_MS) return;
+ repoCheckThrottle.set(hackathonId, now.getTime());
+ try {
+ const [claimed] = await db
+ .update(judgingConfigs)
+ .set({ repoCheckLastRunAt: now })
+ .where(
+ and(
+ eq(judgingConfigs.hackathonId, hackathonId),
+ eq(judgingConfigs.repoCheckEnabled, true),
+ eq(judgingConfigs.state, "open"),
+ or(
+ isNull(judgingConfigs.repoCheckLastRunAt),
+ lt(
+ judgingConfigs.repoCheckLastRunAt,
+ new Date(now.getTime() - REPO_CHECK_EVERY_MS),
+ ),
+ ),
+ ),
+ )
+ .returning({ hackathonId: judgingConfigs.hackathonId });
+ if (!claimed) return;
+ return { done: runRepoCheck(db, hackathonId, { ...deps, now }) };
+ } catch (error) {
+ // eslint-disable-next-line no-console
+ console.error(`[Judging] Repo check for ${hackathonId} failed:`, error);
+ }
+}
From bb07f5ae0a6b446d36ef400c398afbc015cf1a13 Mon Sep 17 00:00:00 2001
From: aamoghS
Date: Sun, 25 Oct 2026 17:30:11 -0400
Subject: [PATCH 03/11] feat(api): add judging repo check procedures
Organisers switch the check on per edition, run it on demand and confirm
or clear each flag. Disqualifying removes the project from judging the way
removeEntry does; reinstating restores it through the shared restore path.
---
.../repo-check-decisions.test.ts | 363 ++++++++++++++++++
packages/api/src/routers/judging/admin.ts | 2 +
.../api/src/routers/judging/admin/console.ts | 4 +
.../api/src/routers/judging/admin/entries.ts | 27 +-
.../src/routers/judging/admin/repo-check.ts | 288 ++++++++++++++
.../api/src/routers/judging/admin/shared.ts | 31 ++
6 files changed, 690 insertions(+), 25 deletions(-)
create mode 100644 packages/api/src/.internal-tests/repo-check-decisions.test.ts
create mode 100644 packages/api/src/routers/judging/admin/repo-check.ts
diff --git a/packages/api/src/.internal-tests/repo-check-decisions.test.ts b/packages/api/src/.internal-tests/repo-check-decisions.test.ts
new file mode 100644
index 00000000..d6225a0e
--- /dev/null
+++ b/packages/api/src/.internal-tests/repo-check-decisions.test.ts
@@ -0,0 +1,363 @@
+/**
+ * judging.admin.decideRepoCheck, reinstateRepoCheck and setRepoCheck: what an
+ * organiser's decision on a late-push flag does to the project. The schema and
+ * the router are the real ones; the database handle keeps the rows of the
+ * tables they touch. A disqualified project is removed the way removeEntry
+ * removes one (`deletedAt`), which is the column placements, results, the
+ * queue, the judge itinerary and the judge assigner already leave out.
+ */
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { PgDialect } from "drizzle-orm/pg-core";
+import {
+ auditLogs,
+ hackathonProjects,
+ hackathons,
+ judgingConfigs,
+ judgingEntries,
+ judgingRepoChecks,
+} from "@query/db";
+import { appRouter } from "../root";
+import { cache } from "../middleware/cache";
+import type * as Challenges from "../services/judging/challenges";
+import { repoCheckThrottle } from "../services/judging/repo-check";
+
+vi.mock("../services/judging/challenges", async (importActual) => ({
+ ...(await importActual()),
+ lockEdition: async () => undefined,
+}));
+
+const id = (n: number) =>
+ `00000000-0000-4000-8000-${String(n).padStart(12, "0")}`;
+const HACK = id(1);
+const OTHER = id(2);
+const [LATE, DEVPOST, FORGOTTEN, ELSEWHERE, WITHDRAWN] = [10, 11, 12, 13, 14].map(id);
+
+const dialect = new PgDialect();
+const render = (condition: unknown) => dialect.sqlToQuery(condition as never);
+
+type Row = Record;
+
+let adminRow: Row | undefined;
+let entries: Row[] = [];
+let checks: Row[] = [];
+let projects: Row[] = [];
+let config: Row = {};
+let audits: Row[] = [];
+
+const find = (rows: Row[], key: string, params: unknown[]) =>
+ rows.filter((r) => r[key] === params[0]);
+
+const fakeDb: any = {
+ query: { admins: { findFirst: async () => adminRow } },
+ select: () => {
+ let table: unknown = null;
+ let where: unknown = null;
+ const chain: any = {
+ then: (ok: any, err: any) => {
+ const { params } = render(where);
+ const rows =
+ table === judgingEntries
+ ? find(entries, "id", params)
+ : table === judgingRepoChecks
+ ? find(checks, "entryId", params)
+ : table === hackathonProjects
+ ? find(projects, "id", params)
+ : table === hackathons
+ ? [{ id: HACK, name: "Hack" }]
+ : [];
+ return Promise.resolve(rows.map((row) => ({ ...row }))).then(ok, err);
+ },
+ };
+ chain.from = (from: unknown) => {
+ table = from;
+ return chain;
+ };
+ chain.where = (condition: unknown) => {
+ where = condition;
+ return chain;
+ };
+ chain.limit = () => chain;
+ return chain;
+ },
+ insert: (table: unknown) => ({
+ values: (values: Row) => {
+ if (table === auditLogs) audits.push(values);
+ return Object.assign(Promise.resolve(undefined), {
+ onConflictDoNothing: () => ({ returning: async () => [{ ...values }] }),
+ });
+ },
+ }),
+ update: (table: unknown) => ({
+ set: (values: Row) => ({
+ where: (where: unknown) => {
+ const { params } = render(where);
+ if (table === judgingEntries) {
+ for (const row of find(entries, "id", params)) Object.assign(row, values);
+ } else if (table === judgingRepoChecks) {
+ for (const row of find(checks, "entryId", params)) {
+ Object.assign(row, values);
+ }
+ } else if (table === judgingConfigs) {
+ Object.assign(config, values);
+ }
+ return Promise.resolve(undefined);
+ },
+ }),
+ }),
+ transaction: async (callback: (tx: unknown) => unknown) => {
+ const savedEntries = entries.map((r) => ({ ...r }));
+ const savedChecks = checks.map((r) => ({ ...r }));
+ try {
+ return await callback(fakeDb);
+ } catch (error) {
+ entries = savedEntries;
+ checks = savedChecks;
+ throw error;
+ }
+ },
+};
+
+const admin = () =>
+ appRouter.createCaller({
+ db: fakeDb,
+ session: { user: { id: "admin_user" } },
+ userId: "admin_user",
+ cache,
+ clientIp: "127.0.0.1",
+ req: { headers: { get: () => null } },
+ } as any);
+
+const entry = (entryId: string, over: Row = {}): Row => ({
+ id: entryId,
+ hackathonId: HACK,
+ title: `Project ${entryId.slice(-2)}`,
+ source: "devpost",
+ sourceProjectId: null,
+ deletedAt: null,
+ deletedById: null,
+ ...over,
+});
+
+const check = (entryId: string, over: Row = {}): Row => ({
+ entryId,
+ hackathonId: HACK,
+ outcome: "late",
+ decision: "pending",
+ decidedById: null,
+ decidedAt: null,
+ note: null,
+ removedEntry: false,
+ ...over,
+});
+
+const byId = (entryId: string) => entries.find((e) => e.id === entryId)!;
+const checkOf = (entryId: string) => checks.find((c) => c.entryId === entryId)!;
+
+beforeEach(() => {
+ cache.clear();
+ repoCheckThrottle.clear();
+ adminRow = { userId: "admin_user", isActive: true, role: "admin" };
+ entries = [
+ entry(LATE),
+ entry(DEVPOST),
+ entry(FORGOTTEN, { deletedAt: new Date("2026-02-08T10:00:00Z") }),
+ entry(ELSEWHERE, { hackathonId: OTHER }),
+ entry(WITHDRAWN, {
+ source: "portal",
+ sourceProjectId: id(50),
+ deletedAt: new Date("2026-02-08T10:00:00Z"),
+ }),
+ ];
+ checks = [
+ check(LATE),
+ check(DEVPOST),
+ check(FORGOTTEN),
+ check(ELSEWHERE, { hackathonId: OTHER }),
+ check(WITHDRAWN, {
+ decision: "disqualified",
+ removedEntry: true,
+ }),
+ ];
+ projects = [{ id: id(50), status: "draft", withdrawnByAdminAt: null }];
+ config = {};
+ audits = [];
+});
+
+describe("judging.admin.decideRepoCheck", () => {
+ it("disqualifying removes the project from judging and records who and why", async () => {
+ await admin().judging.admin.decideRepoCheck({
+ hackathonId: HACK,
+ entryId: LATE,
+ decision: "disqualified",
+ note: " pushed 40 minutes after the deadline ",
+ });
+
+ expect(byId(LATE).deletedAt).toBeInstanceOf(Date);
+ expect(byId(LATE).deletedById).toBe("admin_user");
+ expect(checkOf(LATE)).toMatchObject({
+ decision: "disqualified",
+ decidedById: "admin_user",
+ note: "pushed 40 minutes after the deadline",
+ removedEntry: true,
+ });
+ expect(audits).toHaveLength(1);
+ expect(audits[0]).toMatchObject({
+ action: "judging.repoCheck.disqualify",
+ resourceId: LATE,
+ severity: "warn",
+ });
+ });
+
+ it("clearing only closes the flag", async () => {
+ await admin().judging.admin.decideRepoCheck({
+ hackathonId: HACK,
+ entryId: DEVPOST,
+ decision: "cleared",
+ });
+ expect(byId(DEVPOST).deletedAt).toBeNull();
+ expect(checkOf(DEVPOST)).toMatchObject({
+ decision: "cleared",
+ removedEntry: false,
+ note: null,
+ });
+ expect(audits[0]).toMatchObject({ action: "judging.repoCheck.clear" });
+ });
+
+ it("does not claim a project an organiser had already removed", async () => {
+ await admin().judging.admin.decideRepoCheck({
+ hackathonId: HACK,
+ entryId: FORGOTTEN,
+ decision: "disqualified",
+ });
+ expect(byId(FORGOTTEN).deletedById).toBeNull();
+ expect(checkOf(FORGOTTEN)).toMatchObject({
+ decision: "disqualified",
+ removedEntry: false,
+ });
+ });
+
+ it("refuses a project of another edition and one with no check", async () => {
+ await expect(
+ admin().judging.admin.decideRepoCheck({
+ hackathonId: HACK,
+ entryId: ELSEWHERE,
+ decision: "disqualified",
+ }),
+ ).rejects.toMatchObject({ code: "NOT_FOUND" });
+ checks = [];
+ await expect(
+ admin().judging.admin.decideRepoCheck({
+ hackathonId: HACK,
+ entryId: LATE,
+ decision: "disqualified",
+ }),
+ ).rejects.toMatchObject({ code: "NOT_FOUND" });
+ expect(byId(LATE).deletedAt).toBeNull();
+ expect(audits).toHaveLength(0);
+ });
+
+ it("refuses to decide twice on a disqualified project, or to clear one that was removed", async () => {
+ await expect(
+ admin().judging.admin.decideRepoCheck({
+ hackathonId: HACK,
+ entryId: WITHDRAWN,
+ decision: "cleared",
+ }),
+ ).rejects.toMatchObject({ code: "PRECONDITION_FAILED" });
+
+ checkOf(DEVPOST).removedEntry = true;
+ await expect(
+ admin().judging.admin.decideRepoCheck({
+ hackathonId: HACK,
+ entryId: DEVPOST,
+ decision: "cleared",
+ }),
+ ).rejects.toMatchObject({
+ message: expect.stringContaining("Reinstate"),
+ });
+ expect(audits).toHaveLength(0);
+ });
+
+ it("is for admins only", async () => {
+ adminRow = undefined;
+ await expect(
+ admin().judging.admin.decideRepoCheck({
+ hackathonId: HACK,
+ entryId: LATE,
+ decision: "disqualified",
+ }),
+ ).rejects.toMatchObject({ code: "FORBIDDEN" });
+ expect(byId(LATE).deletedAt).toBeNull();
+ });
+});
+
+describe("judging.admin.reinstateRepoCheck", () => {
+ it("brings back a project the disqualification removed", async () => {
+ await admin().judging.admin.decideRepoCheck({
+ hackathonId: HACK,
+ entryId: LATE,
+ decision: "disqualified",
+ });
+ audits = [];
+ await admin().judging.admin.reinstateRepoCheck({
+ hackathonId: HACK,
+ entryId: LATE,
+ note: "team showed the push was a README fix",
+ });
+
+ expect(byId(LATE).deletedAt).toBeNull();
+ expect(checkOf(LATE)).toMatchObject({
+ decision: "cleared",
+ removedEntry: false,
+ note: "team showed the push was a README fix",
+ });
+ expect(audits[0]).toMatchObject({ action: "judging.repoCheck.reinstate" });
+ });
+
+ it("keeps the restore refusal for a withdrawn portal submission", async () => {
+ await expect(
+ admin().judging.admin.reinstateRepoCheck({
+ hackathonId: HACK,
+ entryId: WITHDRAWN,
+ }),
+ ).rejects.toMatchObject({ code: "PRECONDITION_FAILED" });
+ expect(byId(WITHDRAWN).deletedAt).toBeInstanceOf(Date);
+ expect(checkOf(WITHDRAWN).decision).toBe("disqualified");
+ });
+
+ it("refuses a project that is not disqualified", async () => {
+ await expect(
+ admin().judging.admin.reinstateRepoCheck({
+ hackathonId: HACK,
+ entryId: DEVPOST,
+ }),
+ ).rejects.toMatchObject({ code: "PRECONDITION_FAILED" });
+ });
+});
+
+describe("judging.admin.setRepoCheck", () => {
+ it("switches the check on and makes the next poll run it", async () => {
+ repoCheckThrottle.set(HACK, Date.now());
+ await admin().judging.admin.setRepoCheck({
+ hackathonId: HACK,
+ enabled: true,
+ graceMinutes: 15,
+ });
+ expect(config).toMatchObject({
+ repoCheckEnabled: true,
+ repoCheckGraceMinutes: 15,
+ repoCheckLastRunAt: null,
+ });
+ expect(repoCheckThrottle.has(HACK)).toBe(false);
+ expect(audits[0]).toMatchObject({ action: "judging.repoCheck.configure" });
+ });
+
+ it("refuses an empty change and a grace past a day", async () => {
+ await expect(
+ admin().judging.admin.setRepoCheck({ hackathonId: HACK }),
+ ).rejects.toThrow();
+ await expect(
+ admin().judging.admin.setRepoCheck({ hackathonId: HACK, graceMinutes: 1441 }),
+ ).rejects.toThrow();
+ });
+});
diff --git a/packages/api/src/routers/judging/admin.ts b/packages/api/src/routers/judging/admin.ts
index 43245940..f939a482 100644
--- a/packages/api/src/routers/judging/admin.ts
+++ b/packages/api/src/routers/judging/admin.ts
@@ -7,6 +7,7 @@ import { roomLinkProcedures } from "./admin/room-links";
import { rubricAndStateProcedures } from "./admin/rubric-and-state";
import { announcementProcedures } from "./admin/announcements";
import { resetProcedures } from "./admin/resets";
+import { repoCheckProcedures } from "./admin/repo-check";
// Organiser setup and live control of room judging: getting projects in,
// shaping challenges, rooms and the rubric, the switches flipped on judging
@@ -23,4 +24,5 @@ export const judgingAdminRouter = createTRPCRouter({
...rubricAndStateProcedures,
...announcementProcedures,
...resetProcedures,
+ ...repoCheckProcedures,
});
diff --git a/packages/api/src/routers/judging/admin/console.ts b/packages/api/src/routers/judging/admin/console.ts
index 95bfe85e..58395720 100644
--- a/packages/api/src/routers/judging/admin/console.ts
+++ b/packages/api/src/routers/judging/admin/console.ts
@@ -41,6 +41,7 @@ import {
} from "../../../services/judging/checklist";
import { holdsEveryRating } from "../../../services/judging/scoring-sql";
import { openCheckInFlagCount } from "../../../services/check-in-flags";
+import { repoCheckIfDue } from "../../../services/judging/repo-check";
import { hackathonInput, SEATED_WINDOW_MS } from "./shared";
export const consoleProcedures = {
@@ -50,6 +51,9 @@ export const consoleProcedures = {
const id = input.hackathonId;
const hackathon = await assertHackathonExists(db, id);
const config = await ensureJudgingConfig(db, id);
+ // The console polls this; it is where the late-push check is started from.
+ // Not awaited: GitHub never holds up the poll.
+ void repoCheckIfDue(db, id);
const seatedSince = new Date(Date.now() - SEATED_WINDOW_MS);
const [
diff --git a/packages/api/src/routers/judging/admin/entries.ts b/packages/api/src/routers/judging/admin/entries.ts
index 70091d3f..06ec49a1 100644
--- a/packages/api/src/routers/judging/admin/entries.ts
+++ b/packages/api/src/routers/judging/admin/entries.ts
@@ -17,7 +17,6 @@ import {
} from "drizzle-orm";
import {
hackathonCheckInFlags,
- hackathonProjects,
judgingAppointments,
judgingChallenges,
judgingEntries,
@@ -46,6 +45,7 @@ import {
evictResults,
entriesWithOpenFlags,
lockEntry,
+ restoreLockedEntry,
} from "./shared";
export const entryProcedures = {
@@ -430,30 +430,7 @@ export const entryProcedures = {
const entry = await db.transaction(async (tx) => {
const entry = await lockEntry(tx, input.entryId);
if (!entry.deletedAt) return null;
-
- if (entry.source === "portal" && entry.sourceProjectId) {
- const [project] = await tx
- .select({
- status: hackathonProjects.status,
- withdrawnByAdminAt: hackathonProjects.withdrawnByAdminAt,
- })
- .from(hackathonProjects)
- .where(eq(hackathonProjects.id, entry.sourceProjectId))
- .limit(1);
- if (
- project &&
- (project.withdrawnByAdminAt || project.status === "draft")
- ) {
- throw refuse(
- "This team's portal submission was withdrawn, so it cannot be judged. Ask the team to submit it again.",
- );
- }
- }
-
- await tx
- .update(judgingEntries)
- .set({ deletedAt: null, deletedById: null, updatedAt: new Date() })
- .where(eq(judgingEntries.id, entry.id));
+ await restoreLockedEntry(tx, entry);
return entry;
});
if (!entry) return { restored: false };
diff --git a/packages/api/src/routers/judging/admin/repo-check.ts b/packages/api/src/routers/judging/admin/repo-check.ts
new file mode 100644
index 00000000..53ccb96a
--- /dev/null
+++ b/packages/api/src/routers/judging/admin/repo-check.ts
@@ -0,0 +1,288 @@
+// The late-push check: its switch, the flags it raised and what an organiser
+// decides about them. Flags come from services/judging/repo-check.ts; nothing
+// here is shown to hackers or on public results.
+
+import { z } from "zod";
+import { and, asc, eq } from "drizzle-orm";
+import { judgingConfigs, judgingEntries, judgingRepoChecks } from "@query/db";
+import type { DrizzleDB } from "@query/db";
+import { isAdmin } from "../../../middleware/procedures";
+import { recordAdminAction } from "../../../middleware/audit";
+import {
+ assertHackathonExists,
+ ensureJudgingConfig,
+} from "../../../services/judging/config";
+import {
+ repoCheckRunning,
+ repoCheckThrottle,
+ runRepoCheck,
+} from "../../../services/judging/repo-check";
+import {
+ computeSubmissionWindow,
+ loadHackathonBaseTime,
+} from "../../team/windows";
+import {
+ evictResults,
+ hackathonInput,
+ lockEntry,
+ notFound,
+ refuse,
+ restoreLockedEntry,
+ uuid,
+} from "./shared";
+
+const note = z.string().trim().max(500).optional();
+
+/** What a decision about one entry's repo check needs, under the edition lock. */
+async function lockCheck(
+ tx: Parameters[0]>[0],
+ hackathonId: string,
+ entryId: string,
+) {
+ const entry = await lockEntry(tx, entryId);
+ if (entry.hackathonId !== hackathonId) {
+ throw notFound("That project is gone. Refresh the list.");
+ }
+ const [check] = await tx
+ .select()
+ .from(judgingRepoChecks)
+ .where(eq(judgingRepoChecks.entryId, entryId))
+ .limit(1);
+ if (!check) throw notFound("That project has not been checked yet.");
+ return { entry, check };
+}
+
+export const repoCheckProcedures = {
+ /** The switch, the grace, the deadline and every project the check could not clear. */
+ getRepoCheck: isAdmin.input(hackathonInput).query(async ({ ctx, input }) => {
+ const db = ctx.db as DrizzleDB;
+ await assertHackathonExists(db, input.hackathonId);
+ const config = await ensureJudgingConfig(db, input.hackathonId);
+ const base = await loadHackathonBaseTime(db, input.hackathonId);
+
+ const checks = await db
+ .select({
+ entryId: judgingRepoChecks.entryId,
+ title: judgingEntries.title,
+ removed: judgingEntries.deletedAt,
+ repoOwner: judgingRepoChecks.repoOwner,
+ repoName: judgingRepoChecks.repoName,
+ outcome: judgingRepoChecks.outcome,
+ pushedAt: judgingRepoChecks.pushedAt,
+ evidence: judgingRepoChecks.evidence,
+ checkedAt: judgingRepoChecks.checkedAt,
+ decision: judgingRepoChecks.decision,
+ decidedAt: judgingRepoChecks.decidedAt,
+ note: judgingRepoChecks.note,
+ removedEntry: judgingRepoChecks.removedEntry,
+ })
+ .from(judgingRepoChecks)
+ .innerJoin(
+ judgingEntries,
+ eq(judgingEntries.id, judgingRepoChecks.entryId),
+ )
+ .where(eq(judgingRepoChecks.hackathonId, input.hackathonId))
+ .orderBy(asc(judgingEntries.title), asc(judgingEntries.id));
+
+ const flagged = checks
+ .filter((row) => row.outcome !== "ok")
+ .sort(
+ (a, b) =>
+ Number(a.decision !== "pending") - Number(b.decision !== "pending"),
+ );
+ return {
+ enabled: config.repoCheckEnabled,
+ graceMinutes: config.repoCheckGraceMinutes,
+ lastRunAt: config.repoCheckLastRunAt,
+ running: repoCheckRunning(input.hackathonId),
+ judgingOpen: config.state === "open",
+ // Unauthenticated GitHub allows 60 requests an hour: one small edition.
+ hasToken: !!process.env.GITHUB_TOKEN,
+ deadline: computeSubmissionWindow(base, new Date()).closesAt,
+ checked: checks.length,
+ flagged,
+ };
+ }),
+
+ /** Turns the check on or off, or sets the grace. Either makes the next poll run it. */
+ setRepoCheck: isAdmin
+ .input(
+ hackathonInput
+ .extend({
+ enabled: z.boolean().optional(),
+ graceMinutes: z.number().int().min(0).max(1440).optional(),
+ })
+ .refine((v) => v.enabled !== undefined || v.graceMinutes !== undefined, {
+ message: "Say what to change.",
+ }),
+ )
+ .mutation(async ({ ctx, input }) => {
+ const db = ctx.db as DrizzleDB;
+ await assertHackathonExists(db, input.hackathonId);
+ await ensureJudgingConfig(db, input.hackathonId);
+ await db
+ .update(judgingConfigs)
+ .set({
+ ...(input.enabled !== undefined && {
+ repoCheckEnabled: input.enabled,
+ }),
+ ...(input.graceMinutes !== undefined && {
+ repoCheckGraceMinutes: input.graceMinutes,
+ }),
+ // Due at once; the throttle here would otherwise hold a new setting
+ // back for up to 15 minutes.
+ repoCheckLastRunAt: null,
+ updatedAt: new Date(),
+ })
+ .where(eq(judgingConfigs.hackathonId, input.hackathonId));
+ repoCheckThrottle.delete(input.hackathonId);
+ await recordAdminAction(db, {
+ userId: ctx.userId,
+ action: "judging.repoCheck.configure",
+ resourceId: input.hackathonId,
+ metadata: {
+ enabled: input.enabled,
+ graceMinutes: input.graceMinutes,
+ },
+ });
+ return { ok: true };
+ }),
+
+ /** Starts a run now and returns; the next read shows what it found. */
+ runRepoCheckNow: isAdmin.input(hackathonInput).mutation(async ({ ctx, input }) => {
+ const db = ctx.db as DrizzleDB;
+ await assertHackathonExists(db, input.hackathonId);
+ await ensureJudgingConfig(db, input.hackathonId);
+ if (repoCheckRunning(input.hackathonId)) return { started: false };
+ const now = new Date();
+ await db
+ .update(judgingConfigs)
+ .set({ repoCheckLastRunAt: now })
+ .where(eq(judgingConfigs.hackathonId, input.hackathonId));
+ repoCheckThrottle.set(input.hackathonId, now.getTime());
+ void runRepoCheck(db, input.hackathonId, { now });
+ await recordAdminAction(db, {
+ userId: ctx.userId,
+ action: "judging.repoCheck.run",
+ resourceId: input.hackathonId,
+ });
+ return { started: true };
+ }),
+
+ /**
+ * Confirms or clears a flag. Disqualifying takes the project out of judging
+ * the way removeEntry does (its not-yet-started presentations stop counting
+ * and it leaves results and new assignments); clearing only closes the flag.
+ */
+ decideRepoCheck: isAdmin
+ .input(
+ hackathonInput.extend({
+ entryId: uuid,
+ decision: z.enum(["disqualified", "cleared"]),
+ note,
+ }),
+ )
+ .mutation(async ({ ctx, input }) => {
+ const db = ctx.db as DrizzleDB;
+ const { entry, removed } = await db.transaction(async (tx) => {
+ const { entry, check } = await lockCheck(
+ tx,
+ input.hackathonId,
+ input.entryId,
+ );
+ if (check.decision === "disqualified") {
+ throw refuse(
+ `${entry.title} is already disqualified. Reinstate it to change that.`,
+ );
+ }
+ if (input.decision === "cleared" && check.removedEntry) {
+ throw refuse(
+ `${entry.title} was taken out of judging by an earlier disqualification. Reinstate it instead.`,
+ );
+ }
+
+ const now = new Date();
+ const removed = input.decision === "disqualified" && !entry.deletedAt;
+ if (removed) {
+ await tx
+ .update(judgingEntries)
+ .set({ deletedAt: now, deletedById: ctx.userId, updatedAt: now })
+ .where(eq(judgingEntries.id, entry.id));
+ }
+ await tx
+ .update(judgingRepoChecks)
+ .set({
+ decision: input.decision,
+ decidedById: ctx.userId,
+ decidedAt: now,
+ note: input.note || null,
+ removedEntry: check.removedEntry || removed,
+ })
+ .where(eq(judgingRepoChecks.entryId, entry.id));
+ return { entry, removed };
+ });
+
+ if (removed) evictResults(ctx.cache, entry.hackathonId);
+ await recordAdminAction(db, {
+ userId: ctx.userId,
+ action:
+ input.decision === "disqualified"
+ ? "judging.repoCheck.disqualify"
+ : "judging.repoCheck.clear",
+ resourceId: entry.id,
+ severity: input.decision === "disqualified" ? "warn" : "info",
+ metadata: {
+ hackathonId: entry.hackathonId,
+ removed,
+ note: input.note || null,
+ },
+ });
+ return { decision: input.decision };
+ }),
+
+ /** Undoes a disqualification: the project comes back (restoreEntry) and the flag is cleared. */
+ reinstateRepoCheck: isAdmin
+ .input(hackathonInput.extend({ entryId: uuid, note }))
+ .mutation(async ({ ctx, input }) => {
+ const db = ctx.db as DrizzleDB;
+ const entry = await db.transaction(async (tx) => {
+ const { entry, check } = await lockCheck(
+ tx,
+ input.hackathonId,
+ input.entryId,
+ );
+ if (check.decision !== "disqualified" && !check.removedEntry) {
+ throw refuse(`${entry.title} is not disqualified.`);
+ }
+ if (check.removedEntry && entry.deletedAt) {
+ await restoreLockedEntry(tx, entry);
+ }
+ await tx
+ .update(judgingRepoChecks)
+ .set({
+ decision: "cleared",
+ decidedById: ctx.userId,
+ decidedAt: new Date(),
+ note: input.note || null,
+ removedEntry: false,
+ })
+ .where(
+ and(
+ eq(judgingRepoChecks.entryId, entry.id),
+ eq(judgingRepoChecks.hackathonId, input.hackathonId),
+ ),
+ );
+ return entry;
+ });
+
+ evictResults(ctx.cache, entry.hackathonId);
+ await recordAdminAction(db, {
+ userId: ctx.userId,
+ action: "judging.repoCheck.reinstate",
+ resourceId: entry.id,
+ severity: "warn",
+ metadata: { hackathonId: entry.hackathonId, note: input.note || null },
+ });
+ return { decision: "cleared" as const };
+ }),
+};
diff --git a/packages/api/src/routers/judging/admin/shared.ts b/packages/api/src/routers/judging/admin/shared.ts
index 115711cf..2dc638e2 100644
--- a/packages/api/src/routers/judging/admin/shared.ts
+++ b/packages/api/src/routers/judging/admin/shared.ts
@@ -4,6 +4,7 @@ import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { and, eq, isNull } from "drizzle-orm";
import {
+ hackathonProjects,
judgingAnnouncements,
judgingBuildings,
judgingChallenges,
@@ -115,6 +116,36 @@ export async function lockEntry(tx: JudgingDb, entryId: string) {
return entry;
}
+/**
+ * Puts a removed project back into judging. Refused for a portal submission its
+ * team's withdrawal or an organiser's pull took out of the gallery.
+ */
+export async function restoreLockedEntry(
+ tx: JudgingDb,
+ entry: Awaited>,
+) {
+ if (entry.source === "portal" && entry.sourceProjectId) {
+ const [project] = await tx
+ .select({
+ status: hackathonProjects.status,
+ withdrawnByAdminAt: hackathonProjects.withdrawnByAdminAt,
+ })
+ .from(hackathonProjects)
+ .where(eq(hackathonProjects.id, entry.sourceProjectId))
+ .limit(1);
+ if (project && (project.withdrawnByAdminAt || project.status === "draft")) {
+ throw refuse(
+ "This team's portal submission was withdrawn, so it cannot be judged. Ask the team to submit it again.",
+ );
+ }
+ }
+
+ await tx
+ .update(judgingEntries)
+ .set({ deletedAt: null, deletedById: null, updatedAt: new Date() })
+ .where(eq(judgingEntries.id, entry.id));
+}
+
export async function lockChallenge(tx: JudgingDb, challengeId: string) {
const [scope] = await tx
.select({ hackathonId: judgingChallenges.hackathonId })
From 27aec239740f5baa200a9a47fe31a51618ec8076 Mon Sep 17 00:00:00 2001
From: aamoghS
Date: Sun, 25 Oct 2026 17:30:21 -0400
Subject: [PATCH 04/11] refactor(scripts): move repo scripts into a typed
@query/scripts package
Scripts now live in tooling/scripts as TypeScript and run on Node's built-in type stripping, with no build step and no install needed for the uptime probe. Invocations, the root test script and docs are updated.
---
.env.example | 2 +-
.github/workflows/uptime.yml | 2 +-
docs/features/status.md | 8 +--
docs/getting-started.md | 2 +-
docs/operations/testing.md | 2 +-
monitoring/README.md | 6 +-
package.json | 8 +--
packages/db/package.json | 6 +-
packages/db/scripts/seed-local.ts | 2 +-
pnpm-lock.yaml | 18 +++++
sites/mainweb/lib/uptime.ts | 5 +-
sites/mainweb/package.json | 2 +-
tooling/scripts/eslint.config.mjs | 16 +++++
tooling/scripts/package.json | 17 +++++
.../scripts/src/clickup-task.ts | 40 ++++++++---
.../scripts/src/guard-local.test.ts | 2 +-
.../scripts/src/guard-local.ts | 26 ++++---
.../local.mjs => tooling/scripts/src/local.ts | 8 +--
.../scripts/src/uptime-probe.test.ts | 8 ++-
.../scripts/src/uptime-probe.ts | 68 +++++++++++++++----
tooling/scripts/tsconfig.json | 18 +++++
21 files changed, 208 insertions(+), 58 deletions(-)
create mode 100644 tooling/scripts/eslint.config.mjs
create mode 100644 tooling/scripts/package.json
rename scripts/clickup-task.mjs => tooling/scripts/src/clickup-task.ts (69%)
rename scripts/guard-local.test.mjs => tooling/scripts/src/guard-local.test.ts (97%)
rename scripts/guard-local.mjs => tooling/scripts/src/guard-local.ts (87%)
rename scripts/local.mjs => tooling/scripts/src/local.ts (92%)
rename scripts/uptime-probe.test.mjs => tooling/scripts/src/uptime-probe.test.ts (90%)
rename scripts/uptime-probe.mjs => tooling/scripts/src/uptime-probe.ts (72%)
create mode 100644 tooling/scripts/tsconfig.json
diff --git a/.env.example b/.env.example
index 6d4a3a74..a853c756 100644
--- a/.env.example
+++ b/.env.example
@@ -2,7 +2,7 @@
# sites/mainweb/.env.local, which is where Next.js looks. Never commit either.
#
# Every default below points at the docker-compose.local.yml services, so a
-# fresh checkout touches nothing outside this machine. scripts/guard-local.mjs
+# fresh checkout touches nothing outside this machine. tooling/scripts/src/guard-local.ts
# refuses to migrate, seed or test against a non-local DATABASE_URL, and refuses
# Stripe live keys outside production.
diff --git a/.github/workflows/uptime.yml b/.github/workflows/uptime.yml
index 596807bd..0d90b64b 100644
--- a/.github/workflows/uptime.yml
+++ b/.github/workflows/uptime.yml
@@ -63,7 +63,7 @@ jobs:
- name: Probe
env:
UPTIME_FILE: data/uptime.json
- run: node scripts/uptime-probe.mjs
+ run: node tooling/scripts/src/uptime-probe.ts
# Always one commit: a fresh orphan, force-pushed over the old one.
- name: Publish
diff --git a/docs/features/status.md b/docs/features/status.md
index ed2eab42..ff26a244 100644
--- a/docs/features/status.md
+++ b/docs/features/status.md
@@ -25,7 +25,7 @@ Consequences, stated plainly:
The sampled numbers have a blind spot: no traffic means no data, and the app cannot see itself being unreachable. A GitHub Actions workflow, `.github/workflows/uptime.yml`, covers that from outside. The repo is public, so the minutes are free.
-- Every 5 minutes (`cron: "*/5 * * * *"`, also `workflow_dispatch`) it runs `scripts/uptime-probe.mjs`: plain Node, no install step. The script requests `https://datasciencegt.org/api/ping` up to 3 times with a 10 second timeout each. A non-2xx answer or a timeout is a failed attempt. It records the status code and latency of the first success, or of the last failure (`code` 0 means no answer).
+- Every 5 minutes (`cron: "*/5 * * * *"`, also `workflow_dispatch`) it runs `tooling/scripts/src/uptime-probe.ts`: plain Node, no install step. The script requests `https://datasciencegt.org/api/ping` up to 3 times with a 10 second timeout each. A non-2xx answer or a timeout is a failed attempt. It records the status code and latency of the first success, or of the last failure (`code` 0 means no answer).
- `/api/ping` (`app/api/ping/route.ts`) returns `{ ok: true }` with `cache-control: no-store` and touches nothing else. It deliberately never reaches the database: Neon bills compute while awake and suspends after about 5 idle minutes, so a probe that ran a query would keep it awake 24 hours a day. It is not a tRPC route, so it does not count as traffic in `status_sample`, and `proxy.ts` only sets headers on it.
- The result goes to `uptime.json` on the `status-data` branch. Every run builds a new orphan commit and force-pushes it, so the branch is always exactly one commit and the repository does not grow. The workflow uses the `github-actions[bot]` identity and only needs `contents: write`.
- The same run records the latest `main` commit (SHA, commit time) and the conclusion of its `App Hosting - Rollout` check run as `deploy`, using the workflow token. The site never calls the GitHub API; a failed lookup keeps the previous value.
@@ -40,7 +40,7 @@ The sampled numbers have a blind spot: no traffic means no data, and the app can
| `days` | Up to 90 UTC days of probes older than 24 hours: `{ day, probes, up, worstMs }` |
| `deploy` | `{ sha, committedAt, rollout }` for the newest `main` commit |
-Rolling `recent` into `days` is the pure function `applyProbe` in the script, tested in `scripts/uptime-probe.test.mjs`. The read side (windows, stale and down rules) is `sites/mainweb/lib/uptime.ts`, tested in `lib/uptime.test.ts`.
+Rolling `recent` into `days` is the pure function `applyProbe` in the script, tested in `tooling/scripts/src/uptime-probe.test.ts`. The read side (windows, stale and down rules) is `sites/mainweb/lib/uptime.ts`, tested in `lib/uptime.test.ts`.
How the headline uses it:
@@ -192,7 +192,7 @@ See [Admin tools](./admin.md).
| Timing middleware | `packages/api/src/trpc.ts` (`recordDuration`) |
| Endpoint | `sites/mainweb/app/(portal)/api/metrics/route.ts` |
| Status page | `sites/mainweb/app/status/` (`page.tsx`, `load.ts`, `uptime.ts`, `AutoRefresh.tsx`), rules in `sites/mainweb/lib/status.ts` and `lib/uptime.ts` |
-| External probe | `.github/workflows/uptime.yml`, `scripts/uptime-probe.mjs`, `sites/mainweb/app/api/ping/route.ts` |
+| External probe | `.github/workflows/uptime.yml`, `tooling/scripts/src/uptime-probe.ts`, `sites/mainweb/app/api/ping/route.ts` |
| Status samples | `packages/api/src/services/status-samples.ts`, table `packages/db/src/schemas/status.ts` |
| Stack and rules | `monitoring/` (`prometheus.yml`, `rules/payments.yml`, `rules/hackathon.yml`, `grafana/dashboards/dsgt-portal.json`, `README.md`) |
-| Tests | `services/latency.test.ts`, `services/hackathon-metrics.test.ts`, `services/status-samples.test.ts`, `lib/status.test.ts`, `lib/uptime.test.ts`, `app/status/load.test.ts`, `app/status/uptime.test.ts`, `scripts/uptime-probe.test.mjs` |
+| Tests | `services/latency.test.ts`, `services/hackathon-metrics.test.ts`, `services/status-samples.test.ts`, `lib/status.test.ts`, `lib/uptime.test.ts`, `app/status/load.test.ts`, `app/status/uptime.test.ts`, `tooling/scripts/src/uptime-probe.test.ts` |
diff --git a/docs/getting-started.md b/docs/getting-started.md
index d2ad9fb3..959c0186 100644
--- a/docs/getting-started.md
+++ b/docs/getting-started.md
@@ -39,7 +39,7 @@ pnpm dev
**Payments.** `STRIPE_MOCK_MODE=true` (the default in `.env.example`) records a paid membership with no network call. To try real Checkout, switch it off and set **test-mode** keys (`sk_test_`, `pk_test_`); see [Stripe test cards](https://docs.stripe.com/testing), e.g. `4242 4242 4242 4242`.
-**Guardrails.** `scripts/guard-local.mjs` runs before `migrate:push`, `db:seed`, `db:seed:club-projects`, `pnpm test`, `pnpm local:up` and the mainweb `dev` script. It refuses a `DATABASE_URL` that is not `localhost`, `127.0.0.1`, `::1`, `db` or `postgres`, and an SMTP host that is not local, unless `ALLOW_NON_LOCAL_SERVICES=1` is set. It refuses Stripe live keys (`sk_live_`, `rk_live_`, `pk_live_`) whenever `NODE_ENV` is not `production`, with no override. An unset `DATABASE_URL` passes, which is how CI runs. Deploys call `drizzle-kit push` directly and are unaffected. The resume and bootcamp buckets are unset locally, so uploads return 503 rather than touching Cloud Storage.
+**Guardrails.** `tooling/scripts/src/guard-local.ts` runs before `migrate:push`, `db:seed`, `db:seed:club-projects`, `pnpm test`, `pnpm local:up` and the mainweb `dev` script. It refuses a `DATABASE_URL` that is not `localhost`, `127.0.0.1`, `::1`, `db` or `postgres`, and an SMTP host that is not local, unless `ALLOW_NON_LOCAL_SERVICES=1` is set. It refuses Stripe live keys (`sk_live_`, `rk_live_`, `pk_live_`) whenever `NODE_ENV` is not `production`, with no override. An unset `DATABASE_URL` passes, which is how CI runs. Deploys call `drizzle-kit push` directly and are unaffected. The resume and bootcamp buckets are unset locally, so uploads return 503 rather than touching Cloud Storage.
## Local database
diff --git a/docs/operations/testing.md b/docs/operations/testing.md
index c45f61ed..6dab0182 100644
--- a/docs/operations/testing.md
+++ b/docs/operations/testing.md
@@ -12,7 +12,7 @@ That is:
vitest run packages/api packages/db sites/mainweb/lib
```
-`pnpm test` first runs `scripts/guard-local.mjs`, which refuses a non-local `DATABASE_URL` or a Stripe live key. The suites mock the database, so CI sets neither and passes.
+`pnpm test` first runs `tooling/scripts/src/guard-local.ts`, which refuses a non-local `DATABASE_URL` or a Stripe live key. The suites mock the database, so CI sets neither and passes.
CI: the `verify` job in `.github/workflows/ci.yml` on pushes to `main`/`dev` and on pull requests.
diff --git a/monitoring/README.md b/monitoring/README.md
index 46503c91..7117d745 100644
--- a/monitoring/README.md
+++ b/monitoring/README.md
@@ -95,7 +95,7 @@ The alerts that matter:
have landed for 30 minutes.
To route them into ClickUp, run Alertmanager with a webhook receiver and pipe
-its payload to `node scripts/clickup-task.mjs --stdin`. That step is not built
+its payload to `node tooling/scripts/src/clickup-task.ts --stdin`. That step is not built
here.
## ClickHouse
@@ -119,11 +119,11 @@ SELECT * FROM dsgt.plan_mix_monthly;
## ClickUp
-`scripts/clickup-task.mjs` files a task from the command line:
+`tooling/scripts/src/clickup-task.ts` files a task from the command line:
```bash
CLICKUP_TOKEN=pk_… CLICKUP_LIST_ID=901… \
- node scripts/clickup-task.mjs "Bootcamp schedule still unset" "Room, time and Deepnote URL are null"
+ node tooling/scripts/src/clickup-task.ts "Bootcamp schedule still unset" "Room, time and Deepnote URL are null"
```
Deliberately a script and not a route on the site: an inbound endpoint that
diff --git a/package.json b/package.json
index 3354c77e..320c18bb 100644
--- a/package.json
+++ b/package.json
@@ -12,10 +12,10 @@
"lint": "turbo run lint",
"format": "prettier --write .",
"typecheck": "turbo run typecheck",
- "test": "node scripts/guard-local.mjs && vitest run scripts packages/api packages/auth packages/db sites/mainweb/lib sites/mainweb/components",
- "local:up": "node scripts/local.mjs up",
- "local:down": "node scripts/local.mjs down",
- "local:reset": "node scripts/local.mjs reset"
+ "test": "node tooling/scripts/src/guard-local.ts && vitest run tooling/scripts packages/api packages/auth packages/db sites/mainweb/lib sites/mainweb/components",
+ "local:up": "node tooling/scripts/src/local.ts up",
+ "local:down": "node tooling/scripts/src/local.ts down",
+ "local:reset": "node tooling/scripts/src/local.ts reset"
},
"dependencies": {
"next": "16.4.3",
diff --git a/packages/db/package.json b/packages/db/package.json
index 545d7e93..b8757b65 100644
--- a/packages/db/package.json
+++ b/packages/db/package.json
@@ -9,12 +9,12 @@
"./services/membership": "./src/services/membership.ts"
},
"scripts": {
- "migrate:push": "node ../../scripts/guard-local.mjs && drizzle-kit push",
+ "migrate:push": "node ../../tooling/scripts/src/guard-local.ts && drizzle-kit push",
"db:check": "tsx scripts/check-drift.mts",
"migrate:generate": "drizzle-kit generate",
"studio": "drizzle-kit studio",
- "db:seed": "node ../../scripts/guard-local.mjs && tsx scripts/seed-local.ts",
- "db:seed:club-projects": "node ../../scripts/guard-local.mjs && tsx scripts/seed-club-projects.ts",
+ "db:seed": "node ../../tooling/scripts/src/guard-local.ts && tsx scripts/seed-local.ts",
+ "db:seed:club-projects": "node ../../tooling/scripts/src/guard-local.ts && tsx scripts/seed-club-projects.ts",
"export:clickhouse": "tsx scripts/export-clickhouse.mts",
"lint": "eslint . --max-warnings 0",
"typecheck": "tsc --noEmit"
diff --git a/packages/db/scripts/seed-local.ts b/packages/db/scripts/seed-local.ts
index 9daca1e4..95693aa2 100644
--- a/packages/db/scripts/seed-local.ts
+++ b/packages/db/scripts/seed-local.ts
@@ -6,7 +6,7 @@
* Idempotent: people, events and status samples are keyed on stable ids and
* skipped when present; the hackathon and its judging setup are written once,
* with the edition. To start clean, run `pnpm local:reset` then `pnpm local:up`.
- * Never deletes. Refuses a non-local DATABASE_URL via scripts/guard-local.mjs.
+ * Never deletes. Refuses a non-local DATABASE_URL via tooling/scripts/src/guard-local.ts.
*
* Everyone signs in with an email code, which lands in Mailpit. Sign in as
* admin@example.test for staff access. Nothing here is a real person.
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 7dd4e83f..e482bb94 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -527,6 +527,24 @@ importers:
specifier: ^7.0.2
version: 7.0.2
+ tooling/scripts:
+ devDependencies:
+ '@query/eslint-config':
+ specifier: workspace:*
+ version: link:../eslint
+ '@query/tsconfig':
+ specifier: workspace:*
+ version: link:../typescript
+ '@types/node':
+ specifier: ^24.19.2
+ version: 24.19.2
+ eslint:
+ specifier: ^10.11.0
+ version: 10.11.0(jiti@2.7.0)
+ typescript:
+ specifier: ^7.0.2
+ version: 7.0.2
+
tooling/tailwind:
dependencies:
'@tailwindcss/postcss':
diff --git a/sites/mainweb/lib/uptime.ts b/sites/mainweb/lib/uptime.ts
index 8bf54c26..30f7ba05 100644
--- a/sites/mainweb/lib/uptime.ts
+++ b/sites/mainweb/lib/uptime.ts
@@ -1,5 +1,6 @@
-// Read side of the external uptime probe (scripts/uptime-probe.mjs writes
-// uptime.json; .github/workflows/uptime.yml runs it). Free of React and the
+// Read side of the external uptime probe (tooling/scripts/src/uptime-probe.ts
+// writes uptime.json; .github/workflows/uptime.yml runs it). The types below
+// mirror that file's; keep them in sync. Free of React and the
// network so the rules are tested in one place.
//
// The probe hits /api/ping from outside every 5 minutes. Down needs two failed
diff --git a/sites/mainweb/package.json b/sites/mainweb/package.json
index 08c72e46..ecc67a6d 100644
--- a/sites/mainweb/package.json
+++ b/sites/mainweb/package.json
@@ -4,7 +4,7 @@
"type": "module",
"private": true,
"scripts": {
- "dev": "node ../../scripts/guard-local.mjs --stripe --email && next dev --port 3001",
+ "dev": "node ../../tooling/scripts/src/guard-local.ts --stripe --email && next dev --port 3001",
"build": "next build",
"start": "next start",
"lint": "eslint . --max-warnings 0",
diff --git a/tooling/scripts/eslint.config.mjs b/tooling/scripts/eslint.config.mjs
new file mode 100644
index 00000000..1a709d24
--- /dev/null
+++ b/tooling/scripts/eslint.config.mjs
@@ -0,0 +1,16 @@
+import { config } from "@query/eslint-config/base";
+
+// These are command-line tools: they print to the console, and their env
+// variables belong to the caller, not to a turbo task, so they are not
+// declared in turbo.json.
+/** @type {import("eslint").Linter.Config[]} */
+export default [
+ ...config,
+ {
+ files: ["**/*.ts"],
+ rules: {
+ "no-console": "off",
+ "turbo/no-undeclared-env-vars": "off",
+ },
+ },
+];
diff --git a/tooling/scripts/package.json b/tooling/scripts/package.json
new file mode 100644
index 00000000..fd00114c
--- /dev/null
+++ b/tooling/scripts/package.json
@@ -0,0 +1,17 @@
+{
+ "name": "@query/scripts",
+ "version": "0.0.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "lint": "eslint . --max-warnings 0",
+ "typecheck": "tsc --noEmit"
+ },
+ "devDependencies": {
+ "@query/eslint-config": "workspace:*",
+ "@query/tsconfig": "workspace:*",
+ "@types/node": "^24.19.2",
+ "eslint": "^10.11.0",
+ "typescript": "^7.0.2"
+ }
+}
diff --git a/scripts/clickup-task.mjs b/tooling/scripts/src/clickup-task.ts
similarity index 69%
rename from scripts/clickup-task.mjs
rename to tooling/scripts/src/clickup-task.ts
index 4aff33a6..393b0388 100644
--- a/scripts/clickup-task.mjs
+++ b/tooling/scripts/src/clickup-task.ts
@@ -1,9 +1,8 @@
-#!/usr/bin/env node
/**
* Files a ClickUp task from the command line.
*
* CLICKUP_TOKEN=pk_… CLICKUP_LIST_ID=901… \
- * node scripts/clickup-task.mjs "Bootcamp schedule still unset" "Room, time and Deepnote URL are null in lib/bootcamp-schedule.ts"
+ * node tooling/scripts/src/clickup-task.ts "Bootcamp schedule still unset" "Room, time and Deepnote URL are null in lib/bootcamp-schedule.ts"
*
* A script rather than an inbound webhook route on purpose. Routing a firing
* Prometheus alert into ClickUp needs an endpoint that creates tasks on an
@@ -19,6 +18,29 @@
* CLICKUP_LIST_ID the list tasks land in (the number in the list URL)
*/
+/** The slice of an Alertmanager webhook alert this script reads. */
+interface AlertmanagerAlert {
+ annotations?: { summary?: string; description?: string };
+ labels?: { alertname?: string; severity?: string };
+ generatorURL?: string;
+}
+interface AlertmanagerPayload extends AlertmanagerAlert {
+ alerts?: AlertmanagerAlert[];
+}
+
+/** The body of ClickUp's create-task request. */
+interface ClickUpTask {
+ name: string;
+ description: string;
+ priority: number;
+}
+
+/** The slice of ClickUp's create-task response this script reads. */
+interface ClickUpCreated {
+ id: string;
+ url?: string;
+}
+
const token = process.env.CLICKUP_TOKEN;
const listId = process.env.CLICKUP_LIST_ID;
@@ -33,10 +55,12 @@ const args = process.argv.slice(2);
const useStdin = args[0] === "--stdin";
/** Reads an Alertmanager webhook payload and turns each alert into a task. */
-async function tasksFromStdin() {
- const chunks = [];
+async function tasksFromStdin(): Promise {
+ const chunks: Buffer[] = [];
for await (const chunk of process.stdin) chunks.push(chunk);
- const payload = JSON.parse(Buffer.concat(chunks).toString("utf8"));
+ const payload = JSON.parse(
+ Buffer.concat(chunks).toString("utf8"),
+ ) as AlertmanagerPayload;
const alerts = Array.isArray(payload.alerts) ? payload.alerts : [payload];
return alerts.map((alert) => ({
@@ -54,12 +78,12 @@ async function tasksFromStdin() {
}));
}
-const tasks = useStdin
+const tasks: ClickUpTask[] = useStdin
? await tasksFromStdin()
: [{ name: args[0], description: args[1] ?? "", priority: 3 }];
if (!tasks.length || !tasks[0].name) {
- console.error('Nothing to file. Usage: node scripts/clickup-task.mjs "title" ["description"]');
+ console.error('Nothing to file. Usage: node tooling/scripts/src/clickup-task.ts "title" ["description"]');
process.exit(1);
}
@@ -83,7 +107,7 @@ for (const task of tasks) {
continue;
}
- const created = await res.json();
+ const created = (await res.json()) as ClickUpCreated;
console.log(`Created ${created.id}: ${created.url ?? task.name}`);
}
diff --git a/scripts/guard-local.test.mjs b/tooling/scripts/src/guard-local.test.ts
similarity index 97%
rename from scripts/guard-local.test.mjs
rename to tooling/scripts/src/guard-local.test.ts
index d71de8c6..ffdd8b09 100644
--- a/scripts/guard-local.test.mjs
+++ b/tooling/scripts/src/guard-local.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
-import { violations } from "./guard-local.mjs";
+import { violations } from "./guard-local.ts";
describe("guard-local", () => {
it("passes with nothing configured, as in CI", () => {
diff --git a/scripts/guard-local.mjs b/tooling/scripts/src/guard-local.ts
similarity index 87%
rename from scripts/guard-local.mjs
rename to tooling/scripts/src/guard-local.ts
index 21aa2ad8..d5a8268d 100644
--- a/scripts/guard-local.mjs
+++ b/tooling/scripts/src/guard-local.ts
@@ -1,7 +1,7 @@
/**
* Refuses to run a command that could touch production from a dev machine.
*
- * node scripts/guard-local.mjs [--db] [--stripe] [--email]
+ * node tooling/scripts/src/guard-local.ts [--db] [--stripe] [--email]
*
* No flag runs every check. It sits in front of `migrate:push`, the seed
* scripts, `pnpm test` and `pnpm local:up`:
@@ -35,7 +35,12 @@ const LOCAL_HOSTS = new Set([
const OVERRIDE = "ALLOW_NON_LOCAL_SERVICES";
const LIVE_KEY = /^(sk|rk|pk)_live_/;
-const hostOf = (value) => {
+export type Check = "db" | "stripe" | "email";
+/** A reason a run must be refused. */
+export type Violation = string;
+type Env = Record;
+
+const hostOf = (value: string) => {
try {
return new URL(value).hostname.replace(/^\[|\]$/g, "").toLowerCase();
} catch {
@@ -44,8 +49,11 @@ const hostOf = (value) => {
};
/** Returns the reasons a run must be refused; empty means go ahead. */
-export function violations(env, checks = ["db", "stripe", "email"]) {
- const problems = [];
+export function violations(
+ env: Env,
+ checks: Check[] = ["db", "stripe", "email"],
+): Violation[] {
+ const problems: Violation[] = [];
const overridden = env[OVERRIDE] === "1";
if (checks.includes("db") && env.DATABASE_URL && !overridden) {
@@ -82,9 +90,9 @@ export function violations(env, checks = ["db", "stripe", "email"]) {
// Minimal KEY=VALUE reader for an env file; a variable already in the
// environment wins, as with dotenv.
-function readEnvFile(file) {
+function readEnvFile(file: string): Env {
if (!existsSync(file)) return {};
- const out = {};
+ const out: Env = {};
for (const line of readFileSync(file, "utf8").split(/\r?\n/)) {
const match = line.match(
/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/,
@@ -99,8 +107,10 @@ if (
process.argv[1] &&
resolve(process.argv[1]) === fileURLToPath(import.meta.url)
) {
- const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
- const flags = process.argv.slice(2).map((arg) => arg.replace(/^--/, ""));
+ const root = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
+ const flags = process.argv
+ .slice(2)
+ .map((arg) => arg.replace(/^--/, "")) as Check[];
// Each env file is checked on its own, never merged: drizzle and the seeds
// read the root .env while Next.js reads the web app's files, so a local
// value in one must not hide a hosted one in another.
diff --git a/scripts/local.mjs b/tooling/scripts/src/local.ts
similarity index 92%
rename from scripts/local.mjs
rename to tooling/scripts/src/local.ts
index 481bedeb..e8a65d49 100644
--- a/scripts/local.mjs
+++ b/tooling/scripts/src/local.ts
@@ -12,10 +12,10 @@ import { copyFileSync, existsSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
-const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
+const root = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
const compose = ["docker", "compose", "-f", "docker-compose.local.yml"];
-function run(command) {
+function run(command: string[]) {
// One command string: passing args alongside shell: true is deprecated, and
// the shell is what finds pnpm.cmd on Windows.
const { status } = spawnSync(command.join(" "), {
@@ -41,7 +41,7 @@ const command = process.argv[2];
if (command === "up") {
ensureEnvFiles();
// Before anything starts: a .env that points at a hosted database stops here.
- run(["node", "scripts/guard-local.mjs"]);
+ run(["node", "tooling/scripts/src/guard-local.ts"]);
run([...compose, "up", "-d", "--wait"]);
run(["pnpm", "--filter", "@query/db", "migrate:push"]);
run(["pnpm", "--filter", "@query/db", "db:seed"]);
@@ -54,6 +54,6 @@ Local environment is up.
} else if (command === "down" || command === "reset") {
run([...compose, "down", ...(command === "reset" ? ["--volumes"] : [])]);
} else {
- console.error("Usage: node scripts/local.mjs up|down|reset");
+ console.error("Usage: node tooling/scripts/src/local.ts up|down|reset");
process.exit(1);
}
diff --git a/scripts/uptime-probe.test.mjs b/tooling/scripts/src/uptime-probe.test.ts
similarity index 90%
rename from scripts/uptime-probe.test.mjs
rename to tooling/scripts/src/uptime-probe.test.ts
index 52899344..528aadb3 100644
--- a/scripts/uptime-probe.test.mjs
+++ b/tooling/scripts/src/uptime-probe.test.ts
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
-import { applyProbe, emptyData } from "./uptime-probe.mjs";
+import { applyProbe, emptyData } from "./uptime-probe.ts";
+import type { UptimeData } from "./uptime-probe.ts";
const NOW = Date.UTC(2026, 5, 15, 12, 0, 0);
const HOUR = 60 * 60 * 1000;
@@ -17,7 +18,8 @@ describe("applyProbe", () => {
});
it("starts over on an unknown version", () => {
- const data = applyProbe({ version: 9, recent: [1], days: [1] }, up, NOW);
+ const stale = { version: 9, recent: [1], days: [1] };
+ const data = applyProbe(stale as unknown as UptimeData, up, NOW);
expect(data.recent).toHaveLength(1);
expect(data.days).toEqual([]);
});
@@ -57,7 +59,7 @@ describe("applyProbe", () => {
});
it("stays bounded under a burst of probes", () => {
- let data = null;
+ let data = emptyData();
for (let i = 0; i < 500; i++) data = applyProbe(data, up, NOW + i * 1000);
expect(data.recent.length).toBeLessThanOrEqual(400);
});
diff --git a/scripts/uptime-probe.mjs b/tooling/scripts/src/uptime-probe.ts
similarity index 72%
rename from scripts/uptime-probe.mjs
rename to tooling/scripts/src/uptime-probe.ts
index 51665b3c..64997dca 100644
--- a/scripts/uptime-probe.mjs
+++ b/tooling/scripts/src/uptime-probe.ts
@@ -1,12 +1,13 @@
/**
* External uptime probe, run by .github/workflows/uptime.yml every 5 minutes.
*
- * UPTIME_FILE=data/uptime.json node scripts/uptime-probe.mjs
+ * UPTIME_FILE=data/uptime.json node tooling/scripts/src/uptime-probe.ts
*
* Probes the DB-free /api/ping, appends the result to uptime.json and keeps the
* file bounded: the last 24 hours of probes in full, then one aggregate per UTC
* day for 90 days. /status reads the file; it never calls GitHub itself.
- * Plain Node, no dependencies, so the workflow needs no install step.
+ * Plain Node (built-in type stripping), no dependencies, so the workflow
+ * needs no install step.
*
* Environment:
* UPTIME_URL probe target (default: production /api/ping)
@@ -27,9 +28,43 @@ const KEEP_DAYS = 90;
/** One probe every 5 minutes is 288; the cap only guards against a burst. */
const MAX_RECENT = 400;
-const dayOf = (t) => new Date(t).toISOString().slice(0, 10);
+/**
+ * The shape of uptime.json. sites/mainweb/lib/uptime.ts declares the same
+ * types for the read side; the probe must not import from the web app, so the
+ * two are kept in sync by hand.
+ */
+export type Probe = { t: number; ok: boolean; ms: number; code: number };
+export type ProbeDay = {
+ day: string;
+ probes: number;
+ up: number;
+ worstMs: number;
+};
+export type Deploy = {
+ sha: string;
+ committedAt: string | null;
+ rollout: string;
+};
+export type UptimeData = {
+ version: 1;
+ updatedAt: number;
+ recent: Probe[];
+ days: ProbeDay[];
+ deploy: Deploy | null;
+};
+
+/** The outcome of one probe, before it is stamped with a time. */
+export type ProbeResult = Omit;
-export function emptyData() {
+export type ProbeOptions = {
+ attempts?: number;
+ timeoutMs?: number;
+ retryDelayMs?: number;
+};
+
+const dayOf = (t: number) => new Date(t).toISOString().slice(0, 10);
+
+export function emptyData(): UptimeData {
return { version: 1, updatedAt: 0, recent: [], days: [], deploy: null };
}
@@ -38,10 +73,14 @@ export function emptyData() {
* first success, else the last failure. `code` is 0 when no response arrived.
*/
export async function probe(
- url,
- { attempts = ATTEMPTS, timeoutMs = TIMEOUT_MS, retryDelayMs = 2000 } = {},
-) {
- let last = { ok: false, ms: 0, code: 0 };
+ url: string,
+ {
+ attempts = ATTEMPTS,
+ timeoutMs = TIMEOUT_MS,
+ retryDelayMs = 2000,
+ }: ProbeOptions = {},
+): Promise {
+ let last: ProbeResult = { ok: false, ms: 0, code: 0 };
for (let i = 0; i < attempts; i++) {
if (i > 0) {
await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
@@ -75,7 +114,12 @@ export async function probe(
* Pure update. Appends the probe, moves probes older than 24 hours into their
* UTC day, and drops days beyond 90. Returns a new object.
*/
-export function applyProbe(previous, result, now, deploy) {
+export function applyProbe(
+ previous: UptimeData | null,
+ result: ProbeResult,
+ now: number,
+ deploy?: Deploy | null,
+): UptimeData {
const data = previous && previous.version === 1 ? previous : emptyData();
const cutoff = now - DAY_MS;
const all = [...data.recent, { t: now, ...result }].sort((a, b) => a.t - b.t);
@@ -104,9 +148,9 @@ export function applyProbe(previous, result, now, deploy) {
};
}
-async function readData(file) {
+async function readData(file: string): Promise {
try {
- return JSON.parse(await readFile(file, "utf8"));
+ return JSON.parse(await readFile(file, "utf8")) as UptimeData | null;
} catch {
// First run, or a file that cannot be read: start over rather than fail
// the probe. The history is a convenience, not a record.
@@ -121,7 +165,7 @@ async function main() {
retryDelayMs: Number(process.env.UPTIME_RETRY_DELAY_MS ?? 2000),
});
const { DEPLOY_SHA, DEPLOY_COMMITTED_AT, DEPLOY_ROLLOUT } = process.env;
- const deploy = DEPLOY_SHA
+ const deploy: Deploy | null = DEPLOY_SHA
? {
sha: DEPLOY_SHA,
committedAt: DEPLOY_COMMITTED_AT || null,
diff --git a/tooling/scripts/tsconfig.json b/tooling/scripts/tsconfig.json
new file mode 100644
index 00000000..b6531c56
--- /dev/null
+++ b/tooling/scripts/tsconfig.json
@@ -0,0 +1,18 @@
+{
+ "extends": "@query/tsconfig/base.json",
+ "compilerOptions": {
+ // Run by Node's own type stripping, never compiled: erasable syntax only,
+ // and imports name the .ts file because Node does no resolution magic.
+ "module": "NodeNext",
+ "moduleResolution": "NodeNext",
+ "lib": ["ES2023"],
+ "types": ["node"],
+ "allowJs": false,
+ "erasableSyntaxOnly": true,
+ "allowImportingTsExtensions": true,
+ "verbatimModuleSyntax": true,
+ "noEmit": true
+ },
+ "include": ["src/**/*.ts"],
+ "exclude": ["node_modules"]
+}
From 002a455f223fe7ec3a68ccff9ea0c7e1807142a4 Mon Sep 17 00:00:00 2001
From: aamoghS
Date: Sun, 25 Oct 2026 17:33:43 -0400
Subject: [PATCH 05/11] feat(mainweb): add repo check section to the judging
console
---
.../admin/judging/[hackathonId]/page.tsx | 4 +
.../admin/judging/RepoCheckSection.test.tsx | 137 ++++++
.../admin/judging/RepoCheckSection.tsx | 437 ++++++++++++++++++
.../components/admin/judging/shared.tsx | 1 +
4 files changed, 579 insertions(+)
create mode 100644 sites/mainweb/components/admin/judging/RepoCheckSection.test.tsx
create mode 100644 sites/mainweb/components/admin/judging/RepoCheckSection.tsx
diff --git a/sites/mainweb/app/(portal)/admin/judging/[hackathonId]/page.tsx b/sites/mainweb/app/(portal)/admin/judging/[hackathonId]/page.tsx
index 588bc39f..7a1dc58b 100644
--- a/sites/mainweb/app/(portal)/admin/judging/[hackathonId]/page.tsx
+++ b/sites/mainweb/app/(portal)/admin/judging/[hackathonId]/page.tsx
@@ -29,6 +29,7 @@ import { RoomsSection } from "@/components/admin/judging/RoomsSection";
import { RubricSection } from "@/components/admin/judging/RubricSection";
import { LaunchSection } from "@/components/admin/judging/LaunchSection";
import { AnnouncementsSection } from "@/components/admin/judging/AnnouncementsSection";
+import { RepoCheckSection } from "@/components/admin/judging/RepoCheckSection";
import { DangerZone } from "@/components/admin/judging/DangerZone";
import { ScheduleTab as JudgingSchedule } from "@/components/admin/judging/schedule/ScheduleTab";
import { ClaimsPanel as JudgingClaims } from "@/components/admin/judging/claims/ClaimsPanel";
@@ -202,6 +203,9 @@ export default function AdminEditionJudgingPage() {
{section === "results" && (
)}
+ {section === "repo-check" && (
+
+ )}
{section === "announcements" && (
)}
diff --git a/sites/mainweb/components/admin/judging/RepoCheckSection.test.tsx b/sites/mainweb/components/admin/judging/RepoCheckSection.test.tsx
new file mode 100644
index 00000000..7635598a
--- /dev/null
+++ b/sites/mainweb/components/admin/judging/RepoCheckSection.test.tsx
@@ -0,0 +1,137 @@
+import * as React from "react";
+import { renderToStaticMarkup } from "react-dom/server";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { RepoCheckSection } from "./RepoCheckSection";
+
+// Rendered to markup with the tRPC hooks stubbed, as EntriesList.test.tsx does.
+Object.assign(globalThis, { React });
+
+type MutationOptions = { onSuccess?: () => void };
+
+const mocks = vi.hoisted(() => ({
+ invalidateJudging: vi.fn(),
+ invalidateResults: vi.fn(),
+ options: {} as Record,
+ flag: {
+ entryId: "entry-1",
+ title: "Tidewater",
+ removed: null,
+ repoOwner: "team",
+ repoName: "app",
+ outcome: "late",
+ pushedAt: "2026-02-08T13:00:00.000Z",
+ evidence: {
+ commits: [
+ {
+ sha: "abcdef123456",
+ message: "last minute fix",
+ author: "Ada",
+ committedAt: "1999-01-01T00:00:00Z",
+ url: "https://github.com/team/app/commit/abcdef1",
+ },
+ ],
+ pushes: [{ at: "2026-02-08T13:00:00Z", ref: "refs/heads/dev", pusher: "ada" }],
+ },
+ checkedAt: "2026-02-08T14:00:00.000Z",
+ decision: "pending",
+ decidedAt: null,
+ note: null,
+ removedEntry: false,
+ } as Record,
+}));
+
+vi.mock("@/lib/use-portal-context", () => ({
+ READ_ONLY_TITLE: "Read-only",
+ useReadOnly: () => false,
+}));
+
+vi.mock("@/lib/trpc", () => {
+ const mutation = (name: string) => (options: MutationOptions) => {
+ mocks.options[name] = options;
+ return { isPending: false, error: null, mutate: () => undefined };
+ };
+ return {
+ trpc: {
+ useUtils: () => ({
+ judging: { invalidate: mocks.invalidateJudging },
+ hackathon: { getResults: { invalidate: mocks.invalidateResults } },
+ }),
+ judging: {
+ admin: {
+ getRepoCheck: {
+ useQuery: () => ({
+ data: {
+ enabled: true,
+ graceMinutes: 15,
+ lastRunAt: "2026-02-08T14:00:00.000Z",
+ running: false,
+ judgingOpen: true,
+ hasToken: false,
+ deadline: "2026-02-08T12:00:00.000Z",
+ checked: 12,
+ flagged: [mocks.flag],
+ },
+ isLoading: false,
+ isError: false,
+ error: null,
+ refetch: () => undefined,
+ }),
+ },
+ setRepoCheck: { useMutation: mutation("setRepoCheck") },
+ runRepoCheckNow: { useMutation: mutation("runRepoCheckNow") },
+ decideRepoCheck: { useMutation: mutation("decideRepoCheck") },
+ reinstateRepoCheck: { useMutation: mutation("reinstateRepoCheck") },
+ },
+ },
+ },
+ };
+});
+
+const render = () =>
+ renderToStaticMarkup(
+ ,
+ );
+
+beforeEach(() => {
+ mocks.invalidateJudging.mockClear();
+ mocks.invalidateResults.mockClear();
+ mocks.options = {};
+});
+
+describe("RepoCheckSection", () => {
+ it("lists a late project with its repo, times in Eastern and evidence", () => {
+ const html = render();
+ expect(html).toContain("Tidewater");
+ expect(html).toContain("https://github.com/team/app");
+ // Feb 8 13:00 UTC is 8:00 AM in New York; the deadline is 7:00 AM.
+ expect(html).toContain("Last push Sun, Feb 8, 8:00 AM");
+ expect(html).toContain("deadline Sun, Feb 8, 7:00 AM plus 15 minutes");
+ expect(html).toContain("last minute fix");
+ expect(html).toContain("Pushes to any branch count");
+ expect(html).toContain("for context only");
+ expect(html).toContain("Disqualify");
+ expect(html).toContain("Clear");
+ expect(html).not.toContain("Reinstate");
+ });
+
+ it("offers reinstating, not clearing, once disqualified", () => {
+ mocks.flag.decision = "disqualified";
+ mocks.flag.removedEntry = true;
+ const html = render();
+ expect(html).toContain("Reinstate");
+ expect(html).not.toContain(">Disqualify<");
+ expect(html).not.toContain(">Clear<");
+ mocks.flag.decision = "pending";
+ mocks.flag.removedEntry = false;
+ });
+
+ it.each(["decideRepoCheck", "reinstateRepoCheck"])(
+ "refetches the public results after %s",
+ (name) => {
+ render();
+ mocks.options[name]?.onSuccess?.();
+ expect(mocks.invalidateJudging).toHaveBeenCalledTimes(1);
+ expect(mocks.invalidateResults).toHaveBeenCalledTimes(1);
+ },
+ );
+});
diff --git a/sites/mainweb/components/admin/judging/RepoCheckSection.tsx b/sites/mainweb/components/admin/judging/RepoCheckSection.tsx
new file mode 100644
index 00000000..269fb568
--- /dev/null
+++ b/sites/mainweb/components/admin/judging/RepoCheckSection.tsx
@@ -0,0 +1,437 @@
+"use client";
+
+import { useState } from "react";
+import type { RouterOutputs } from "@query/api";
+import { trpc } from "@/lib/trpc";
+import { trpcErrorMessage } from "@/lib/trpc-error";
+import { READ_ONLY_TITLE, useReadOnly } from "@/lib/use-portal-context";
+import { eventMoment } from "@/components/hackathon/judging/event-time";
+import {
+ body,
+ btnDanger,
+ btnInk,
+ btnSecondary,
+ fieldHint,
+ fieldLabel,
+ input,
+ label,
+ meta,
+ sectionRule,
+ sectionTitle,
+ status,
+ textLink,
+} from "@/components/portal/ui";
+import type { Tone } from "@/components/portal/ui";
+import {
+ ErrorLine,
+ OFFICER_POLL_MS,
+ Toggle,
+ plural,
+ shortTime,
+ useRefreshJudging,
+} from "@/components/admin/judging/shared";
+
+type Flag = RouterOutputs["judging"]["admin"]["getRepoCheck"]["flagged"][number];
+
+const OUTCOME: Record = {
+ ok: { label: "On time", tone: "success" },
+ late: { label: "Pushed after the deadline", tone: "danger" },
+ no_repo: { label: "No repo link", tone: "warning" },
+ unreadable: { label: "Repo not readable", tone: "warning" },
+ error: { label: "Could not check", tone: "neutral" },
+};
+
+const DECISION: Record = {
+ pending: { label: "Waiting for you", tone: "warning" },
+ disqualified: { label: "Disqualified", tone: "danger" },
+ cleared: { label: "Cleared", tone: "success" },
+};
+
+const MAX_NOTE = 500;
+
+/**
+ * The late-push check: flags projects whose GitHub repo was pushed to after
+ * the submission deadline, for an organiser to confirm or clear.
+ */
+export function RepoCheckSection({ hackathonId }: { hackathonId: string }) {
+ const readOnly = useReadOnly();
+ const refresh = useRefreshJudging();
+ const check = trpc.judging.admin.getRepoCheck.useQuery(
+ { hackathonId },
+ { refetchInterval: OFFICER_POLL_MS },
+ );
+ const configure = trpc.judging.admin.setRepoCheck.useMutation({
+ onSuccess: () => void refresh(),
+ });
+ const runNow = trpc.judging.admin.runRepoCheckNow.useMutation({
+ onSuccess: () => void refresh(),
+ });
+
+ const [grace, setGrace] = useState(null);
+ const data = check.data;
+ const graceText = grace ?? String(data?.graceMinutes ?? 0);
+ const graceValue = Number(graceText);
+ const graceValid =
+ graceText.trim() !== "" &&
+ Number.isInteger(graceValue) &&
+ graceValue >= 0 &&
+ graceValue <= 1440;
+
+ return (
+
+
+
Repo check
+
+ Looks at each project's GitHub repo and flags the ones pushed to
+ after the submission deadline. It goes by GitHub's own record of
+ the last push, on any branch. Commit dates are shown for context
+ only: they come from the hacker's computer and can be set to
+ anything. Nothing is disqualified until you confirm it.
+
+
+
+ {check.isLoading ? (
+
Loading the repo check…
+ ) : check.isError || !data ? (
+
+ {trpcErrorMessage(check.error, "Could not load the repo check.")}{" "}
+
+
+
+ {trpcErrorMessage(configure.error, "Could not save that.")}
+
+
+ )}
+
+
+
+
+
+
+
+
Run
+
+ {data.running
+ ? "Checking repos now. Results appear here as they come in."
+ : data.lastRunAt
+ ? `Last run ${shortTime(data.lastRunAt)}.`
+ : "Not run yet."}{" "}
+ {plural(data.checked, "project")} checked.
+
+ {!data.hasToken && (
+
+ No GitHub token is set, so GitHub allows 60 requests an hour.
+ Projects past that are retried on the next run. See{" "}
+ GITHUB_TOKEN in the environment docs.
+
+ )}
+
+
+
+ {runNow.error && (
+
+
+ {trpcErrorMessage(runNow.error, "Could not start the check.")}
+
+
+ )}
+
+
+
+
Flagged projects
+ {data.flagged.length === 0 ? (
+
+ {data.checked === 0
+ ? "Nothing checked yet."
+ : "No project is flagged."}
+
+ {trpcErrorMessage(error, "Could not save that.")}
+
+ )}
+
+ );
+}
diff --git a/sites/mainweb/components/admin/judging/shared.tsx b/sites/mainweb/components/admin/judging/shared.tsx
index b4362c52..18a6ea7e 100644
--- a/sites/mainweb/components/admin/judging/shared.tsx
+++ b/sites/mainweb/components/admin/judging/shared.tsx
@@ -20,6 +20,7 @@ export const SECTIONS = [
{ id: "schedule", label: "Schedule" },
{ id: "claims", label: "Claims" },
{ id: "results", label: "Results" },
+ { id: "repo-check", label: "Repo check" },
{ id: "announcements", label: "Announcements" },
{ id: "danger", label: "Danger zone" },
] as const;
From 9fd3222091414dda8910b0a171dc19a44a0f64a2 Mon Sep 17 00:00:00 2001
From: aamoghS
Date: Sun, 25 Oct 2026 17:35:46 -0400
Subject: [PATCH 06/11] docs(judging): document the late-push repo check
---
.env.example | 3 ++
docs/judging/README.md | 4 +-
docs/judging/repo-check.md | 83 ++++++++++++++++++++++++++++++++++
docs/operations/environment.md | 1 +
docs/packages/db.md | 2 +-
5 files changed, 91 insertions(+), 2 deletions(-)
create mode 100644 docs/judging/repo-check.md
diff --git a/.env.example b/.env.example
index 6d4a3a74..0fc656c7 100644
--- a/.env.example
+++ b/.env.example
@@ -28,6 +28,9 @@ GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
+# Optional: raises the judging repo check GitHub limit from 60 to 5000 requests
+# an hour. A token with no scopes is enough for public repos.
+GITHUB_TOKEN=
# --- Email ------------------------------------------------------------------
# Mailpit from docker-compose.local.yml. The 6-digit sign-in code and every other
diff --git a/docs/judging/README.md b/docs/judging/README.md
index 27ec8f87..d0c2410d 100644
--- a/docs/judging/README.md
+++ b/docs/judging/README.md
@@ -12,6 +12,7 @@ The code is one tRPC router, `judging`, with five sub-routers (`packages/api/src
| [Judging day](./judging-day.md) | The judge desk, presence, appointment status, takeover, grading hold, leaving a room, announcements, hacker itinerary |
| [Scoring and results](./scoring-and-results.md) | Evaluations, scoring, calibration, the judge's private shortlist, placements, publishing, reopening a grade |
| [Scheduler](./scheduler.md) | How room presentations are placed on a timetable and judges assigned to them |
+| [Repo check](./repo-check.md) | Flagging projects pushed to on GitHub after the submission deadline, and the organiser decisions |
| [Adaptive features](./adaptive.md) | Setup advice, suggested timing, archiving rooms, live re-balance of judges |
| [Simulations](./simulations.md) | Load simulations and what they found |
@@ -74,7 +75,7 @@ The order is a checklist, not a hard gate. `judging.admin.checklist` reports sev
| Table | Holds |
| --- | --- |
-| `judging_config` | One row per edition: state, switches (`showAllScores`, `itineraryPublished`, `emergencyMode`, `autoRebalance`), lock timestamps |
+| `judging_config` | One row per edition: state, switches (`showAllScores`, `itineraryPublished`, `emergencyMode`, `autoRebalance`, `repoCheckEnabled` and its grace), lock timestamps |
| `judging_entry`, `judging_entry_member`, `judging_claim_link` | Projects, their teammates, one-time claim links |
| `judging_challenge`, `judging_entry_challenge` | The challenge tree and which projects are in which challenge |
| `judging_building`, `judging_room`, `judging_room_link`, `judging_guest_session` | Halls, rooms and tables, QR links, guest cookie sessions |
@@ -83,6 +84,7 @@ The order is a checklist, not a hard gate. `judging.admin.checklist` reports sev
| `judging_rubric_item`, `judging_evaluation`, `judging_evaluation_draft`, `judging_evaluation_rating`, `judging_evaluation_response`, `judging_evaluation_revision` | The rubric and judges' answers |
| `judging_deliberation_section`, `judging_deliberation_entry` | A judge's private shortlist |
| `judging_placement` | Computed and published placings |
+| `judging_repo_check` | Per project: GitHub last-push check against the submission deadline and the organiser decision, see [repo check](./repo-check.md) |
| `judging_announcement` | The pinned message for all judges or one room |
The older `judging_project`, `judge_vote`, `judge_queue` and `hackathon_result` tables are retired and kept for past editions. `hackathon.getResults` still merges their published rows with new placements.
diff --git a/docs/judging/repo-check.md b/docs/judging/repo-check.md
new file mode 100644
index 00000000..c34ac977
--- /dev/null
+++ b/docs/judging/repo-check.md
@@ -0,0 +1,83 @@
+# Repo check
+
+A late-push check for judging. When an organiser turns it on for an edition, the app looks at each project's GitHub repo and flags the ones that were pushed to after the submission deadline. An organiser then confirms (disqualifies) or clears each flag. Nothing is disqualified automatically. Back to the [judging overview](./README.md).
+
+The code is `services/judging/repo-check.ts` (the check), `routers/judging/admin/repo-check.ts` (the switch and the decisions) and `RepoCheckSection.tsx` in `sites/mainweb/components/admin/judging/` (the console's Repo check section).
+
+## The signal
+
+The check goes by GitHub's own record of the last push, the `pushed_at` field of `GET /repos/{owner}/{repo}`. That is the server's clock and covers a push to any branch. Commit dates are never used to decide: they are set by the hacker's computer and can be faked in either direction.
+
+When `pushed_at` is later than the deadline plus the edition's grace, the project is flagged `late` and the check also fetches evidence for the organiser:
+
+- Commits since the deadline (`GET /repos/{owner}/{repo}/commits?since=...`), the latest 20: sha, first line of the message, author, commit date, link. The commit date is shown for context only. This lists the default branch.
+- Pushes since the deadline from the repo's events (`GET /repos/{owner}/{repo}/events`), the latest 20 PushEvents: time, ref and who pushed. GitHub keeps only recent events.
+
+The deadline is the submission close time, the same one the team submission window uses (`computeSubmissionWindow`: 36 hours after hacking starts). A push exactly on the deadline plus grace is on time.
+
+## Which repo
+
+The portal project's GitHub link, else the first github.com repo link among the Devpost demo links. The link is read strictly: a trailing `.git`, `/tree/...` or any other path after the repo is ignored, and anything that is not `github.com/owner/repo` (a profile, another host) counts as no repo.
+
+| Outcome | Meaning |
+| --- | --- |
+| `ok` | Last push at or before the deadline plus grace |
+| `late` | Pushed after the deadline plus grace |
+| `no_repo` | No usable repo link |
+| `unreadable` | GitHub answered 401, 403, 404, 410 or 451: private, deleted or blocked |
+| `error` | Rate limit, timeout or network failure. Retried on the next run. |
+
+Everything except `ok` appears in the flagged list.
+
+## Organiser flow
+
+Open the Repo check section of the judging console.
+
+1. Optionally set the grace in minutes (default 0). Only pushes after the deadline plus the grace are flagged.
+2. Turn the check on. While judging is open and someone has the console open, it runs every 15 minutes. Check now runs it at once, any time, whatever the switch.
+3. For each flagged project, read the repo link, the last push against the deadline (Eastern time) and the evidence, add an optional note, then:
+ - **Disqualify** takes the project out of judging, the same way removing it does (see below).
+ - **Clear** closes the flag and changes nothing else.
+ - **Reinstate** undoes a disqualification: the project comes back through the same path as restoring a removed project, and the flag is cleared.
+
+Every decision writes an audit log entry. The note is kept with the decision for organisers only; hackers and the public results never see a reason.
+
+A project that was decided is not looked at again unless its repo was pushed to after the decision. Then the flag reopens as pending with the new evidence. A disqualified project that reopens is still out until an organiser reinstates it.
+
+## What disqualifying does
+
+It sets `deletedAt` on the judging entry, which is what removing a project does. Everything that already leaves a removed project out follows from that: placements and public results, the unscheduled queue, the judge itinerary, and judge assignment and re-balancing (its saved appointments count as removed, so no judge is assigned and nothing is moved for them). Its evaluations stay, pointing at it. A hacker on that team sees what they see for any removed project and no reason. A portal submission removed this way cannot be resubmitted by its team.
+
+Unlike Remove project, it does not require the saved schedule to be dropped first: the board already treats a removed project's appointments as removed.
+
+Reinstating clears `deletedAt` through `restoreLockedEntry`, which is also what Restore uses, and so refuses a portal submission whose team withdrew it. It only restores a project the disqualification itself removed (`removed_entry` on the check row); one an organiser had already removed stays removed.
+
+## How it runs
+
+There is no background worker, matching the lazy re-balance. `judging.admin.overview`, which the console polls every 10 seconds, calls `repoCheckIfDue` without waiting for it:
+
+- An in-process throttle allows one attempt per edition every 15 minutes.
+- The attempt claims the run in the database: one update of `judging_config.repo_check_last_run_at`, only if the switch is on, judging is `open` and the last run is older than 15 minutes. Only the instance that wins the claim starts a run, so several instances do not all call GitHub.
+- The run happens in the background; the poll returns at once and a later poll shows the results.
+- At most 5 GitHub requests are in flight, each with a 10 second timeout and a `User-Agent`.
+- Turning the switch on or changing the grace makes the next poll run it.
+
+## GitHub token and rate limits
+
+Set `GITHUB_TOKEN` (optional, any token that can read public repos) for 5000 requests an hour. Without it GitHub allows 60 an hour, enough for a small edition. When GitHub answers with its rate limit, or the last response says no requests remain, the run stops: projects not yet checked are recorded as `error` (a decided project keeps its decision) and the next run, 15 minutes later, picks them up.
+
+## Limits
+
+- Pushes to any branch count, including a branch the team never submitted.
+- A force-push can move a branch back, but `pushed_at` still moves forward, so it is still flagged. The commit list may not show what was pushed.
+- A repo made private or deleted after the deadline reads as `unreadable`. The check cannot tell that from one that was private all along.
+- A team can push from a fork, or keep its real work elsewhere and link another repo. The check does not see either.
+- `pushed_at` can move for reasons that are not code, so the evidence is there for a person to read.
+- The in-process throttle is per instance. A switch changed on one instance can take up to 15 minutes to be picked up by the poll on another; Check now is immediate.
+
+## Data
+
+| Table | Holds |
+| --- | --- |
+| `judging_repo_check` | One row per project: repo, outcome, `pushed_at`, evidence (JSON, at most 20 commits and 20 pushes), when it was checked, and the decision (`pending`, `disqualified`, `cleared`) with who, when and the note |
+| `judging_config` | `repo_check_enabled`, `repo_check_grace_minutes`, `repo_check_last_run_at` |
diff --git a/docs/operations/environment.md b/docs/operations/environment.md
index b8817ee8..e4a16784 100644
--- a/docs/operations/environment.md
+++ b/docs/operations/environment.md
@@ -22,6 +22,7 @@ Without `DATABASE_URL`, `db` is null, sessions fall back to JWT, and tRPC proced
| `GOOGLE_CLIENT_SECRET` | Google (`AUTH_GOOGLE_SECRET`) |
| `GITHUB_CLIENT_ID` | GitHub (`AUTH_GITHUB_ID`) — optional; both GitHub vars required to register the provider |
| `GITHUB_CLIENT_SECRET` | GitHub (`AUTH_GITHUB_SECRET`) |
+| `GITHUB_TOKEN` | Optional. Lets the judging [repo check](../judging/repo-check.md) read repos at 5000 requests an hour instead of 60. Needs no scopes for public repos. |
## Email
diff --git a/docs/packages/db.md b/docs/packages/db.md
index d6c0aec2..0f3af481 100644
--- a/docs/packages/db.md
+++ b/docs/packages/db.md
@@ -29,7 +29,7 @@ Files in `src/schemas/`, re-exported from `schemas/index.ts`. `drizzle.config.ts
| `admins.ts` | `admin` |
| `hackathons.ts` | `hackathon`, `hackathon_team`, `hackathon_participant`, `hackathon_project`, `hackathon_interest`, `hackathon_event`, `hackathon_event_attendee`, `hackathon_check_in_flag`, `hackathon_announcement`, `hackathon_announcement_recipient` |
| `judge.ts` | `judge`, `judge_assignment` (live roster); `judging_project`, `judge_vote`, `judge_queue`, `hackathon_result` (retired, kept for past editions) |
-| `judging.ts` | `judging_*`: config, entries and members, challenges, buildings, rooms and room links, judges, guest sessions, presence, schedule and appointments, rubric, evaluations, deliberation, placements |
+| `judging.ts` | `judging_*`: config, entries and members, challenges, buildings, rooms and room links, judges, guest sessions, presence, schedule and appointments, rubric, evaluations, deliberation, placements, repo checks |
| `points.ts` | `hackathon_point_adjustment`, `hackathon_store_item`, `hackathon_store_purchase` |
| `retired.ts` | Enum types the retired panel judging service left in the database, declared so push does not try to drop them |
| `initiatives.ts` | `project_leader`, `initiative`, `initiative_application` |
From 28e59bcf1b8811f837ef4c724c83ea62e387966d Mon Sep 17 00:00:00 2001
From: aamoghS
Date: Sun, 25 Oct 2026 17:46:17 -0400
Subject: [PATCH 07/11] fix(api): read GitHub JSON without any, declare
GITHUB_TOKEN for turbo
---
.../src/services/judging/repo-check.test.ts | 32 ++++++++----
.../api/src/services/judging/repo-check.ts | 49 +++++++++++--------
turbo.json | 1 +
3 files changed, 51 insertions(+), 31 deletions(-)
diff --git a/packages/api/src/services/judging/repo-check.test.ts b/packages/api/src/services/judging/repo-check.test.ts
index 6fe15ff1..09c068c0 100644
--- a/packages/api/src/services/judging/repo-check.test.ts
+++ b/packages/api/src/services/judging/repo-check.test.ts
@@ -1,6 +1,7 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { getTableName } from "drizzle-orm";
import { judgingConfigs, judgingEntries, judgingRepoChecks } from "@query/db";
+import type { DrizzleDB } from "@query/db";
import {
GithubClient,
REPO_CHECK_EVERY_MS,
@@ -454,6 +455,14 @@ describe("nextCheck", () => {
});
});
+type Write = { table: string; values: Record };
+type Chain = PromiseLike & {
+ from: (table: unknown) => Chain;
+ leftJoin: () => Chain;
+ where: () => Chain;
+ limit: () => Chain;
+};
+
/** A database whose selects answer by table and whose writes are recorded. */
function fakeDb(opts: {
config?: Record | null;
@@ -461,12 +470,12 @@ function fakeDb(opts: {
entries?: Record[];
checks?: Record[];
}) {
- const writes: { table: string; values: any }[] = [];
+ const writes: Write[] = [];
const claims: unknown[] = [];
const select = () => {
let table = "";
- const chain: any = {
- then: (ok: any, err: any) => {
+ const chain = {
+ then: (ok: (v: unknown) => unknown, err: (e: unknown) => unknown) => {
const rows =
table === getTableName(judgingConfigs)
? [opts.config === null ? undefined : (opts.config ?? { graceMinutes: 0 })]
@@ -475,8 +484,8 @@ function fakeDb(opts: {
: (opts.checks ?? []);
return Promise.resolve(rows.filter(Boolean)).then(ok, err);
},
- };
- chain.from = (t: unknown) => {
+ } as unknown as Chain;
+ chain.from = (t) => {
table = getTableName(t as never);
return chain;
};
@@ -485,7 +494,7 @@ function fakeDb(opts: {
chain.limit = () => chain;
return chain;
};
- const db: any = {
+ const db = {
query: {
hackathons: {
findFirst: async () => ({
@@ -508,12 +517,15 @@ function fakeDb(opts: {
insert: (table: unknown) => ({
values: (values: unknown) => ({
onConflictDoUpdate: async () => {
- writes.push({ table: getTableName(table as never), values });
+ writes.push({
+ table: getTableName(table as never),
+ values: values as Write["values"],
+ });
},
}),
}),
};
- return { db, writes, claims };
+ return { db: db as unknown as DrizzleDB, writes, claims };
}
const entryRow = (id: string, link: string | null) => ({
@@ -524,7 +536,7 @@ const entryRow = (id: string, link: string | null) => ({
});
describe("runRepoCheck", () => {
- const written = (writes: { table: string; values: any }[]) =>
+ const written = (writes: Write[]) =>
new Map(
writes
.filter((w) => w.table === getTableName(judgingRepoChecks))
@@ -729,7 +741,7 @@ describe("repoCheckIfDue", () => {
it("never throws", async () => {
vi.spyOn(console, "error").mockImplementation(() => undefined);
const { db } = fakeDb({ claim: true });
- db.update = () => {
+ (db as unknown as { update: () => never }).update = () => {
throw new Error("db down");
};
await expect(repoCheckIfDue(db, HACK, t0, { fetchFn })).resolves.toBeUndefined();
diff --git a/packages/api/src/services/judging/repo-check.ts b/packages/api/src/services/judging/repo-check.ts
index 38229ad6..86b015a5 100644
--- a/packages/api/src/services/judging/repo-check.ts
+++ b/packages/api/src/services/judging/repo-check.ts
@@ -158,7 +158,13 @@ export class GithubClient {
}
}
-type Json = Record | null;
+// GitHub's JSON is read defensively: a field that is not what it should be
+// reads as missing.
+const obj = (value: unknown): Record =>
+ typeof value === "object" && value !== null
+ ? (value as Record)
+ : {};
+const text = (value: unknown) => (typeof value === "string" ? value : null);
/** Commits and pushes after the deadline. Context only; a failure leaves it short. */
async function gatherEvidence(
@@ -173,15 +179,17 @@ async function gatherEvidence(
`${base}/commits?since=${encodeURIComponent(deadline.toISOString())}&per_page=${EVIDENCE_LIMIT}`,
);
if (commits.status === 200 && Array.isArray(commits.body)) {
- evidence.commits = (commits.body as Json[]).slice(0, EVIDENCE_LIMIT).map(
- (c) => ({
- sha: String(c?.sha ?? ""),
- message: String(c?.commit?.message ?? "").split("\n")[0]!.slice(0, 200),
- author: c?.commit?.author?.name ?? null,
- committedAt: c?.commit?.committer?.date ?? null,
- url: String(c?.html_url ?? ""),
- }),
- );
+ evidence.commits = commits.body.slice(0, EVIDENCE_LIMIT).map((item) => {
+ const c = obj(item);
+ const commit = obj(c.commit);
+ return {
+ sha: text(c.sha) ?? "",
+ message: (text(commit.message) ?? "").split("\n")[0]!.slice(0, 200),
+ author: text(obj(commit.author).name),
+ committedAt: text(obj(commit.committer).date),
+ url: text(c.html_url) ?? "",
+ };
+ });
}
} catch {
// Evidence is optional: pushed_at has already decided.
@@ -189,18 +197,17 @@ async function gatherEvidence(
try {
const events = await client.get(`${base}/events?per_page=100`);
if (events.status === 200 && Array.isArray(events.body)) {
- evidence.pushes = (events.body as Json[])
- .filter(
- (e) =>
- e?.type === "PushEvent" &&
- typeof e.created_at === "string" &&
- new Date(e.created_at) > deadline,
- )
+ evidence.pushes = events.body
+ .map(obj)
+ .filter((e) => {
+ const at = text(e.created_at);
+ return e.type === "PushEvent" && at !== null && new Date(at) > deadline;
+ })
.slice(0, EVIDENCE_LIMIT)
.map((e) => ({
- at: e!.created_at as string,
- ref: e!.payload?.ref ?? null,
- pusher: e!.actor?.login ?? null,
+ at: text(e.created_at)!,
+ ref: text(obj(e.payload).ref),
+ pusher: text(obj(e.actor).login),
}));
}
} catch {
@@ -229,7 +236,7 @@ export async function checkRepo(
if ([401, 403, 404, 410, 451].includes(reply.status)) {
return { outcome: "unreadable", ref, pushedAt: null, evidence: null };
}
- const pushed = (reply.body as Json)?.pushed_at;
+ const pushed = obj(reply.body).pushed_at;
if (reply.status !== 200 || (pushed != null && typeof pushed !== "string")) {
return { outcome: "error", ref, pushedAt: null, evidence: null };
}
diff --git a/turbo.json b/turbo.json
index 2b4cc640..153183fe 100644
--- a/turbo.json
+++ b/turbo.json
@@ -63,6 +63,7 @@
"GOOGLE_CLIENT_SECRET",
"GITHUB_CLIENT_ID",
"GITHUB_CLIENT_SECRET",
+ "GITHUB_TOKEN",
"EMAIL_SERVER_HOST",
"EMAIL_SERVER_PORT",
"EMAIL_SERVER_USER",
From 1131b62a9b5a50eafc014f0aec78488d73334112 Mon Sep 17 00:00:00 2001
From: aamoghS
Date: Sun, 25 Oct 2026 18:07:15 -0400
Subject: [PATCH 08/11] fix(api): keep decisions and late flags across repo
check runs
Results are saved per entry under the edition lock against the current row, so a decision made during a run survives it. A pending late flag is no longer wiped by a later error, unreadable or ok check, and the latest attempt is kept beside it. Every distinct repo link (at most three) is checked, entries checked longest ago go first, and a 403 that mentions a rate limit stops the run instead of reading as unreadable.
---
.../src/services/judging/repo-check.test.ts | 261 ++++++++++++++++--
.../api/src/services/judging/repo-check.ts | 203 ++++++++++----
packages/db/src/schemas/judging.ts | 29 ++
3 files changed, 423 insertions(+), 70 deletions(-)
diff --git a/packages/api/src/services/judging/repo-check.test.ts b/packages/api/src/services/judging/repo-check.test.ts
index 09c068c0..d5c0d7e4 100644
--- a/packages/api/src/services/judging/repo-check.test.ts
+++ b/packages/api/src/services/judging/repo-check.test.ts
@@ -1,5 +1,6 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { getTableName } from "drizzle-orm";
+import { PgDialect } from "drizzle-orm/pg-core";
import { judgingConfigs, judgingEntries, judgingRepoChecks } from "@query/db";
import type { DrizzleDB } from "@query/db";
import {
@@ -8,14 +9,18 @@ import {
checkRepo,
isLate,
nextCheck,
+ checkEntry,
parseRepoUrl,
repoCheckIfDue,
repoCheckThrottle,
- repoOfEntry,
+ reposOfEntry,
runRepoCheck,
} from "./repo-check";
import type { RepoCheckRow, RepoResult } from "./repo-check";
+// The edition lock is a row lock; there is nothing to hold in a fake.
+vi.mock("./challenges", () => ({ lockEdition: async () => undefined }));
+
const HACK = "hackathon";
const DEADLINE = new Date("2026-02-08T12:00:00Z");
const MINUTE = 60 * 1000;
@@ -88,44 +93,64 @@ describe("parseRepoUrl", () => {
});
});
-describe("repoOfEntry", () => {
- it("prefers the portal project's link", () => {
+describe("reposOfEntry", () => {
+ it("puts the portal project's link first", () => {
expect(
- repoOfEntry({
+ reposOfEntry({
githubUrl: "https://github.com/team/app",
demoLinks: ["https://github.com/other/thing"],
}),
- ).toEqual(ref);
+ ).toEqual([ref, { owner: "other", repo: "thing" }]);
});
- it("falls back to the first repo among the demo links", () => {
+ it("takes the repo links among the demo links and skips the rest", () => {
expect(
- repoOfEntry({
+ reposOfEntry({
githubUrl: null,
demoLinks: [
"https://devpost.com/software/app",
"https://github.com/team",
"https://github.com/team/app/tree/dev",
- "https://github.com/other/thing",
],
}),
- ).toEqual(ref);
+ ).toEqual([ref]);
+ });
+
+ it("lists a repo once however it is linked", () => {
+ expect(
+ reposOfEntry({
+ githubUrl: "https://github.com/team/app",
+ demoLinks: [
+ "https://github.com/Team/App.git",
+ "https://github.com/team/app/issues",
+ ],
+ }),
+ ).toEqual([ref]);
});
it("uses the demo links when the project's own link is not a repo", () => {
expect(
- repoOfEntry({
+ reposOfEntry({
githubUrl: "https://github.com/team",
demoLinks: ["https://github.com/team/app"],
}),
- ).toEqual(ref);
+ ).toEqual([ref]);
});
- it("is null with no repo anywhere", () => {
- expect(repoOfEntry({ githubUrl: null, demoLinks: [] })).toBeNull();
+ it("stops at three", () => {
expect(
- repoOfEntry({ githubUrl: "", demoLinks: ["https://example.com"] }),
- ).toBeNull();
+ reposOfEntry({
+ githubUrl: null,
+ demoLinks: [1, 2, 3, 4, 5].map((n) => `https://github.com/t/r${n}`),
+ }).map((r) => r.repo),
+ ).toEqual(["r1", "r2", "r3"]);
+ });
+
+ it("is empty with no repo anywhere", () => {
+ expect(reposOfEntry({ githubUrl: null, demoLinks: [] })).toEqual([]);
+ expect(
+ reposOfEntry({ githubUrl: "", demoLinks: ["https://example.com"] }),
+ ).toEqual([]);
});
});
@@ -334,6 +359,20 @@ describe("checkRepo", () => {
).rejects.toThrow("rate limited");
});
+ it("stops on a 403 whose message is a secondary rate limit", async () => {
+ await expect(
+ run([
+ [
+ "/repos/team/app",
+ {
+ status: 403,
+ body: { message: "You have exceeded a secondary rate limit." },
+ },
+ ],
+ ]).result,
+ ).rejects.toThrow("rate limited");
+ });
+
it("does not mistake a private repo's 403 for a limit", async () => {
await expect(
run([
@@ -361,11 +400,87 @@ describe("checkRepo", () => {
});
});
+describe("checkEntry", () => {
+ const refs = [
+ { owner: "t", repo: "a" },
+ { owner: "t", repo: "b" },
+ ];
+ const client = (rules: Parameters[0]) =>
+ new GithubClient(fakeFetch(rules).fn, undefined);
+
+ it("flags the entry when any of its repos is late, with that repo's evidence", async () => {
+ const done = await checkEntry(
+ client([
+ ["/repos/t/a", repoReply("2026-02-08T10:00:00Z")],
+ ["/repos/t/b", repoReply("2026-02-08T13:00:00Z")],
+ ]),
+ refs,
+ DEADLINE,
+ 0,
+ );
+ expect(done).toMatchObject({ outcome: "late", ref: refs[1] });
+ expect(done.repos.map((r) => [r.repo, r.outcome])).toEqual([
+ ["a", "ok"],
+ ["b", "late"],
+ ]);
+ expect(done.repos[1]!.evidence).not.toBeNull();
+ expect(done.repos[0]!.evidence).toBeNull();
+ });
+
+ it("is ok only when every repo is", async () => {
+ const rules: Parameters[0] = [
+ ["/repos/t/a", repoReply("2026-02-08T10:00:00Z")],
+ ["/repos/t/b", { status: 404 }],
+ ];
+ expect((await checkEntry(client(rules), refs, DEADLINE, 0)).outcome).toBe(
+ "unreadable",
+ );
+ rules[1] = ["/repos/t/b", repoReply("2026-02-08T11:00:00Z")];
+ expect((await checkEntry(client(rules), refs, DEADLINE, 0)).outcome).toBe("ok");
+ });
+
+ it("has no repo to check without a link", async () => {
+ expect(await checkEntry(client([]), [], DEADLINE, 0)).toMatchObject({
+ outcome: "no_repo",
+ repos: [],
+ });
+ });
+
+ it("is an error when the rate limit hits before any repo is late", async () => {
+ const done = await checkEntry(
+ client([
+ ["/repos/t/a", repoReply("2026-02-08T10:00:00Z")],
+ ["/repos/t/b", { status: 429 }],
+ ]),
+ refs,
+ DEADLINE,
+ 0,
+ );
+ expect(done.outcome).toBe("error");
+ });
+
+ it("still reports a late repo found before the rate limit", async () => {
+ const done = await checkEntry(
+ client([
+ ["/commits", { status: 500 }],
+ ["/events", { status: 500 }],
+ ["/repos/t/a", repoReply("2026-02-08T13:00:00Z")],
+ ["/repos/t/b", { status: 429 }],
+ ]),
+ refs,
+ DEADLINE,
+ 0,
+ );
+ expect(done.outcome).toBe("late");
+ });
+});
+
const result = (over: Partial): RepoResult => ({
outcome: "late",
ref,
pushedAt: new Date("2026-02-08T13:00:00Z"),
evidence: { commits: [], pushes: [] },
+ repos: [],
...over,
});
@@ -383,6 +498,9 @@ const decided = (over: Partial = {}): RepoCheckRow => ({
decidedAt: new Date("2026-02-08T15:00:00Z"),
note: "agreed with the team",
removedEntry: false,
+ removedDeletedAt: null,
+ repos: [],
+ lastOutcome: "late",
...over,
});
@@ -398,8 +516,39 @@ describe("nextCheck", () => {
});
it("refreshes a pending check", () => {
- const row = nextCheck(decided({ decision: "pending", decidedAt: null }), result({ outcome: "ok" }), checkedAt);
- expect(row).toMatchObject({ outcome: "ok", decision: "pending" });
+ const row = nextCheck(
+ decided({ decision: "pending", decidedAt: null, outcome: "ok" }),
+ result({ outcome: "late" }),
+ checkedAt,
+ );
+ expect(row).toMatchObject({ outcome: "late", decision: "pending" });
+ });
+
+ it("keeps a pending late flag when a later check cannot look or finds nothing", () => {
+ const flag = decided({ decision: "pending", decidedAt: null });
+ for (const outcome of ["error", "unreadable", "no_repo", "ok"] as const) {
+ expect(
+ nextCheck(flag, result({ outcome, pushedAt: null, evidence: null }), checkedAt),
+ ).toMatchObject({
+ outcome: "late",
+ pushedAt: flag.pushedAt,
+ evidence: flag.evidence,
+ decision: "pending",
+ lastOutcome: outcome,
+ checkedAt,
+ });
+ }
+ });
+
+ it("replaces a pending late flag with a newer late one", () => {
+ const pushedAt = new Date("2026-02-08T18:00:00Z");
+ expect(
+ nextCheck(
+ decided({ decision: "pending", decidedAt: null }),
+ result({ pushedAt }),
+ checkedAt,
+ ),
+ ).toMatchObject({ outcome: "late", pushedAt, lastOutcome: "late" });
});
it("leaves a decided entry alone when nothing was pushed since", () => {
@@ -459,7 +608,7 @@ type Write = { table: string; values: Record };
type Chain = PromiseLike & {
from: (table: unknown) => Chain;
leftJoin: () => Chain;
- where: () => Chain;
+ where: (condition?: unknown) => Chain;
limit: () => Chain;
};
@@ -474,14 +623,18 @@ function fakeDb(opts: {
const claims: unknown[] = [];
const select = () => {
let table = "";
+ let first: unknown = null;
const chain = {
then: (ok: (v: unknown) => unknown, err: (e: unknown) => unknown) => {
+ const checks = (opts.checks ?? []).filter(
+ (c) => first === HACK || c.entryId === first,
+ );
const rows =
table === getTableName(judgingConfigs)
? [opts.config === null ? undefined : (opts.config ?? { graceMinutes: 0 })]
: table === getTableName(judgingEntries)
? (opts.entries ?? [])
- : (opts.checks ?? []);
+ : checks;
return Promise.resolve(rows.filter(Boolean)).then(ok, err);
},
} as unknown as Chain;
@@ -490,11 +643,15 @@ function fakeDb(opts: {
return chain;
};
chain.leftJoin = () => chain;
- chain.where = () => chain;
+ chain.where = (condition?: unknown) => {
+ first = condition ? new PgDialect().sqlToQuery(condition as never).params[0] : null;
+ return chain;
+ };
chain.limit = () => chain;
return chain;
};
const db = {
+ transaction: async (callback: (tx: unknown) => unknown) => callback(db),
query: {
hackathons: {
findFirst: async () => ({
@@ -658,6 +815,70 @@ describe("runRepoCheck", () => {
});
});
+ it("keeps a decision an organiser makes while the run is in flight", async () => {
+ const checks: Record[] = [];
+ const { db, writes } = fakeDb({
+ entries: [
+ entryRow("e1", "https://github.com/t/a"),
+ entryRow("e2", "https://github.com/t/b"),
+ ],
+ checks,
+ });
+ // GitHub answers late for both; while it does, e1 is disqualified.
+ const fetchFn = vi.fn(async (input: unknown) => {
+ if (String(input).endsWith("/repos/t/a")) {
+ checks.push(
+ decided({
+ entryId: "e1",
+ decision: "disqualified",
+ removedEntry: true,
+ decidedAt: new Date("2026-02-09T00:00:00Z"),
+ }),
+ );
+ }
+ return new Response(JSON.stringify({ pushed_at: "2026-02-08T13:00:00Z" }));
+ }) as unknown as typeof fetch;
+ await runRepoCheck(db, HACK, {
+ fetchFn,
+ now: new Date("2026-02-09T00:00:01Z"),
+ });
+ const rows = written(writes);
+ expect(rows.has("e1")).toBe(false);
+ expect(rows.get("e2")).toMatchObject({ outcome: "late", decision: "pending" });
+ });
+
+ it("checks the longest unchecked first", async () => {
+ const { db } = fakeDb({
+ entries: [
+ entryRow("fresh", "https://github.com/t/fresh"),
+ entryRow("never", "https://github.com/t/never"),
+ entryRow("stale", "https://github.com/t/stale"),
+ ],
+ checks: [
+ decided({
+ entryId: "fresh",
+ decision: "pending",
+ decidedAt: null,
+ checkedAt: new Date("2026-02-08T19:00:00Z"),
+ }),
+ decided({
+ entryId: "stale",
+ decision: "pending",
+ decidedAt: null,
+ checkedAt: new Date("2026-02-08T13:00:00Z"),
+ }),
+ ],
+ });
+ const fetched = fakeFetch([["/repos", repoReply(null)]]);
+ // One at a time so the order of requests is the order of the work.
+ await runRepoCheck(db, HACK, { fetchFn: fetched.fn });
+ expect(fetched.calls.map((c) => c.url.split("/").pop())).toEqual([
+ "never",
+ "stale",
+ "fresh",
+ ]);
+ });
+
it("does not run twice at once", async () => {
const { db } = fakeDb({ entries: [entryRow("a", "https://github.com/t/a")] });
const fetched = fakeFetch([["/repos/t/a", repoReply(null)]]);
diff --git a/packages/api/src/services/judging/repo-check.ts b/packages/api/src/services/judging/repo-check.ts
index 86b015a5..f2a298d4 100644
--- a/packages/api/src/services/judging/repo-check.ts
+++ b/packages/api/src/services/judging/repo-check.ts
@@ -22,6 +22,7 @@ import {
computeSubmissionWindow,
loadHackathonBaseTime,
} from "../../routers/team/windows";
+import { lockEdition } from "./challenges";
export type RepoRef = { owner: string; repo: string };
export type RepoOutcome = "ok" | "late" | "no_repo" | "unreadable" | "error";
@@ -30,13 +31,24 @@ export type RepoEvidence = NonNullable<
>;
export type RepoCheckRow = typeof judgingRepoChecks.$inferSelect;
-export type RepoResult = {
+export type RepoPart = NonNullable[number];
+
+/** One repo against the deadline. */
+export type RepoOne = {
outcome: RepoOutcome;
ref: RepoRef | null;
pushedAt: Date | null;
evidence: RepoEvidence | null;
};
+/**
+ * An entry against the deadline: the repo that decided the outcome, and every
+ * repo that was looked at.
+ */
+export type RepoResult = RepoOne & { repos: RepoPart[] };
+
+const MAX_REPOS = 3;
+
const GITHUB_API = "https://api.github.com";
const REQUEST_TIMEOUT_MS = 10_000;
const MAX_IN_FLIGHT = 5;
@@ -93,18 +105,26 @@ export function parseRepoUrl(raw: string | null | undefined): RepoRef | null {
return { owner, repo };
}
-/** The portal project's repo link, else the first repo link among the demo links. */
-export function repoOfEntry(entry: {
+/**
+ * The distinct repos an entry links to, at most three: the portal project's
+ * link first, then the repo links among the demo links in order.
+ */
+export function reposOfEntry(entry: {
githubUrl: string | null;
demoLinks: readonly string[];
-}): RepoRef | null {
- const own = parseRepoUrl(entry.githubUrl);
- if (own) return own;
- for (const link of entry.demoLinks) {
- const found = parseRepoUrl(link);
- if (found) return found;
+}): RepoRef[] {
+ const found: RepoRef[] = [];
+ const seen = new Set();
+ for (const link of [entry.githubUrl, ...entry.demoLinks]) {
+ const ref = parseRepoUrl(link);
+ if (!ref) continue;
+ const key = `${ref.owner}/${ref.repo}`.toLowerCase();
+ if (seen.has(key)) continue;
+ seen.add(key);
+ found.push(ref);
+ if (found.length === MAX_REPOS) break;
}
- return null;
+ return found;
}
/** Later than the deadline plus grace. A push exactly on the line is on time. */
@@ -112,6 +132,14 @@ export function isLate(pushedAt: Date, deadline: Date, graceMinutes: number) {
return pushedAt.getTime() > deadline.getTime() + graceMinutes * MINUTE_MS;
}
+// GitHub's JSON is read defensively: a field that is not what it should be
+// reads as missing.
+const obj = (value: unknown): Record =>
+ typeof value === "object" && value !== null
+ ? (value as Record)
+ : {};
+const text = (value: unknown) => (typeof value === "string" ? value : null);
+
/** GitHub said to slow down, or the budget for this run is spent. */
export class RateLimited extends Error {}
@@ -145,26 +173,22 @@ export class GithubClient {
const remaining = response.headers.get("x-ratelimit-remaining");
if (remaining === "0") this.exhausted = true;
+ const body: unknown = await response.json().catch(() => null);
+ // A secondary rate limit is a 403 whose message says so.
const limited =
response.status === 429 ||
(response.status === 403 &&
- (remaining === "0" || response.headers.has("retry-after")));
+ (remaining === "0" ||
+ response.headers.has("retry-after") ||
+ /rate limit/i.test(text(obj(body).message) ?? "")));
if (limited) {
this.exhausted = true;
throw new RateLimited("rate limited");
}
- const body: unknown = await response.json().catch(() => null);
return { status: response.status, body };
}
}
-// GitHub's JSON is read defensively: a field that is not what it should be
-// reads as missing.
-const obj = (value: unknown): Record =>
- typeof value === "object" && value !== null
- ? (value as Record)
- : {};
-const text = (value: unknown) => (typeof value === "string" ? value : null);
/** Commits and pushes after the deadline. Context only; a failure leaves it short. */
async function gatherEvidence(
@@ -225,7 +249,7 @@ export async function checkRepo(
ref: RepoRef,
deadline: Date,
graceMinutes: number,
-): Promise {
+): Promise {
let reply: GithubReply;
try {
reply = await client.get(`/repos/${ref.owner}/${ref.repo}`);
@@ -253,11 +277,66 @@ export async function checkRepo(
};
}
+/**
+ * Every repo an entry links to (at most three), one after the other. Late if
+ * any of them is, with that repo's evidence; otherwise the worst thing that
+ * happened (a rate limit or error, then an unreadable repo). Throws nothing:
+ * a rate limit part way through ends the entry with what it has.
+ */
+export async function checkEntry(
+ client: GithubClient,
+ refs: readonly RepoRef[],
+ deadline: Date,
+ graceMinutes: number,
+): Promise {
+ if (refs.length === 0) {
+ return { outcome: "no_repo", ref: null, pushedAt: null, evidence: null, repos: [] };
+ }
+ const done: RepoOne[] = [];
+ let limited = false;
+ for (const ref of refs) {
+ try {
+ done.push(await checkRepo(client, ref, deadline, graceMinutes));
+ } catch (error) {
+ if (!(error instanceof RateLimited)) throw error;
+ limited = true;
+ break;
+ }
+ }
+ const repos: RepoPart[] = done.map((one) => ({
+ owner: one.ref!.owner,
+ repo: one.ref!.repo,
+ outcome: one.outcome as RepoPart["outcome"],
+ pushedAt: one.pushedAt?.toISOString() ?? null,
+ evidence: one.evidence,
+ }));
+
+ const late = done
+ .filter((one) => one.outcome === "late")
+ .sort((a, b) => b.pushedAt!.getTime() - a.pushedAt!.getTime());
+ const primary =
+ late[0] ??
+ (limited ? undefined : done.find((one) => one.outcome === "error")) ??
+ done.find((one) => one.outcome === "unreadable") ??
+ done
+ .filter((one) => one.outcome === "ok")
+ .sort(
+ (a, b) => (b.pushedAt?.getTime() ?? 0) - (a.pushedAt?.getTime() ?? 0),
+ )[0];
+ if (primary && (primary.outcome === "late" || !limited)) {
+ return { ...primary, repos };
+ }
+ return { outcome: "error", ref: refs[0]!, pushedAt: null, evidence: null, repos };
+}
+
/**
* The row to store after a check, or null to leave what is there. An entry an
* organiser has decided stays decided unless the repo was pushed to after the
* decision, which reopens it as pending with the new evidence; a check that
- * could not look (error, unreadable, no repo) never undoes a decision.
+ * could not look (error, unreadable, no repo) never undoes a decision. A flag
+ * not yet decided is kept, evidence and all, until a later check finds it
+ * late again: `pushed_at` only moves forward, and a repo made private after
+ * the push must not wipe it. The latest attempt is kept in `lastOutcome`.
*/
export function nextCheck(
existing: RepoCheckRow | undefined,
@@ -270,13 +349,21 @@ export function nextCheck(
outcome: result.outcome,
pushedAt: result.pushedAt,
evidence: result.evidence,
+ repos: result.repos,
+ lastOutcome: result.outcome,
checkedAt,
decision: "pending" as const,
decidedById: null,
decidedAt: null,
note: null,
removedEntry: existing?.removedEntry ?? false,
+ removedDeletedAt: existing?.removedDeletedAt ?? null,
};
+ if (existing?.decision === "pending" && existing.outcome === "late") {
+ if (result.outcome === "late") return fresh;
+ const { entryId: _entryId, hackathonId: _hackathonId, ...kept } = existing;
+ return { ...kept, lastOutcome: result.outcome, checkedAt };
+ }
if (!existing || existing.decision === "pending") return fresh;
const pushedSince =
result.outcome === "late" &&
@@ -303,11 +390,17 @@ async function mapLimit(
type RunDeps = { fetchFn?: typeof fetch; token?: string; now?: Date };
-/** Editions with a run going in this process, so two never overlap. */
-const running = new Set();
+/**
+ * Editions with a run going in this process, so two never overlap. A run older
+ * than this is taken for stuck and no longer blocks the next one.
+ */
+const running = new Map();
+const RUN_STALE_MS = 10 * 60 * 1000;
-export const repoCheckRunning = (hackathonId: string) =>
- running.has(hackathonId);
+export const repoCheckRunning = (hackathonId: string) => {
+ const run = running.get(hackathonId);
+ return run !== undefined && Date.now() - run.at < RUN_STALE_MS;
+};
/**
* Checks every project of the edition, five repos at a time. When GitHub's rate
@@ -319,8 +412,9 @@ export async function runRepoCheck(
hackathonId: string,
deps: RunDeps = {},
) {
- if (running.has(hackathonId)) return;
- running.add(hackathonId);
+ if (repoCheckRunning(hackathonId)) return;
+ const mine = { at: Date.now() };
+ running.set(hackathonId, mine);
try {
const now = deps.now ?? new Date();
const [config] = await db
@@ -354,34 +448,43 @@ export async function runRepoCheck(
const before = new Map(existing.map((row) => [row.entryId, row]));
// A removed project is looked at only if it was disqualified here, to see
// whether it was pushed to again.
- const todo = entries.filter(
- (entry) =>
- !entry.deletedAt || before.get(entry.id)?.decision === "disqualified",
- );
+ // Longest unchecked first, so with a small rate limit the tail still gets
+ // its turn over successive runs.
+ const checkedAt = (id: string) => before.get(id)?.checkedAt.getTime() ?? 0;
+ const todo = entries
+ .filter(
+ (entry) =>
+ !entry.deletedAt || before.get(entry.id)?.decision === "disqualified",
+ )
+ .sort((a, b) => checkedAt(a.id) - checkedAt(b.id));
const client = new GithubClient(
deps.fetchFn ?? fetch,
deps.token ?? (process.env.GITHUB_TOKEN || undefined),
);
await mapLimit(todo, MAX_IN_FLIGHT, async (entry) => {
- const ref = repoOfEntry(entry);
- let result: RepoResult;
- if (!ref) {
- result = { outcome: "no_repo", ref: null, pushedAt: null, evidence: null };
- } else {
- try {
- result = await checkRepo(client, ref, deadline, config.graceMinutes);
- } catch (error) {
- if (!(error instanceof RateLimited)) throw error;
- result = { outcome: "error", ref, pushedAt: null, evidence: null };
- }
- }
- const row = nextCheck(before.get(entry.id), result, now);
- if (!row) return;
- await db
- .insert(judgingRepoChecks)
- .values({ entryId: entry.id, hackathonId, ...row })
- .onConflictDoUpdate({ target: judgingRepoChecks.entryId, set: row });
+ const result = await checkEntry(
+ client,
+ reposOfEntry(entry),
+ deadline,
+ config.graceMinutes,
+ );
+ // Under the edition lock the decision procedures take, and against the
+ // row as it is now: an organiser may have decided while GitHub answered.
+ await db.transaction(async (tx) => {
+ await lockEdition(tx, hackathonId);
+ const [current] = await tx
+ .select()
+ .from(judgingRepoChecks)
+ .where(eq(judgingRepoChecks.entryId, entry.id))
+ .limit(1);
+ const row = nextCheck(current, result, now);
+ if (!row) return;
+ await tx
+ .insert(judgingRepoChecks)
+ .values({ entryId: entry.id, hackathonId, ...row })
+ .onConflictDoUpdate({ target: judgingRepoChecks.entryId, set: row });
+ });
});
} catch (error) {
// Deliberate operational logging: nothing awaits a background run, so this
@@ -389,7 +492,7 @@ export async function runRepoCheck(
// eslint-disable-next-line no-console
console.error(`[Judging] Repo check for ${hackathonId} failed:`, error);
} finally {
- running.delete(hackathonId);
+ if (running.get(hackathonId) === mine) running.delete(hackathonId);
}
}
diff --git a/packages/db/src/schemas/judging.ts b/packages/db/src/schemas/judging.ts
index 7cd4d350..2cdaecb6 100644
--- a/packages/db/src/schemas/judging.ts
+++ b/packages/db/src/schemas/judging.ts
@@ -209,6 +209,35 @@ export const judgingRepoChecks = pgTable(
decidedAt: timestamp("decided_at"),
note: text("note"),
removedEntry: boolean("removed_entry").notNull().default(false),
+ // The `deletedAt` the disqualification set, so reinstating undoes only
+ // that removal and not a later manual one.
+ removedDeletedAt: timestamp("removed_deleted_at"),
+ // Every repo checked for the entry (at most 3); the columns above describe
+ // the one that decided the outcome.
+ repos: jsonb("repos").$type<
+ {
+ owner: string;
+ repo: string;
+ outcome: "ok" | "late" | "unreadable" | "error";
+ pushedAt: string | null;
+ evidence: {
+ commits: {
+ sha: string;
+ message: string;
+ author: string | null;
+ committedAt: string | null;
+ url: string;
+ }[];
+ pushes: { at: string; ref: string | null; pusher: string | null }[];
+ } | null;
+ }[]
+ >(),
+ // How the latest attempt went. A flag raised by an earlier attempt is kept
+ // when a later one cannot look (a repo made private), so this is shown
+ // beside it.
+ lastOutcome: text("last_outcome", {
+ enum: ["ok", "late", "no_repo", "unreadable", "error"],
+ }),
},
(table) => [index("judging_repo_check_hackathon_idx").on(table.hackathonId)],
);
From 5a2c880eda907fcf15c45d204cdf6896b8097a58 Mon Sep 17 00:00:00 2001
From: aamoghS
Date: Sun, 25 Oct 2026 18:07:15 -0400
Subject: [PATCH 09/11] fix(api): wait for check now, make reinstate exact
Check now waits up to 25 seconds for the run, a stale in-process run no longer blocks the next, a new grace re-works pending flags, and reinstating restores only the removal the disqualification made.
---
.../repo-check-decisions.test.ts | 37 ++++++++++++++
.../src/routers/judging/admin/repo-check.ts | 48 +++++++++++++++++--
2 files changed, 80 insertions(+), 5 deletions(-)
diff --git a/packages/api/src/.internal-tests/repo-check-decisions.test.ts b/packages/api/src/.internal-tests/repo-check-decisions.test.ts
index d6225a0e..d71d916a 100644
--- a/packages/api/src/.internal-tests/repo-check-decisions.test.ts
+++ b/packages/api/src/.internal-tests/repo-check-decisions.test.ts
@@ -87,6 +87,15 @@ const fakeDb: any = {
});
},
}),
+ delete: (table: unknown) => ({
+ where: () => {
+ // A new grace drops the flags nobody has decided, unless the project is out.
+ if (table === judgingRepoChecks) {
+ checks = checks.filter((c) => c.decision !== "pending" || c.removedEntry);
+ }
+ return Promise.resolve(undefined);
+ },
+ }),
update: (table: unknown) => ({
set: (values: Row) => ({
where: (where: unknown) => {
@@ -147,6 +156,7 @@ const check = (entryId: string, over: Row = {}): Row => ({
decidedAt: null,
note: null,
removedEntry: false,
+ removedDeletedAt: null,
...over,
});
@@ -176,6 +186,7 @@ beforeEach(() => {
check(WITHDRAWN, {
decision: "disqualified",
removedEntry: true,
+ removedDeletedAt: new Date("2026-02-08T10:00:00Z"),
}),
];
projects = [{ id: id(50), status: "draft", withdrawnByAdminAt: null }];
@@ -199,6 +210,7 @@ describe("judging.admin.decideRepoCheck", () => {
decidedById: "admin_user",
note: "pushed 40 minutes after the deadline",
removedEntry: true,
+ removedDeletedAt: byId(LATE).deletedAt,
});
expect(audits).toHaveLength(1);
expect(audits[0]).toMatchObject({
@@ -314,6 +326,28 @@ describe("judging.admin.reinstateRepoCheck", () => {
expect(audits[0]).toMatchObject({ action: "judging.repoCheck.reinstate" });
});
+ it("does not undo a removal made after the disqualification", async () => {
+ await admin().judging.admin.decideRepoCheck({
+ hackathonId: HACK,
+ entryId: LATE,
+ decision: "disqualified",
+ });
+ // Restored by hand and removed again for another reason.
+ const removedAgain = new Date(byId(LATE).deletedAt.getTime() + 60_000);
+ byId(LATE).deletedAt = removedAgain;
+
+ await admin().judging.admin.reinstateRepoCheck({
+ hackathonId: HACK,
+ entryId: LATE,
+ });
+ expect(byId(LATE).deletedAt).toEqual(removedAgain);
+ expect(checkOf(LATE)).toMatchObject({
+ decision: "cleared",
+ removedEntry: false,
+ removedDeletedAt: null,
+ });
+ });
+
it("keeps the restore refusal for a withdrawn portal submission", async () => {
await expect(
admin().judging.admin.reinstateRepoCheck({
@@ -349,6 +383,9 @@ describe("judging.admin.setRepoCheck", () => {
repoCheckLastRunAt: null,
});
expect(repoCheckThrottle.has(HACK)).toBe(false);
+ // The pending flags are worked out again with the new grace.
+ expect(checks.map((c) => c.entryId)).not.toContain(LATE);
+ expect(checkOf(WITHDRAWN)).toBeDefined();
expect(audits[0]).toMatchObject({ action: "judging.repoCheck.configure" });
});
diff --git a/packages/api/src/routers/judging/admin/repo-check.ts b/packages/api/src/routers/judging/admin/repo-check.ts
index 53ccb96a..579c6ddd 100644
--- a/packages/api/src/routers/judging/admin/repo-check.ts
+++ b/packages/api/src/routers/judging/admin/repo-check.ts
@@ -33,6 +33,8 @@ import {
const note = z.string().trim().max(500).optional();
+const RUN_NOW_WAIT_MS = 25_000;
+
/** What a decision about one entry's repo check needs, under the edition lock. */
async function lockCheck(
tx: Parameters[0]>[0],
@@ -75,6 +77,8 @@ export const repoCheckProcedures = {
decidedAt: judgingRepoChecks.decidedAt,
note: judgingRepoChecks.note,
removedEntry: judgingRepoChecks.removedEntry,
+ repos: judgingRepoChecks.repos,
+ lastOutcome: judgingRepoChecks.lastOutcome,
})
.from(judgingRepoChecks)
.innerJoin(
@@ -120,6 +124,19 @@ export const repoCheckProcedures = {
const db = ctx.db as DrizzleDB;
await assertHackathonExists(db, input.hackathonId);
await ensureJudgingConfig(db, input.hackathonId);
+ // Flags not yet decided were worked out with the old grace; they are
+ // checked afresh. Decided ones, and ones whose project is out, stay.
+ if (input.graceMinutes !== undefined) {
+ await db
+ .delete(judgingRepoChecks)
+ .where(
+ and(
+ eq(judgingRepoChecks.hackathonId, input.hackathonId),
+ eq(judgingRepoChecks.decision, "pending"),
+ eq(judgingRepoChecks.removedEntry, false),
+ ),
+ );
+ }
await db
.update(judgingConfigs)
.set({
@@ -148,25 +165,37 @@ export const repoCheckProcedures = {
return { ok: true };
}),
- /** Starts a run now and returns; the next read shows what it found. */
+ /**
+ * Runs now. Waits for it up to 25 seconds, as work left running after the
+ * response may be stalled; every project is saved as it is done, so what
+ * finished by then is kept and the run carries on for the rest.
+ */
runRepoCheckNow: isAdmin.input(hackathonInput).mutation(async ({ ctx, input }) => {
const db = ctx.db as DrizzleDB;
await assertHackathonExists(db, input.hackathonId);
await ensureJudgingConfig(db, input.hackathonId);
- if (repoCheckRunning(input.hackathonId)) return { started: false };
+ if (repoCheckRunning(input.hackathonId)) {
+ return { started: false, finished: false };
+ }
const now = new Date();
await db
.update(judgingConfigs)
.set({ repoCheckLastRunAt: now })
.where(eq(judgingConfigs.hackathonId, input.hackathonId));
repoCheckThrottle.set(input.hackathonId, now.getTime());
- void runRepoCheck(db, input.hackathonId, { now });
+ const run = runRepoCheck(db, input.hackathonId, { now }).then(() => true);
+ const finished = await Promise.race([
+ run,
+ new Promise((resolve) =>
+ setTimeout(() => resolve(false), RUN_NOW_WAIT_MS).unref?.(),
+ ),
+ ]);
await recordAdminAction(db, {
userId: ctx.userId,
action: "judging.repoCheck.run",
resourceId: input.hackathonId,
});
- return { started: true };
+ return { started: true, finished };
}),
/**
@@ -217,6 +246,7 @@ export const repoCheckProcedures = {
decidedAt: now,
note: input.note || null,
removedEntry: check.removedEntry || removed,
+ removedDeletedAt: removed ? now : check.removedDeletedAt,
})
.where(eq(judgingRepoChecks.entryId, entry.id));
return { entry, removed };
@@ -254,7 +284,14 @@ export const repoCheckProcedures = {
if (check.decision !== "disqualified" && !check.removedEntry) {
throw refuse(`${entry.title} is not disqualified.`);
}
- if (check.removedEntry && entry.deletedAt) {
+ // Only the removal the disqualification made: an organiser who
+ // restored the project and removed it again for another reason has
+ // that removal stand.
+ if (
+ check.removedEntry &&
+ entry.deletedAt &&
+ entry.deletedAt.getTime() === check.removedDeletedAt?.getTime()
+ ) {
await restoreLockedEntry(tx, entry);
}
await tx
@@ -265,6 +302,7 @@ export const repoCheckProcedures = {
decidedAt: new Date(),
note: input.note || null,
removedEntry: false,
+ removedDeletedAt: null,
})
.where(
and(
From a65804c8468ad74c12bceb91e61dfe06ed190119 Mon Sep 17 00:00:00 2001
From: aamoghS
Date: Sun, 25 Oct 2026 18:07:15 -0400
Subject: [PATCH 10/11] feat(mainweb): show every repo, Eastern times and a
missing-token warning
Documents the new behaviour and adds the commented-out GITHUB_TOKEN entry to apphosting.yaml.
---
apphosting.yaml | 8 +
docs/judging/repo-check.md | 15 +-
.../admin/judging/RepoCheckSection.test.tsx | 39 +++-
.../admin/judging/RepoCheckSection.tsx | 202 +++++++++++-------
4 files changed, 165 insertions(+), 99 deletions(-)
diff --git a/apphosting.yaml b/apphosting.yaml
index c85f4c2c..d09536d6 100644
--- a/apphosting.yaml
+++ b/apphosting.yaml
@@ -47,6 +47,14 @@ env:
secret: AUTH_GITHUB_ID
- variable: GITHUB_CLIENT_SECRET
secret: AUTH_GITHUB_SECRET
+ # Optional: raises the judging repo check from 60 to 5000 GitHub requests an
+ # hour. Left commented out because a reference to a secret that does not exist
+ # breaks the rollout. Create it first (a token with no scopes is enough), then
+ # uncomment:
+ # firebase apphosting:secrets:set GITHUB_REPO_CHECK_TOKEN
+ # firebase apphosting:secrets:grantaccess GITHUB_REPO_CHECK_TOKEN
+ # - variable: GITHUB_TOKEN
+ # secret: GITHUB_REPO_CHECK_TOKEN
- variable: AUTH_URL
value: https://datasciencegt.org
- variable: NEXTAUTH_URL
diff --git a/docs/judging/repo-check.md b/docs/judging/repo-check.md
index c34ac977..e3723f7d 100644
--- a/docs/judging/repo-check.md
+++ b/docs/judging/repo-check.md
@@ -17,24 +17,24 @@ The deadline is the submission close time, the same one the team submission wind
## Which repo
-The portal project's GitHub link, else the first github.com repo link among the Devpost demo links. The link is read strictly: a trailing `.git`, `/tree/...` or any other path after the repo is ignored, and anything that is not `github.com/owner/repo` (a profile, another host) counts as no repo.
+Every distinct github.com repo the entry links to, at most three: the portal project's GitHub link, then the repo links among the Devpost demo links. The entry is flagged if any of them is late, and evidence is kept per repo. A link is read strictly: a trailing `.git`, `/tree/...` or any other path after the repo is ignored, and anything that is not `github.com/owner/repo` (a profile, another host) counts as no repo.
| Outcome | Meaning |
| --- | --- |
| `ok` | Last push at or before the deadline plus grace |
| `late` | Pushed after the deadline plus grace |
| `no_repo` | No usable repo link |
-| `unreadable` | GitHub answered 401, 403, 404, 410 or 451: private, deleted or blocked |
+| `unreadable` | GitHub answered 401, 403, 404, 410 or 451: private, deleted or blocked (a 403 that mentions a rate limit is `error` instead) |
| `error` | Rate limit, timeout or network failure. Retried on the next run. |
-Everything except `ok` appears in the flagged list.
+Everything except `ok` appears in the flagged list. A flag that is still pending is kept, evidence and all, when a later check cannot look (a repo made private) or finds nothing, since `pushed_at` only moves forward; the latest attempt is shown beside it. Changing the grace re-works pending flags from scratch.
## Organiser flow
Open the Repo check section of the judging console.
1. Optionally set the grace in minutes (default 0). Only pushes after the deadline plus the grace are flagged.
-2. Turn the check on. While judging is open and someone has the console open, it runs every 15 minutes. Check now runs it at once, any time, whatever the switch.
+2. Turn the check on. While judging is open and someone has the console open, it runs every 15 minutes. Check now runs it at once, any time, whatever the switch, and waits up to 25 seconds; whatever finished is saved and the run carries on for the rest.
3. For each flagged project, read the repo link, the last push against the deadline (Eastern time) and the evidence, add an optional note, then:
- **Disqualify** takes the project out of judging, the same way removing it does (see below).
- **Clear** closes the flag and changes nothing else.
@@ -50,7 +50,7 @@ It sets `deletedAt` on the judging entry, which is what removing a project does.
Unlike Remove project, it does not require the saved schedule to be dropped first: the board already treats a removed project's appointments as removed.
-Reinstating clears `deletedAt` through `restoreLockedEntry`, which is also what Restore uses, and so refuses a portal submission whose team withdrew it. It only restores a project the disqualification itself removed (`removed_entry` on the check row); one an organiser had already removed stays removed.
+Reinstating restores only if the project is still deleted with the `deletedAt` the disqualification set (`removed_deleted_at`), so a manual restore followed by another removal is not undone. It clears `deletedAt` through `restoreLockedEntry`, which is also what Restore uses, and so refuses a portal submission whose team withdrew it. It only restores a project the disqualification itself removed (`removed_entry` on the check row); one an organiser had already removed stays removed.
## How it runs
@@ -59,12 +59,15 @@ There is no background worker, matching the lazy re-balance. `judging.admin.over
- An in-process throttle allows one attempt per edition every 15 minutes.
- The attempt claims the run in the database: one update of `judging_config.repo_check_last_run_at`, only if the switch is on, judging is `open` and the last run is older than 15 minutes. Only the instance that wins the claim starts a run, so several instances do not all call GitHub.
- The run happens in the background; the poll returns at once and a later poll shows the results.
+- Projects checked longest ago (or never) go first, so a low rate limit still reaches the tail over several runs.
+- Each result is saved under the same edition lock the decisions take, against the row as it is then, so a decision made during a run is never overwritten.
+- A run in this process older than 10 minutes no longer blocks the next one.
- At most 5 GitHub requests are in flight, each with a 10 second timeout and a `User-Agent`.
- Turning the switch on or changing the grace makes the next poll run it.
## GitHub token and rate limits
-Set `GITHUB_TOKEN` (optional, any token that can read public repos) for 5000 requests an hour. Without it GitHub allows 60 an hour, enough for a small edition. When GitHub answers with its rate limit, or the last response says no requests remain, the run stops: projects not yet checked are recorded as `error` (a decided project keeps its decision) and the next run, 15 minutes later, picks them up.
+Set `GITHUB_TOKEN` (optional, any token that can read public repos) for 5000 requests an hour. The console warns while it is missing, and `apphosting.yaml` has the entry commented out until the secret exists. Without it GitHub allows 60 an hour, enough for a small edition. When GitHub answers with its rate limit, or the last response says no requests remain, the run stops: projects not yet checked are recorded as `error` (a decided project keeps its decision) and the next run, 15 minutes later, picks them up.
## Limits
diff --git a/sites/mainweb/components/admin/judging/RepoCheckSection.test.tsx b/sites/mainweb/components/admin/judging/RepoCheckSection.test.tsx
index 7635598a..2d3ddf98 100644
--- a/sites/mainweb/components/admin/judging/RepoCheckSection.test.tsx
+++ b/sites/mainweb/components/admin/judging/RepoCheckSection.test.tsx
@@ -20,18 +20,30 @@ const mocks = vi.hoisted(() => ({
repoName: "app",
outcome: "late",
pushedAt: "2026-02-08T13:00:00.000Z",
- evidence: {
- commits: [
- {
- sha: "abcdef123456",
- message: "last minute fix",
- author: "Ada",
- committedAt: "1999-01-01T00:00:00Z",
- url: "https://github.com/team/app/commit/abcdef1",
+ lastOutcome: "unreadable",
+ repos: [
+ {
+ owner: "team",
+ repo: "app",
+ outcome: "late",
+ pushedAt: "2026-02-08T13:00:00.000Z",
+ evidence: {
+ commits: [
+ {
+ sha: "abcdef123456",
+ message: "last minute fix",
+ author: "Ada",
+ committedAt: "1999-01-01T00:00:00Z",
+ url: "https://github.com/team/app/commit/abcdef1",
+ },
+ ],
+ pushes: [
+ { at: "2026-02-08T13:00:00Z", ref: "refs/heads/dev", pusher: "ada" },
+ ],
},
- ],
- pushes: [{ at: "2026-02-08T13:00:00Z", ref: "refs/heads/dev", pusher: "ada" }],
- },
+ },
+ { owner: "team", repo: "docs", outcome: "ok", pushedAt: null, evidence: null },
+ ],
checkedAt: "2026-02-08T14:00:00.000Z",
decision: "pending",
decidedAt: null,
@@ -109,6 +121,11 @@ describe("RepoCheckSection", () => {
expect(html).toContain("last minute fix");
expect(html).toContain("Pushes to any branch count");
expect(html).toContain("for context only");
+ expect(html).toContain("https://github.com/team/docs");
+ expect(html).toContain("Latest check: repo not readable");
+ // The last run, 14:00 UTC, is 9:00 AM Eastern.
+ expect(html).toContain("Last run Sun, Feb 8, 9:00 AM Eastern");
+ expect(html).toContain("GitHub allows only 60 requests an hour");
expect(html).toContain("Disqualify");
expect(html).toContain("Clear");
expect(html).not.toContain("Reinstate");
diff --git a/sites/mainweb/components/admin/judging/RepoCheckSection.tsx b/sites/mainweb/components/admin/judging/RepoCheckSection.tsx
index 269fb568..e46fe9c0 100644
--- a/sites/mainweb/components/admin/judging/RepoCheckSection.tsx
+++ b/sites/mainweb/components/admin/judging/RepoCheckSection.tsx
@@ -25,9 +25,9 @@ import type { Tone } from "@/components/portal/ui";
import {
ErrorLine,
OFFICER_POLL_MS,
+ NoticeLine,
Toggle,
plural,
- shortTime,
useRefreshJudging,
} from "@/components/admin/judging/shared";
@@ -105,6 +105,15 @@ export function RepoCheckSection({ hackathonId }: { hackathonId: string }) {
) : (
<>
+ {!data.hasToken && (
+
+ No GitHub token is configured (GITHUB_TOKEN), so
+ GitHub allows only 60 requests an hour. That is roughly
+ one request per repo; projects past it are left as "could
+ not check" and picked up on later runs. Ask whoever runs the
+ deployment to add a token before judging day.
+
+ )}
- {!data.hasToken && (
-
- No GitHub token is set, so GitHub allows 60 requests an hour.
- Projects past that are retried on the next run. See{" "}
- GITHUB_TOKEN in the environment docs.
-