2026-07-03 05:15:02 +07:00
// scripts/generate-ai-blog.js
// Fetches recent GitHub activity -> summarizes via LLM -> updates README.md markers
const https = require ( "https" );
const fs = require ( "fs" );
const path = require ( "path" );
// -- Configuration ----------------------------------------------
const GITHUB_USER = "asepharyana" ;
const GITHUB_TOKEN = process . env . GITHUB_TOKEN || "" ;
const AI_API_KEY = process . env . AI_LLM_API_KEY || "" ;
const AI_BASE_URL = ( process . env . AI_LLM_BASE_URL || "" ). replace ( /\/+$/ , "" );
const AI_MODEL = process . env . AI_LLM_MODEL || "text" ;
const README_PATH = path . join ( __dirname , ".." , "README.md" );
const MARKER_START = "<!--AI_ACTIVITY_START-->" ;
const MARKER_END = "<!--AI_ACTIVITY_END-->" ;
const EVENTS_DAYS_BACK = 7 ;
const MAX_EVENTS = 100 ;
2026-07-03 05:27:28 +07:00
// -- HTTP Helpers -----------------------------------------------
2026-07-03 05:15:02 +07:00
function fetch ( url , options = {}) {
return new Promise (( resolve , reject ) => {
const req = https . get ( url , { headers : options . headers || {} }, ( res ) => {
let data = "" ;
res . on ( "data" , ( chunk ) => ( data += chunk ));
res . on ( "end" , () => resolve ({ status : res . statusCode , data }));
});
req . on ( "error" , reject );
2026-07-03 05:27:28 +07:00
req . setTimeout ( 15000 , () => { req . destroy (); reject ( new Error ( "Request timeout" )); });
2026-07-03 05:15:02 +07:00
});
}
function postJSON ( url , body , headers = {}) {
return new Promise (( resolve , reject ) => {
const urlObj = new URL ( url );
const data = JSON . stringify ( body );
const opts = {
hostname : urlObj . hostname ,
port : urlObj . port || 443 ,
path : urlObj . pathname + urlObj . search ,
method : "POST" ,
headers : { "Content-Type" : "application/json" , ... headers },
};
const req = https . request ( opts , ( res ) => {
let raw = "" ;
res . on ( "data" , ( chunk ) => ( raw += chunk ));
res . on ( "end" , () => resolve ({ status : res . statusCode , data : raw }));
});
req . on ( "error" , reject );
2026-07-03 05:27:28 +07:00
req . setTimeout ( 30000 , () => { req . destroy (); reject ( new Error ( "LLM request timeout" )); });
2026-07-03 05:15:02 +07:00
req . write ( data );
req . end ();
});
}
2026-07-03 05:27:28 +07:00
function authHeaders () {
return {
"User-Agent" : "generate-ai-blog" ,
Authorization : `Bearer ${ GITHUB_TOKEN } ` ,
Accept : "application/vnd.github.v3+json" ,
};
}
2026-07-03 05:15:02 +07:00
function isRecent ( createdAt , days ) {
const then = new Date ( createdAt ). getTime ();
const cutoff = Date . now () - days * 24 * 60 * 60 * 1000 ;
return then >= cutoff ;
}
2026-07-03 05:27:28 +07:00
// -- 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 --
2026-07-03 05:15:02 +07:00
function formatEvent ( event ) {
const repo = event . repo ? . name || "unknown" ;
switch ( event . type ) {
case "PushEvent" : {
const branch = ( event . payload ? . ref || "" ). replace ( "refs/heads/" , "" );
2026-07-03 05:27:28 +07:00
const cd = event . _commitData ;
if ( ! cd || cd . total === 0 ) return null ; // no data to show
const label = ` ${ cd . total } commit ${ cd . total > 1 ? "s" : "" } ` ;
if ( cd . messages . length > 0 ) {
return `• Push ${ label } ke \` ${ repo } \` ( ${ branch } ): ${ cd . messages . join ( "; " ) } ` ;
2026-07-03 05:25:41 +07:00
}
2026-07-03 05:27:28 +07:00
return `• Push ${ label } ke \` ${ repo } \` ( ${ branch } )` ;
2026-07-03 05:15:02 +07:00
}
case "CreateEvent" : {
const refType = event . payload ? . ref_type || "" ;
const ref = event . payload ? . ref || refType ;
2026-07-03 05:23:33 +07:00
if ( refType === "branch" ) return null ;
2026-07-03 05:15:02 +07:00
return `• Membuat ${ refType } baru: \` ${ ref } \` di \` ${ repo } \`` ;
}
case "PullRequestEvent" : {
const action = event . payload ? . action || "" ;
const merged = event . payload ? . pull_request ? . merged || false ;
const title = event . payload ? . pull_request ? . title || "" ;
let label = action ;
if ( action === "opened" ) label = "Open" ;
else if ( action === "closed" && merged ) label = "Merge" ;
else if ( action === "closed" ) label = "Tutup" ;
return `• ${ label } PR: " ${ title } " di \` ${ repo } \`` ;
}
case "IssuesEvent" : {
const action = event . payload ? . action || "" ;
const title = event . payload ? . issue ? . title || "" ;
return `• ${ action . charAt ( 0 ). toUpperCase () + action . slice ( 1 ) } issue: " ${ title } " di \` ${ repo } \`` ;
}
case "WatchEvent" :
return `• ⭐ Star \` ${ repo } \`` ;
case "ForkEvent" :
return `• 🍴 Fork \` ${ repo } \`` ;
default :
return `• ${ event . type } di \` ${ repo } \`` ;
}
}
function deduplicate ( events ) {
const seen = new Set ();
const result = [];
for ( const ev of events ) {
const key = ` ${ ev . type } : ${ ev . repo ? . name } ` ;
if ( ! seen . has ( key )) {
seen . add ( key );
result . push ( ev );
if ( result . length >= 8 ) break ;
}
}
return result ;
}
// -- Main -------------------------------------------------------
async function main () {
2026-07-03 05:27:28 +07:00
const start = Date . now ();
2026-07-03 05:15:02 +07:00
console . log ( "[AI Blog] Starting..." );
if ( ! GITHUB_TOKEN ) {
2026-07-03 05:27:28 +07:00
console . log ( "[AI Blog] No GITHUB_TOKEN, skipping" );
2026-07-03 05:15:02 +07:00
writeFallback ();
return ;
}
2026-07-03 05:27:28 +07:00
// 1. Fetch raw events
const { status , data } = await fetch (
`https://api.github.com/users/ ${ GITHUB_USER } /events?per_page= ${ MAX_EVENTS } ` ,
{ headers : authHeaders () }
);
2026-07-03 05:15:02 +07:00
if ( status !== 200 ) {
console . log ( `[AI Blog] GitHub API returned ${ status } , keeping existing content` );
return ;
}
let events ;
2026-07-03 05:27:28 +07:00
try { events = JSON . parse ( data ); } catch { writeFallback (); return ; }
if ( ! Array . isArray ( events ) || events . length === 0 ) { writeFallback (); return ; }
2026-07-03 05:15:02 +07:00
2026-07-03 05:27:28 +07:00
// 2. Filter: recent + relevant types + exclude profile repo
2026-07-03 05:23:33 +07:00
const profileRepo = ` ${ GITHUB_USER } / ${ GITHUB_USER } ` ;
const relevantTypes = [ "PushEvent" , "CreateEvent" , "PullRequestEvent" , "IssuesEvent" , "WatchEvent" , "ForkEvent" ];
2026-07-03 05:15:02 +07:00
const recent = events . filter (
2026-07-03 05:27:28 +07:00
( e ) => isRecent ( e . created_at , EVENTS_DAYS_BACK ) && relevantTypes . includes ( e . type ) && e . repo ? . name !== profileRepo
2026-07-03 05:15:02 +07:00
);
2026-07-03 05:27:28 +07:00
if ( recent . length === 0 ) { console . log ( "[AI Blog] No recent activity" ); writeFallback (); return ; }
2026-07-03 05:15:02 +07:00
2026-07-03 05:27:28 +07:00
// 3. Deduplicate
2026-07-03 05:15:02 +07:00
const deduped = deduplicate ( recent );
2026-07-03 05:23:33 +07:00
2026-07-03 05:27:28 +07:00
// 4. Enrich PushEvents with real commit data (calls Compare API)
const enriched = await enrichPushEvents ( deduped );
2026-07-03 05:15:02 +07:00
2026-07-03 05:27:28 +07:00
// 5. Format
const formatted = enriched . map ( formatEvent ). filter ( Boolean ). join ( "\n" );
if ( ! formatted ) { console . log ( "[AI Blog] No meaningful activity" ); writeFallback (); return ; }
2026-07-03 05:15:02 +07:00
2026-07-03 05:27:28 +07:00
console . log ( `[AI Blog] ${ enriched . length } events enriched in ${ Date . now () - start } ms:\n ${ formatted } ` );
// 6. Build LLM prompt
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 } ` ;
// 7. Call LLM
if ( ! AI_API_KEY ) { writeContent ( formatted ); return ; }
2026-07-03 05:15:02 +07:00
const payload = {
model : AI_MODEL ,
messages : [{ role : "user" , content : prompt }],
max_tokens : 300 ,
temperature : 0.7 ,
};
console . log ( `[AI Blog] Calling LLM at ${ AI_BASE_URL } ...` );
let llmResult ;
try {
2026-07-03 05:27:28 +07:00
const resp = await postJSON ( ` ${ AI_BASE_URL } /chat/completions` , payload , { Authorization : `Bearer ${ AI_API_KEY } ` });
console . log ( `[AI Blog] LLM response status: ${ resp . status } ` );
if ( resp . status === 200 ) {
const parsed = JSON . parse ( resp . data );
2026-07-03 05:15:02 +07:00
llmResult = parsed . choices ? .[ 0 ] ? . message ? . content ? . trim ();
}
} catch ( err ) {
console . log ( `[AI Blog] LLM call failed: ${ err . message } ` );
}
2026-07-03 05:27:28 +07:00
writeContent ( llmResult || formatted . slice ( 0 , 300 ));
2026-07-03 05:15:02 +07:00
}
function writeContent ( summary ) {
2026-07-03 05:27:28 +07:00
if ( ! fs . existsSync ( README_PATH )) { console . error ( `[AI Blog] README.md not found` ); process . exit ( 0 ); }
2026-07-03 05:15:02 +07:00
let readme = fs . readFileSync ( README_PATH , "utf-8" );
const startIdx = readme . indexOf ( MARKER_START );
const endIdx = readme . indexOf ( MARKER_END );
2026-07-03 05:27:28 +07:00
if ( startIdx === - 1 || endIdx === - 1 ) { console . error ( "[AI Blog] Markers not found" ); process . exit ( 0 ); }
2026-07-03 05:15:02 +07:00
const before = readme . slice ( 0 , startIdx + MARKER_START . length );
const after = readme . slice ( endIdx );
2026-07-03 05:27:28 +07:00
const middle = `\n\n<p align="center">\n <i> ${ escapeHtml ( summary ) } </i>\n</p>\n\n` ;
fs . writeFileSync ( README_PATH , before + middle + after , "utf-8" );
2026-07-03 05:15:02 +07:00
console . log ( "[AI Blog] README.md updated successfully" );
}
2026-07-03 05:27:28 +07:00
function escapeHtml ( text ) {
return String ( text )
. replace ( /&/g , "&" )
. replace ( /</g , "<" )
. replace ( />/g , ">" )
. replace ( /"/g , """ );
}
2026-07-03 05:15:02 +07:00
function writeFallback () {
2026-07-03 05:27:28 +07:00
writeContent ( "▸ Belum ada aktivitas GitHub dalam 7 hari terakhir. Pantau terus! 🚀" );
2026-07-03 05:15:02 +07:00
}
main (). catch (( err ) => {
console . error ( `[AI Blog] Error: ${ err . message } ` );
process . exit ( 0 );
});