mirror of
https://github.com/tiennm99/blog.git
synced 2026-10-11 03:13:10 +00:00
refactor(projects): build the projects list on the octokit SDK
Replace hand-rolled REST paging, GraphQL cursor loops and header plumbing with octokit, which also brings request retry and rate-limit backoff. Introduces the repo's first package.json, lockfile and ESLint flat config; the Hugo site build itself still needs no Node step. Output verified against the previous implementation: identical counts and rows apart from live star drift on four upstream repos.
This commit is contained in:
1 parent
f0b6b4904d
commit
a0de52c877
8 files changed
+1742
-91
No files matched your search
@@ -1,8 +1,10 @@
|
||||
// GitHub API access for the projects list: owned namespaces, their repos, and
|
||||
// the repos the user contributed merged pull requests to.
|
||||
//
|
||||
// Built on the `octokit` SDK, which supplies auth, REST and GraphQL cursor
|
||||
// pagination, retry and rate-limit backoff.
|
||||
|
||||
const API = "https://api.github.com";
|
||||
const GRAPHQL = `${API}/graphql`;
|
||||
import { Octokit } from "octokit";
|
||||
|
||||
/**
|
||||
* @typedef {object} Repo
|
||||
@@ -26,69 +28,18 @@ const GRAPHQL = `${API}/graphql`;
|
||||
* @property {number} mergedPrs
|
||||
*/
|
||||
|
||||
/** Minimal GitHub client bound to one token and one user. */
|
||||
export class GitHubClient {
|
||||
/** Projects-list queries for one user. */
|
||||
export class GitHubProjects {
|
||||
/**
|
||||
* @param {string} token Personal access token with `repo` and `read:org`.
|
||||
* @param {string} user Login whose projects are listed.
|
||||
*/
|
||||
constructor(token, user) {
|
||||
this.token = token;
|
||||
this.user = user;
|
||||
}
|
||||
|
||||
/** @returns {Record<string, string>} */
|
||||
get restHeaders() {
|
||||
return {
|
||||
Authorization: `Bearer ${this.token}`,
|
||||
Accept: "application/vnd.github+json",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
"User-Agent": `${this.user}-projects-updater`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch every page of a REST collection endpoint.
|
||||
* @param {string} pathAndQuery Path starting with `/`, query without `page`.
|
||||
* @returns {Promise<object[]>}
|
||||
*/
|
||||
async restPaged(pathAndQuery) {
|
||||
const all = [];
|
||||
const sep = pathAndQuery.includes("?") ? "&" : "?";
|
||||
for (let page = 1; page <= 20; page += 1) {
|
||||
const res = await fetch(`${API}${pathAndQuery}${sep}page=${page}`, {
|
||||
headers: this.restHeaders,
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`REST ${pathAndQuery} ${res.status}: ${await res.text()}`);
|
||||
}
|
||||
const batch = await res.json();
|
||||
if (!Array.isArray(batch) || batch.length === 0) break;
|
||||
all.push(...batch);
|
||||
if (batch.length < 100) break;
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} query
|
||||
* @param {Record<string, unknown>} variables
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
async graphql(query, variables) {
|
||||
const res = await fetch(GRAPHQL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.token}`,
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": `${this.user}-projects-updater`,
|
||||
},
|
||||
body: JSON.stringify({ query, variables }),
|
||||
this.octokit = new Octokit({
|
||||
auth: token,
|
||||
userAgent: `${user}-projects-updater`,
|
||||
});
|
||||
if (!res.ok) throw new Error(`GraphQL ${res.status}: ${await res.text()}`);
|
||||
const body = await res.json();
|
||||
if (body.errors) throw new Error(`GraphQL errors: ${JSON.stringify(body.errors)}`);
|
||||
return body.data;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -99,10 +50,14 @@ export class GitHubClient {
|
||||
async fetchOwnedLogins() {
|
||||
let memberships;
|
||||
try {
|
||||
memberships = await this.restPaged("/user/memberships/orgs?state=active&per_page=100");
|
||||
memberships = await this.octokit.paginate(
|
||||
this.octokit.rest.orgs.listMembershipsForAuthenticatedUser,
|
||||
{ state: "active", per_page: 100 },
|
||||
);
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`Cannot read organization memberships (the token needs the read:org scope): ${err.message}`,
|
||||
"Cannot read organization memberships (the token needs the read:org scope)",
|
||||
{ cause: err },
|
||||
);
|
||||
}
|
||||
const logins = new Set([this.user.toLowerCase()]);
|
||||
@@ -122,11 +77,19 @@ export class GitHubClient {
|
||||
async fetchOwnedRepos(ownedLogins) {
|
||||
const byName = new Map();
|
||||
for (const login of ownedLogins) {
|
||||
const endpoint =
|
||||
login === this.user.toLowerCase()
|
||||
? `/users/${login}/repos?type=owner&sort=updated&per_page=100`
|
||||
: `/orgs/${login}/repos?type=all&sort=updated&per_page=100`;
|
||||
for (const r of await this.restPaged(endpoint)) {
|
||||
const isSelf = login === this.user.toLowerCase();
|
||||
const listed = isSelf
|
||||
? await this.octokit.paginate(this.octokit.rest.repos.listForUser, {
|
||||
username: login,
|
||||
type: "owner",
|
||||
per_page: 100,
|
||||
})
|
||||
: await this.octokit.paginate(this.octokit.rest.repos.listForOrg, {
|
||||
org: login,
|
||||
type: "all",
|
||||
per_page: 100,
|
||||
});
|
||||
for (const r of listed) {
|
||||
if (r.private) continue;
|
||||
if (!byName.has(r.full_name)) byName.set(r.full_name, fromRest(r));
|
||||
}
|
||||
@@ -136,29 +99,22 @@ export class GitHubClient {
|
||||
|
||||
/**
|
||||
* Repos the user has merged pull requests in, with the per-repo count.
|
||||
* Uses the search API, which caps at 1000 results.
|
||||
* Backed by the search API, which caps at 1000 results.
|
||||
* @returns {Promise<Contribution[]>}
|
||||
*/
|
||||
async fetchMergedPrContributions() {
|
||||
const result = await this.octokit.graphql.paginate(MERGED_PR_QUERY, {
|
||||
q: `is:pr is:merged is:public author:${this.user}`,
|
||||
});
|
||||
|
||||
const counts = new Map();
|
||||
const repos = new Map();
|
||||
let cursor = null;
|
||||
for (let i = 0; i < 10; i += 1) {
|
||||
const data = await this.graphql(MERGED_PR_QUERY, {
|
||||
q: `is:pr is:merged is:public author:${this.user}`,
|
||||
cursor,
|
||||
});
|
||||
const search = data?.search;
|
||||
if (!search) break;
|
||||
for (const node of search.nodes) {
|
||||
const repo = node?.repository;
|
||||
if (!repo || repo.isPrivate) continue;
|
||||
const key = repo.nameWithOwner;
|
||||
counts.set(key, (counts.get(key) || 0) + 1);
|
||||
if (!repos.has(key)) repos.set(key, fromGraphql(repo));
|
||||
}
|
||||
if (!search.pageInfo.hasNextPage) break;
|
||||
cursor = search.pageInfo.endCursor;
|
||||
for (const node of result.search.nodes) {
|
||||
const repo = node?.repository;
|
||||
if (!repo || repo.isPrivate) continue;
|
||||
const key = repo.nameWithOwner;
|
||||
counts.set(key, (counts.get(key) || 0) + 1);
|
||||
if (!repos.has(key)) repos.set(key, fromGraphql(repo));
|
||||
}
|
||||
return [...counts.entries()].map(([key, mergedPrs]) => ({
|
||||
repo: repos.get(key),
|
||||
@@ -167,8 +123,10 @@ export class GitHubClient {
|
||||
}
|
||||
}
|
||||
|
||||
// `$cursor` and the `pageInfo` selection are what octokit.graphql.paginate
|
||||
// drives; it concatenates `nodes` across pages.
|
||||
const MERGED_PR_QUERY = `
|
||||
query($q: String!, $cursor: String) {
|
||||
query paginate($q: String!, $cursor: String) {
|
||||
search(query: $q, type: ISSUE, first: 100, after: $cursor) {
|
||||
pageInfo { hasNextPage endCursor }
|
||||
nodes {
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import { GitHubClient } from "./lib/github-api.mjs";
|
||||
import { GitHubProjects } from "./lib/github-api.mjs";
|
||||
import {
|
||||
injectBlock,
|
||||
renderContributed,
|
||||
@@ -42,7 +42,7 @@ async function main() {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const gh = new GitHubClient(token, user);
|
||||
const gh = new GitHubProjects(token, user);
|
||||
const ownedLogins = await gh.fetchOwnedLogins();
|
||||
const [ownedRepos, contributions] = await Promise.all([
|
||||
gh.fetchOwnedRepos(ownedLogins),
|
||||
|
||||
@@ -29,6 +29,10 @@ jobs:
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Refresh projects list
|
||||
env:
|
||||
|
||||
@@ -35,3 +35,6 @@ resources
|
||||
|
||||
assets/jsconfig.json
|
||||
.DS_Store
|
||||
|
||||
# Node dependencies
|
||||
node_modules
|
||||
@@ -51,9 +51,9 @@ Sắp xếp theo lần cập nhật gần nhất.
|
||||
|
||||
| Name | Description | URL |
|
||||
| --- | --- | --- |
|
||||
| [tiennm99/blog](https://github.com/tiennm99/blog) | Source of miti99.com — personal blog (VN/EN) built with Hugo + Stack theme. | [https://miti99.com](https://miti99.com) |
|
||||
| [tiennm99/awesome-ai-dev-tools](https://github.com/tiennm99/awesome-ai-dev-tools) | Daily-refreshed ranking of open-source AI developer tools — coding agents, ADEs, AI editors and code-review tools — by GitHub stars, with 7-day deltas, star-history charts and a tag-filtered dashboard. | [https://awesome-ai-dev-tools.pages.dev/](https://awesome-ai-dev-tools.pages.dev/) |
|
||||
| [tiennm99/tiennm99](https://github.com/tiennm99/tiennm99) | Config files for my GitHub profile. | [https://github.com/tiennm99](https://github.com/tiennm99) |
|
||||
| [tiennm99/blog](https://github.com/tiennm99/blog) | Source of miti99.com — personal blog (VN/EN) built with Hugo + Stack theme. | [https://miti99.com](https://miti99.com) |
|
||||
| [tiennm99/miti99bot](https://github.com/tiennm99/miti99bot) | Plug-n-play Telegram bot framework in Go, self-hosted on Coolify + MongoDB Atlas with cron, games, schedules, and paper trading modules. | [https://t.me/miti99bot](https://t.me/miti99bot) |
|
||||
| [tiennm99dev/mathmax](https://github.com/tiennm99dev/mathmax) | MathMax — Toán tương tác cho học sinh THCS Việt Nam (lớp 6-9). Static site. | [https://tiennm99dev.github.io/mathmax/](https://tiennm99dev.github.io/mathmax/) |
|
||||
| [tiennm99/penny-pincher-provider](https://github.com/tiennm99/penny-pincher-provider) | Curated list of affordable + free LLM API providers — Claude Code passes, coding-plan subs, free-tier APIs. | [https://tiennm99.github.io/penny-pincher-provider/](https://tiennm99.github.io/penny-pincher-provider/) |
|
||||
@@ -124,21 +124,21 @@ PR thì repo cập nhật gần đây hơn lên trước.
|
||||
| [is-a-dev/register](https://github.com/is-a-dev/register) | Grab your own sweet-looking '.is-a.dev' subdomain. | 4 | 11323 |
|
||||
| [is-a-good-dev/register](https://github.com/is-a-good-dev/register) | Register for your is-a-good.dev domain! | 4 | 620 |
|
||||
| [open-domains/register](https://github.com/open-domains/register) | Free subdomains for personal sites, open-source projects, and more. | 4 | 2601 |
|
||||
| [coollabsio/coolify](https://github.com/coollabsio/coolify) | An open-source, self-hostable PaaS alternative to Vercel, Heroku & Netlify that lets you easily deploy static sites, databases, full-stack applications and 280+ one-click services on your own servers. | 3 | 61962 |
|
||||
| [coollabsio/coolify](https://github.com/coollabsio/coolify) | An open-source, self-hostable PaaS alternative to Vercel, Heroku & Netlify that lets you easily deploy static sites, databases, full-stack applications and 280+ one-click services on your own servers. | 3 | 61964 |
|
||||
| [tarampampam/domains](https://github.com/tarampampam/domains) | 🌐 DNS configuration for some of my domains | 3 | 807 |
|
||||
| [zfoo-project/zfoo](https://github.com/zfoo-project/zfoo) | 💡Extremely fast enterprise server framework, can be used in RPC, game server, web server. | 3 | 2014 |
|
||||
| [old-free-domains/register](https://github.com/old-free-domains/register) | Free subdomains for personal sites, open-source projects, and more. | 2 | 598 |
|
||||
| [onyx-dot-app/documentation](https://github.com/onyx-dot-app/documentation) | Documentation for the Onyx Software | 1 | 33 |
|
||||
| [oraios/serena](https://github.com/oraios/serena) | A powerful MCP toolkit for coding, providing semantic retrieval and editing capabilities - the IDE for your agent | 1 | 29543 |
|
||||
| [oraios/serena](https://github.com/oraios/serena) | A powerful MCP toolkit for coding, providing semantic retrieval and editing capabilities - the IDE for your agent | 1 | 29544 |
|
||||
| [sirmalloc/ccstatusline](https://github.com/sirmalloc/ccstatusline) | 🚀 Beautiful highly customizable statusline for Claude Code CLI with powerline support, themes, and more. | 1 | 12930 |
|
||||
| [ripienaar/free-for-dev](https://github.com/ripienaar/free-for-dev) | A list of SaaS, PaaS and IaaS offerings that have free tiers of interest to devops and infradev | 1 | 137712 |
|
||||
| [Bogdanp/awesome-advent-of-code](https://github.com/Bogdanp/awesome-advent-of-code) | A collection of awesome resources related to the yearly Advent of Code challenge. | 1 | 3225 |
|
||||
| [kaitranntt/ccs](https://github.com/kaitranntt/ccs) | Switch between Claude accounts, Gemini, Copilot, OpenRouter (300+ models) via CLIProxyAPI OAuth proxy. Visual dashboard, remote proxy support, WebSearch fallback. Zero-config to production-ready. | 1 | 2856 |
|
||||
| [projectlombok/lombok](https://github.com/projectlombok/lombok) | Very spicy additions to the Java programming language. | 1 | 13484 |
|
||||
| [HugoBlox/kit](https://github.com/HugoBlox/kit) | 🧱 Describe your site, AI builds it, you own it as Markdown. Snap together Tailwind blocks like Lego — landing pages, blogs, portfolios, docs & more. No AI slop. Free to deploy anywhere 👇 | 1 | 9722 |
|
||||
| [HugoBlox/kit](https://github.com/HugoBlox/kit) | 🧱 Describe your site, AI builds it, you own it as Markdown. Snap together Tailwind blocks like Lego — landing pages, blogs, portfolios, docs & more. No AI slop. Free to deploy anywhere 👇 | 1 | 9723 |
|
||||
| [tkowalcz/tjahzi](https://github.com/tkowalcz/tjahzi) | Java clients, log4j2 and logback appenders for Grafana Loki | 1 | 139 |
|
||||
| [dhyeythumar/awesome-readme-tools](https://github.com/dhyeythumar/awesome-readme-tools) | A curated list of awesome readme tools. ✌ | 1 | 265 |
|
||||
| [thedev-id/thedev.id](https://github.com/thedev-id/thedev.id) | Identity for developers on the web. | 1 | 1728 |
|
||||
| [thedev-id/thedev.id](https://github.com/thedev-id/thedev.id) | Identity for developers on the web. | 1 | 1729 |
|
||||
| [is-amazing/register](https://github.com/is-amazing/register) | Register your is-amaz.ing project domain here! | 1 | 149 |
|
||||
| [DavidNiessen/SimpleGuicePlugin](https://github.com/DavidNiessen/SimpleGuicePlugin) | An example for dependency injection (Google Guice) in a Spigot plugin | 1 | 2 |
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import js from "@eslint/js";
|
||||
|
||||
export default [
|
||||
{ ignores: ["public/**", "resources/**", "themes/**"] },
|
||||
js.configs.recommended,
|
||||
{
|
||||
files: ["**/*.mjs", "**/*.js"],
|
||||
languageOptions: {
|
||||
ecmaVersion: "latest",
|
||||
sourceType: "module",
|
||||
globals: { console: "readonly", process: "readonly", fetch: "readonly" },
|
||||
},
|
||||
rules: {
|
||||
"no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
|
||||
eqeqeq: ["error", "always"],
|
||||
"prefer-const": "error",
|
||||
},
|
||||
},
|
||||
];
|
||||
Generated
+1646
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "blog",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"description": "Tooling for miti99.com — the Hugo site itself needs no Node build step.",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=24"
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "eslint .",
|
||||
"projects:refresh": "node .github/scripts/update-projects-list.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"octokit": "^5.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"eslint": "^10.10.0"
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user