mirror of
https://github.com/tiennm99/blog.git
synced 2026-10-11 03:13:10 +00:00
feat(projects): split projects page into starred, linked and contributed lists
Resolve owned namespaces from org memberships with the admin role instead of assuming a fixed owner list, so repos under orgs I own are included. - starred: owned public non-fork repos with at least one star, by stars then most recent push - linked: owned public non-fork repos exposing a homepage or Pages URL, by most recent push - contributed: public repos owned by others, by my merged PR count then most recent push Private repos are excluded everywhere; forks are excluded from the owned lists because their homepage belongs to upstream. Split the generator into API access and rendering modules.
This commit is contained in:
1 parent
aad39d9369
commit
a9bb2f0621
4 files changed
+515
-250
No files matched your search
@@ -0,0 +1,235 @@
|
||||
// GitHub API access for the projects list: owned namespaces, their repos, and
|
||||
// the repos the user contributed merged pull requests to.
|
||||
|
||||
const API = "https://api.github.com";
|
||||
const GRAPHQL = `${API}/graphql`;
|
||||
|
||||
/**
|
||||
* @typedef {object} Repo
|
||||
* @property {string} nameWithOwner
|
||||
* @property {string} name
|
||||
* @property {string} owner
|
||||
* @property {string} htmlUrl
|
||||
* @property {string} description
|
||||
* @property {string} homepage
|
||||
* @property {boolean} hasPages
|
||||
* @property {number} stars
|
||||
* @property {boolean} isFork
|
||||
* @property {boolean} isPrivate
|
||||
* @property {boolean} isArchived
|
||||
* @property {string} pushedAt Last push timestamp, ISO 8601.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} Contribution
|
||||
* @property {Repo} repo
|
||||
* @property {number} mergedPrs
|
||||
*/
|
||||
|
||||
/** Minimal GitHub client bound to one token and one user. */
|
||||
export class GitHubClient {
|
||||
/**
|
||||
* @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 }),
|
||||
});
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Logins the user controls: their own account plus every organization where
|
||||
* their membership role is `admin`.
|
||||
* @returns {Promise<Set<string>>}
|
||||
*/
|
||||
async fetchOwnedLogins() {
|
||||
let memberships;
|
||||
try {
|
||||
memberships = await this.restPaged("/user/memberships/orgs?state=active&per_page=100");
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`Cannot read organization memberships (the token needs the read:org scope): ${err.message}`,
|
||||
);
|
||||
}
|
||||
const logins = new Set([this.user.toLowerCase()]);
|
||||
for (const m of memberships) {
|
||||
if (m.role === "admin" && m.organization?.login) {
|
||||
logins.add(m.organization.login.toLowerCase());
|
||||
}
|
||||
}
|
||||
return logins;
|
||||
}
|
||||
|
||||
/**
|
||||
* Public repos across every owned login, deduplicated by full name.
|
||||
* @param {Set<string>} ownedLogins
|
||||
* @returns {Promise<Repo[]>}
|
||||
*/
|
||||
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)) {
|
||||
if (r.private) continue;
|
||||
if (!byName.has(r.full_name)) byName.set(r.full_name, fromRest(r));
|
||||
}
|
||||
}
|
||||
return [...byName.values()];
|
||||
}
|
||||
|
||||
/**
|
||||
* Repos the user has merged pull requests in, with the per-repo count.
|
||||
* Uses the search API, which caps at 1000 results.
|
||||
* @returns {Promise<Contribution[]>}
|
||||
*/
|
||||
async fetchMergedPrContributions() {
|
||||
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;
|
||||
}
|
||||
return [...counts.entries()].map(([key, mergedPrs]) => ({
|
||||
repo: repos.get(key),
|
||||
mergedPrs,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
const MERGED_PR_QUERY = `
|
||||
query($q: String!, $cursor: String) {
|
||||
search(query: $q, type: ISSUE, first: 100, after: $cursor) {
|
||||
pageInfo { hasNextPage endCursor }
|
||||
nodes {
|
||||
... on PullRequest {
|
||||
repository {
|
||||
nameWithOwner
|
||||
name
|
||||
url
|
||||
description
|
||||
homepageUrl
|
||||
stargazerCount
|
||||
pushedAt
|
||||
isPrivate
|
||||
isFork
|
||||
isArchived
|
||||
owner { login }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`;
|
||||
|
||||
/**
|
||||
* @param {object} r REST repository payload.
|
||||
* @returns {Repo}
|
||||
*/
|
||||
function fromRest(r) {
|
||||
return {
|
||||
nameWithOwner: r.full_name,
|
||||
name: r.name,
|
||||
owner: r.owner?.login || "",
|
||||
htmlUrl: r.html_url,
|
||||
description: r.description || "",
|
||||
homepage: r.homepage || "",
|
||||
hasPages: !!r.has_pages,
|
||||
stars: r.stargazers_count || 0,
|
||||
isFork: !!r.fork,
|
||||
isPrivate: !!r.private,
|
||||
isArchived: !!r.archived,
|
||||
pushedAt: r.pushed_at || "",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} r GraphQL repository node.
|
||||
* @returns {Repo}
|
||||
*/
|
||||
function fromGraphql(r) {
|
||||
return {
|
||||
nameWithOwner: r.nameWithOwner,
|
||||
name: r.name,
|
||||
owner: r.owner?.login || "",
|
||||
htmlUrl: r.url,
|
||||
description: r.description || "",
|
||||
homepage: r.homepageUrl || "",
|
||||
// has_pages is not exposed by GraphQL; contributed repos rely on homepage.
|
||||
hasPages: false,
|
||||
stars: r.stargazerCount || 0,
|
||||
isFork: !!r.isFork,
|
||||
isPrivate: !!r.isPrivate,
|
||||
isArchived: !!r.isArchived,
|
||||
pushedAt: r.pushedAt || "",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
// Markdown rendering and marker injection for the projects page.
|
||||
|
||||
/**
|
||||
* Public URL of a repo: its declared homepage, else its GitHub Pages URL when
|
||||
* Pages is enabled. Returns an empty string when the repo has neither.
|
||||
* @param {import("./github-api.mjs").Repo} repo
|
||||
* @returns {string}
|
||||
*/
|
||||
export function resolveUrl(repo) {
|
||||
const home = (repo.homepage || "").trim();
|
||||
if (home) return home;
|
||||
if (repo.hasPages) return `https://${repo.owner}.github.io/${repo.name}/`;
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} text
|
||||
* @returns {string} Cell-safe single-line text.
|
||||
*/
|
||||
function cell(text) {
|
||||
return (text || "")
|
||||
.replace(/\|/g, "\\|")
|
||||
.replace(/\r?\n/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} url
|
||||
* @returns {string}
|
||||
*/
|
||||
function linkOrDash(url) {
|
||||
return url ? `[${url}](${url})` : "—";
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string[]} headers
|
||||
* @param {string[][]} rows
|
||||
* @returns {string[]} Markdown table lines, or an empty-state line.
|
||||
*/
|
||||
function table(headers, rows) {
|
||||
if (rows.length === 0) return ["_Chưa có project nào._"];
|
||||
return [
|
||||
`| ${headers.join(" | ")} |`,
|
||||
`| ${headers.map(() => "---").join(" | ")} |`,
|
||||
...rows.map((r) => `| ${r.join(" | ")} |`),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Repos with at least one star, most-starred first.
|
||||
* @param {import("./github-api.mjs").Repo[]} repos
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function renderStarred(repos) {
|
||||
const rows = repos.map((r) => [
|
||||
`[${r.nameWithOwner}](${r.htmlUrl})`,
|
||||
cell(r.description),
|
||||
String(r.stars),
|
||||
linkOrDash(resolveUrl(r)),
|
||||
]);
|
||||
return table(["Name", "Description", "Stars", "URL"], rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* Repos that have a clickable URL, by name.
|
||||
* @param {import("./github-api.mjs").Repo[]} repos
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function renderLinked(repos) {
|
||||
const rows = repos.map((r) => [
|
||||
`[${r.nameWithOwner}](${r.htmlUrl})`,
|
||||
cell(r.description),
|
||||
linkOrDash(resolveUrl(r)),
|
||||
]);
|
||||
return table(["Name", "Description", "URL"], rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* Repos owned by others, by merged pull request count.
|
||||
* @param {import("./github-api.mjs").Contribution[]} contributions
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function renderContributed(contributions) {
|
||||
const rows = contributions.map((c) => [
|
||||
`[${c.repo.nameWithOwner}](${c.repo.htmlUrl})`,
|
||||
cell(c.repo.description),
|
||||
String(c.mergedPrs),
|
||||
String(c.repo.stars),
|
||||
]);
|
||||
return table(["Name", "Description", "Merged PRs", "Stars"], rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the content between a marker pair. Throws when the pair is missing,
|
||||
* so a renamed marker fails loudly instead of silently dropping a list.
|
||||
* @param {string} source
|
||||
* @param {string} marker Marker base name, e.g. `PROJECTS_STARRED`.
|
||||
* @param {string[]} lines
|
||||
* @returns {string}
|
||||
*/
|
||||
export function injectBlock(source, marker, lines) {
|
||||
const start = `<!-- ${marker}_START -->`;
|
||||
const end = `<!-- ${marker}_END -->`;
|
||||
const pattern = new RegExp(`${start}[\\s\\S]*?${end}`);
|
||||
if (!pattern.test(source)) {
|
||||
throw new Error(`Marker pair ${marker} not found in projects page`);
|
||||
}
|
||||
return source.replace(pattern, [start, "", ...lines, "", end].join("\n"));
|
||||
}
|
||||
@@ -1,189 +1,79 @@
|
||||
#!/usr/bin/env node
|
||||
// Build a projects list (owned + contributed) and inject it into
|
||||
// content/page/projects/index.md between the PROJECTS_LIST markers.
|
||||
// Build the three project lists and inject them into the projects page:
|
||||
//
|
||||
// A project is included if it has a URL, meaning either:
|
||||
// - repo.homepage is set, OR
|
||||
// - repo.has_pages is true (owned repos only — GraphQL does not expose
|
||||
// has_pages for contributed repos, so those must have homepage set).
|
||||
// 1. Starred — public repos I own (personal account or an org I administer)
|
||||
// with at least one star, by stars then most recent push.
|
||||
// 2. Linked — public repos I own that have a clickable URL (homepage or
|
||||
// GitHub Pages), no star requirement, by most recent push.
|
||||
// May overlap list 1.
|
||||
// 3. Contributed — public repos owned by someone else, by how many of my pull
|
||||
// requests were merged there, then most recent push.
|
||||
//
|
||||
// Forks are excluded from lists 1 and 2: their homepage belongs to upstream,
|
||||
// not to me. Private repos are excluded everywhere — the page is public.
|
||||
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
const token = process.env.GH_TOKEN;
|
||||
const user = process.env.GH_USER;
|
||||
import { GitHubClient } from "./lib/github-api.mjs";
|
||||
import {
|
||||
injectBlock,
|
||||
renderContributed,
|
||||
renderLinked,
|
||||
renderStarred,
|
||||
resolveUrl,
|
||||
} from "./lib/render-projects.mjs";
|
||||
|
||||
if (!token || !user) {
|
||||
console.error("GH_TOKEN and GH_USER env vars are required");
|
||||
process.exit(1);
|
||||
}
|
||||
const PAGE = "content/page/projects/index.md";
|
||||
|
||||
const restHeaders = {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: "application/vnd.github+json",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
"User-Agent": `${user}-projects-updater`,
|
||||
};
|
||||
|
||||
async function fetchOwnedRepos() {
|
||||
const all = [];
|
||||
for (let page = 1; page <= 20; page += 1) {
|
||||
const url = `https://api.github.com/users/${user}/repos?type=all&sort=updated&per_page=100&page=${page}`;
|
||||
const res = await fetch(url, { headers: restHeaders });
|
||||
if (!res.ok) {
|
||||
throw new Error(`REST /users/${user}/repos ${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.map((r) => ({
|
||||
nameWithOwner: r.full_name,
|
||||
name: r.name,
|
||||
htmlUrl: r.html_url,
|
||||
description: r.description,
|
||||
homepage: r.homepage,
|
||||
hasPages: !!r.has_pages,
|
||||
owner: r.owner?.login,
|
||||
isOwned: true,
|
||||
}));
|
||||
}
|
||||
|
||||
const CONTRIB_QUERY = `
|
||||
query($login: String!, $cursor: String) {
|
||||
user(login: $login) {
|
||||
repositoriesContributedTo(
|
||||
first: 100
|
||||
after: $cursor
|
||||
includeUserRepositories: false
|
||||
privacy: PUBLIC
|
||||
contributionTypes: [COMMIT, PULL_REQUEST, PULL_REQUEST_REVIEW]
|
||||
orderBy: { field: UPDATED_AT, direction: DESC }
|
||||
) {
|
||||
pageInfo { hasNextPage endCursor }
|
||||
nodes {
|
||||
nameWithOwner
|
||||
name
|
||||
url
|
||||
description
|
||||
homepageUrl
|
||||
owner { login }
|
||||
}
|
||||
}
|
||||
}
|
||||
}`;
|
||||
|
||||
async function fetchContributedRepos() {
|
||||
const all = [];
|
||||
let cursor = null;
|
||||
for (let i = 0; i < 20; i += 1) {
|
||||
const res = await fetch("https://api.github.com/graphql", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": `${user}-projects-updater`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
query: CONTRIB_QUERY,
|
||||
variables: { login: user, cursor },
|
||||
}),
|
||||
});
|
||||
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)}`);
|
||||
}
|
||||
const node = body.data?.user?.repositoriesContributedTo;
|
||||
if (!node) break;
|
||||
for (const r of node.nodes) {
|
||||
all.push({
|
||||
nameWithOwner: r.nameWithOwner,
|
||||
name: r.name,
|
||||
htmlUrl: r.url,
|
||||
description: r.description,
|
||||
homepage: r.homepageUrl,
|
||||
hasPages: false, // not exposed via GraphQL; rely on homepage
|
||||
owner: r.owner.login,
|
||||
isOwned: false,
|
||||
});
|
||||
}
|
||||
if (!node.pageInfo.hasNextPage) break;
|
||||
cursor = node.pageInfo.endCursor;
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
function resolveUrl(repo) {
|
||||
const home = (repo.homepage || "").trim();
|
||||
if (home) return home;
|
||||
if (repo.hasPages && repo.isOwned) {
|
||||
return `https://${repo.owner}.github.io/${repo.name}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function toRow(repo, url) {
|
||||
const desc = (repo.description || "")
|
||||
.replace(/\|/g, "\\|")
|
||||
.replace(/\r?\n/g, " ")
|
||||
.trim();
|
||||
return `| [${repo.nameWithOwner}](${repo.htmlUrl}) | ${desc} | [${url}](${url}) |`;
|
||||
}
|
||||
|
||||
const START = "<!-- PROJECTS_LIST_START -->";
|
||||
const END = "<!-- PROJECTS_LIST_END -->";
|
||||
|
||||
function buildBlock(rows) {
|
||||
return [
|
||||
START,
|
||||
"",
|
||||
"| Name | Description | URL |",
|
||||
"| --- | --- | --- |",
|
||||
...rows,
|
||||
"",
|
||||
END,
|
||||
].join("\n");
|
||||
}
|
||||
/**
|
||||
* Most recently pushed first; a repo with no timestamp sorts last.
|
||||
* @param {import("./lib/github-api.mjs").Repo} a
|
||||
* @param {import("./lib/github-api.mjs").Repo} b
|
||||
* @returns {number}
|
||||
*/
|
||||
const byRecentPush = (a, b) => (b.pushedAt || "").localeCompare(a.pushedAt || "");
|
||||
|
||||
async function main() {
|
||||
const [owned, contributed] = await Promise.all([
|
||||
fetchOwnedRepos(),
|
||||
fetchContributedRepos(),
|
||||
const token = process.env.GH_TOKEN;
|
||||
const user = process.env.GH_USER;
|
||||
if (!token || !user) {
|
||||
console.error("GH_TOKEN and GH_USER env vars are required");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const gh = new GitHubClient(token, user);
|
||||
const ownedLogins = await gh.fetchOwnedLogins();
|
||||
const [ownedRepos, contributions] = await Promise.all([
|
||||
gh.fetchOwnedRepos(ownedLogins),
|
||||
gh.fetchMergedPrContributions(),
|
||||
]);
|
||||
|
||||
const merged = new Map();
|
||||
for (const r of [...owned, ...contributed]) {
|
||||
if (!merged.has(r.nameWithOwner)) merged.set(r.nameWithOwner, r);
|
||||
}
|
||||
const mine = ownedRepos.filter((r) => !r.isFork);
|
||||
|
||||
const rows = [];
|
||||
const sorted = [...merged.values()].sort((a, b) =>
|
||||
a.nameWithOwner.localeCompare(b.nameWithOwner),
|
||||
);
|
||||
for (const r of sorted) {
|
||||
const url = resolveUrl(r);
|
||||
if (!url) continue;
|
||||
rows.push(toRow(r, url));
|
||||
}
|
||||
const starred = mine
|
||||
.filter((r) => r.stars > 0)
|
||||
.sort((a, b) => b.stars - a.stars || byRecentPush(a, b));
|
||||
|
||||
const block = buildBlock(rows);
|
||||
const file = path.resolve("content/page/projects/index.md");
|
||||
const linked = mine.filter((r) => resolveUrl(r) !== "").sort(byRecentPush);
|
||||
|
||||
const contributed = contributions
|
||||
.filter((c) => !ownedLogins.has(c.repo.owner.toLowerCase()))
|
||||
.sort((a, b) => b.mergedPrs - a.mergedPrs || byRecentPush(a.repo, b.repo));
|
||||
|
||||
const file = path.resolve(PAGE);
|
||||
const current = await fs.readFile(file, "utf8");
|
||||
const marker = /<!-- PROJECTS_LIST_START -->[\s\S]*?<!-- PROJECTS_LIST_END -->/;
|
||||
const next = marker.test(current)
|
||||
? current.replace(marker, block)
|
||||
: `${current.trimEnd()}\n\n${block}\n`;
|
||||
let next = injectBlock(current, "PROJECTS_STARRED", renderStarred(starred));
|
||||
next = injectBlock(next, "PROJECTS_LINKED", renderLinked(linked));
|
||||
next = injectBlock(next, "PROJECTS_CONTRIBUTED", renderContributed(contributed));
|
||||
|
||||
const summary = `${starred.length} starred, ${linked.length} linked, ${contributed.length} contributed`;
|
||||
if (next === current) {
|
||||
console.log(`No changes (${rows.length} projects).`);
|
||||
console.log(`No changes (${summary}).`);
|
||||
return;
|
||||
}
|
||||
await fs.writeFile(file, next);
|
||||
console.log(`Updated ${file} with ${rows.length} projects.`);
|
||||
console.log(`Updated ${PAGE} (${summary}).`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
|
||||
Reference in new issue
Block a user