From bf5d6cdaaf7b50d6799c1bc6e6208daea2f093ed Mon Sep 17 00:00:00 2001 From: asepharyana Date: Fri, 3 Jul 2026 05:15:02 +0700 Subject: [PATCH] feat: add AI-powered activity log --- .github/workflows/ai-activity.yml | 43 +++++ package.json | 7 + scripts/generate-ai-blog.js | 261 ++++++++++++++++++++++++++++++ 3 files changed, 311 insertions(+) create mode 100644 .github/workflows/ai-activity.yml create mode 100644 package.json create mode 100644 scripts/generate-ai-blog.js diff --git a/.github/workflows/ai-activity.yml b/.github/workflows/ai-activity.yml new file mode 100644 index 0000000..52dae69 --- /dev/null +++ b/.github/workflows/ai-activity.yml @@ -0,0 +1,43 @@ +name: AI Activity Log + +on: + schedule: + - cron: "0 */6 * * *" + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: write + +jobs: + generate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Install dependencies + run: npm install --save-dev prettier + + - name: Generate AI activity log + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + AI_LLM_API_KEY: ${{ secrets.AI_LLM_API_KEY }} + AI_LLM_BASE_URL: ${{ secrets.AI_LLM_BASE_URL }} + AI_LLM_MODEL: ${{ secrets.AI_LLM_MODEL }} + run: node scripts/generate-ai-blog.js + + - name: Format README + run: npx prettier --write README.md || true + + - name: Commit changes + uses: stefanzweifel/git-auto-commit-action@v5 + with: + commit_message: "chore: update AI activity log [skip ci]" + file_pattern: README.md diff --git a/package.json b/package.json new file mode 100644 index 0000000..eb19ba7 --- /dev/null +++ b/package.json @@ -0,0 +1,7 @@ +{ + "name": "profile-readme", + "private": true, + "scripts": { + "generate-ai": "node scripts/generate-ai-blog.js" + } +} diff --git a/scripts/generate-ai-blog.js b/scripts/generate-ai-blog.js new file mode 100644 index 0000000..642f0d3 --- /dev/null +++ b/scripts/generate-ai-blog.js @@ -0,0 +1,261 @@ +// 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 = ""; +const MARKER_END = ""; +const EVENTS_DAYS_BACK = 7; +const MAX_EVENTS = 100; + +// -- Helpers ---------------------------------------------------- + +/** Minimal HTTPS fetch returning body string */ +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); + req.setTimeout(15000, () => { + req.destroy(); + reject(new Error("Request timeout")); + }); + }); +} + +/** Post JSON to URL and return parsed response */ +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); + req.setTimeout(30000, () => { + req.destroy(); + reject(new Error("LLM request timeout")); + }); + req.write(data); + req.end(); + }); +} + +/** Check if event happened within the last N days */ +function isRecent(createdAt, days) { + const then = new Date(createdAt).getTime(); + const cutoff = Date.now() - days * 24 * 60 * 60 * 1000; + return then >= cutoff; +} + +/** Format a single event into readable text */ +function formatEvent(event) { + const repo = event.repo?.name || "unknown"; + switch (event.type) { + case "PushEvent": { + const branch = (event.payload?.ref || "").replace("refs/heads/", ""); + const commits = event.payload?.commits || []; + const msgs = commits + .slice(0, 3) + .map((c) => c.message?.split("\n")[0] || "no message") + .filter(Boolean); + return `• Push ${commits.length} commit${commits.length > 1 ? "s" : ""} ke \`${repo}\` (${branch}): ${msgs.join("; ")}`; + } + case "CreateEvent": { + const refType = event.payload?.ref_type || ""; + const ref = event.payload?.ref || refType; + 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}\``; + } +} + +/** Deduplicate events: group same repo + type, limit to 8 entries */ +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() { + console.log("[AI Blog] Starting..."); + + // 1. Fetch GitHub events + if (!GITHUB_TOKEN) { + console.log("[AI Blog] No GITHUB_TOKEN, skipping activity fetch"); + writeFallback(); + return; + } + + const eventsUrl = `https://api.github.com/users/${GITHUB_USER}/events?per_page=${MAX_EVENTS}`; + const { status, data } = await fetch(eventsUrl, { + headers: { + "User-Agent": "generate-ai-blog", + Authorization: `Bearer ${GITHUB_TOKEN}`, + Accept: "application/vnd.github.v3+json", + }, + }); + + if (status !== 200) { + console.log(`[AI Blog] GitHub API returned ${status}, keeping existing content`); + return; + } + + let events; + try { + events = JSON.parse(data); + } catch { + console.log("[AI Blog] Failed to parse GitHub API response"); + writeFallback(); + return; + } + + if (!Array.isArray(events) || events.length === 0) { + console.log("[AI Blog] No events found"); + writeFallback(); + return; + } + + // 2. Filter to recent + relevant event types + const recent = events.filter( + (e) => + isRecent(e.created_at, EVENTS_DAYS_BACK) && + ["PushEvent", "CreateEvent", "PullRequestEvent", "IssuesEvent", "WatchEvent", "ForkEvent"].includes( + e.type + ) + ); + + if (recent.length === 0) { + console.log("[AI Blog] No recent activity found"); + writeFallback(); + return; + } + + // 3. Format and deduplicate + const deduped = deduplicate(recent); + const formatted = deduped.map(formatEvent).join("\n"); + console.log(`[AI Blog] Found ${deduped.length} unique events:\n${formatted}`); + + // 4. 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, tanpa intro:\n\n${formatted}`; + + // 5. Call LLM + if (!AI_API_KEY) { + console.log("[AI Blog] No AI_API_KEY, using raw activity text"); + writeContent(formatted, formatted); + return; + } + + const chatUrl = `${AI_BASE_URL}/chat/completions`; + 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 { + const llmResp = await postJSON(chatUrl, payload, { + Authorization: `Bearer ${AI_API_KEY}`, + }); + console.log(`[AI Blog] LLM response status: ${llmResp.status}`); + + if (llmResp.status === 200) { + const parsed = JSON.parse(llmResp.data); + llmResult = parsed.choices?.[0]?.message?.content?.trim(); + } + } catch (err) { + console.log(`[AI Blog] LLM call failed: ${err.message}`); + } + + const summary = llmResult || formatted.slice(0, 300); + writeContent(summary, formatted); +} + +/** Write content between README markers */ +function writeContent(summary) { + if (!fs.existsSync(README_PATH)) { + console.error(`[AI Blog] README.md not found at ${README_PATH}`); + process.exit(0); + } + + let readme = fs.readFileSync(README_PATH, "utf-8"); + const startIdx = readme.indexOf(MARKER_START); + const endIdx = readme.indexOf(MARKER_END); + + 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 after = readme.slice(endIdx); + const middle = `\n\n

\n ${summary}\n

\n\n`; + const newReadme = before + middle + after; + + fs.writeFileSync(README_PATH, newReadme, "utf-8"); + console.log("[AI Blog] README.md updated successfully"); +} + +/** Write fallback text when no activity is found */ +function writeFallback() { + const fallback = "▸ Belum ada aktivitas GitHub dalam 7 hari terakhir. Pantau terus! 🚀"; + writeContent(fallback); +} + +main().catch((err) => { + console.error(`[AI Blog] Error: ${err.message}`); + process.exit(0); +});