diff --git a/.github/workflows/agentblame.yml b/.github/workflows/agentblame.yml index 23e480f..5ea5529 100644 --- a/.github/workflows/agentblame.yml +++ b/.github/workflows/agentblame.yml @@ -23,6 +23,11 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v1 + - name: Configure git identity + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + - name: Install dependencies run: bun install diff --git a/package.json b/package.json index e4e51e2..ce68cde 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "agentblame", "displayName": "Agent Blame", "description": "Track AI-generated vs human-written code. Provides git notes storage, CLI, and GitHub PR attribution.", - "version": "0.2.3", + "version": "0.2.5", "private": true, "license": "Apache-2.0", "repository": { diff --git a/packages/chrome/build-chrome.ts b/packages/chrome/build-chrome.ts index 618831f..1dd60f3 100644 --- a/packages/chrome/build-chrome.ts +++ b/packages/chrome/build-chrome.ts @@ -71,29 +71,17 @@ async function bundle(): Promise { }); console.log("✓ Bundled popup.js"); - // Bundle content script + // Bundle unified content script router await build({ - entryPoints: [join(SRC_DIR, "content", "content.ts")], + entryPoints: [join(SRC_DIR, "content", "router.ts")], bundle: true, - outfile: join(DIST_DIR, "content", "content.js"), + outfile: join(DIST_DIR, "content", "router.js"), format: "iife", target: "chrome100", minify: false, sourcemap: true, }); - console.log("✓ Bundled content.js"); - - // Bundle analytics entry script (for repo pages) - await build({ - entryPoints: [join(SRC_DIR, "content", "analytics-entry.ts")], - bundle: true, - outfile: join(DIST_DIR, "content", "analytics-entry.js"), - format: "iife", - target: "chrome100", - minify: false, - sourcemap: true, - }); - console.log("✓ Bundled analytics-entry.js"); + console.log("✓ Bundled router.js"); // Bundle background service worker await build({ diff --git a/packages/chrome/package.json b/packages/chrome/package.json index 8210e8c..18a229d 100644 --- a/packages/chrome/package.json +++ b/packages/chrome/package.json @@ -1,6 +1,6 @@ { "name": "@agentblame/chrome", - "version": "0.2.3", + "version": "0.2.5", "description": "Agent Blame Chrome Extension - See AI attribution on GitHub PRs", "private": true, "scripts": { diff --git a/packages/chrome/src/content/analytics-entry.ts b/packages/chrome/src/content/analytics-entry.ts index 842d62c..23819dd 100644 --- a/packages/chrome/src/content/analytics-entry.ts +++ b/packages/chrome/src/content/analytics-entry.ts @@ -12,60 +12,42 @@ import { handleHashChange, } from "./analytics-tab"; -let observer: MutationObserver | null = null; +// Track if we've already set up listeners (avoid duplicates) +let listenersInitialized = false; /** - * Initialize analytics sidebar injection + * Check if current URL could navigate to insights pages + * (i.e., we're on a repo page) */ -function init(): void { - console.log("[Agent Blame] Analytics entry loaded on:", window.location.href); - console.log("[Agent Blame] isInsightsPage:", isInsightsPage()); - - if (isInsightsPage()) { - // Wait a bit for GitHub to fully render the page - setTimeout(() => { - console.log("[Agent Blame] Injecting sidebar item..."); - injectSidebarItem(); - }, 500); - } - - // Watch for DOM changes (GitHub uses dynamic rendering) - setupObserver(); - - // Handle hash changes for navigation - setupHashListener(); - - // Handle GitHub's Turbo navigation - setupTurboListener(); +function isRepoPage(): boolean { + // Match: github.com/owner/repo or github.com/owner/repo/* + return /^https:\/\/github\.com\/[^/]+\/[^/]+/.test(window.location.href); } /** - * Setup MutationObserver for dynamic content + * Initialize analytics sidebar injection */ -function setupObserver(): void { - if (observer) { - observer.disconnect(); +function init(): void { + // Quick exit if not on a repo page - no setup needed + if (!isRepoPage()) { + return; } - observer = new MutationObserver(() => { - if (isInsightsPage()) { - injectSidebarItem(); - } else { - removeSidebarItem(); - } - }); + // Only set up listeners once + if (listenersInitialized) { + return; + } + listenersInitialized = true; - // Observe the sidebar area for changes - const sidebar = document.querySelector(".Layout-sidebar"); - if (sidebar) { - observer.observe(sidebar, { childList: true, subtree: true }); + // On insights page - inject immediately + if (isInsightsPage()) { + setTimeout(() => injectSidebarItem(), 500); } - // Also observe body for major page changes - observer.observe(document.body, { - childList: true, - subtree: false, - }); + // Lightweight setup - only what's needed for navigation detection + setupHistoryListener(); + setupHashListener(); + setupTurboListener(); } /** @@ -73,14 +55,12 @@ function setupObserver(): void { */ function setupHashListener(): void { window.addEventListener("hashchange", () => { - console.log("[Agent Blame] Hash changed to:", window.location.hash); handleHashChange(); }); - // Also handle popstate for back/forward + // Handle popstate for back/forward window.addEventListener("popstate", () => { - console.log("[Agent Blame] Popstate - hash:", window.location.hash); - handleHashChange(); + handleNavigation(); }); } @@ -91,27 +71,53 @@ function setupTurboListener(): void { // Turbo Drive fires these events on navigation document.addEventListener("turbo:load", () => { console.log("[Agent Blame] Turbo load"); - if (isInsightsPage()) { - setTimeout(() => injectSidebarItem(), 100); - } + handleNavigation(); }); document.addEventListener("turbo:render", () => { console.log("[Agent Blame] Turbo render"); - if (isInsightsPage()) { - setTimeout(() => injectSidebarItem(), 100); - } + handleNavigation(); }); // Also handle the older pjax events (some GitHub pages still use these) document.addEventListener("pjax:end", () => { console.log("[Agent Blame] PJAX end"); - if (isInsightsPage()) { - setTimeout(() => injectSidebarItem(), 100); - } + handleNavigation(); }); } +/** + * Handle navigation by intercepting History API + */ +function setupHistoryListener(): void { + // Intercept pushState + const originalPushState = history.pushState.bind(history); + history.pushState = (...args) => { + originalPushState(...args); + console.log("[Agent Blame] pushState:", window.location.href); + setTimeout(handleNavigation, 100); + }; + + // Intercept replaceState + const originalReplaceState = history.replaceState.bind(history); + history.replaceState = (...args) => { + originalReplaceState(...args); + console.log("[Agent Blame] replaceState:", window.location.href); + setTimeout(handleNavigation, 100); + }; +} + +/** + * Handle navigation - inject or remove sidebar item based on current page + */ +function handleNavigation(): void { + if (isInsightsPage()) { + setTimeout(() => injectSidebarItem(), 200); + } else { + removeSidebarItem(); + } +} + // Initialize when DOM is ready if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", init); diff --git a/packages/chrome/src/content/analytics-overlay.ts b/packages/chrome/src/content/analytics-overlay.ts index d33a418..65e9577 100644 --- a/packages/chrome/src/content/analytics-overlay.ts +++ b/packages/chrome/src/content/analytics-overlay.ts @@ -15,6 +15,33 @@ import { const PAGE_CONTAINER_ID = "agentblame-page-container"; const ORIGINAL_CONTENT_ATTR = "data-agentblame-hidden"; +// Tool color palette - GitHub Primer colors that work in light/dark themes +const TOOL_COLOR_PALETTE = [ + "#0969da", // Blue + "#8250df", // Purple + "#bf3989", // Pink + "#0a3069", // Dark blue + "#1a7f37", // Green + "#9a6700", // Yellow/brown + "#cf222e", // Red + "#6e7781", // Gray +]; + +/** + * Format provider/tool name for display + */ +function formatProviderName(provider: string): string { + const names: Record = { + cursor: "Cursor", + claudeCode: "Claude Code", + copilot: "Copilot", + windsurf: "Windsurf", + aider: "Aider", + cline: "Cline", + }; + return names[provider] || provider.replace(/([A-Z])/g, " $1").trim(); +} + /** * Show the Agent Blame analytics page */ @@ -330,9 +357,9 @@ function renderAnalyticsPage( const lastUpdated = fullAnalytics?.summary.updated || analytics.summary.updated; return ` -
+
-
+

