Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions src/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,12 @@ export function parseEnv(input: NodeJS.ProcessEnv): AppEnv {
]);
}

const normalizedKey = normalizePrivateKey(privateKey);

return {
...parsed,
GITHUB_PRIVATE_KEY: normalizePrivateKey(privateKey),
GITHUB_PRIVATE_KEY_PEM: normalizePrivateKey(privateKey),
GITHUB_PRIVATE_KEY: normalizedKey,
GITHUB_PRIVATE_KEY_PEM: normalizedKey,
};
}

Expand Down
30 changes: 15 additions & 15 deletions src/github/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,22 +219,22 @@ function getGitHubStatusCode(error: unknown) {
return undefined;
}

function getRetryAfterHeader(error: unknown): string | undefined {
if (typeof error !== "object" || error === null) return undefined;
const response = (error as Record<string, unknown>)["response"];
if (typeof response !== "object" || response === null) return undefined;
const headers = (response as Record<string, unknown>)["headers"];
if (typeof headers !== "object" || headers === null) return undefined;
const value = (headers as Record<string, unknown>)["retry-after"];
return value !== undefined ? String(value) : undefined;
}

function getRetryDelayMs(error: unknown, attempt: number) {
const headerValue =
typeof error === "object" &&
error !== null &&
"response" in error &&
typeof error.response === "object" &&
error.response !== null &&
"headers" in error.response &&
typeof error.response.headers === "object" &&
error.response.headers !== null &&
"retry-after" in error.response.headers
? Number(error.response.headers["retry-after"])
: undefined;

if (typeof headerValue === "number" && Number.isFinite(headerValue) && headerValue >= 0) {
return Math.min(headerValue * 1000, 5_000);
const headerValue = getRetryAfterHeader(error);
const delaySeconds = headerValue !== undefined ? Number(headerValue) : undefined;

if (typeof delaySeconds === "number" && Number.isFinite(delaySeconds) && delaySeconds >= 0) {
return Math.min(delaySeconds * 1000, 5_000);
}

return Math.min(250 * 2 ** attempt, 1_000);
Expand Down
9 changes: 8 additions & 1 deletion src/lib/replayStore.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
export class ReplayStore {
private readonly ttlMs: number;
private readonly pruneIntervalMs: number;
private readonly values = new Map<string, { expiresAt: number; status: "in_flight" | "completed" }>();
private lastPruneTime = 0;

constructor(ttlMs = 15 * 60 * 1000) {
constructor(ttlMs = 15 * 60 * 1000, pruneIntervalMs = 60_000) {
this.ttlMs = ttlMs;
this.pruneIntervalMs = pruneIntervalMs;
}

getStatus(key: string, now = Date.now()) {
Expand Down Expand Up @@ -32,6 +35,10 @@ export class ReplayStore {
}

prune(now = Date.now()) {
if (now - this.lastPruneTime < this.pruneIntervalMs) {
return;
}
this.lastPruneTime = now;
for (const [key, value] of this.values.entries()) {
if (value.expiresAt <= now) {
this.values.delete(key);
Expand Down
4 changes: 3 additions & 1 deletion src/webhooks/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,8 @@ async function buildReleaseJob(
});
}

const COMMIT_SHA_RE = /^[a-f0-9]{7,64}$/i;

function looksLikeCommitSha(value: string) {
return /^[a-f0-9]{7,64}$/i.test(value);
return COMMIT_SHA_RE.test(value);
}
105 changes: 105 additions & 0 deletions tests/replayStore.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { describe, expect, it } from "vitest";
import { ReplayStore } from "../src/lib/replayStore";

describe("ReplayStore", () => {
it("returns null for an unknown key", () => {
const store = new ReplayStore();
expect(store.getStatus("unknown")).toBeNull();
});

it("begin() returns 'started' for a new key", () => {
const store = new ReplayStore();
expect(store.begin("key-1")).toBe("started");
});

it("begin() returns 'in_flight' for a key already in flight", () => {
const store = new ReplayStore();
store.begin("key-1");
expect(store.begin("key-1")).toBe("in_flight");
});

it("begin() returns 'completed' for a completed key within TTL", () => {
const store = new ReplayStore();
const now = Date.now();
store.begin("key-1", now);
store.complete("key-1", now);
expect(store.begin("key-1", now + 1_000)).toBe("completed");
});

it("release() removes a key so it can be re-started", () => {
const store = new ReplayStore();
store.begin("key-1");
store.release("key-1");
expect(store.getStatus("key-1")).toBeNull();
expect(store.begin("key-1")).toBe("started");
});

describe("prune() throttling", () => {
it("does not scan entries before the prune interval elapses", () => {
const pruneIntervalMs = 60_000;
const store = new ReplayStore(15 * 60 * 1000, pruneIntervalMs);
const now = 1_000_000;
const farPast = now - 20 * 60 * 1000; // 20 minutes before now

// Insert an entry that has already expired
store.begin("expired-key", farPast);
// Manually force expiry by completing with a past time
store.complete("expired-key", farPast);

// The first prune call at `now` runs (lastPruneTime is 0, so interval has elapsed).
store.prune(now);

// Add a fresh entry
store.begin("live-key", now);

// A prune call just before the interval elapses should NOT scan (lastPruneTime = now)
const justBeforeInterval = now + pruneIntervalMs - 1;
store.prune(justBeforeInterval);

// live-key must still be accessible
expect(store.getStatus("live-key", justBeforeInterval)).toBe("in_flight");
});

it("scans entries once the prune interval has elapsed", () => {
const pruneIntervalMs = 60_000;
const ttlMs = 5 * 60 * 1000;
const store = new ReplayStore(ttlMs, pruneIntervalMs);
const now = 2_000_000;

// Insert a key and immediately expire it by completing it with an old timestamp
store.begin("old-key", now - ttlMs - 1);
store.complete("old-key", now - ttlMs - 1);

// First prune triggers the initial scan (lastPruneTime starts at 0)
store.prune(now);

// Insert another expired entry after the first prune
store.begin("another-old-key", now);
// Make it expire by the time the next prune fires
store.complete("another-old-key", now);

// Advance past the prune interval — this should trigger a scan
const nextPrune = now + pruneIntervalMs + 1;
store.prune(nextPrune);

// Entry with expiresAt = now + ttlMs should still be valid at nextPrune
// (it expires at now + ttlMs, and nextPrune = now + 60_001 < now + ttlMs = now + 300_000)
expect(store.getStatus("another-old-key", nextPrune)).toBe("completed");
});

it("expired entries are invisible after the prune interval fires", () => {
const pruneIntervalMs = 60_000;
const ttlMs = 1_000; // very short TTL for test
const store = new ReplayStore(ttlMs, pruneIntervalMs);
const now = 3_000_000;

store.begin("expiring-key", now);
// The entry expires at now + 1_000

// Advance past both TTL and prune interval
const future = now + ttlMs + pruneIntervalMs + 1;
// Calling getStatus triggers prune() which should run and remove the expired entry
expect(store.getStatus("expiring-key", future)).toBeNull();
});
});
});
Loading