Skip to content

Commit ff59055

Browse files
committed
Add org profile README with activity chart and top-repo tables
Generated automatically by generate-profile.mjs, scheduled daily via update-profile.yml.
0 parents  commit ff59055

6 files changed

Lines changed: 377 additions & 0 deletions

File tree

Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
#!/usr/bin/env node
2+
// Regenerates the org activity chart + top-repo tables in profile/README.md
3+
// from live GitHub data. No npm dependencies — run directly with `node`.
4+
5+
import { readFile, writeFile } from "node:fs/promises";
6+
import { fileURLToPath } from "node:url";
7+
import path from "node:path";
8+
9+
const ORG = process.env.GH_ORG || "meshmy";
10+
const TOKEN = process.env.GITHUB_TOKEN;
11+
const API = "https://api.github.com";
12+
const YEAR_MS = 365 * 24 * 60 * 60 * 1000;
13+
const WEEKS = 52;
14+
const MAX_COMMIT_PAGES = 5; // per repo cap, keeps API usage bounded
15+
16+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
17+
const ROOT = path.resolve(__dirname, "..", "..");
18+
const PROFILE_DIR = path.join(ROOT, "profile");
19+
20+
async function ghFetch(pathname) {
21+
const res = await fetch(`${API}${pathname}`, {
22+
headers: {
23+
Accept: "application/vnd.github+json",
24+
...(TOKEN ? { Authorization: `Bearer ${TOKEN}` } : {}),
25+
},
26+
});
27+
if (!res.ok) {
28+
throw new Error(`GitHub API ${res.status} for ${pathname}: ${await res.text()}`);
29+
}
30+
return res;
31+
}
32+
33+
function parseNextLink(linkHeader) {
34+
if (!linkHeader) return null;
35+
const match = linkHeader.split(",").find((part) => part.includes('rel="next"'));
36+
if (!match) return null;
37+
return match.split(";")[0].trim().replace(/^<|>$/g, "");
38+
}
39+
40+
async function ghPaginate(pathname) {
41+
const results = [];
42+
let url = `${pathname}${pathname.includes("?") ? "&" : "?"}per_page=100`;
43+
let count = 0;
44+
while (url && count < 20) {
45+
const res = await ghFetch(url.replace(API, ""));
46+
results.push(...(await res.json()));
47+
url = parseNextLink(res.headers.get("link"));
48+
count += 1;
49+
}
50+
return results;
51+
}
52+
53+
async function fetchOrgRepos() {
54+
const repos = await ghPaginate(`/orgs/${ORG}/repos?type=public`);
55+
return repos.filter((r) => !r.archived);
56+
}
57+
58+
async function fetchOrgMembers() {
59+
const members = await ghPaginate(`/orgs/${ORG}/members`);
60+
return new Set(members.map((m) => m.login));
61+
}
62+
63+
// For one repo: walk commits (newest first) and collect the ones authored
64+
// by an org member, up to MAX_COMMIT_PAGES pages, since 1 year ago.
65+
async function fetchOrgAuthoredCommits(repoName, memberLogins, sinceISO) {
66+
const commits = [];
67+
let url = `/repos/${ORG}/${repoName}/commits?since=${sinceISO}&per_page=100`;
68+
let page = 0;
69+
while (url && page < MAX_COMMIT_PAGES) {
70+
let res;
71+
try {
72+
res = await ghFetch(url.replace(API, ""));
73+
} catch {
74+
break; // empty repo / no commit history / disabled — treat as no activity
75+
}
76+
const batch = await res.json();
77+
for (const c of batch) {
78+
const login = c.author?.login;
79+
if (login && memberLogins.has(login)) {
80+
commits.push(c.commit.author.date);
81+
}
82+
}
83+
url = parseNextLink(res.headers.get("link"));
84+
page += 1;
85+
}
86+
return commits;
87+
}
88+
89+
function engagementScore(repo) {
90+
return (
91+
(repo.stargazers_count || 0) +
92+
(repo.forks_count || 0) +
93+
(repo.watchers_count || 0) +
94+
(repo.open_issues_count || 0)
95+
);
96+
}
97+
98+
function bucketWeekly(allDates, now) {
99+
const buckets = new Array(WEEKS).fill(0);
100+
for (const iso of allDates) {
101+
const ageMs = now - new Date(iso).getTime();
102+
const weekIdx = Math.floor(ageMs / (7 * 24 * 60 * 60 * 1000));
103+
if (weekIdx >= 0 && weekIdx < WEEKS) {
104+
buckets[weekIdx] += 1;
105+
}
106+
}
107+
return buckets.reverse(); // oldest -> newest, left to right
108+
}
109+
110+
const THEMES = {
111+
dark: { bg: "#0d1117", border: "#30363d", text: "#c9d1d9", subtext: "#8b949e", bar: "#58a6ff", grid: "#21262d" },
112+
light: { bg: "#ffffff", border: "#d0d7de", text: "#24292f", subtext: "#57606a", bar: "#0969da", grid: "#eaeef2" },
113+
};
114+
115+
function renderChartSVG(themeName, weeklyCounts, totalCommits) {
116+
const theme = THEMES[themeName];
117+
const width = 760;
118+
const height = 260;
119+
const padding = { top: 56, right: 24, bottom: 36, left: 24 };
120+
const chartW = width - padding.left - padding.right;
121+
const chartH = height - padding.top - padding.bottom;
122+
const maxCount = Math.max(1, ...weeklyCounts);
123+
const barGap = 2;
124+
const barW = chartW / WEEKS - barGap;
125+
126+
const now = new Date();
127+
const bars = weeklyCounts
128+
.map((count, i) => {
129+
const barH = count === 0 ? 2 : Math.max(4, (count / maxCount) * chartH);
130+
const x = padding.left + i * (chartW / WEEKS);
131+
const y = padding.top + chartH - barH;
132+
const weeksAgo = WEEKS - 1 - i;
133+
const weekDate = new Date(now.getTime() - weeksAgo * 7 * 24 * 60 * 60 * 1000);
134+
const title = `${weekDate.toISOString().slice(0, 10)}: ${count} commit${count === 1 ? "" : "s"}`;
135+
return `<rect x="${x.toFixed(1)}" y="${y.toFixed(1)}" width="${barW.toFixed(1)}" height="${barH.toFixed(1)}" rx="1.5" fill="${theme.bar}" fill-opacity="${count === 0 ? 0.25 : 0.9}"><title>${title}</title></rect>`;
136+
})
137+
.join("");
138+
139+
// Month tick labels every ~4 weeks, skipping immediate repeats of the same month
140+
const ticks = [];
141+
let lastLabel = null;
142+
for (let i = 0; i < WEEKS; i += 4) {
143+
const weeksAgo = WEEKS - 1 - i;
144+
const weekDate = new Date(now.getTime() - weeksAgo * 7 * 24 * 60 * 60 * 1000);
145+
const x = padding.left + i * (chartW / WEEKS);
146+
const label = weekDate.toLocaleString("en-US", { month: "short" });
147+
if (label === lastLabel) continue;
148+
lastLabel = label;
149+
ticks.push(
150+
`<text x="${x.toFixed(1)}" y="${height - 12}" font-size="11" fill="${theme.subtext}" font-family="-apple-system,Segoe UI,Helvetica,Arial,sans-serif">${label}</text>`
151+
);
152+
}
153+
154+
const baselineY = padding.top + chartH;
155+
156+
return `<svg width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="meshmy organization commit activity, last 52 weeks">
157+
<rect x="0.5" y="0.5" width="${width - 1}" height="${height - 1}" rx="12" fill="${theme.bg}" stroke="${theme.border}" />
158+
<text x="24" y="34" font-size="16" font-weight="600" fill="${theme.text}" font-family="-apple-system,Segoe UI,Helvetica,Arial,sans-serif">meshmy Organization Activity</text>
159+
<text x="24" y="52" font-size="12" fill="${theme.subtext}" font-family="-apple-system,Segoe UI,Helvetica,Arial,sans-serif">${totalCommits} commits by org members in the last ${WEEKS} weeks</text>
160+
<line x1="${padding.left}" y1="${baselineY}" x2="${width - padding.right}" y2="${baselineY}" stroke="${theme.grid}" stroke-width="1" />
161+
${bars}
162+
${ticks.join("\n ")}
163+
</svg>
164+
`;
165+
}
166+
167+
function escapeHtml(str) {
168+
return (str || "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
169+
}
170+
171+
function formatDate(isoOrDate) {
172+
return new Date(isoOrDate).toISOString().slice(0, 10);
173+
}
174+
175+
function renderRepoList(repos, metricLabel, metricFn) {
176+
const items = repos
177+
.map((repo) => {
178+
const desc = repo.description ? escapeHtml(repo.description) : "<i>no description</i>";
179+
return `<li><a href="${repo.html_url}"><b>${escapeHtml(repo.name)}</b></a><br /><sub>${desc}</sub><br /><sub>${metricLabel}: ${metricFn(repo)}</sub></li>`;
180+
})
181+
.join("\n ");
182+
return ` <ol>\n ${items}\n </ol>`;
183+
}
184+
185+
function renderRepoTable(recentRepos, topRepos) {
186+
return `<table>
187+
<tr>
188+
<td valign="top" width="50%">
189+
<h3>🕒 Recently Active</h3>
190+
${renderRepoList(recentRepos, "last org activity", (r) => formatDate(r._lastOrgActivity))}
191+
</td>
192+
<td valign="top" width="50%">
193+
<h3>🔥 Top by Engagement</h3>
194+
${renderRepoList(topRepos, "engagement score", (r) => r._engagement)}
195+
</td>
196+
</tr>
197+
</table>`;
198+
}
199+
200+
function injectSection(content, marker, body) {
201+
const start = `<!-- ${marker}:START -->`;
202+
const end = `<!-- ${marker}:END -->`;
203+
const startIdx = content.indexOf(start);
204+
const endIdx = content.indexOf(end);
205+
if (startIdx === -1 || endIdx === -1) {
206+
throw new Error(`Missing ${marker} markers in profile/README.md`);
207+
}
208+
return content.slice(0, startIdx + start.length) + "\n" + body + "\n" + content.slice(endIdx);
209+
}
210+
211+
async function main() {
212+
const now = Date.now();
213+
const sinceISO = new Date(now - YEAR_MS).toISOString();
214+
215+
const [repos, memberLogins] = await Promise.all([fetchOrgRepos(), fetchOrgMembers()]);
216+
217+
const allOrgCommitDates = [];
218+
for (const repo of repos) {
219+
const dates = await fetchOrgAuthoredCommits(repo.name, memberLogins, sinceISO);
220+
allOrgCommitDates.push(...dates);
221+
repo._lastOrgActivity = dates.length > 0 ? dates.reduce((a, b) => (a > b ? a : b)) : repo.created_at;
222+
repo._engagement = engagementScore(repo);
223+
}
224+
225+
const recentRepos = [...repos]
226+
.sort((a, b) => new Date(b._lastOrgActivity) - new Date(a._lastOrgActivity))
227+
.slice(0, 5);
228+
const topRepos = [...repos].sort((a, b) => b._engagement - a._engagement).slice(0, 5);
229+
230+
const weeklyCounts = bucketWeekly(allOrgCommitDates, now);
231+
const totalCommits = allOrgCommitDates.length;
232+
233+
await writeFile(path.join(PROFILE_DIR, "activity-graph-dark.svg"), renderChartSVG("dark", weeklyCounts, totalCommits));
234+
await writeFile(path.join(PROFILE_DIR, "activity-graph-light.svg"), renderChartSVG("light", weeklyCounts, totalCommits));
235+
236+
const readmePath = path.join(PROFILE_DIR, "README.md");
237+
let readme = await readFile(readmePath, "utf8");
238+
readme = injectSection(readme, "REPOS", renderRepoTable(recentRepos, topRepos));
239+
await writeFile(readmePath, readme);
240+
241+
console.log(`Updated profile for ${repos.length} repos, ${totalCommits} org-authored commits in last ${WEEKS} weeks.`);
242+
}
243+
244+
main().catch((err) => {
245+
console.error(err);
246+
process.exit(1);
247+
});
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
name: Update org profile
2+
3+
on:
4+
schedule:
5+
- cron: "0 3 * * *"
6+
workflow_dispatch: {}
7+
8+
permissions:
9+
contents: write
10+
11+
jobs:
12+
update-profile:
13+
runs-on: ubuntu-latest
14+
steps:
15+
- uses: actions/checkout@v4
16+
17+
- uses: actions/setup-node@v4
18+
with:
19+
node-version: "20"
20+
21+
- name: Generate profile
22+
env:
23+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
24+
GH_ORG: meshmy
25+
run: node .github/scripts/generate-profile.mjs
26+
27+
- name: Commit changes
28+
run: |
29+
git config user.name "github-actions[bot]"
30+
git config user.email "github-actions[bot]@users.noreply.github.com"
31+
git add profile/
32+
git diff --staged --quiet || git commit -m "chore: refresh org activity"
33+
git push

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# .github
2+
3+
This is the [meshmy](https://github.com/meshmy) organization's special `.github` repo. Its only job is to hold [`profile/README.md`](profile/README.md), which GitHub renders as the org's homepage at [github.com/meshmy](https://github.com/meshmy).
4+
5+
The activity chart and repository lists on that page are regenerated automatically by [`update-profile.yml`](.github/workflows/update-profile.yml), which runs [`generate-profile.mjs`](.github/scripts/generate-profile.mjs) on a daily schedule.

profile/README.md

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
<div align="center">
2+
3+
<img src="https://github.com/meshmy.png" width="120" alt="Mesh Malaysia" />
4+
5+
# Mesh Malaysia
6+
7+
**LoRa mesh users Malaysia 🇲🇾**
8+
9+
📍 Malaysia · 🔗 [linktr.ee/meshmy](https://linktr.ee/meshmy)
10+
11+
</div>
12+
13+
---
14+
15+
## 📡 Org Activity
16+
17+
<!-- ACTIVITY:START -->
18+
<picture>
19+
<source media="(prefers-color-scheme: dark)" srcset="activity-graph-dark.svg" />
20+
<source media="(prefers-color-scheme: light)" srcset="activity-graph-light.svg" />
21+
<img alt="meshmy organization activity graph" src="activity-graph-dark.svg" width="100%" />
22+
</picture>
23+
<!-- ACTIVITY:END -->
24+
25+
## 📦 Repositories
26+
27+
<!-- REPOS:START -->
28+
<table>
29+
<tr>
30+
<td valign="top" width="50%">
31+
<h3>🕒 Recently Active</h3>
32+
<ol>
33+
<li><a href="https://github.com/meshmy/tool-stm32flash"><b>tool-stm32flash</b></a><br /><sub>Open, buildable recipe reproducing PlatformIO&#39;s tool-stm32flash package from public upstream source</sub><br /><sub>last org activity: 2026-07-16</sub></li>
34+
<li><a href="https://github.com/meshmy/tool-openocd"><b>tool-openocd</b></a><br /><sub>Buildable, reverse-engineered recipe reproducing PlatformIO&#39;s tool-openocd (xPack OpenOCD 0.12.0) across darwin/linux/windows</sub><br /><sub>last org activity: 2026-07-16</sub></li>
35+
<li><a href="https://github.com/meshmy/meshmy.github.io"><b>meshmy.github.io</b></a><br /><sub>The MeshMY community website</sub><br /><sub>last org activity: 2026-07-16</sub></li>
36+
<li><a href="https://github.com/meshmy/jungle-buoy"><b>jungle-buoy</b></a><br /><sub>Jungle Buoy solar buoy PCB replacement</sub><br /><sub>last org activity: 2026-07-13</sub></li>
37+
<li><a href="https://github.com/meshmy/meshtastic-firmware"><b>meshtastic-firmware</b></a><br /><sub>Meshtastic device firmware</sub><br /><sub>last org activity: 2026-05-01</sub></li>
38+
</ol>
39+
</td>
40+
<td valign="top" width="50%">
41+
<h3>🔥 Top by Engagement</h3>
42+
<ol>
43+
<li><a href="https://github.com/meshmy/meshtastic-config-my-sg"><b>meshtastic-config-my-sg</b></a><br /><sub>Meshtastic Configuration for Malaysia and Singapore</sub><br /><sub>engagement score: 9</sub></li>
44+
<li><a href="https://github.com/meshmy/meshtastic-firmware"><b>meshtastic-firmware</b></a><br /><sub>Meshtastic device firmware</sub><br /><sub>engagement score: 2</sub></li>
45+
<li><a href="https://github.com/meshmy/russell"><b>russell</b></a><br /><sub>Russell is a board designed to mount on an ER34615/IFR32700 cell and go Up! on a balloon</sub><br /><sub>engagement score: 2</sub></li>
46+
<li><a href="https://github.com/meshmy/device-ui"><b>device-ui</b></a><br /><sub>meshtastic device-ui library</sub><br /><sub>engagement score: 2</sub></li>
47+
<li><a href="https://github.com/meshmy/tdeck-maps"><b>tdeck-maps</b></a><br /><sub><i>no description</i></sub><br /><sub>engagement score: 1</sub></li>
48+
</ol>
49+
</td>
50+
</tr>
51+
</table>
52+
<!-- REPOS:END -->
53+
54+
<sub>This page and the charts above are regenerated automatically by a scheduled GitHub Action — see <a href="https://github.com/meshmy/.github/tree/main/.github/workflows/update-profile.yml">update-profile.yml</a>.</sub>

0 commit comments

Comments
 (0)