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
50 changes: 37 additions & 13 deletions .github/workflows/gittensor-impact.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
name: Gittensor Impact

# Renders the README contributor-impact card with scripts/gittensor-impact-card.mjs
# (our own script, styled to this repo's brand — see apps/gittensory-ui/src/styles.css)
# and publishes it to the gittensor-impact-assets branch, which the README embeds
# directly. No third-party action: this replaced matthewevans/gittensor-impact-action
# once its generic template no longer matched the site's design.

on:
workflow_dispatch:
schedule:
Expand All @@ -17,20 +23,38 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 10
# Scoped here rather than at workflow level (defense-in-depth): this is the
# only job that needs write access, to push SVG assets to the dedicated
# gittensor-impact-assets branch via matthewevans/gittensor-impact-action.
# only job that needs write access, to push the rendered SVG to the
# dedicated gittensor-impact-assets branch.
permissions:
contents: write
# matthewevans/gittensor-impact-action is a ~1.5-day-old repo; require a
# human approval before it runs with contents:write, on top of the SHA pin.
environment: gittensor-impact
steps:
- name: Generate and publish Gittensor impact card
uses: matthewevans/gittensor-impact-action@397fd470899cba47367458289a374eea9cb0d76a # main, 2026-07-06 (#1 per-repo API endpoint)
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false

- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
title: Gittensory
# GitHub's release-asset uploads now reject re-uploads to an
# immutable release (HTTP 422), which the action's default
# release publish-mode hits every run. Branch mode force-pushes
# the SVGs to a dedicated orphan branch instead, sidestepping it.
publish-mode: branch
node-version: 22

- name: Render impact card
run: node scripts/gittensor-impact-card.mjs JSONbored/gittensory gittensor-impact-dark.svg

- name: Publish to gittensor-impact-assets
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
workdir="$RUNNER_TEMP/gittensor-impact-publish"
rm -rf "$workdir"
mkdir -p "$workdir"
cp gittensor-impact-dark.svg "$workdir/"
cd "$workdir"
git init -b gittensor-impact-assets --quiet
git remote add origin "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git"
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add gittensor-impact-dark.svg
git commit -m "chore: refresh Gittensor impact card" --quiet
git push --force origin HEAD:gittensor-impact-assets
6 changes: 1 addition & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,11 +126,7 @@ npm run ui:build

<p align="center">
<a href="https://gittensor.io/miners/repository?name=JSONbored/gittensory">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/JSONbored/gittensory/gittensor-impact-assets/gittensor-impact-dark.svg">
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/JSONbored/gittensory/gittensor-impact-assets/gittensor-impact-light.svg">
<img src="https://raw.githubusercontent.com/JSONbored/gittensory/gittensor-impact-assets/gittensor-impact-light.svg" alt="Gittensor contributor impact" width="600">
</picture>
<img src="https://raw.githubusercontent.com/JSONbored/gittensory/gittensor-impact-assets/gittensor-impact-dark.svg" alt="Gittensor contributor impact" width="600">
</a>
</p>

Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
163 changes: 163 additions & 0 deletions scripts/gittensor-impact-card.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
#!/usr/bin/env node
// Renders the "Gittensor impact" README card for this repo: a dark-themed SVG
// with 12-week sparklines (merged PRs, contributors, lines changed) and a
// meter (emission share), styled with this repo's own brand tokens rather
// than a generic third-party template. Replaces matthewevans/gittensor-impact-action's
// rendering (its per-repo data-fetch approach inspired this, but the visual
// design here is our own, matching apps/gittensory-ui/src/styles.css).
//
// Usage: node scripts/gittensor-impact-card.mjs <owner/repo> <out-file.svg>

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

const WEEKS = 12;
const THEME = {
cardBg: "#0e100d",
fg: "#f3f6f3",
muted: "#949a93",
accent: "#d5e43f",
accentTrack: "#333821",
border: "#2a2c29",
radius: 24,
};

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(__dirname, "..");
const repoIconPath = path.join(repoRoot, "apps/gittensory-ui/public/brand/gittensory-icon-citron.svg");

function compact(n) {
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + "M";
if (n >= 1000) return (n / 1000).toFixed(0) + "k";
return String(n);
}

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

function bucketWeekly(prs, now) {
const weekMs = 7 * 24 * 60 * 60 * 1000;
const bucketStart = new Date(now.getTime() - WEEKS * weekMs);
const prBuckets = Array(WEEKS).fill(0);
const locBuckets = Array(WEEKS).fill(0);
const seenByBucket = Array.from({ length: WEEKS }, () => new Set());
const contributorBuckets = Array(WEEKS).fill(0);

for (const pr of prs) {
const t = new Date(pr.mergedAt);
if (t < bucketStart || t > now) continue;
const idx = Math.min(WEEKS - 1, Math.floor((t - bucketStart) / weekMs));
prBuckets[idx] += 1;
locBuckets[idx] += (pr.additions || 0) + (pr.deletions || 0);
seenByBucket[idx].add(pr.author);
}

const seenSoFar = new Set();
for (let i = 0; i < WEEKS; i++) {
for (const a of seenByBucket[i]) seenSoFar.add(a);
contributorBuckets[i] = seenSoFar.size;
}
return { prBuckets, locBuckets, contributorBuckets };
}

