name: Monthly Metrics Snapshot on: schedule: - cron: '0 14 1 * *' # Monthly on the 1st at 14:00 UTC workflow_dispatch: permissions: contents: read issues: write jobs: snapshot: name: Update metrics issue runs-on: ubuntu-latest steps: - name: Update monthly metrics issue uses: actions/github-script@v7 with: script: | const owner = context.repo.owner; const repo = context.repo.repo; const title = "Monthly Metrics Snapshot"; const label = "metrics-snapshot"; const monthKey = new Date().toISOString().slice(0, 7); function parseLastPage(linkHeader) { if (!linkHeader) return null; const match = linkHeader.match(/&page=(\d+)>; rel="last"/); return match ? Number(match[1]) : null; } function fmt(value) { if (value === null || value === undefined) return "n/a"; return Number(value).toLocaleString("en-US"); } async function getNpmDownloads(range, pkg) { try { const res = await fetch(`https://api.npmjs.org/downloads/point/${range}/${pkg}`); if (!res.ok) return null; const data = await res.json(); return data.downloads ?? null; } catch { return null; } } async function getContributorsCount() { try { const resp = await github.rest.repos.listContributors({ owner, repo, per_page: 1, anon: "false" }); return parseLastPage(resp.headers.link) ?? resp.data.length; } catch { return null; } } async function getReleasesCount() { try { const resp = await github.rest.repos.listReleases({ owner, repo, per_page: 1 }); return parseLastPage(resp.headers.link) ?? resp.data.length; } catch { return null; } } async function getTraffic(metric) { try { const route = metric === "clones" ? "GET /repos/{owner}/{repo}/traffic/clones" : "GET /repos/{owner}/{repo}/traffic/views"; const resp = await github.request(route, { owner, repo }); return resp.data?.count ?? null; } catch { return null; } } const [ mainWeek, shieldWeek, mainMonth, shieldMonth, repoData, contributors, releases, views14d, clones14d ] = await Promise.all([ getNpmDownloads("last-week", "ecc-universal"), getNpmDownloads("last-week", "ecc-agentshield"), getNpmDownloads("last-month", "ecc-universal"), getNpmDownloads("last-month", "ecc-agentshield"), github.rest.repos.get({ owner, repo }), getContributorsCount(), getReleasesCount(), getTraffic("views"), getTraffic("clones") ]); const stars = repoData.data.stargazers_count; const forks = repoData.data.forks_count; const tableHeader = [ "| Month (UTC) | ecc-universal (week) | ecc-agentshield (week) | ecc-universal (30d) | ecc-agentshield (30d) | Stars | Forks | Contributors | GitHub App installs (manual) | Views (14d) | Clones (14d) | Releases |", "|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|" ].join("\n"); const row = `| ${monthKey} | ${fmt(mainWeek)} | ${fmt(shieldWeek)} | ${fmt(mainMonth)} | ${fmt(shieldMonth)} | ${fmt(stars)} | ${fmt(forks)} | ${fmt(contributors)} | n/a | ${fmt(views14d)} | ${fmt(clones14d)} | ${fmt(releases)} |`; const intro = [ "# Monthly Metrics Snapshot", "", "Automated monthly snapshot for sponsor/partner reporting.", "", "- `GitHub App installs (manual)` is intentionally manual until a stable public API path is available.", "- Traffic metrics are 14-day rolling windows from the GitHub traffic API and can show `n/a` if unavailable.", "", tableHeader ].join("\n"); try { await github.rest.issues.getLabel({ owner, repo, name: label }); } catch (error) { if (error.status === 404) { await github.rest.issues.createLabel({ owner, repo, name: label, color: "0e8a16", description: "Automated monthly project metrics snapshots" }); } else { throw error; } } const issuesResp = await github.rest.issues.listForRepo({ owner, repo, state: "open", labels: label, per_page: 100 }); let issue = issuesResp.data.find((item) => item.title === title); if (!issue) { const created = await github.rest.issues.create({ owner, repo, title, labels: [label], body: `${intro}\n${row}\n` }); console.log(`Created issue #${created.data.number}`); return; } const currentBody = issue.body || ""; if (currentBody.includes(`| ${monthKey} |`)) { console.log(`Issue #${issue.number} already has snapshot row for ${monthKey}`); return; } const body = currentBody.includes("| Month (UTC) |") ? `${currentBody.trimEnd()}\n${row}\n` : `${intro}\n${row}\n`; await github.rest.issues.update({ owner, repo, issue_number: issue.number, body }); console.log(`Updated issue #${issue.number}`);