Agent Blame

@@ -384,11 +411,37 @@ function renderRepositorySection( const claudeLines = summary.providers.claudeCode || 0; const humanPercent = 100 - aiPercent; + // Build provider data dynamically + const providerEntries = Object.entries(summary.providers) + .filter(([, lines]) => lines > 0) + .sort(([, a], [, b]) => b - a); + const totalProviderLines = providerEntries.reduce((sum, [, lines]) => sum + lines, 0); + + // Calculate percentages and assign colors + const providerData = providerEntries.map(([name, lines], index) => ({ + name: formatProviderName(name), + lines, + percent: totalProviderLines > 0 ? Math.round((lines / totalProviderLines) * 100) : 0, + color: TOOL_COLOR_PALETTE[index % TOOL_COLOR_PALETTE.length], + })); + + // Build conic-gradient stops + let gradientStops = ""; + let currentPercent = 0; + for (const provider of providerData) { + gradientStops += `${provider.color} ${currentPercent}% ${currentPercent + provider.percent}%, `; + currentPercent += provider.percent; + } + gradientStops = gradientStops.slice(0, -2); // Remove trailing comma + // Get top models sorted by lines const modelEntries = Object.entries(summary.models).sort( ([, a], [, b]) => b - a ); - const totalModelLines = modelEntries.reduce((sum, [, v]) => sum + v, 0); + + // Colors + const aiColor = "var(--color-severe-fg, #f78166)"; // GitHub's coral orange + const humanColor = "var(--color-success-fg, #238636)"; // GitHub's addition green return `
@@ -396,35 +449,32 @@ function renderRepositorySection(

Repository Overview

- -
- -
-
${aiPercent}%
-
AI-written code
+ +
+ +
+
${aiPercent}%
+
AI-Written Code
${summary.aiLines.toLocaleString()} of ${summary.totalLines.toLocaleString()} lines
- -
-
${cursorLines.toLocaleString()}
-
Cursor
-
- -
-
${claudeLines.toLocaleString()}
-
Claude Code
-
-
- -
-
- AI ${aiPercent}% - Human ${humanPercent}% + +
+
+
By Tool
+
+ ${providerData.map(p => `${p.name} ${p.percent}%`).join('')} +
-
-
-
+ + +
+
+
AI vs Human
+
+ AI ${aiPercent}% + Human ${humanPercent}% +
@@ -434,25 +484,27 @@ function renderRepositorySection( ? `

By Model

-
- ${modelEntries - .slice(0, 5) - .map(([model, lines]) => { - const percent = - totalModelLines > 0 - ? Math.round((lines / totalModelLines) * 100) - : 0; - return ` -
- ${escapeHtml(formatModelName(model))} - - - - ${percent}% -
- `; - }) - .join("")} +
+ ${(() => { + const totalLines = modelEntries.reduce((sum, [, l]) => sum + l, 0); + return modelEntries + .slice(0, 10) + .map(([model, lines]) => { + const barPercent = totalLines > 0 ? Math.round((lines / totalLines) * 100) : 0; + return ` +
+
+ ${escapeHtml(formatModelName(model))} + ${lines.toLocaleString()} lines +
+
+ +
+
+ `; + }) + .join(""); + })()}
` @@ -469,8 +521,7 @@ function renderRepositorySection( function renderContributorsSection(analytics: AnalyticsData): string { const contributors = Object.entries(analytics.contributors) .map(([username, stats]) => ({ username, ...stats })) - .sort((a, b) => b.totalLines - a.totalLines) - .slice(0, 10); + .sort((a, b) => b.totalLines - a.totalLines); if (contributors.length === 0) { return ` @@ -535,7 +586,7 @@ function renderPullRequestsSection( owner: string, repo: string ): string { - const recentPRs = analytics.history.slice(0, 10); + const recentPRs = analytics.history.slice(0, 20); if (recentPRs.length === 0) { return ` diff --git a/packages/chrome/src/content/analytics-tab.ts b/packages/chrome/src/content/analytics-tab.ts index aea041a..41e82c2 100644 --- a/packages/chrome/src/content/analytics-tab.ts +++ b/packages/chrome/src/content/analytics-tab.ts @@ -97,112 +97,126 @@ function findInsightsSidebar(): Element | null { // Track repos we've already checked (to avoid repeated API calls) const checkedRepos = new Map(); +// Guard against concurrent injection attempts +let isInjecting = false; + /** * Inject the Agent Blame sidebar item (only if analytics exist) */ export async function injectSidebarItem(): Promise { - // Check if already injected - if (document.getElementById(SIDEBAR_ITEM_ID)) { - return; - } - - const context = extractRepoContext(); - if (!context) { + // Check if already injected or injection in progress + if (document.getElementById(SIDEBAR_ITEM_ID) || isInjecting) { return; } - const repoKey = `${context.owner}/${context.repo}`; + isInjecting = true; - // Check if we've already verified this repo has no analytics - if (checkedRepos.has(repoKey) && !checkedRepos.get(repoKey)) { - console.log("[Agent Blame] Skipping - already checked, no analytics for this repo"); - return; - } + try { + const context = extractRepoContext(); + if (!context) { + return; + } - // Check if analytics exist for this repo (only if not already checked) - if (!checkedRepos.has(repoKey)) { - const hasAnalytics = await checkAnalyticsExist(context.owner, context.repo); - checkedRepos.set(repoKey, hasAnalytics); + const repoKey = `${context.owner}/${context.repo}`; - if (!hasAnalytics) { - console.log("[Agent Blame] No analytics found, not showing sidebar item"); + // Check if we've already verified this repo has no analytics + if (checkedRepos.has(repoKey) && !checkedRepos.get(repoKey)) { + console.log("[Agent Blame] Skipping - already checked, no analytics for this repo"); return; } - } - const sidebar = findInsightsSidebar(); - if (!sidebar) { - console.log("[Agent Blame] Could not find Insights sidebar"); - return; - } + // Check if analytics exist for this repo (only if not already checked) + if (!checkedRepos.has(repoKey)) { + const hasAnalytics = await checkAnalyticsExist(context.owner, context.repo); + checkedRepos.set(repoKey, hasAnalytics); - // Find the Pulse link to insert after - const pulseLink = sidebar.querySelector('a[href$="/pulse"]'); - if (!pulseLink) { - console.log("[Agent Blame] Could not find Pulse link in sidebar"); - return; - } + if (!hasAnalytics) { + console.log("[Agent Blame] No analytics found, not showing sidebar item"); + return; + } + } - // Determine the structure - is it a menu-item or just links? - const pulseItem = pulseLink.closest(".menu-item") || pulseLink; - const isMenuItem = pulseItem.classList.contains("menu-item"); + // Double-check not injected (in case another call completed while we were checking analytics) + if (document.getElementById(SIDEBAR_ITEM_ID)) { + return; + } - // Create the sidebar item - const sidebarItem = document.createElement("a"); - sidebarItem.id = SIDEBAR_ITEM_ID; - sidebarItem.href = `/${context.owner}/${context.repo}/pulse#agent-blame`; + const sidebar = findInsightsSidebar(); + if (!sidebar) { + console.log("[Agent Blame] Could not find Insights sidebar"); + return; + } - if (isMenuItem) { - // Use GitHub's menu-item styling - sidebarItem.className = "menu-item"; - } else { - // Copy classes from Pulse link - sidebarItem.className = pulseLink.className; - } + // Find the Pulse link to insert after + const pulseLink = sidebar.querySelector('a[href$="/pulse"]'); + if (!pulseLink) { + console.log("[Agent Blame] Could not find Pulse link in sidebar"); + return; + } - sidebarItem.textContent = "Agent Blame"; + // Determine the structure - is it a menu-item or just links? + const pulseItem = pulseLink.closest(".menu-item") || pulseLink; + const isMenuItem = pulseItem.classList.contains("menu-item"); + + // Create the sidebar item + const sidebarItem = document.createElement("a"); + sidebarItem.id = SIDEBAR_ITEM_ID; + sidebarItem.href = `/${context.owner}/${context.repo}/pulse#agent-blame`; + + if (isMenuItem) { + // Use GitHub's menu-item styling + sidebarItem.className = "menu-item"; + } else { + // Copy classes from Pulse link + sidebarItem.className = pulseLink.className; + } - // Handle click - show the Agent Blame page - sidebarItem.addEventListener("click", (e) => { - e.preventDefault(); - e.stopPropagation(); + sidebarItem.textContent = "Agent Blame"; - // Update URL hash - window.history.pushState(null, "", `/${context.owner}/${context.repo}/pulse#agent-blame`); + // Handle click - show the Agent Blame page + sidebarItem.addEventListener("click", (e) => { + e.preventDefault(); + e.stopPropagation(); - // Remove active state from other items - sidebar.querySelectorAll(".selected, [aria-current='page']").forEach((el) => { - el.classList.remove("selected"); - el.removeAttribute("aria-current"); - }); + // Update URL hash + window.history.pushState(null, "", `/${context.owner}/${context.repo}/pulse#agent-blame`); - // Add active state to our item - sidebarItem.classList.add("selected"); - sidebarItem.setAttribute("aria-current", "page"); + // Remove active state from other items + sidebar.querySelectorAll(".selected, [aria-current='page']").forEach((el) => { + el.classList.remove("selected"); + el.removeAttribute("aria-current"); + }); - // Show the Agent Blame page - showAnalyticsPage(context.owner, context.repo); - }); + // Add active state to our item + sidebarItem.classList.add("selected"); + sidebarItem.setAttribute("aria-current", "page"); - // Insert after Pulse - if (pulseItem.nextSibling) { - pulseItem.parentNode?.insertBefore(sidebarItem, pulseItem.nextSibling); - } else { - pulseItem.parentNode?.appendChild(sidebarItem); - } + // Show the Agent Blame page + showAnalyticsPage(context.owner, context.repo); + }); - console.log("[Agent Blame] Sidebar item injected"); + // Insert after Pulse + if (pulseItem.nextSibling) { + pulseItem.parentNode?.insertBefore(sidebarItem, pulseItem.nextSibling); + } else { + pulseItem.parentNode?.appendChild(sidebarItem); + } - // If URL already has #agent-blame, show the page - if (isAgentBlamePage()) { - // Remove active state from other items - sidebar.querySelectorAll(".selected, [aria-current='page']").forEach((el) => { - el.classList.remove("selected"); - el.removeAttribute("aria-current"); - }); - sidebarItem.classList.add("selected"); - sidebarItem.setAttribute("aria-current", "page"); - showAnalyticsPage(context.owner, context.repo); + console.log("[Agent Blame] Sidebar item injected"); + + // If URL already has #agent-blame, show the page + if (isAgentBlamePage()) { + // Remove active state from other items + sidebar.querySelectorAll(".selected, [aria-current='page']").forEach((el) => { + el.classList.remove("selected"); + el.removeAttribute("aria-current"); + }); + sidebarItem.classList.add("selected"); + sidebarItem.setAttribute("aria-current", "page"); + showAnalyticsPage(context.owner, context.repo); + } + } finally { + isInjecting = false; } } diff --git a/packages/chrome/src/content/content.css b/packages/chrome/src/content/content.css index 37b679c..9869078 100644 --- a/packages/chrome/src/content/content.css +++ b/packages/chrome/src/content/content.css @@ -2,7 +2,7 @@ /* Attribution gutter colors */ :root { - --ab-ai-color: #A93000; /* Mesa Orange */ + --ab-ai-color: var(--color-severe-fg, #f78166); /* GitHub's coral orange */ } /* Attribution gutter - box-shadow on the new line number cell */ @@ -15,7 +15,7 @@ cursor: help; } -/* PR summary banner - compact design with Mesa orange accent */ +/* PR summary banner - compact design with coral orange accent */ .ab-pr-summary { display: flex; align-items: center; @@ -24,7 +24,7 @@ padding: 8px 16px; margin: 12px 0; background: var(--bgColor-default, var(--color-canvas-default, #ffffff)); - border: 1px solid rgba(169, 48, 0, 0.25); + border: 1px solid rgba(247, 129, 102, 0.25); border-left: 3px solid var(--ab-ai-color); border-radius: 6px; font-size: 13px; @@ -184,7 +184,7 @@ @media (prefers-color-scheme: dark) { .ab-pr-summary { background: var(--bgColor-default, #0d1117); - border-color: rgba(169, 48, 0, 0.4); + border-color: rgba(247, 129, 102, 0.4); border-left-color: var(--ab-ai-color); } diff --git a/packages/chrome/src/content/content.ts b/packages/chrome/src/content/content.ts index 5e60869..6e23818 100644 --- a/packages/chrome/src/content/content.ts +++ b/packages/chrome/src/content/content.ts @@ -41,12 +41,37 @@ function logError(..._args: unknown[]): void { // Logging disabled for production } +/** + * Check if current URL is a repo page (could navigate to PR) + */ +function isRepoPage(): boolean { + return /^https:\/\/github\.com\/[^/]+\/[^/]+/.test(window.location.href); +} + +/** + * Check if current URL is a PR page + */ +function isPRPage(): boolean { + return /^https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/\d+/.test(window.location.href); +} + /** * Initialize the content script */ async function init(): Promise { log("Content script initializing..."); + // Quick exit if not on a repo page + if (!isRepoPage()) { + return; + } + + // Only do heavy initialization if on PR page or might navigate to one + if (!isPRPage()) { + // Not on PR page yet, but set up navigation listener for when we navigate to one + return; + } + // Check if enabled const enabled = await isEnabled(); if (!enabled) { @@ -386,6 +411,22 @@ function resetState(): void { removeAllMarkers(); } +/** + * Handle navigation - initialize if we landed on a PR page + */ +function handleNavigation(): void { + if (isPRPage()) { + resetState(); + init(); + } else { + // Navigated away from PR page + if (wasOnFilesChangedTab) { + removeAllMarkers(); + } + resetState(); + } +} + /** * Handle URL changes (GitHub uses History API) */ @@ -393,8 +434,7 @@ function setupNavigationListener(): void { // Listen for popstate (back/forward) window.addEventListener("popstate", () => { log("Navigation: popstate"); - resetState(); - setTimeout(() => processPage(), 100); + setTimeout(handleNavigation, 100); }); // Override pushState and replaceState to detect navigation @@ -404,15 +444,13 @@ function setupNavigationListener(): void { history.pushState = (...args) => { originalPushState(...args); log("Navigation: pushState"); - resetState(); - setTimeout(() => processPage(), 100); + setTimeout(handleNavigation, 100); }; history.replaceState = (...args) => { originalReplaceState(...args); log("Navigation: replaceState"); - resetState(); - setTimeout(() => processPage(), 100); + setTimeout(handleNavigation, 100); }; } diff --git a/packages/chrome/src/content/router.ts b/packages/chrome/src/content/router.ts new file mode 100644 index 0000000..5065b34 --- /dev/null +++ b/packages/chrome/src/content/router.ts @@ -0,0 +1,428 @@ +/** + * Agent Blame Content Script Router + * + * Single entry point for all GitHub pages. Routes to: + * - PR attribution (Files Changed tab markers) + * - Analytics sidebar (Insights pages) + * + * Handles navigation detection via History API interception. + */ + +import type { GitNotesAttribution, LineAttribution } from "../types"; +import { getToken, isEnabled } from "../lib/storage"; +import { GitHubAPI } from "../lib/github-api"; +import { + extractPRContext, + getDiffContainers, + getFilePath, + getAddedLines, + injectMarker, + removeAllMarkers, + injectPRSummary, + injectFileBadge, + showLoading, + hideLoading, + showError, + isFilesChangedTab, +} from "./github-dom"; +import { + isInsightsPage, + injectSidebarItem, + removeSidebarItem, + handleHashChange, +} from "./analytics-tab"; + +// ============================================================================= +// URL Detection +// ============================================================================= + +function isRepoPage(): boolean { + return /^https:\/\/github\.com\/[^/]+\/[^/]+/.test(window.location.href); +} + +function isPRPage(): boolean { + return /^https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/\d+/.test(window.location.href); +} + +// ============================================================================= +// PR Attribution State & Logic +// ============================================================================= + +let api: GitHubAPI | null = null; +let isProcessing = false; +let hasProcessedSuccessfully = false; +let wasOnFilesChangedTab = false; +let prObserver: MutationObserver | null = null; +let pendingProcess: ReturnType | null = null; + +async function initPRAttribution(): Promise { + // Check if enabled + const enabled = await isEnabled(); + if (!enabled) return; + + // Check for token + const token = await getToken(); + if (!token) return; + + // Initialize API client + api = new GitHubAPI(token); + + // Process the page + await processPRPage(); + + // Watch for DOM changes + setupPRObserver(); +} + +async function processPRPage(): Promise { + if (isProcessing) return; + + const onFilesTab = isFilesChangedTab(); + + if (!onFilesTab) { + if (wasOnFilesChangedTab) { + removeAllMarkers(); + hasProcessedSuccessfully = false; + } + wasOnFilesChangedTab = false; + return; + } + + wasOnFilesChangedTab = true; + isProcessing = true; + + try { + const context = extractPRContext(); + if (!context) return; + + const containers = getDiffContainers(); + if (containers.length === 0) return; + + showLoading(); + + if (!api) { + hideLoading(); + return; + } + + const commits = await api.getPRCommits( + context.owner, + context.repo, + context.prNumber, + ); + if (commits.length === 0) { + hideLoading(); + return; + } + + const notes = await api.fetchNotesForCommits( + context.owner, + context.repo, + commits, + ); + + hideLoading(); + + if (notes.size === 0) return; + + const attributionMap = buildAttributionMap(notes); + + let totalLines = 0; + let aiGeneratedLines = 0; + + for (const container of containers) { + const filePath = getFilePath(container); + const addedLines = getAddedLines(container); + + let fileAiLines = 0; + let fileTotal = 0; + + for (const line of addedLines) { + let lineText = line.element.textContent || ""; + lineText = lineText.replace(/^[+-]/, "").trim(); + if (lineText === "") continue; + + totalLines++; + fileTotal++; + + const attr = findAttribution(attributionMap, filePath, line.lineNumber); + if (attr) { + injectMarker(line.element, attr); + fileAiLines++; + aiGeneratedLines++; + } + } + + if (fileTotal > 0) { + injectFileBadge(container, fileAiLines, fileTotal); + } + } + + injectPRSummary({ + total: totalLines, + aiGenerated: aiGeneratedLines, + }); + + hasProcessedSuccessfully = true; + } catch (error) { + showError("Failed to load attribution data"); + } finally { + isProcessing = false; + } +} + +function buildAttributionMap( + notes: Map, +): Map { + const map = new Map(); + + for (const [_commitSha, note] of notes) { + if (!note.attributions) continue; + + for (const attr of note.attributions) { + for (let line = attr.startLine; line <= attr.endLine; line++) { + const key = `${attr.path}:${line}`; + map.set(key, { + category: attr.category, + provider: attr.provider, + model: attr.model, + }); + } + } + } + + return map; +} + +function findAttribution( + map: Map, + filePath: string, + lineNumber: number, +): LineAttribution | null { + const key = `${filePath}:${lineNumber}`; + const exactMatch = map.get(key); + if (exactMatch) return exactMatch; + + const variants = [ + filePath, + filePath.replace(/^\//, ""), + `/${filePath}`, + filePath.split("/").slice(-1)[0], + ]; + + for (const variant of variants) { + const variantKey = `${variant}:${lineNumber}`; + const variantMatch = map.get(variantKey); + if (variantMatch) return variantMatch; + } + + return null; +} + +function setupPRObserver(): void { + if (prObserver) { + prObserver.disconnect(); + } + + prObserver = new MutationObserver((mutations) => { + const hasTabChange = mutations.some((m) => { + if (m.type === "attributes" && m.attributeName === "aria-selected") { + return true; + } + for (const node of Array.from(m.addedNodes)) { + if (node instanceof HTMLElement) { + if ( + node.matches?.('[role="tabpanel"], [data-tab-container]') || + node.querySelector?.('[role="tabpanel"]') + ) { + return true; + } + } + } + return false; + }); + + if (hasTabChange) { + if (pendingProcess) clearTimeout(pendingProcess); + pendingProcess = setTimeout(() => { + pendingProcess = null; + hasProcessedSuccessfully = false; + processPRPage(); + }, 150); + return; + } + + if (hasProcessedSuccessfully && wasOnFilesChangedTab) return; + + const hasSignificantChanges = mutations.some((m) => { + for (const node of Array.from(m.addedNodes)) { + if (node instanceof HTMLElement) { + const dominated = node.querySelectorAll("*").length; + if (dominated > 10) return true; + if ( + node.matches?.( + "[data-tagsearch-path], .file, .diff-table, [data-hpc], .js-diff-load-container, tr.diff-line-row", + ) || + node.querySelector?.( + "[data-tagsearch-path], .file, .diff-table, .blob-code-addition, tr.diff-line-row", + ) + ) { + return true; + } + } + } + return false; + }); + + if (hasSignificantChanges) { + if (pendingProcess) clearTimeout(pendingProcess); + pendingProcess = setTimeout(() => { + pendingProcess = null; + processPRPage(); + }, 200); + } + }); + + prObserver.observe(document.body, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ["aria-selected"], + }); +} + +function resetPRState(): void { + hasProcessedSuccessfully = false; + wasOnFilesChangedTab = false; + if (pendingProcess) { + clearTimeout(pendingProcess); + pendingProcess = null; + } + removeAllMarkers(); +} + +function cleanupPR(): void { + if (prObserver) { + prObserver.disconnect(); + prObserver = null; + } + resetPRState(); +} + +// ============================================================================= +// Analytics Sidebar Logic +// ============================================================================= + +function initAnalytics(): void { + if (isInsightsPage()) { + setTimeout(() => injectSidebarItem(), 500); + } +} + +function cleanupAnalytics(): void { + removeSidebarItem(); +} + +// ============================================================================= +// Navigation Handling +// ============================================================================= + +let lastPageType: "pr" | "insights" | "other" = "other"; + +function detectPageType(): "pr" | "insights" | "other" { + if (isPRPage()) return "pr"; + if (isInsightsPage()) return "insights"; + return "other"; +} + +function handleNavigation(): void { + const newPageType = detectPageType(); + + // Clean up previous page type if changed + if (lastPageType !== newPageType) { + if (lastPageType === "pr") { + cleanupPR(); + } else if (lastPageType === "insights") { + cleanupAnalytics(); + } + } + + lastPageType = newPageType; + + // Initialize for new page type + if (newPageType === "pr") { + initPRAttribution(); + } else if (newPageType === "insights") { + initAnalytics(); + } +} + +function setupNavigationListener(): void { + // Listen for popstate (back/forward) + window.addEventListener("popstate", () => { + setTimeout(handleNavigation, 100); + }); + + // Handle hash changes for analytics + window.addEventListener("hashchange", () => { + handleHashChange(); + }); + + // Intercept pushState + const originalPushState = history.pushState.bind(history); + history.pushState = (...args) => { + originalPushState(...args); + setTimeout(handleNavigation, 100); + }; + + // Intercept replaceState + const originalReplaceState = history.replaceState.bind(history); + history.replaceState = (...args) => { + originalReplaceState(...args); + setTimeout(handleNavigation, 100); + }; + + // Turbo Drive events + document.addEventListener("turbo:load", () => handleNavigation()); + document.addEventListener("turbo:render", () => handleNavigation()); + document.addEventListener("pjax:end", () => handleNavigation()); +} + +// ============================================================================= +// Message Handling +// ============================================================================= + +chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { + if (message.type === "SETTINGS_CHANGED") { + if (message.enabled) { + handleNavigation(); + } else { + cleanupPR(); + cleanupAnalytics(); + } + sendResponse({ success: true }); + } + return true; +}); + +// ============================================================================= +// Initialization +// ============================================================================= + +function init(): void { + // Quick exit if not on a repo page + if (!isRepoPage()) return; + + // Set up navigation listener (once) + setupNavigationListener(); + + // Handle current page + handleNavigation(); +} + +// Initialize when DOM is ready +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", init); +} else { + init(); +} diff --git a/packages/chrome/src/manifest.json b/packages/chrome/src/manifest.json index 3c7fc27..dba2727 100644 --- a/packages/chrome/src/manifest.json +++ b/packages/chrome/src/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "Agent Blame", - "version": "0.2.3", + "version": "0.2.5", "description": "See AI-generated vs human-written code on GitHub PRs", "icons": { "16": "icons/icon16.png", @@ -21,23 +21,9 @@ }, "content_scripts": [ { - "matches": ["https://github.com/*/*/pull/*"], - "js": ["content/content.js"], - "css": ["content/content.css"], - "run_at": "document_idle" - }, - { - "matches": [ - "https://github.com/*/*/pulse", - "https://github.com/*/*/pulse/*", - "https://github.com/*/*/graphs/*", - "https://github.com/*/*/community", - "https://github.com/*/*/network", - "https://github.com/*/*/network/*", - "https://github.com/*/*/forks" - ], - "js": ["content/analytics-entry.js"], - "css": ["content/chart.css"], + "matches": ["https://github.com/*/*"], + "js": ["content/router.js"], + "css": ["content/content.css", "content/chart.css"], "run_at": "document_idle" } ], diff --git a/packages/cli/package.json b/packages/cli/package.json index a02a21d..e593ef9 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@mesadev/agentblame", - "version": "0.2.3", + "version": "0.2.5", "description": "CLI to track AI-generated vs human-written code", "license": "Apache-2.0", "repository": { diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 1fa4924..de6792f 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -81,6 +81,10 @@ async function main(): Promise { case "prune": await runPrune(); break; + case "--version": + case "-v": + printVersion(); + break; case "--help": case "-h": case undefined: @@ -115,6 +119,16 @@ Examples: `); } +function printVersion(): void { + const packageJsonPath = path.join(__dirname, "..", "package.json"); + try { + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")); + console.log(`agentblame v${packageJson.version}`); + } catch { + console.log("agentblame (version unknown)"); + } +} + /** * Create the analytics anchor tag on the root commit. * This tag is used to store repository-wide analytics. diff --git a/packages/cli/src/lib/hooks.ts b/packages/cli/src/lib/hooks.ts index 7ab850e..2456e54 100644 --- a/packages/cli/src/lib/hooks.ts +++ b/packages/cli/src/lib/hooks.ts @@ -402,6 +402,11 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v1 + - name: Configure git identity + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + - name: Install agentblame run: npm install -g @mesadev/agentblame diff --git a/packages/cli/src/post-merge.ts b/packages/cli/src/post-merge.ts index f1e3cf7..b2cb15c 100644 --- a/packages/cli/src/post-merge.ts +++ b/packages/cli/src/post-merge.ts @@ -712,17 +712,33 @@ function writeAnalyticsNote(analytics: AnalyticsNote): boolean { /** * Get PR diff stats (additions/deletions) + * Only counts non-empty lines to match how attributions are counted */ function getPRDiffStats(): { additions: number; deletions: number } { - const stat = run(`git diff --shortstat ${BASE_SHA}..${MERGE_SHA || "HEAD"}`); - // Format: " 5 files changed, 120 insertions(+), 30 deletions(-)" - const addMatch = stat.match(/(\d+) insertion/); - const delMatch = stat.match(/(\d+) deletion/); + const diff = run(`git diff ${BASE_SHA}..${MERGE_SHA || "HEAD"}`); + if (!diff) return { additions: 0, deletions: 0 }; - return { - additions: addMatch ? parseInt(addMatch[1], 10) : 0, - deletions: delMatch ? parseInt(delMatch[1], 10) : 0, - }; + let additions = 0; + let deletions = 0; + + for (const line of diff.split("\n")) { + // Added line (but not diff header) + if (line.startsWith("+") && !line.startsWith("+++")) { + const content = line.slice(1).trim(); + if (content !== "") { + additions++; + } + } + // Deleted line (but not diff header) + else if (line.startsWith("-") && !line.startsWith("---")) { + const content = line.slice(1).trim(); + if (content !== "") { + deletions++; + } + } + } + + return { additions, deletions }; } /**