fix: fetch real commit data via Compare API instead of guessing fields
This commit is contained in:
+104
-108
@@ -16,9 +16,8 @@ const MARKER_END = "<!--AI_ACTIVITY_END-->";
|
|||||||
const EVENTS_DAYS_BACK = 7;
|
const EVENTS_DAYS_BACK = 7;
|
||||||
const MAX_EVENTS = 100;
|
const MAX_EVENTS = 100;
|
||||||
|
|
||||||
// -- Helpers ----------------------------------------------------
|
// -- HTTP Helpers -----------------------------------------------
|
||||||
|
|
||||||
/** Minimal HTTPS fetch returning body string */
|
|
||||||
function fetch(url, options = {}) {
|
function fetch(url, options = {}) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const req = https.get(url, { headers: options.headers || {} }, (res) => {
|
const req = https.get(url, { headers: options.headers || {} }, (res) => {
|
||||||
@@ -27,14 +26,10 @@ function fetch(url, options = {}) {
|
|||||||
res.on("end", () => resolve({ status: res.statusCode, data }));
|
res.on("end", () => resolve({ status: res.statusCode, data }));
|
||||||
});
|
});
|
||||||
req.on("error", reject);
|
req.on("error", reject);
|
||||||
req.setTimeout(15000, () => {
|
req.setTimeout(15000, () => { req.destroy(); reject(new Error("Request timeout")); });
|
||||||
req.destroy();
|
|
||||||
reject(new Error("Request timeout"));
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Post JSON to URL and return parsed response */
|
|
||||||
function postJSON(url, body, headers = {}) {
|
function postJSON(url, body, headers = {}) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const urlObj = new URL(url);
|
const urlObj = new URL(url);
|
||||||
@@ -52,44 +47,86 @@ function postJSON(url, body, headers = {}) {
|
|||||||
res.on("end", () => resolve({ status: res.statusCode, data: raw }));
|
res.on("end", () => resolve({ status: res.statusCode, data: raw }));
|
||||||
});
|
});
|
||||||
req.on("error", reject);
|
req.on("error", reject);
|
||||||
req.setTimeout(30000, () => {
|
req.setTimeout(30000, () => { req.destroy(); reject(new Error("LLM request timeout")); });
|
||||||
req.destroy();
|
|
||||||
reject(new Error("LLM request timeout"));
|
|
||||||
});
|
|
||||||
req.write(data);
|
req.write(data);
|
||||||
req.end();
|
req.end();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Check if event happened within the last N days */
|
function authHeaders() {
|
||||||
|
return {
|
||||||
|
"User-Agent": "generate-ai-blog",
|
||||||
|
Authorization: `Bearer ${GITHUB_TOKEN}`,
|
||||||
|
Accept: "application/vnd.github.v3+json",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function isRecent(createdAt, days) {
|
function isRecent(createdAt, days) {
|
||||||
const then = new Date(createdAt).getTime();
|
const then = new Date(createdAt).getTime();
|
||||||
const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
|
const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
|
||||||
return then >= cutoff;
|
return then >= cutoff;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Format a single event into readable text */
|
// -- Fetch real commit data via Compare API --
|
||||||
|
// /users/{user}/events PushEvent payload does NOT include commits[] or size.
|
||||||
|
// We must call the Compare API to get actual commit count + messages.
|
||||||
|
async function fetchCommitData(repoFullName, beforeSha, headSha) {
|
||||||
|
const url = `https://api.github.com/repos/${repoFullName}/compare/${beforeSha}...${headSha}`;
|
||||||
|
try {
|
||||||
|
const { status, data } = await fetch(url, { headers: authHeaders() });
|
||||||
|
if (status !== 200) return null;
|
||||||
|
const parsed = JSON.parse(data);
|
||||||
|
const total = parsed.total_commits || 0;
|
||||||
|
const msgs = (parsed.commits || [])
|
||||||
|
.slice(0, 3)
|
||||||
|
.map((c) => c.commit?.message?.split("\n")[0] || "")
|
||||||
|
.filter(Boolean);
|
||||||
|
return { total, messages: msgs };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Enrich all PushEvents with real commit data --
|
||||||
|
async function enrichPushEvents(events) {
|
||||||
|
const results = [];
|
||||||
|
for (const ev of events) {
|
||||||
|
if (ev.type === "PushEvent") {
|
||||||
|
const repo = ev.repo?.name;
|
||||||
|
const before = ev.payload?.before;
|
||||||
|
const head = ev.payload?.head;
|
||||||
|
if (repo && before && head && before !== head) {
|
||||||
|
const data = await fetchCommitData(repo, before, head);
|
||||||
|
if (data) {
|
||||||
|
results.push({ ...ev, _commitData: data });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If Compare API fails, skip this PushEvent entirely (no data to show)
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
results.push(ev);
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Format a single event into readable text --
|
||||||
function formatEvent(event) {
|
function formatEvent(event) {
|
||||||
const repo = event.repo?.name || "unknown";
|
const repo = event.repo?.name || "unknown";
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case "PushEvent": {
|
case "PushEvent": {
|
||||||
const branch = (event.payload?.ref || "").replace("refs/heads/", "");
|
const branch = (event.payload?.ref || "").replace("refs/heads/", "");
|
||||||
const commits = event.payload?.commits || [];
|
const cd = event._commitData;
|
||||||
const size = event.payload?.size || commits.length;
|
if (!cd || cd.total === 0) return null; // no data to show
|
||||||
const msgs = commits
|
const label = `${cd.total} commit${cd.total > 1 ? "s" : ""}`;
|
||||||
.slice(0, 3)
|
if (cd.messages.length > 0) {
|
||||||
.map((c) => c.message?.split("\n")[0] || "no message")
|
return `• Push ${label} ke \`${repo}\` (${branch}): ${cd.messages.join("; ")}`;
|
||||||
.filter(Boolean);
|
|
||||||
const countLabel = `${size} commit${size > 1 ? "s" : ""}`;
|
|
||||||
if (msgs.length > 0) {
|
|
||||||
return `• Push ${countLabel} ke \`${repo}\` (${branch}): ${msgs.join("; ")}`;
|
|
||||||
}
|
}
|
||||||
return `• Push ${countLabel} ke \`${repo}\` (${branch})`;
|
return `• Push ${label} ke \`${repo}\` (${branch})`;
|
||||||
}
|
}
|
||||||
case "CreateEvent": {
|
case "CreateEvent": {
|
||||||
const refType = event.payload?.ref_type || "";
|
const refType = event.payload?.ref_type || "";
|
||||||
const ref = event.payload?.ref || refType;
|
const ref = event.payload?.ref || refType;
|
||||||
// Skip branch creations (usually noisy — CI/initial setup)
|
|
||||||
if (refType === "branch") return null;
|
if (refType === "branch") return null;
|
||||||
return `• Membuat ${refType} baru: \`${ref}\` di \`${repo}\``;
|
return `• Membuat ${refType} baru: \`${ref}\` di \`${repo}\``;
|
||||||
}
|
}
|
||||||
@@ -117,7 +154,6 @@ function formatEvent(event) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Deduplicate events: group same repo + type, limit to 8 entries */
|
|
||||||
function deduplicate(events) {
|
function deduplicate(events) {
|
||||||
const seen = new Set();
|
const seen = new Set();
|
||||||
const result = [];
|
const result = [];
|
||||||
@@ -135,23 +171,20 @@ function deduplicate(events) {
|
|||||||
// -- Main -------------------------------------------------------
|
// -- Main -------------------------------------------------------
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
|
const start = Date.now();
|
||||||
console.log("[AI Blog] Starting...");
|
console.log("[AI Blog] Starting...");
|
||||||
|
|
||||||
// 1. Fetch GitHub events
|
|
||||||
if (!GITHUB_TOKEN) {
|
if (!GITHUB_TOKEN) {
|
||||||
console.log("[AI Blog] No GITHUB_TOKEN, skipping activity fetch");
|
console.log("[AI Blog] No GITHUB_TOKEN, skipping");
|
||||||
writeFallback();
|
writeFallback();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const eventsUrl = `https://api.github.com/users/${GITHUB_USER}/events?per_page=${MAX_EVENTS}`;
|
// 1. Fetch raw events
|
||||||
const { status, data } = await fetch(eventsUrl, {
|
const { status, data } = await fetch(
|
||||||
headers: {
|
`https://api.github.com/users/${GITHUB_USER}/events?per_page=${MAX_EVENTS}`,
|
||||||
"User-Agent": "generate-ai-blog",
|
{ headers: authHeaders() }
|
||||||
Authorization: `Bearer ${GITHUB_TOKEN}`,
|
);
|
||||||
Accept: "application/vnd.github.v3+json",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (status !== 200) {
|
if (status !== 200) {
|
||||||
console.log(`[AI Blog] GitHub API returned ${status}, keeping existing content`);
|
console.log(`[AI Blog] GitHub API returned ${status}, keeping existing content`);
|
||||||
@@ -159,61 +192,35 @@ async function main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let events;
|
let events;
|
||||||
try {
|
try { events = JSON.parse(data); } catch { writeFallback(); return; }
|
||||||
events = JSON.parse(data);
|
if (!Array.isArray(events) || events.length === 0) { writeFallback(); return; }
|
||||||
} catch {
|
|
||||||
console.log("[AI Blog] Failed to parse GitHub API response");
|
|
||||||
writeFallback();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!Array.isArray(events) || events.length === 0) {
|
// 2. Filter: recent + relevant types + exclude profile repo
|
||||||
console.log("[AI Blog] No events found");
|
|
||||||
writeFallback();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Filter: recent + relevant event types, and exclude profile repo itself
|
|
||||||
const profileRepo = `${GITHUB_USER}/${GITHUB_USER}`;
|
const profileRepo = `${GITHUB_USER}/${GITHUB_USER}`;
|
||||||
const relevantTypes = ["PushEvent", "CreateEvent", "PullRequestEvent", "IssuesEvent", "WatchEvent", "ForkEvent"];
|
const relevantTypes = ["PushEvent", "CreateEvent", "PullRequestEvent", "IssuesEvent", "WatchEvent", "ForkEvent"];
|
||||||
const recent = events.filter(
|
const recent = events.filter(
|
||||||
(e) =>
|
(e) => isRecent(e.created_at, EVENTS_DAYS_BACK) && relevantTypes.includes(e.type) && e.repo?.name !== profileRepo
|
||||||
isRecent(e.created_at, EVENTS_DAYS_BACK) &&
|
|
||||||
relevantTypes.includes(e.type) &&
|
|
||||||
e.repo?.name !== profileRepo
|
|
||||||
);
|
);
|
||||||
|
if (recent.length === 0) { console.log("[AI Blog] No recent activity"); writeFallback(); return; }
|
||||||
|
|
||||||
if (recent.length === 0) {
|
// 3. Deduplicate
|
||||||
console.log("[AI Blog] No recent activity found");
|
|
||||||
writeFallback();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Format and deduplicate
|
|
||||||
const deduped = deduplicate(recent);
|
const deduped = deduplicate(recent);
|
||||||
const formatted = deduped
|
|
||||||
.map(formatEvent)
|
|
||||||
.filter(Boolean)
|
|
||||||
.join("\n");
|
|
||||||
|
|
||||||
if (!formatted) {
|
// 4. Enrich PushEvents with real commit data (calls Compare API)
|
||||||
console.log("[AI Blog] No meaningful activity after filtering");
|
const enriched = await enrichPushEvents(deduped);
|
||||||
writeFallback();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
console.log(`[AI Blog] Found ${deduped.length} unique events:\n${formatted}`);
|
|
||||||
|
|
||||||
// 4. Build LLM prompt
|
// 5. Format
|
||||||
const prompt = `Buat summary aktivitas GitHub dalam bahasa Indonesia kasual, maksimal 3 kalimat, pakai emoji. Jangan pake format list. Langsung kontennya aja, tanpa intro:\n\n${formatted}`;
|
const formatted = enriched.map(formatEvent).filter(Boolean).join("\n");
|
||||||
|
if (!formatted) { console.log("[AI Blog] No meaningful activity"); writeFallback(); return; }
|
||||||
|
|
||||||
// 5. Call LLM
|
console.log(`[AI Blog] ${enriched.length} events enriched in ${Date.now() - start}ms:\n${formatted}`);
|
||||||
if (!AI_API_KEY) {
|
|
||||||
console.log("[AI Blog] No AI_API_KEY, using raw activity text");
|
// 6. Build LLM prompt
|
||||||
writeContent(formatted, formatted);
|
const prompt = `Buat summary aktivitas GitHub dalam bahasa Indonesia kasual, maksimal 3 kalimat, pakai emoji. Jangan pake format list. Langsung kontennya aja:\n\n${formatted}`;
|
||||||
return;
|
|
||||||
}
|
// 7. Call LLM
|
||||||
|
if (!AI_API_KEY) { writeContent(formatted); return; }
|
||||||
|
|
||||||
const chatUrl = `${AI_BASE_URL}/chat/completions`;
|
|
||||||
const payload = {
|
const payload = {
|
||||||
model: AI_MODEL,
|
model: AI_MODEL,
|
||||||
messages: [{ role: "user", content: prompt }],
|
messages: [{ role: "user", content: prompt }],
|
||||||
@@ -224,52 +231,41 @@ async function main() {
|
|||||||
console.log(`[AI Blog] Calling LLM at ${AI_BASE_URL}...`);
|
console.log(`[AI Blog] Calling LLM at ${AI_BASE_URL}...`);
|
||||||
let llmResult;
|
let llmResult;
|
||||||
try {
|
try {
|
||||||
const llmResp = await postJSON(chatUrl, payload, {
|
const resp = await postJSON(`${AI_BASE_URL}/chat/completions`, payload, { Authorization: `Bearer ${AI_API_KEY}` });
|
||||||
Authorization: `Bearer ${AI_API_KEY}`,
|
console.log(`[AI Blog] LLM response status: ${resp.status}`);
|
||||||
});
|
if (resp.status === 200) {
|
||||||
console.log(`[AI Blog] LLM response status: ${llmResp.status}`);
|
const parsed = JSON.parse(resp.data);
|
||||||
|
|
||||||
if (llmResp.status === 200) {
|
|
||||||
const parsed = JSON.parse(llmResp.data);
|
|
||||||
llmResult = parsed.choices?.[0]?.message?.content?.trim();
|
llmResult = parsed.choices?.[0]?.message?.content?.trim();
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.log(`[AI Blog] LLM call failed: ${err.message}`);
|
console.log(`[AI Blog] LLM call failed: ${err.message}`);
|
||||||
}
|
}
|
||||||
|
writeContent(llmResult || formatted.slice(0, 300));
|
||||||
const summary = llmResult || formatted.slice(0, 300);
|
|
||||||
writeContent(summary, formatted);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Write content between README markers */
|
|
||||||
function writeContent(summary) {
|
function writeContent(summary) {
|
||||||
if (!fs.existsSync(README_PATH)) {
|
if (!fs.existsSync(README_PATH)) { console.error(`[AI Blog] README.md not found`); process.exit(0); }
|
||||||
console.error(`[AI Blog] README.md not found at ${README_PATH}`);
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
let readme = fs.readFileSync(README_PATH, "utf-8");
|
let readme = fs.readFileSync(README_PATH, "utf-8");
|
||||||
const startIdx = readme.indexOf(MARKER_START);
|
const startIdx = readme.indexOf(MARKER_START);
|
||||||
const endIdx = readme.indexOf(MARKER_END);
|
const endIdx = readme.indexOf(MARKER_END);
|
||||||
|
if (startIdx === -1 || endIdx === -1) { console.error("[AI Blog] Markers not found"); process.exit(0); }
|
||||||
if (startIdx === -1 || endIdx === -1) {
|
|
||||||
console.error("[AI Blog] Section markers not found in README.md");
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
const before = readme.slice(0, startIdx + MARKER_START.length);
|
const before = readme.slice(0, startIdx + MARKER_START.length);
|
||||||
const after = readme.slice(endIdx);
|
const after = readme.slice(endIdx);
|
||||||
const middle = `\n\n<p align="center">\n <i>${summary}</i>\n</p>\n\n`;
|
const middle = `\n\n<p align="center">\n <i>${escapeHtml(summary)}</i>\n</p>\n\n`;
|
||||||
const newReadme = before + middle + after;
|
fs.writeFileSync(README_PATH, before + middle + after, "utf-8");
|
||||||
|
|
||||||
fs.writeFileSync(README_PATH, newReadme, "utf-8");
|
|
||||||
console.log("[AI Blog] README.md updated successfully");
|
console.log("[AI Blog] README.md updated successfully");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Write fallback text when no activity is found */
|
function escapeHtml(text) {
|
||||||
|
return String(text)
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
function writeFallback() {
|
function writeFallback() {
|
||||||
const fallback = "▸ Belum ada aktivitas GitHub dalam 7 hari terakhir. Pantau terus! 🚀";
|
writeContent("▸ Belum ada aktivitas GitHub dalam 7 hari terakhir. Pantau terus! 🚀");
|
||||||
writeContent(fallback);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
main().catch((err) => {
|
main().catch((err) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user