function sparkline(x, y, w, h, values, mutedColor, accentColor, cardBg) {
const max = Math.max(...values, 1);
const min = Math.min(...values, 0);
const range = max - min || 1;
const n = values.length;
const stepX = w / (n - 1);
const pts = values.map((v, i) => [x + i * stepX, y + h - ((v - min) / range) * h]);
const svgPath = pts.map(([px, py], i) => `${i === 0 ? "M" : "L"}${px.toFixed(1)},${py.toFixed(1)}`).join(" ");
const [lastX, lastY] = pts[pts.length - 1];
return `
<path d="${svgPath}" fill="none" stroke="${mutedColor}" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>
<circle cx="${lastX.toFixed(1)}" cy="${lastY.toFixed(1)}" r="6" fill="${cardBg}"/>
<circle cx="${lastX.toFixed(1)}" cy="${lastY.toFixed(1)}" r="4" fill="${accentColor}"/>`;
}

function meter(x, y, w, h, value, max, accentColor, trackColor) {
const fillW = Math.max(h, Math.min(value / max, 1) * w);
return `
<rect x="${x}" y="${y}" width="${w}" height="${h}" rx="${h / 2}" fill="${trackColor}"/>
<rect x="${x}" y="${y}" width="${fillW.toFixed(1)}" height="${h}" rx="${h / 2}" fill="${accentColor}"/>`;
}

function render({ repo, impact, buckets, gtLogoB64, repoIconB64 }) {
const { cardBg, fg, muted, accent, accentTrack, border, radius } = THEME;
const W = 1200, H = 420;
const pad = 56;
const cols = 4;
const colW = (W - 2 * pad) / cols;
const font = "'DM Sans', ui-sans-serif, system-ui, -apple-system, sans-serif";
const displayFont = "'Space Grotesk', ui-sans-serif, system-ui, -apple-system, sans-serif";
const sparkW = colW - 40;
const sparkH = 56;
const sparkY = 150;

const stats = [
{ type: "sparkline", series: buckets.prBuckets, value: impact.totalPRs.toLocaleString(), label: "merged PRs" },
{ type: "sparkline", series: buckets.contributorBuckets, value: String(impact.totalContributors), label: "contributors" },
{ type: "sparkline", series: buckets.locBuckets, value: compact(impact.totalLinesChanged), label: "lines changed" },
{ type: "meter", raw: impact.emissionShare * 100, max: 100, value: `${(impact.emissionShare * 100).toFixed(1)}%`, label: "emission share" },
];

let statsSvg = "";
stats.forEach((s, i) => {
const x = pad + i * colW;
if (s.type === "meter") {
statsSvg += meter(x, sparkY + sparkH / 2 - 7, sparkW, 14, s.raw, s.max, accent, accentTrack);
} else {
statsSvg += sparkline(x, sparkY, sparkW, sparkH, s.series, muted, accent, cardBg);
}
statsSvg += `
<text x="${x}" y="${sparkY + sparkH + 78}" font-family="${font}" font-size="60" font-weight="700" fill="${fg}">${s.value}</text>
<text x="${x}" y="${sparkY + sparkH + 112}" font-family="${font}" font-size="21" font-weight="500" fill="${muted}">${s.label}</text>`;
});

const logoW = 48, logoH = 48 / (708 / 567); // gittensor.io/gt-logo.svg aspect ratio
const repoIconSize = 26;
const repoIconX = W - pad - repoIconSize;
const repoIconY = 396 - 14 - (repoIconSize - 16);
const repoTextX = repoIconX - 10;

return `<svg xmlns="http://www.w3.org/2000/svg" width="600" height="210" viewBox="0 0 ${W} ${H}" role="img">
<rect x="1" y="1" width="${W - 2}" height="${H - 2}" rx="${radius}" fill="${cardBg}" stroke="${border}" stroke-width="1"/>
<image href="data:image/svg+xml;base64,${gtLogoB64}" x="${pad}" y="${(80 - logoH / 2 - 4).toFixed(1)}" width="${logoW}" height="${logoH.toFixed(1)}"/>
<text x="${pad + logoW + 14}" y="80" font-family="${displayFont}" font-size="22" font-weight="500" letter-spacing="0.08em" fill="${muted}">GITTENSOR IMPACT</text>
<line x1="${pad}" y1="124" x2="${W - pad}" y2="124" stroke="${border}" stroke-width="1"/>
${statsSvg}
<text x="${pad}" y="396" font-family="${font}" font-size="19" font-weight="400" fill="${muted}">Updated weekly &#183; gittensor.io</text>
<image href="data:image/svg+xml;base64,${repoIconB64}" x="${repoIconX}" y="${repoIconY}" width="${repoIconSize}" height="${repoIconSize}"/>
<text x="${repoTextX}" y="396" font-family="${displayFont}" font-size="20" font-weight="500" letter-spacing="-0.01em" fill="${fg}" text-anchor="end">${repo}</text>
</svg>`;
}

async function main() {
const [repo, outFile] = process.argv.slice(2);
if (!repo || !outFile) {
console.error("Usage: node scripts/gittensor-impact-card.mjs <owner/repo> <out-file.svg>");
process.exit(1);
}
const encoded = repo.replace("/", "%2F");
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()),
]);
const buckets = bucketWeekly(prs, new Date());
const gtLogoB64 = Buffer.from(gtLogoSvg).toString("base64");
const repoIconB64 = Buffer.from(readFileSync(repoIconPath)).toString("base64");

const svg = render({ repo, impact, buckets, gtLogoB64, repoIconB64 });
writeFileSync(outFile, svg);
console.log(`Wrote ${outFile} (${svg.length} bytes)`);
}

main().catch((err) => {
console.error(err);
process.exit(1);
});
Loading