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
14 changes: 14 additions & 0 deletions scripts/gittensor-impact-card.d.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// Deliberately not `typeof fetch`: the global fetch type (in a Cloudflare Workers-typed environment) is
// overloaded to accept URL/RequestInfo/CfProperties, which a plainly-typed vi.fn() mock can't satisfy under
// strict function-type checking (see scripts/load-test-worker.d.mts's identical note). Both functions here
// only ever call fetchImpl with a string URL and a plain {headers?, signal} init.
export type ImpactCardFetch = (
url: string,
init?: { headers?: Record<string, string>; signal?: AbortSignal },
) => Promise<{ ok?: boolean; status?: number; json?: () => Promise<unknown>; text?: () => Promise<string> }>;

export declare const GITTENSOR_IMPACT_CARD_FETCH_TIMEOUT_MS: number;

export declare function fetchJson(url: string, fetchImpl?: ImpactCardFetch): Promise<unknown>;

export declare function fetchGtLogoSvg(fetchImpl?: ImpactCardFetch): Promise<string>;
35 changes: 27 additions & 8 deletions scripts/gittensor-impact-card.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,15 @@
// Usage: node scripts/gittensor-impact-card.mjs <owner/repo> <out-file.svg>

import { readFileSync, writeFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { fileURLToPath, pathToFileURL } from "node:url";
import path from "node:path";

const WEEKS = 12;
// Bounds every outbound fetch in this script so a hung api.gittensor.io/gittensor.io connection can't block
// the README-card regeneration job indefinitely -- matches the AbortSignal.timeout discipline every other
// external-fetch script in this repo already follows (src/github/client.ts's GITHUB_FETCH_TIMEOUT_MS,
// scripts/check-mcp-release-due.mjs's GITHUB_REQUEST_TIMEOUT_MS, etc.), which this script had been missing.
export const GITTENSOR_IMPACT_CARD_FETCH_TIMEOUT_MS = 10_000;
const THEME = {
cardBg: "#0e100d",
fg: "#f3f6f3",
Expand Down Expand Up @@ -47,14 +52,22 @@ function escapeXml(value) {
.replace(/'/g, "&#x27;");
}

async function fetchJson(url) {
const res = await fetch(url, {
export async function fetchJson(url, fetchImpl = fetch) {
const res = await fetchImpl(url, {
headers: { "User-Agent": "gittensor-impact-card/1.0" },
signal: AbortSignal.timeout(GITTENSOR_IMPACT_CARD_FETCH_TIMEOUT_MS),
});
if (!res.ok) throw new Error(`fetch failed: ${url} (${res.status})`);
return res.json();
}

export async function fetchGtLogoSvg(fetchImpl = fetch) {
const res = await fetchImpl("https://gittensor.io/gt-logo.svg", {
signal: AbortSignal.timeout(GITTENSOR_IMPACT_CARD_FETCH_TIMEOUT_MS),
});
return res.text();
}

function bucketWeekly(prs, now) {
const weekMs = 7 * 24 * 60 * 60 * 1000;
const bucketStart = new Date(now.getTime() - WEEKS * weekMs);
Expand Down Expand Up @@ -214,7 +227,7 @@ async function main() {
const [impact, prs, gtLogoSvg] = await Promise.all([
fetchJson(`https://api.gittensor.io/repos/${encoded}/impact`),
fetchJson(`https://api.gittensor.io/repos/${encoded}/prs`),
fetch("https://gittensor.io/gt-logo.svg").then((r) => r.text()),
fetchGtLogoSvg(),
]);
const buckets = bucketWeekly(prs, new Date());
const gtLogoB64 = Buffer.from(gtLogoSvg).toString("base64");
Expand All @@ -227,7 +240,13 @@ async function main() {
console.log(`Wrote ${outFile} (${svg.length} bytes)`);
}

main().catch((err) => {
console.error(err);
process.exit(1);
});
// Run only when invoked directly as a CLI script, not when imported (e.g. by this file's own unit tests
// for fetchJson/fetchGtLogoSvg) -- matches every other CLI script's entrypoint guard in this repo (e.g.
// scripts/check-mcp-release-due.mjs).
const entrypointPath = process.argv[1] ? path.resolve(process.argv[1]) : "";
if (import.meta.url === pathToFileURL(entrypointPath).href) {
main().catch((err) => {
console.error(err);
process.exit(1);
});
}
75 changes: 75 additions & 0 deletions test/unit/gittensor-impact-card-script.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
fetchGtLogoSvg,
fetchJson,
GITTENSOR_IMPACT_CARD_FETCH_TIMEOUT_MS,
} from "../../scripts/gittensor-impact-card.mjs";

describe("gittensor-impact-card.mjs (#7231)", () => {
afterEach(() => {
vi.restoreAllMocks();
});

it("fetchJson returns the parsed JSON body on a 200 response", async () => {
const fetchImpl = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ totalPRs: 5 }),
});
await expect(fetchJson("https://api.gittensor.io/repos/x/impact", fetchImpl)).resolves.toEqual({
totalPRs: 5,
});
});

it("fetchJson throws on a non-ok response", async () => {
const fetchImpl = vi.fn().mockResolvedValue({ ok: false, status: 500 });
await expect(fetchJson("https://api.gittensor.io/repos/x/impact", fetchImpl)).rejects.toThrow(
"fetch failed: https://api.gittensor.io/repos/x/impact (500)",
);
});

it("fetchJson bounds its request with the configured AbortSignal.timeout", async () => {
const fetchImpl = vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) });
await fetchJson("https://api.gittensor.io/repos/x/impact", fetchImpl);

expect(fetchImpl).toHaveBeenCalledTimes(1);
const [, init] = fetchImpl.mock.calls[0]!;
expect(init.signal).toBeInstanceOf(AbortSignal);
});

it("fetchJson aborts instead of hanging when fetchImpl never resolves, using the configured timeout value", async () => {
// Node's AbortSignal.timeout schedules its own internal, unmockable timer -- vi.useFakeTimers() doesn't
// intercept it, so asserting the real 10s wait would make this test genuinely slow. Stub
// AbortSignal.timeout itself instead: confirms fetchJson requests exactly the configured timeout value,
// and lets the test fire the abort immediately rather than waiting it out.
const timeoutSpy = vi.spyOn(AbortSignal, "timeout").mockImplementation(() => AbortSignal.abort());
const hangingFetch = vi.fn(
(_url: string, init?: { signal?: AbortSignal }) =>
new Promise<{ ok: boolean }>((_resolve, reject) => {
if (init?.signal?.aborted) reject(new DOMException("aborted", "AbortError"));
else init?.signal?.addEventListener("abort", () => reject(new DOMException("aborted", "AbortError")));
}),
);

await expect(fetchJson("https://api.gittensor.io/repos/x/impact", hangingFetch)).rejects.toThrow();
expect(timeoutSpy).toHaveBeenCalledWith(GITTENSOR_IMPACT_CARD_FETCH_TIMEOUT_MS);
});

it("fetchGtLogoSvg returns the response body text", async () => {
const fetchImpl = vi.fn().mockResolvedValue({ text: async () => "<svg></svg>" });
await expect(fetchGtLogoSvg(fetchImpl)).resolves.toBe("<svg></svg>");
});

it("fetchGtLogoSvg bounds its request with the configured AbortSignal.timeout", async () => {
const fetchImpl = vi.fn().mockResolvedValue({ text: async () => "<svg></svg>" });
await fetchGtLogoSvg(fetchImpl);

expect(fetchImpl).toHaveBeenCalledWith(
"https://gittensor.io/gt-logo.svg",
expect.objectContaining({ signal: expect.any(AbortSignal) }),
);
});

it("exposes the documented default timeout", () => {
expect(GITTENSOR_IMPACT_CARD_FETCH_TIMEOUT_MS).toBe(10_000);
});
});