diff --git a/.github/workflows/agentblame.yml b/.github/workflows/agentblame.yml index 18d2ca0..23e480f 100644 --- a/.github/workflows/agentblame.yml +++ b/.github/workflows/agentblame.yml @@ -5,7 +5,7 @@ on: types: [closed] jobs: - transfer-notes: + post-merge: # Only run if the PR was merged (not just closed) if: github.event.pull_request.merged == true runs-on: ubuntu-latest @@ -26,20 +26,29 @@ jobs: - name: Install dependencies run: bun install - - name: Fetch attribution notes + - name: Fetch notes, tags, and PR head run: | - git fetch origin refs/notes/agentblame:refs/notes/agentblame 2>/dev/null || echo "No existing notes" + git fetch origin refs/notes/agentblame:refs/notes/agentblame 2>/dev/null || echo "No existing attribution notes" + git fetch origin refs/notes/agentblame-analytics:refs/notes/agentblame-analytics 2>/dev/null || echo "No existing analytics notes" + git fetch origin --tags 2>/dev/null || echo "No tags to fetch" + git fetch origin refs/pull/${{ github.event.pull_request.number }}/head:refs/pull/${{ github.event.pull_request.number }}/head 2>/dev/null || echo "Could not fetch PR head" - - name: Transfer notes if needed - run: bun run packages/action/src/transfer-notes.ts + - name: Process merge (transfer notes + update analytics) + run: bun run packages/cli/src/post-merge.ts env: PR_NUMBER: ${{ github.event.pull_request.number }} PR_TITLE: ${{ github.event.pull_request.title }} + PR_AUTHOR: ${{ github.event.pull_request.user.login }} BASE_REF: ${{ github.event.pull_request.base.ref }} BASE_SHA: ${{ github.event.pull_request.base.sha }} HEAD_SHA: ${{ github.event.pull_request.head.sha }} MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }} - - name: Push notes + - name: Push notes and tags run: | - git push origin refs/notes/agentblame 2>/dev/null || echo "No notes to push" + # Push attribution notes + git push origin refs/notes/agentblame 2>/dev/null || echo "No attribution notes to push" + # Push analytics notes + git push origin refs/notes/agentblame-analytics 2>/dev/null || echo "No analytics notes to push" + # Push analytics anchor tag + git push origin agentblame-analytics-anchor 2>/dev/null || echo "No analytics tag to push" diff --git a/.gitignore b/.gitignore index c376781..64dbe07 100644 --- a/.gitignore +++ b/.gitignore @@ -59,3 +59,4 @@ agentblame-chrome.zip # Build artifacts packages/chrome/agentblame-chrome-*.zip +Implementation.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8debcc0..f169655 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -33,7 +33,7 @@ agentblame/ │ │ └── src/ │ │ ├── lib/ # Core types, utilities, git operations │ │ ├── capture.ts # Hook handler for Cursor/Claude Code -│ │ ├── transfer-notes.ts # GitHub Action entry point +│ │ ├── post-merge.ts # GitHub Action entry point │ │ ├── blame.ts # blame command │ │ ├── sync.ts # sync command │ │ └── index.ts # CLI entry point diff --git a/README.md b/README.md index 2525a41..ac58f0c 100644 --- a/README.md +++ b/README.md @@ -242,7 +242,7 @@ agentblame/ │ │ ├── capture.ts │ │ ├── blame.ts │ │ ├── sync.ts -│ │ ├── transfer-notes.ts +│ │ ├── post-merge.ts │ │ └── index.ts │ └── chrome/ # Chrome extension └── docs/ # Documentation diff --git a/package.json b/package.json index 0b39047..e4e51e2 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.1.0", + "version": "0.2.3", "private": true, "license": "Apache-2.0", "repository": { @@ -27,7 +27,7 @@ "ab:blame": "bun run packages/cli/src/index.ts blame", "ab:sync": "bun run packages/cli/src/index.ts sync", "ab:status": "bun run packages/cli/src/index.ts status", - "ab:transfer-notes": "bun run packages/cli/src/transfer-notes.ts", + "ab:post-merge": "bun run packages/cli/src/post-merge.ts", "ab:capture": "bun run packages/cli/src/capture.ts", "cleanup": "bun run packages/cli/src/cleanup.ts", "logs:clear": "rm -f ~/.agentblame/logs/*.log && echo 'Logs cleared.'" diff --git a/packages/chrome/build-chrome.ts b/packages/chrome/build-chrome.ts index 6263e9a..618831f 100644 --- a/packages/chrome/build-chrome.ts +++ b/packages/chrome/build-chrome.ts @@ -42,6 +42,7 @@ function copyStatic(): void { // Copy content CSS copyFileSync(join(SRC_DIR, "content", "content.css"), join(DIST_DIR, "content", "content.css")); + copyFileSync(join(SRC_DIR, "content", "chart.css"), join(DIST_DIR, "content", "chart.css")); // Copy icons (if they exist) const iconsDir = join(SRC_DIR, "icons"); @@ -82,6 +83,18 @@ async function bundle(): Promise { }); 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"); + // Bundle background service worker await build({ entryPoints: [join(SRC_DIR, "background", "background.ts")], diff --git a/packages/chrome/package.json b/packages/chrome/package.json index 325ba30..8210e8c 100644 --- a/packages/chrome/package.json +++ b/packages/chrome/package.json @@ -1,6 +1,6 @@ { "name": "@agentblame/chrome", - "version": "0.1.1", + "version": "0.2.3", "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 new file mode 100644 index 0000000..842d62c --- /dev/null +++ b/packages/chrome/src/content/analytics-entry.ts @@ -0,0 +1,120 @@ +/** + * Analytics Entry Point + * + * Content script entry point for GitHub Insights pages. + * Injects the "Agent Blame" item into the Insights sidebar. + */ + +import { + isInsightsPage, + injectSidebarItem, + removeSidebarItem, + handleHashChange, +} from "./analytics-tab"; + +let observer: MutationObserver | null = null; + +/** + * Initialize analytics sidebar injection + */ +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(); +} + +/** + * Setup MutationObserver for dynamic content + */ +function setupObserver(): void { + if (observer) { + observer.disconnect(); + } + + observer = new MutationObserver(() => { + if (isInsightsPage()) { + injectSidebarItem(); + } else { + removeSidebarItem(); + } + }); + + // Observe the sidebar area for changes + const sidebar = document.querySelector(".Layout-sidebar"); + if (sidebar) { + observer.observe(sidebar, { childList: true, subtree: true }); + } + + // Also observe body for major page changes + observer.observe(document.body, { + childList: true, + subtree: false, + }); +} + +/** + * Handle hash changes (for #agent-blame navigation) + */ +function setupHashListener(): void { + window.addEventListener("hashchange", () => { + console.log("[Agent Blame] Hash changed to:", window.location.hash); + handleHashChange(); + }); + + // Also handle popstate for back/forward + window.addEventListener("popstate", () => { + console.log("[Agent Blame] Popstate - hash:", window.location.hash); + handleHashChange(); + }); +} + +/** + * Handle GitHub's Turbo Drive navigation + */ +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); + } + }); + + document.addEventListener("turbo:render", () => { + console.log("[Agent Blame] Turbo render"); + if (isInsightsPage()) { + setTimeout(() => injectSidebarItem(), 100); + } + }); + + // 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); + } + }); +} + +// Initialize when DOM is ready +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", init); +} else { + init(); +} diff --git a/packages/chrome/src/content/analytics-overlay.ts b/packages/chrome/src/content/analytics-overlay.ts new file mode 100644 index 0000000..d33a418 --- /dev/null +++ b/packages/chrome/src/content/analytics-overlay.ts @@ -0,0 +1,632 @@ +/** + * Analytics Page Component + * + * Full page component showing repository-wide AI attribution analytics. + * Replaces the main content area when "Agent Blame" is selected in the Insights sidebar. + * Uses GitHub's Primer CSS for styling. + */ + +import { + type AnalyticsData, + type AnalyticsHistoryEntry, + getAnalytics, +} from "../lib/mock-analytics"; + +const PAGE_CONTAINER_ID = "agentblame-page-container"; +const ORIGINAL_CONTENT_ATTR = "data-agentblame-hidden"; + +/** + * Show the Agent Blame analytics page + */ +export async function showAnalyticsPage( + owner: string, + repo: string +): Promise { + // Check if already showing + if (document.getElementById(PAGE_CONTAINER_ID)) { + return; + } + + // Find the main content area + const mainContent = + document.querySelector(".Layout-main") || + document.querySelector("main") || + document.querySelector('[data-turbo-frame="repo-content-turbo-frame"]') || + document.querySelector(".container-xl"); + + if (!mainContent) { + console.log("[Agent Blame] Could not find main content area"); + return; + } + + // Hide original content (all direct children) + Array.from(mainContent.children).forEach((child) => { + if (child.id !== PAGE_CONTAINER_ID) { + (child as HTMLElement).setAttribute(ORIGINAL_CONTENT_ATTR, "true"); + (child as HTMLElement).style.display = "none"; + } + }); + + // Create page container + const pageContainer = document.createElement("div"); + pageContainer.id = PAGE_CONTAINER_ID; + + // Show loading state + pageContainer.innerHTML = renderLoadingState(); + + // Insert at the beginning of main content + mainContent.insertBefore(pageContainer, mainContent.firstChild); + + // Fetch and render analytics + try { + const analytics = await getAnalytics(owner, repo); + + if (!analytics) { + pageContainer.innerHTML = renderEmptyState(); + return; + } + + // Store for period filtering + currentOwner = owner; + currentRepo = repo; + currentAnalytics = analytics; + + // Apply current period filter + const filtered = filterAnalyticsByPeriod(analytics, currentPeriod); + pageContainer.innerHTML = renderAnalyticsPage(owner, repo, filtered, analytics); + attachPeriodListeners(); + } catch (error) { + pageContainer.innerHTML = renderErrorState( + error instanceof Error ? error.message : "Unknown error" + ); + } +} + +/** + * Hide the Agent Blame analytics page and restore original content + */ +export function hideAnalyticsPage(): void { + const pageContainer = document.getElementById(PAGE_CONTAINER_ID); + if (pageContainer) { + pageContainer.remove(); + } + + // Restore original content + document.querySelectorAll(`[${ORIGINAL_CONTENT_ATTR}]`).forEach((el) => { + (el as HTMLElement).style.display = ""; + el.removeAttribute(ORIGINAL_CONTENT_ATTR); + }); + + // Reset state + currentOwner = ""; + currentRepo = ""; + currentAnalytics = null; +} + +/** + * Render loading state + */ +function renderLoadingState(): string { + return ` +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `; +} + +/** + * Render error state + */ +function renderErrorState(message: string): string { + return ` +
+
+
+
+ + + + ${escapeHtml(message)} +
+
+
+
+ `; +} + +/** + * Render empty state (no analytics available) + */ +function renderEmptyState(): string { + return ` +
+
+
+
+ + + +

No Analytics Available

+

+ Analytics will appear here after PRs with AI attribution are merged. +

+ +
+
+
+
+ `; +} + +// Period options for filtering +type PeriodOption = "24h" | "1w" | "1m" | "all"; + +const PERIOD_LABELS: Record = { + "24h": "24 hours", + "1w": "1 week", + "1m": "1 month", + "all": "All time", +}; + +// Current selected period (module-level state) +let currentPeriod: PeriodOption = "1m"; +let currentOwner = ""; +let currentRepo = ""; +let currentAnalytics: AnalyticsData | null = null; + +/** + * Filter analytics data by period + */ +function filterAnalyticsByPeriod( + analytics: AnalyticsData, + period: PeriodOption +): AnalyticsData { + if (period === "all") { + return analytics; + } + + const now = Date.now(); + const cutoffs: Record, number> = { + "24h": now - 24 * 60 * 60 * 1000, + "1w": now - 7 * 24 * 60 * 60 * 1000, + "1m": now - 30 * 24 * 60 * 60 * 1000, + }; + + const cutoff = cutoffs[period]; + + // Filter history entries + const filteredHistory = analytics.history.filter((entry) => { + const entryDate = new Date(entry.date).getTime(); + return entryDate >= cutoff; + }); + + // Recalculate summary from filtered history + let totalLines = 0; + let aiLines = 0; + const providers: Record = {}; + const models: Record = {}; + const contributors: Record = {}; + + for (const entry of filteredHistory) { + totalLines += entry.added; + aiLines += entry.aiLines; + + // Aggregate by provider + if (entry.providers) { + for (const [provider, count] of Object.entries(entry.providers)) { + providers[provider] = (providers[provider] || 0) + count; + } + } + + // Aggregate by model + if (entry.models) { + for (const [model, count] of Object.entries(entry.models)) { + models[model] = (models[model] || 0) + count; + } + } + + // Aggregate by contributor + if (!contributors[entry.author]) { + contributors[entry.author] = { + totalLines: 0, + aiLines: 0, + providers: {}, + models: {}, + prCount: 0, + }; + } + const c = contributors[entry.author]; + c.totalLines += entry.added; + c.aiLines += entry.aiLines; + c.prCount += 1; + if (entry.providers) { + for (const [provider, count] of Object.entries(entry.providers)) { + c.providers[provider] = (c.providers[provider] || 0) + count; + } + } + if (entry.models) { + for (const [model, count] of Object.entries(entry.models)) { + c.models[model] = (c.models[model] || 0) + count; + } + } + } + + return { + version: 2, + summary: { + totalLines: totalLines, + aiLines: aiLines, + humanLines: totalLines - aiLines, + providers: providers as AnalyticsData["summary"]["providers"], + models: models, + updated: analytics.summary.updated, + }, + contributors, + history: filteredHistory, + }; +} + +/** + * Handle period change + */ +function handlePeriodChange(period: PeriodOption): void { + currentPeriod = period; + if (currentAnalytics && currentOwner && currentRepo) { + const filtered = filterAnalyticsByPeriod(currentAnalytics, period); + const container = document.getElementById(PAGE_CONTAINER_ID); + if (container) { + container.innerHTML = renderAnalyticsPage(currentOwner, currentRepo, filtered, currentAnalytics); + attachPeriodListeners(); + } + } +} + +/** + * Attach event listeners to period dropdown + */ +function attachPeriodListeners(): void { + const dropdown = document.getElementById("agentblame-period-select"); + if (dropdown) { + dropdown.addEventListener("change", (e) => { + const select = e.target as HTMLSelectElement; + handlePeriodChange(select.value as PeriodOption); + }); + } +} + +/** + * Render the full analytics page with 3 sections + */ +function renderAnalyticsPage( + owner: string, + repo: string, + analytics: AnalyticsData, + fullAnalytics?: AnalyticsData +): string { + const aiPercent = + analytics.summary.totalLines > 0 + ? Math.round( + (analytics.summary.aiLines / analytics.summary.totalLines) * 100 + ) + : 0; + + // Use fullAnalytics for updated if available + const lastUpdated = fullAnalytics?.summary.updated || analytics.summary.updated; + + return ` +
+ +
+
+

Agent Blame

+
+ AI code attribution analytics +
+
+
+ +
+
+ + + ${renderRepositorySection(analytics, aiPercent)} + + + ${renderContributorsSection(analytics)} + + + ${renderPullRequestsSection(analytics, owner, repo)} + + +
+ Last updated: ${formatDate(lastUpdated)} + · + + Powered by Agent Blame + +
+
+ `; +} + +/** + * Render Repository Overview section + */ +function renderRepositorySection( + analytics: AnalyticsData, + aiPercent: number +): string { + const { summary } = analytics; + const cursorLines = summary.providers.cursor || 0; + const claudeLines = summary.providers.claudeCode || 0; + const humanPercent = 100 - aiPercent; + + // 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); + + return ` +
+
+

Repository Overview

+
+
+ +
+ +
+
${aiPercent}%
+
AI-written code
+
${summary.aiLines.toLocaleString()} of ${summary.totalLines.toLocaleString()} lines
+
+ +
+
${cursorLines.toLocaleString()}
+
Cursor
+
+ +
+
${claudeLines.toLocaleString()}
+
Claude Code
+
+
+ + +
+
+ AI ${aiPercent}% + Human ${humanPercent}% +
+
+
+
+
+
+ + + ${ + modelEntries.length > 0 + ? ` +
+

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("")} +
+
+ ` + : "" + } +
+
+ `; +} + +/** + * Render Contributors section + */ +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); + + if (contributors.length === 0) { + return ` +
+
+

Contributors

+
+
+
+

No contributor data available yet.

+
+
+
+ `; + } + + const rows = contributors + .map((c) => { + const aiPercent = + c.totalLines > 0 ? Math.round((c.aiLines / c.totalLines) * 100) : 0; + const humanPercent = 100 - aiPercent; + + return ` +
+ +
+ ${aiPercent}% AI +
+
+
+
+
+
+ ${c.totalLines.toLocaleString()} lines +
+
+ ${c.prCount} PRs +
+
+ `; + }) + .join(""); + + return ` +
+
+

Contributors

+ ${contributors.length} +
+ ${rows} +
+ `; +} + +/** + * Render Recent PRs section + */ +function renderPullRequestsSection( + analytics: AnalyticsData, + owner: string, + repo: string +): string { + const recentPRs = analytics.history.slice(0, 10); + + if (recentPRs.length === 0) { + return ` +
+
+

Recent Pull Requests

+
+
+
+

No pull request data available yet.

+
+
+
+ `; + } + + const rows = recentPRs + .map((pr) => { + const aiPercent = pr.added > 0 ? Math.round((pr.aiLines / pr.added) * 100) : 0; + const badgeStyle = + aiPercent > 50 + ? "background: var(--color-severe-fg); color: white;" + : aiPercent > 0 + ? "background: var(--color-attention-emphasis); color: white;" + : "background: var(--color-success-emphasis); color: white;"; + + return ` +
+ #${pr.pr} + + + ${escapeHtml(pr.author)} + + + +${pr.added}/-${pr.removed} + + + ${aiPercent}% AI + +
+ `; + }) + .join(""); + + return ` +
+
+

Recent Pull Requests

+ ${recentPRs.length} +
+ ${rows} +
+ `; +} + +/** + * Format model name for display + */ +function formatModelName(model: string): string { + return model + .replace(/-/g, " ") + .replace(/\b\w/g, (c) => c.toUpperCase()); +} + +/** + * Format date for display + */ +function formatDate(dateStr: string): string { + try { + const date = new Date(dateStr); + return date.toLocaleDateString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); + } catch { + return dateStr; + } +} + +/** + * Escape HTML to prevent XSS + */ +function escapeHtml(str: string): string { + const div = document.createElement("div"); + div.textContent = str; + return div.innerHTML; +} diff --git a/packages/chrome/src/content/analytics-tab.ts b/packages/chrome/src/content/analytics-tab.ts new file mode 100644 index 0000000..aea041a --- /dev/null +++ b/packages/chrome/src/content/analytics-tab.ts @@ -0,0 +1,256 @@ +/** + * Analytics Sidebar Injection + * + * Injects "Agent Blame" as a sidebar item in GitHub's Insights page, + * positioned after "Pulse". Only shows if analytics data exists for the repo. + */ + +import { showAnalyticsPage, hideAnalyticsPage } from "./analytics-overlay"; +import { checkAnalyticsExist } from "../lib/mock-analytics"; + +const SIDEBAR_ITEM_ID = "agentblame-sidebar-item"; + +/** + * Check if we're on an Insights page + */ +export function isInsightsPage(): boolean { + const path = window.location.pathname; + + // Match Insights pages: /owner/repo/pulse, /owner/repo/graphs/*, etc. + const insightsPatterns = [ + /^\/[^/]+\/[^/]+\/pulse/, + /^\/[^/]+\/[^/]+\/graphs/, + /^\/[^/]+\/[^/]+\/community/, + /^\/[^/]+\/[^/]+\/network/, + /^\/[^/]+\/[^/]+\/forks/, + ]; + + return insightsPatterns.some((pattern) => pattern.test(path)); +} + +/** + * Check if we're on the Agent Blame page (virtual) + */ +export function isAgentBlamePage(): boolean { + return window.location.hash === "#agent-blame"; +} + +/** + * Extract owner and repo from current URL + */ +export function extractRepoContext(): { owner: string; repo: string } | null { + const path = window.location.pathname; + const match = path.match(/^\/([^/]+)\/([^/]+)/); + + if (!match) { + return null; + } + + return { + owner: match[1], + repo: match[2], + }; +} + +/** + * Find the Insights sidebar + */ +function findInsightsSidebar(): Element | null { + // GitHub's Insights sidebar has various selectors depending on the page + // Try multiple selectors + const selectors = [ + // New GitHub UI + 'nav[aria-label="Insights"]', + // Insights menu + '.menu[aria-label="Insights"]', + // Generic sidebar in insights pages + '.Layout-sidebar nav', + '.Layout-sidebar .menu', + // Fallback: look for the menu containing "Pulse" link + '.menu:has(a[href$="/pulse"])', + ]; + + for (const selector of selectors) { + try { + const element = document.querySelector(selector); + if (element) { + return element; + } + } catch { + // :has() may not be supported in all browsers + continue; + } + } + + // Last resort: find by looking for Pulse link's parent menu + const pulseLink = document.querySelector('a[href$="/pulse"]'); + if (pulseLink) { + const menu = pulseLink.closest(".menu, nav, ul"); + if (menu) { + return menu; + } + } + + return null; +} + +// Track repos we've already checked (to avoid repeated API calls) +const checkedRepos = new Map(); + +/** + * 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) { + return; + } + + const repoKey = `${context.owner}/${context.repo}`; + + // 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; + } + + // 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); + + if (!hasAnalytics) { + console.log("[Agent Blame] No analytics found, not showing sidebar item"); + return; + } + } + + const sidebar = findInsightsSidebar(); + if (!sidebar) { + console.log("[Agent Blame] Could not find Insights sidebar"); + return; + } + + // 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; + } + + // 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; + } + + sidebarItem.textContent = "Agent Blame"; + + // Handle click - show the Agent Blame page + sidebarItem.addEventListener("click", (e) => { + e.preventDefault(); + e.stopPropagation(); + + // Update URL hash + window.history.pushState(null, "", `/${context.owner}/${context.repo}/pulse#agent-blame`); + + // Remove active state from other items + sidebar.querySelectorAll(".selected, [aria-current='page']").forEach((el) => { + el.classList.remove("selected"); + el.removeAttribute("aria-current"); + }); + + // Add active state to our item + sidebarItem.classList.add("selected"); + sidebarItem.setAttribute("aria-current", "page"); + + // Show the Agent Blame page + showAnalyticsPage(context.owner, context.repo); + }); + + // Insert after Pulse + if (pulseItem.nextSibling) { + pulseItem.parentNode?.insertBefore(sidebarItem, pulseItem.nextSibling); + } else { + pulseItem.parentNode?.appendChild(sidebarItem); + } + + 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); + } +} + +/** + * Remove the Agent Blame sidebar item + */ +export function removeSidebarItem(): void { + const item = document.getElementById(SIDEBAR_ITEM_ID); + if (item) { + item.remove(); + } + hideAnalyticsPage(); +} + +/** + * Handle hash changes (back/forward navigation) + */ +export function handleHashChange(): void { + const context = extractRepoContext(); + if (!context) return; + + if (isAgentBlamePage()) { + showAnalyticsPage(context.owner, context.repo); + + // Update sidebar selection + const sidebar = findInsightsSidebar(); + if (sidebar) { + sidebar.querySelectorAll(".selected, [aria-current='page']").forEach((el) => { + el.classList.remove("selected"); + el.removeAttribute("aria-current"); + }); + const sidebarItem = document.getElementById(SIDEBAR_ITEM_ID); + if (sidebarItem) { + sidebarItem.classList.add("selected"); + sidebarItem.setAttribute("aria-current", "page"); + } + } + } else { + hideAnalyticsPage(); + + // Restore original sidebar selection based on current path + const sidebar = findInsightsSidebar(); + if (sidebar) { + const sidebarItem = document.getElementById(SIDEBAR_ITEM_ID); + if (sidebarItem) { + sidebarItem.classList.remove("selected"); + sidebarItem.removeAttribute("aria-current"); + } + } + } +} diff --git a/packages/chrome/src/content/chart.css b/packages/chrome/src/content/chart.css new file mode 100644 index 0000000..afd876f --- /dev/null +++ b/packages/chrome/src/content/chart.css @@ -0,0 +1,80 @@ +/** + * Agent Blame Analytics Styles + * + * Minimal custom styles for the analytics overlay. + * Most styling uses GitHub's Primer CSS classes. + */ + +/* Chart line animation */ +@keyframes agentblame-draw-line { + from { + stroke-dashoffset: 1000; + } + to { + stroke-dashoffset: 0; + } +} + +.agentblame-chart-line { + stroke-dasharray: 1000; + stroke-dashoffset: 1000; + animation: agentblame-draw-line 1s ease-out forwards; +} + +/* Skeleton loading animation */ +@keyframes agentblame-skeleton-pulse { + 0%, 100% { + opacity: 0.4; + } + 50% { + opacity: 0.8; + } +} + +.agentblame-skeleton { + animation: agentblame-skeleton-pulse 1.5s ease-in-out infinite; +} + +/* Overlay backdrop */ +.agentblame-overlay-backdrop { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + z-index: 9998; + display: flex; + align-items: center; + justify-content: center; +} + +/* Modal container */ +.agentblame-modal { + background: var(--color-canvas-default, #ffffff); + border-radius: 12px; + max-width: 680px; + width: 90%; + max-height: 85vh; + overflow-y: auto; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2); +} + +/* Chart container */ +.agentblame-chart-container { + width: 100%; + aspect-ratio: 2 / 1; +} + +/* Progress bar styles */ +.agentblame-progress-bar { + height: 8px; + border-radius: 4px; + overflow: hidden; + background: var(--color-neutral-muted, #e1e4e8); +} + +.agentblame-progress-fill { + height: 100%; + transition: width 0.3s ease; +} diff --git a/packages/chrome/src/content/content.css b/packages/chrome/src/content/content.css index ba9f029..37b679c 100644 --- a/packages/chrome/src/content/content.css +++ b/packages/chrome/src/content/content.css @@ -98,7 +98,7 @@ } .ab-stat-ai .ab-stat-value { - color: var(--fgColor-accent, var(--color-accent-fg, #0969da)); + color: var(--ab-ai-color); } .ab-stat-human .ab-stat-value { @@ -205,7 +205,7 @@ } .ab-stat-ai .ab-stat-value { - color: var(--fgColor-accent, #58a6ff); + color: var(--ab-ai-color); } .ab-stat-human .ab-stat-value { diff --git a/packages/chrome/src/content/content.ts b/packages/chrome/src/content/content.ts index eda2339..5e60869 100644 --- a/packages/chrome/src/content/content.ts +++ b/packages/chrome/src/content/content.ts @@ -226,7 +226,7 @@ function buildAttributionMap( for (const attr of note.attributions) { // Add entry for each line in the range - for (let line = attr.start_line; line <= attr.end_line; line++) { + for (let line = attr.startLine; line <= attr.endLine; line++) { const key = `${attr.path}:${line}`; map.set(key, { category: attr.category, diff --git a/packages/chrome/src/icons/icon128.png b/packages/chrome/src/icons/icon128.png index 04949c8..e196489 100644 Binary files a/packages/chrome/src/icons/icon128.png and b/packages/chrome/src/icons/icon128.png differ diff --git a/packages/chrome/src/icons/icon16.png b/packages/chrome/src/icons/icon16.png index f73a8d5..9d13f69 100644 Binary files a/packages/chrome/src/icons/icon16.png and b/packages/chrome/src/icons/icon16.png differ diff --git a/packages/chrome/src/icons/icon48.png b/packages/chrome/src/icons/icon48.png index c364f09..8184acf 100644 Binary files a/packages/chrome/src/icons/icon48.png and b/packages/chrome/src/icons/icon48.png differ diff --git a/packages/chrome/src/icons/logo.svg b/packages/chrome/src/icons/logo.svg new file mode 100644 index 0000000..312f655 --- /dev/null +++ b/packages/chrome/src/icons/logo.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/packages/chrome/src/lib/mock-analytics.ts b/packages/chrome/src/lib/mock-analytics.ts new file mode 100644 index 0000000..66e4227 --- /dev/null +++ b/packages/chrome/src/lib/mock-analytics.ts @@ -0,0 +1,439 @@ +/** + * Analytics Data + * + * Types and functions for fetching repository-wide analytics. + * Uses real API calls to fetch from git notes. + */ + +import { getToken } from "./storage"; + +// Set to true to enable mock data fallback for development/testing +// Set to false for production to only show real analytics +const USE_MOCK_FALLBACK = false; + +export interface AnalyticsHistoryEntry { + date: string; + pr: number; + title?: string; + author: string; + added: number; + removed: number; + aiLines: number; + providers?: Record; + models?: Record; +} + +export interface AnalyticsSummary { + totalLines: number; + aiLines: number; + humanLines: number; + providers: { + cursor?: number; + claudeCode?: number; + }; + models: Record; + updated: string; +} + +export interface ContributorStats { + totalLines: number; + aiLines: number; + providers: Record; + models: Record; + prCount: number; +} + +export interface AnalyticsData { + version: 2; + summary: AnalyticsSummary; + contributors: Record; + history: AnalyticsHistoryEntry[]; +} + +const API_BASE = "https://api.github.com"; + +// Cache TTL in milliseconds (5 minutes) +const CACHE_TTL = 5 * 60 * 1000; + +interface CachedAnalytics { + data: AnalyticsData; + fetchedAt: number; +} + +// In-memory cache for faster access during same page session +const memoryCache = new Map(); + +/** + * Get cached analytics from storage + */ +async function getCachedAnalytics(owner: string, repo: string): Promise { + const cacheKey = `analytics_${owner}_${repo}`; + + // Check memory cache first (instant) + const memCached = memoryCache.get(cacheKey); + if (memCached && Date.now() - memCached.fetchedAt < CACHE_TTL) { + console.log("[Agent Blame] Using memory cache"); + return memCached.data; + } + + // Check chrome.storage.local + try { + const stored = await chrome.storage.local.get(cacheKey); + if (stored[cacheKey]) { + const cached = stored[cacheKey] as CachedAnalytics; + if (Date.now() - cached.fetchedAt < CACHE_TTL) { + // Update memory cache + memoryCache.set(cacheKey, cached); + console.log("[Agent Blame] Using storage cache"); + return cached.data; + } + } + } catch { + // Storage access failed, continue without cache + } + + return null; +} + +/** + * Store analytics in cache + */ +async function setCachedAnalytics(owner: string, repo: string, data: AnalyticsData): Promise { + const cacheKey = `analytics_${owner}_${repo}`; + const cached: CachedAnalytics = { data, fetchedAt: Date.now() }; + + // Update memory cache + memoryCache.set(cacheKey, cached); + + // Update storage cache + try { + await chrome.storage.local.set({ [cacheKey]: cached }); + } catch { + // Storage write failed, memory cache still works + } +} + +/** + * Mock analytics data for UI development + */ +export const MOCK_ANALYTICS: AnalyticsData = { + version: 2, + summary: { + totalLines: 15420, + aiLines: 3847, + humanLines: 11573, + providers: { + cursor: 2100, + claudeCode: 1747, + }, + models: { + "gpt-4": 1200, + "gpt-4o": 900, + "claude-3.5-sonnet": 1147, + "claude-3-opus": 600, + }, + updated: new Date().toISOString(), + }, + contributors: { + alice: { + totalLines: 5000, + aiLines: 2000, + providers: { cursor: 1200, claudeCode: 800 }, + models: { "gpt-4": 800, "claude-3.5-sonnet": 1200 }, + prCount: 15, + }, + bob: { + totalLines: 4000, + aiLines: 1000, + providers: { cursor: 600, claudeCode: 400 }, + models: { "gpt-4o": 600, "claude-3-opus": 400 }, + prCount: 12, + }, + }, + history: [ + { + date: new Date(Date.now() - 0 * 24 * 60 * 60 * 1000).toISOString().split("T")[0], + pr: 58, + title: "Add analytics dashboard", + author: "alice", + added: 450, + removed: 50, + aiLines: 280, + providers: { cursor: 280 }, + models: { "gpt-4": 280 }, + }, + { + date: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString().split("T")[0], + pr: 57, + title: "Fix authentication bug", + author: "bob", + added: 120, + removed: 30, + aiLines: 45, + providers: { claudeCode: 45 }, + models: { "claude-3.5-sonnet": 45 }, + }, + { + date: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString().split("T")[0], + pr: 56, + title: "Update dependencies", + author: "alice", + added: 80, + removed: 20, + aiLines: 0, + }, + { + date: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toISOString().split("T")[0], + pr: 55, + title: "Implement user settings page", + author: "bob", + added: 320, + removed: 40, + aiLines: 200, + providers: { cursor: 120, claudeCode: 80 }, + models: { "gpt-4": 120, "claude-3.5-sonnet": 80 }, + }, + { + date: new Date(Date.now() - 5 * 24 * 60 * 60 * 1000).toISOString().split("T")[0], + pr: 54, + title: "Add API rate limiting", + author: "alice", + added: 180, + removed: 10, + aiLines: 150, + providers: { cursor: 150 }, + models: { "gpt-4o": 150 }, + }, + { + date: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString().split("T")[0], + pr: 53, + title: "Refactor database queries", + author: "bob", + added: 250, + removed: 180, + aiLines: 180, + providers: { claudeCode: 180 }, + models: { "claude-3-opus": 180 }, + }, + { + date: new Date(Date.now() - 10 * 24 * 60 * 60 * 1000).toISOString().split("T")[0], + pr: 52, + title: "Add user authentication", + author: "alice", + added: 400, + removed: 60, + aiLines: 320, + providers: { cursor: 200, claudeCode: 120 }, + models: { "gpt-4": 200, "claude-3.5-sonnet": 120 }, + }, + { + date: new Date(Date.now() - 14 * 24 * 60 * 60 * 1000).toISOString().split("T")[0], + pr: 51, + title: "Initial project setup", + author: "bob", + added: 600, + removed: 0, + aiLines: 400, + providers: { cursor: 250, claudeCode: 150 }, + models: { "gpt-4": 150, "gpt-4o": 100, "claude-3.5-sonnet": 100, "claude-3-opus": 50 }, + }, + ], +}; + +/** + * Fetch real analytics data from GitHub API (via git notes) + */ +async function fetchRealAnalytics( + owner: string, + repo: string, +): Promise { + const token = await getToken(); + if (!token) { + console.log("[Agent Blame] No token, cannot fetch real analytics"); + return null; + } + + try { + // First, get the analytics notes ref + const refResponse = await fetch( + `${API_BASE}/repos/${owner}/${repo}/git/refs/notes/agentblame-analytics`, + { + headers: { + Authorization: `token ${token}`, + Accept: "application/vnd.github.v3+json", + }, + }, + ); + + if (!refResponse.ok) { + if (refResponse.status === 404) { + console.log("[Agent Blame] No analytics notes found for this repo"); + return null; + } + throw new Error(`Failed to fetch analytics ref: ${refResponse.status}`); + } + + const ref = await refResponse.json(); + const commitSha = ref.object.sha; + + // Get the commit to find the tree + const commitResponse = await fetch( + `${API_BASE}/repos/${owner}/${repo}/git/commits/${commitSha}`, + { + headers: { + Authorization: `token ${token}`, + Accept: "application/vnd.github.v3+json", + }, + }, + ); + + if (!commitResponse.ok) { + throw new Error(`Failed to fetch analytics commit: ${commitResponse.status}`); + } + + const commit = await commitResponse.json(); + const treeSha = commit.tree.sha; + + // Get the tree to find the anchor note + const treeResponse = await fetch( + `${API_BASE}/repos/${owner}/${repo}/git/trees/${treeSha}?recursive=1`, + { + headers: { + Authorization: `token ${token}`, + Accept: "application/vnd.github.v3+json", + }, + }, + ); + + if (!treeResponse.ok) { + throw new Error(`Failed to fetch analytics tree: ${treeResponse.status}`); + } + + const tree = await treeResponse.json(); + + // Find the first blob in the tree (should be the analytics note) + const blobEntry = tree.tree.find((entry: { type: string }) => entry.type === "blob"); + if (!blobEntry) { + console.log("[Agent Blame] No analytics blob found in tree"); + return null; + } + + // Get the blob content + const blobResponse = await fetch( + `${API_BASE}/repos/${owner}/${repo}/git/blobs/${blobEntry.sha}`, + { + headers: { + Authorization: `token ${token}`, + Accept: "application/vnd.github.v3+json", + }, + }, + ); + + if (!blobResponse.ok) { + throw new Error(`Failed to fetch analytics blob: ${blobResponse.status}`); + } + + const blob = await blobResponse.json(); + + // Decode base64 content + const content = atob(blob.content.replace(/\n/g, "")); + const analytics = JSON.parse(content); + + // Validate version + if (analytics.version !== 2) { + console.log("[Agent Blame] Unsupported analytics version:", analytics.version); + return null; + } + + console.log("[Agent Blame] Successfully fetched real analytics"); + return analytics as AnalyticsData; + } catch (error) { + console.error("[Agent Blame] Error fetching analytics:", error); + return null; + } +} + +/** + * Check if analytics exist for a repository (without fetching full data) + * Returns true if analytics notes ref exists + */ +export async function checkAnalyticsExist( + owner: string, + repo: string, +): Promise { + const token = await getToken(); + if (!token) { + console.log("[Agent Blame] No token, cannot check analytics"); + return false; + } + + try { + const response = await fetch( + `${API_BASE}/repos/${owner}/${repo}/git/refs/notes/agentblame-analytics`, + { + headers: { + Authorization: `token ${token}`, + Accept: "application/vnd.github.v3+json", + }, + }, + ); + + if (response.ok) { + console.log("[Agent Blame] Analytics exist for this repo"); + return true; + } + + if (response.status === 404) { + console.log("[Agent Blame] No analytics found for this repo"); + return false; + } + + console.log("[Agent Blame] Error checking analytics:", response.status); + return false; + } catch (error) { + console.error("[Agent Blame] Error checking analytics:", error); + return false; + } +} + +/** + * Get analytics data for a repository + * Uses caching, tries real API, optionally falls back to mock data + */ +export async function getAnalytics( + owner: string, + repo: string, +): Promise { + // Check cache first + const cached = await getCachedAnalytics(owner, repo); + if (cached) { + return cached; + } + + // Try to fetch real analytics + const realData = await fetchRealAnalytics(owner, repo); + if (realData) { + // Cache the result + await setCachedAnalytics(owner, repo, realData); + return realData; + } + + // Fall back to mock data only if enabled + if (USE_MOCK_FALLBACK) { + console.log("[Agent Blame] Using mock analytics data (fallback enabled)"); + return MOCK_ANALYTICS; + } + + console.log("[Agent Blame] No analytics available (mock fallback disabled)"); + return null; +} + +/** + * Get mock analytics data (for development/testing) + */ +export function getMockAnalytics(): Promise { + // Simulate network delay + return new Promise((resolve) => { + setTimeout(() => resolve(MOCK_ANALYTICS), 300); + }); +} diff --git a/packages/chrome/src/manifest.json b/packages/chrome/src/manifest.json index 97db021..3c7fc27 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.0", + "version": "0.2.3", "description": "See AI-generated vs human-written code on GitHub PRs", "icons": { "16": "icons/icon16.png", @@ -25,6 +25,20 @@ "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"], + "run_at": "document_idle" } ], "background": { @@ -33,7 +47,7 @@ }, "web_accessible_resources": [ { - "resources": ["icons/*.png"], + "resources": ["icons/*.png", "icons/*.svg"], "matches": ["https://github.com/*"] } ] diff --git a/packages/chrome/src/types.ts b/packages/chrome/src/types.ts index 39b4c1a..7aa8e48 100644 --- a/packages/chrome/src/types.ts +++ b/packages/chrome/src/types.ts @@ -17,11 +17,11 @@ export interface GitNotesAttribution { export interface AttributionEntry { path: string; - start_line: number; - end_line: number; - content_hash: string; - content_hash_normalized: string; - category: "ai_generated"; // Only ai_generated, no ai_assisted + startLine: number; + endLine: number; + contentHash: string; + matchType: string; + category: "ai_generated"; provider: string; model: string | null; confidence: number; diff --git a/packages/cli/package.json b/packages/cli/package.json index ab8796f..a02a21d 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@mesadev/agentblame", - "version": "0.2.2", + "version": "0.2.3", "description": "CLI to track AI-generated vs human-written code", "license": "Apache-2.0", "repository": { diff --git a/packages/cli/src/blame.ts b/packages/cli/src/blame.ts index 8334cbb..2a406c9 100644 --- a/packages/cli/src/blame.ts +++ b/packages/cli/src/blame.ts @@ -24,7 +24,7 @@ const c = { cyan: "\x1b[36m", yellow: "\x1b[33m", green: "\x1b[32m", - magenta: "\x1b[35m", + orange: "\x1b[38;5;166m", // Mesa Orange - matches gutter color blue: "\x1b[34m", gray: "\x1b[90m", }; @@ -115,7 +115,7 @@ export async function blame( if (!pathMatches) return false; // Check if original line number (at commit time) is within the attribution range - return line.origLine >= a.start_line && line.origLine <= a.end_line; + return line.origLine >= a.startLine && line.origLine <= a.endLine; }); return { line, attribution: attr || null }; }); @@ -156,7 +156,7 @@ function outputFormatted(lines: LineAttribution[], filePath: string): void { const model = attribution.model && attribution.model !== "claude" ? attribution.model : ""; const label = model ? `${provider} - ${model}` : provider; visibleLen = label.length + 3; // +2 for emoji (renders 2-wide) + 1 space - attrInfo = `${c.magenta}✨ ${label}${c.reset}`; + attrInfo = `${c.orange}✨ ${label}${c.reset}`; } const attrPadded = attribution @@ -181,12 +181,12 @@ function outputFormatted(lines: LineAttribution[], filePath: string): void { const barWidth = 40; const aiBarWidth = Math.round((aiPct / 100) * barWidth); const humanBarWidth = barWidth - aiBarWidth; - const aiBar = `${c.magenta}${"█".repeat(aiBarWidth)}${c.reset}`; + const aiBar = `${c.orange}${"█".repeat(aiBarWidth)}${c.reset}`; const humanBar = `${c.dim}${"░".repeat(humanBarWidth)}${c.reset}`; console.log(` ${c.dim}${"─".repeat(70)}${c.reset}`); console.log(` ${aiBar}${humanBar}`); - console.log(` ${c.magenta}✨ AI: ${aiGenerated} (${aiPct}%)${c.reset} ${c.dim}│${c.reset} ${c.green}👤 Human: ${human} (${humanPct}%)${c.reset}`); + console.log(` ${c.orange}✨ AI: ${aiGenerated} (${aiPct}%)${c.reset} ${c.dim}│${c.reset} ${c.green}👤 Human: ${human} (${humanPct}%)${c.reset}`); console.log(""); } @@ -217,7 +217,7 @@ function outputSummary(lines: LineAttribution[], filePath: string): void { const provider = attribution.provider; providers.set(provider, (providers.get(provider) || 0) + 1); - const matchType = attribution.match_type; + const matchType = attribution.matchType; matchTypes.set(matchType, (matchTypes.get(matchType) || 0) + 1); } } @@ -258,8 +258,8 @@ function outputJson(lines: LineAttribution[], filePath: string): void { category: attribution.category, provider: attribution.provider, model: attribution.model, - match_type: attribution.match_type, - content_hash: attribution.content_hash, + matchType: attribution.matchType, + contentHash: attribution.contentHash, } : null, })), diff --git a/packages/cli/src/capture.ts b/packages/cli/src/capture.ts index c44dc56..25b3b3c 100644 --- a/packages/cli/src/capture.ts +++ b/packages/cli/src/capture.ts @@ -26,20 +26,20 @@ import { findAgentBlameDir } from "./lib/util"; interface CapturedLine { content: string; hash: string; - hash_normalized: string; + hashNormalized: string; } interface CapturedEdit { timestamp: string; - provider: "cursor" | "claude_code"; - file_path: string; + provider: "cursor" | "claudeCode"; + filePath: string; model: string | null; lines: CapturedLine[]; content: string; - content_hash: string; - content_hash_normalized: string; - edit_type: "addition" | "modification" | "replacement"; - old_content?: string; + contentHash: string; + contentHashNormalized: string; + editType: "addition" | "modification" | "replacement"; + oldContent?: string; } interface CursorPayload { @@ -130,7 +130,7 @@ function hashLines(content: string): CapturedLine[] { result.push({ content: line, hash: computeHash(line), - hash_normalized: computeNormalizedHash(line), + hashNormalized: computeNormalizedHash(line), }); } @@ -166,13 +166,13 @@ function saveEdit(edit: CapturedEdit): void { insertEdit({ timestamp: edit.timestamp, provider: edit.provider, - file_path: edit.file_path, + filePath: edit.filePath, model: edit.model, content: edit.content, - content_hash: edit.content_hash, - content_hash_normalized: edit.content_hash_normalized, - edit_type: edit.edit_type, - old_content: edit.old_content, + contentHash: edit.contentHash, + contentHashNormalized: edit.contentHashNormalized, + editType: edit.editType, + oldContent: edit.oldContent, lines: edit.lines, }); } @@ -211,7 +211,7 @@ function processCursorPayload( edits.push({ timestamp, provider: "cursor", - file_path: payload.file_path, + filePath: payload.file_path, model: payload.model || null, // Line-level data @@ -219,12 +219,12 @@ function processCursorPayload( // Aggregate data content: addedContent, - content_hash: computeHash(addedContent), - content_hash_normalized: computeNormalizedHash(addedContent), + contentHash: computeHash(addedContent), + contentHashNormalized: computeNormalizedHash(addedContent), // Edit context - edit_type: determineEditType(oldString, newString), - old_content: oldString || undefined, + editType: determineEditType(oldString, newString), + oldContent: oldString || undefined, }); } @@ -255,8 +255,8 @@ function processClaudePayload(payload: ClaudePayload): CapturedEdit[] { edits.push({ timestamp, - provider: "claude_code", - file_path: filePath, + provider: "claudeCode", + filePath: filePath, model: "claude", // Line-level data @@ -264,11 +264,11 @@ function processClaudePayload(payload: ClaudePayload): CapturedEdit[] { // Aggregate data content: content, - content_hash: computeHash(content), - content_hash_normalized: computeNormalizedHash(content), + contentHash: computeHash(content), + contentHashNormalized: computeNormalizedHash(content), // Edit context - edit_type: "addition", + editType: "addition", }); return edits; } @@ -284,8 +284,8 @@ function processClaudePayload(payload: ClaudePayload): CapturedEdit[] { edits.push({ timestamp, - provider: "claude_code", - file_path: filePath, + provider: "claudeCode", + filePath: filePath, model: "claude", // Line-level data @@ -293,12 +293,12 @@ function processClaudePayload(payload: ClaudePayload): CapturedEdit[] { // Aggregate data content: addedContent, - content_hash: computeHash(addedContent), - content_hash_normalized: computeNormalizedHash(addedContent), + contentHash: computeHash(addedContent), + contentHashNormalized: computeNormalizedHash(addedContent), // Edit context - edit_type: determineEditType(oldString, newString), - old_content: oldString || undefined, + editType: determineEditType(oldString, newString), + oldContent: oldString || undefined, }); return edits; @@ -344,7 +344,7 @@ export async function runCapture(): Promise { // Save all edits to SQLite database for (const edit of edits) { // Find the agentblame directory for this file - const agentblameDir = findAgentBlameDir(edit.file_path); + const agentblameDir = findAgentBlameDir(edit.filePath); if (!agentblameDir) { // File is not in an initialized repo, skip silently continue; diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index c2f1134..1fa4924 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -27,6 +27,7 @@ import { uninstallGitHook, uninstallGitHubAction, getRepoRoot, + runGit, configureNotesSync, removeNotesSync, initDatabase, @@ -37,6 +38,8 @@ import { cleanupOldEntries, } from "./lib"; +const ANALYTICS_TAG = "agentblame-analytics-anchor"; + /** * Check if Bun is installed and available in PATH. */ @@ -112,6 +115,45 @@ Examples: `); } +/** + * Create the analytics anchor tag on the root commit. + * This tag is used to store repository-wide analytics. + */ +async function createAnalyticsTag(repoRoot: string): Promise { + try { + // Check if tag already exists + const existingTag = await runGit(repoRoot, ["tag", "-l", ANALYTICS_TAG], 5000); + if (existingTag.stdout.trim()) { + // Tag already exists + return true; + } + + // Get the root commit(s) + const rootResult = await runGit(repoRoot, ["rev-list", "--max-parents=0", "HEAD"], 10000); + if (rootResult.exitCode !== 0 || !rootResult.stdout.trim()) { + return false; + } + + const rootLines = rootResult.stdout.trim().split("\n").filter(Boolean); + if (rootLines.length === 0) { + return false; + } + + // Use the first root commit + const rootSha = rootLines[0]; + + // Create the tag + const tagResult = await runGit(repoRoot, ["tag", ANALYTICS_TAG, rootSha], 5000); + if (tagResult.exitCode !== 0) { + return false; + } + + return true; + } catch { + return false; + } +} + /** * Clean up global hooks and database from previous versions. */ @@ -271,6 +313,10 @@ async function runInit(initArgs: string[] = []): Promise { const githubActionSuccess = await installGitHubAction(repoRoot); results.push({ name: "GitHub Actions workflow", success: githubActionSuccess }); + // Create analytics anchor tag + const analyticsTagSuccess = await createAnalyticsTag(repoRoot); + results.push({ name: "Analytics anchor tag", success: analyticsTagSuccess }); + // Print results console.log(" \x1b[2m─────────────────────────────────────────\x1b[0m"); console.log(""); @@ -298,11 +344,12 @@ async function runInit(initArgs: string[] = []): Promise { console.log(""); console.log(" \x1b[1mNext steps:\x1b[0m"); console.log(" \x1b[33m1.\x1b[0m Restart Cursor or Claude Code"); - console.log(" \x1b[33m2.\x1b[0m Make AI edits and commit your changes"); - console.log(" \x1b[33m3.\x1b[0m Run \x1b[36magentblame blame \x1b[0m to see attribution"); + console.log(" \x1b[33m2.\x1b[0m Push the analytics tag: \x1b[36mgit push origin agentblame-analytics-anchor\x1b[0m"); + console.log(" \x1b[33m3.\x1b[0m Make AI edits and commit your changes"); + console.log(" \x1b[33m4.\x1b[0m Run \x1b[36magentblame blame \x1b[0m to see attribution"); console.log(""); console.log(" \x1b[2mWorkflow created at:\x1b[0m .github/workflows/agentblame.yml"); - console.log(" \x1b[2mCommit this file to enable squash/rebase merge support.\x1b[0m"); + console.log(" \x1b[2mCommit this file to enable squash/rebase merge support and analytics.\x1b[0m"); console.log(""); } @@ -458,7 +505,7 @@ async function runStatus(): Promise { const recent = getRecentPendingEdits(5); for (const edit of recent) { const time = new Date(edit.timestamp).toLocaleTimeString(); - const file = edit.file_path.split("/").pop(); + const file = edit.filePath.split("/").pop(); console.log(` [${edit.provider}] ${file} at ${time}`); } diff --git a/packages/cli/src/lib/database.ts b/packages/cli/src/lib/database.ts index cb59d05..b81ac84 100644 --- a/packages/cli/src/lib/database.ts +++ b/packages/cli/src/lib/database.ts @@ -18,24 +18,24 @@ export interface DbEdit { id: number; timestamp: string; provider: AiProvider; - file_path: string; + filePath: string; model: string | null; content: string; - content_hash: string; - content_hash_normalized: string; - edit_type: string; - old_content: string | null; + contentHash: string; + contentHashNormalized: string; + editType: string; + oldContent: string | null; status: string; - matched_commit: string | null; - matched_at: string | null; + matchedCommit: string | null; + matchedAt: string | null; } export interface DbLine { id: number; - edit_id: number; + editId: number; content: string; hash: string; - hash_normalized: string; + hashNormalized: string; } export interface LineMatchResult { @@ -181,13 +181,13 @@ export function initDatabase(): void { export interface InsertEditParams { timestamp: string; provider: AiProvider; - file_path: string; + filePath: string; model: string | null; content: string; - content_hash: string; - content_hash_normalized: string; - edit_type: string; - old_content?: string; + contentHash: string; + contentHashNormalized: string; + editType: string; + oldContent?: string; lines: CapturedLine[]; } @@ -207,13 +207,13 @@ export function insertEdit(params: InsertEditParams): number { const result = editStmt.run( params.timestamp, params.provider, - params.file_path, + params.filePath, params.model, params.content, - params.content_hash, - params.content_hash_normalized, - params.edit_type, - params.old_content || null + params.contentHash, + params.contentHashNormalized, + params.editType, + params.oldContent || null ); const editId = Number(result.lastInsertRowid); @@ -225,7 +225,7 @@ export function insertEdit(params: InsertEditParams): number { `); for (const line of params.lines) { - lineStmt.run(editId, line.content, line.hash, line.hash_normalized); + lineStmt.run(editId, line.content, line.hash, line.hashNormalized); } return editId; @@ -287,10 +287,10 @@ export function findByExactHash( edit: rowToEdit(row), line: { id: row.line_id, - edit_id: row.edit_id, + editId: row.edit_id, content: row.line_content, hash: row.hash, - hash_normalized: row.hash_normalized, + hashNormalized: row.hash_normalized, }, matchType: "exact_hash", confidence: 1.0, @@ -348,10 +348,10 @@ export function findByNormalizedHash( edit: rowToEdit(row), line: { id: row.line_id, - edit_id: row.edit_id, + editId: row.edit_id, content: row.line_content, hash: row.hash, - hash_normalized: row.hash_normalized, + hashNormalized: row.hash_normalized, }, matchType: "normalized_hash", confidence: 0.95, @@ -383,7 +383,14 @@ export function findEditsByFile(filePath: string): DbEdit[] { export function getEditLines(editId: number): DbLine[] { const db = getDatabase(); const stmt = db.prepare(`SELECT * FROM lines WHERE edit_id = ?`); - return stmt.all(editId) as DbLine[]; + const rows = stmt.all(editId) as any[]; + return rows.map(row => ({ + id: row.id, + editId: row.edit_id, + content: row.content, + hash: row.hash, + hashNormalized: row.hash_normalized, + })); } /** @@ -408,10 +415,10 @@ export function findBySubstring( edit, line: { id: 0, - edit_id: edit.id, + editId: edit.id, content: normalizedLine, hash: "", - hash_normalized: "", + hashNormalized: "", }, matchType: "line_in_ai_content", confidence: 0.9, @@ -588,15 +595,15 @@ function rowToEdit(row: any): DbEdit { id: row.id, timestamp: row.timestamp, provider: row.provider as AiProvider, - file_path: row.file_path, + filePath: row.file_path, model: row.model, content: row.content, - content_hash: row.content_hash, - content_hash_normalized: row.content_hash_normalized, - edit_type: row.edit_type, - old_content: row.old_content, + contentHash: row.content_hash, + contentHashNormalized: row.content_hash_normalized, + editType: row.edit_type, + oldContent: row.old_content, status: row.status, - matched_commit: row.matched_commit, - matched_at: row.matched_at, + matchedCommit: row.matched_commit, + matchedAt: row.matched_at, }; } diff --git a/packages/cli/src/lib/git/gitDiff.ts b/packages/cli/src/lib/git/gitDiff.ts index 8f6705a..43940b5 100644 --- a/packages/cli/src/lib/git/gitDiff.ts +++ b/packages/cli/src/lib/git/gitDiff.ts @@ -83,10 +83,10 @@ export function parseDiff(diffOutput: string): DiffHunk[] { let hunkStartLine = 0; let currentLineNum = 0; let hunkLines: Array<{ - line_number: number; + lineNumber: number; content: string; hash: string; - hash_normalized: string; + hashNormalized: string; }> = []; for (const line of diffLines) { @@ -97,11 +97,11 @@ export function parseDiff(diffOutput: string): DiffHunk[] { const content = hunkLines.map((l) => l.content).join("\n"); hunks.push({ path: currentPath, - start_line: hunkStartLine, - end_line: hunkStartLine + hunkLines.length - 1, + startLine: hunkStartLine, + endLine: hunkStartLine + hunkLines.length - 1, content, - content_hash: computeContentHash(content), - content_hash_normalized: computeNormalizedHash(content), + contentHash: computeContentHash(content), + contentHashNormalized: computeNormalizedHash(content), lines: hunkLines, }); hunkLines = []; @@ -117,11 +117,11 @@ export function parseDiff(diffOutput: string): DiffHunk[] { const content = hunkLines.map((l) => l.content).join("\n"); hunks.push({ path: currentPath, - start_line: hunkStartLine, - end_line: hunkStartLine + hunkLines.length - 1, + startLine: hunkStartLine, + endLine: hunkStartLine + hunkLines.length - 1, content, - content_hash: computeContentHash(content), - content_hash_normalized: computeNormalizedHash(content), + contentHash: computeContentHash(content), + contentHashNormalized: computeNormalizedHash(content), lines: hunkLines, }); hunkLines = []; @@ -140,10 +140,10 @@ export function parseDiff(diffOutput: string): DiffHunk[] { if (line.startsWith("+") && !line.startsWith("+++")) { const content = line.slice(1); // Remove leading + hunkLines.push({ - line_number: currentLineNum, + lineNumber: currentLineNum, content, hash: computeContentHash(content), - hash_normalized: computeNormalizedHash(content), + hashNormalized: computeNormalizedHash(content), }); currentLineNum++; } else if (!line.startsWith("-") && !line.startsWith("\\")) { @@ -157,11 +157,11 @@ export function parseDiff(diffOutput: string): DiffHunk[] { const content = hunkLines.map((l) => l.content).join("\n"); hunks.push({ path: currentPath, - start_line: hunkStartLine, - end_line: hunkStartLine + hunkLines.length - 1, + startLine: hunkStartLine, + endLine: hunkStartLine + hunkLines.length - 1, content, - content_hash: computeContentHash(content), - content_hash_normalized: computeNormalizedHash(content), + contentHash: computeContentHash(content), + contentHashNormalized: computeNormalizedHash(content), lines: hunkLines, }); } @@ -188,9 +188,9 @@ export function parseDeletedBlocks(diffOutput: string): DeletedBlock[] { if (currentPath && deletedLines.length >= 3) { blocks.push({ path: currentPath, - start_line: deleteStartLine, + startLine: deleteStartLine, lines: deletedLines, - normalized_content: deletedLines.map((l) => l.trim()).join("\n"), + normalizedContent: deletedLines.map((l) => l.trim()).join("\n"), }); } currentPath = line.slice(6); @@ -204,9 +204,9 @@ export function parseDeletedBlocks(diffOutput: string): DeletedBlock[] { if (currentPath && deletedLines.length >= 3) { blocks.push({ path: currentPath, - start_line: deleteStartLine, + startLine: deleteStartLine, lines: deletedLines, - normalized_content: deletedLines.map((l) => l.trim()).join("\n"), + normalizedContent: deletedLines.map((l) => l.trim()).join("\n"), }); } deletedLines = []; @@ -232,9 +232,9 @@ export function parseDeletedBlocks(diffOutput: string): DeletedBlock[] { if (currentPath && deletedLines.length >= 3) { blocks.push({ path: currentPath, - start_line: deleteStartLine, + startLine: deleteStartLine, lines: deletedLines, - normalized_content: deletedLines.map((l) => l.trim()).join("\n"), + normalizedContent: deletedLines.map((l) => l.trim()).join("\n"), }); } deletedLines = []; @@ -243,9 +243,9 @@ export function parseDeletedBlocks(diffOutput: string): DeletedBlock[] { if (currentPath && deletedLines.length >= 3) { blocks.push({ path: currentPath, - start_line: deleteStartLine, + startLine: deleteStartLine, lines: deletedLines, - normalized_content: deletedLines.map((l) => l.trim()).join("\n"), + normalizedContent: deletedLines.map((l) => l.trim()).join("\n"), }); } deletedLines = []; @@ -257,9 +257,9 @@ export function parseDeletedBlocks(diffOutput: string): DeletedBlock[] { if (currentPath && deletedLines.length >= 3) { blocks.push({ path: currentPath, - start_line: deleteStartLine, + startLine: deleteStartLine, lines: deletedLines, - normalized_content: deletedLines.map((l) => l.trim()).join("\n"), + normalizedContent: deletedLines.map((l) => l.trim()).join("\n"), }); } @@ -290,21 +290,21 @@ export function detectMoves( // Check for matching normalized content const addedNormalized = added.lines.map((l) => l.content.trim()).join("\n"); - if (deleted.normalized_content === addedNormalized) { + if (deleted.normalizedContent === addedNormalized) { moves.push({ - from_path: deleted.path, - from_start_line: deleted.start_line, - to_path: added.path, - to_start_line: added.start_line, - line_count: deleted.lines.length, - normalized_content: deleted.normalized_content, + fromPath: deleted.path, + fromStartLine: deleted.startLine, + toPath: added.path, + toStartLine: added.startLine, + lineCount: deleted.lines.length, + normalizedContent: deleted.normalizedContent, }); break; // Only match once } // Check for partial moves (deleted content is subset of added) if ( - addedNormalized.includes(deleted.normalized_content) && + addedNormalized.includes(deleted.normalizedContent) && deleted.lines.length >= MOVE_THRESHOLD ) { // Find where in the added hunk the deleted content starts @@ -327,12 +327,12 @@ export function detectMoves( if (matchStartIdx >= 0) { moves.push({ - from_path: deleted.path, - from_start_line: deleted.start_line, - to_path: added.path, - to_start_line: added.start_line + matchStartIdx, - line_count: deleted.lines.length, - normalized_content: deleted.normalized_content, + fromPath: deleted.path, + fromStartLine: deleted.startLine, + toPath: added.path, + toStartLine: added.startLine + matchStartIdx, + lineCount: deleted.lines.length, + normalizedContent: deleted.normalizedContent, }); break; } @@ -352,14 +352,14 @@ export function buildMoveIndex( const index = new Map(); for (const move of moves) { - for (let i = 0; i < move.line_count; i++) { + for (let i = 0; i < move.lineCount; i++) { // Key: normalized content of each line - const lineContent = move.normalized_content.split("\n")[i]; + const lineContent = move.normalizedContent.split("\n")[i]; if (lineContent) { - const key = `${move.to_path}:${move.to_start_line + i}`; + const key = `${move.toPath}:${move.toStartLine + i}`; index.set(key, { - fromPath: move.from_path, - fromLine: move.from_start_line + i, + fromPath: move.fromPath, + fromLine: move.fromStartLine + i, }); } } diff --git a/packages/cli/src/lib/git/gitNotes.ts b/packages/cli/src/lib/git/gitNotes.ts index 793797b..f1342d5 100644 --- a/packages/cli/src/lib/git/gitNotes.ts +++ b/packages/cli/src/lib/git/gitNotes.ts @@ -18,18 +18,18 @@ export async function attachNote( attributions: RangeAttribution[] ): Promise { const note: GitNotesAttribution = { - version: 1, + version: 2, timestamp: new Date().toISOString(), attributions: attributions.map((a) => ({ path: a.path, - start_line: a.start_line, - end_line: a.end_line, + startLine: a.startLine, + endLine: a.endLine, category: "ai_generated", provider: a.provider, model: a.model, confidence: a.confidence, - match_type: a.match_type, - content_hash: a.content_hash, + matchType: a.matchType, + contentHash: a.contentHash, })), }; diff --git a/packages/cli/src/lib/hooks.ts b/packages/cli/src/lib/hooks.ts index b6171d7..7ab850e 100644 --- a/packages/cli/src/lib/hooks.ts +++ b/packages/cli/src/lib/hooks.ts @@ -375,7 +375,7 @@ export async function uninstallClaudeHooks(repoRoot: string): Promise { } /** - * GitHub Actions workflow content for handling squash/rebase merges + * GitHub Actions workflow content for handling squash/rebase merges and analytics */ const GITHUB_WORKFLOW_CONTENT = `name: Agent Blame @@ -384,20 +384,20 @@ on: types: [closed] jobs: - transfer-notes: + post-merge: # Only run if the PR was merged (not just closed) if: github.event.pull_request.merged == true runs-on: ubuntu-latest permissions: - contents: write + contents: write # Needed to push notes steps: - name: Checkout repository uses: actions/checkout@v4 with: - fetch-depth: 0 - ref: \${{ github.event.pull_request.base.ref }} + fetch-depth: 0 # Full history needed for notes and blame + ref: \${{ github.event.pull_request.base.ref }} # Checkout target branch (e.g., main) - name: Setup Bun uses: oven-sh/setup-bun@v1 @@ -405,46 +405,47 @@ jobs: - name: Install agentblame run: npm install -g @mesadev/agentblame - - name: Fetch PR head and notes + - name: Fetch notes, tags, and PR head run: | - git fetch origin refs/notes/agentblame:refs/notes/agentblame 2>/dev/null || echo "No existing notes" - git fetch origin refs/pull/\${{ github.event.pull_request.number }}/head:refs/pull/\${{ github.event.pull_request.number }}/head + git fetch origin refs/notes/agentblame:refs/notes/agentblame 2>/dev/null || echo "No existing attribution notes" + git fetch origin refs/notes/agentblame-analytics:refs/notes/agentblame-analytics 2>/dev/null || echo "No existing analytics notes" + git fetch origin --tags 2>/dev/null || echo "No tags to fetch" + git fetch origin refs/pull/\${{ github.event.pull_request.number }}/head:refs/pull/\${{ github.event.pull_request.number }}/head 2>/dev/null || echo "Could not fetch PR head" - - name: Transfer notes + - name: Process merge (transfer notes + update analytics) + run: bun \$(npm root -g)/@mesadev/agentblame/dist/post-merge.js env: PR_NUMBER: \${{ github.event.pull_request.number }} PR_TITLE: \${{ github.event.pull_request.title }} + PR_AUTHOR: \${{ github.event.pull_request.user.login }} BASE_REF: \${{ github.event.pull_request.base.ref }} BASE_SHA: \${{ github.event.pull_request.base.sha }} HEAD_SHA: \${{ github.event.pull_request.head.sha }} MERGE_SHA: \${{ github.event.pull_request.merge_commit_sha }} - run: bun \$(npm root -g)/@mesadev/agentblame/dist/transfer-notes.js - - name: Push notes + - name: Push notes and tags run: | - git push origin refs/notes/agentblame 2>/dev/null || echo "No notes to push" + # Push attribution notes + git push origin refs/notes/agentblame 2>/dev/null || echo "No attribution notes to push" + # Push analytics notes + git push origin refs/notes/agentblame-analytics 2>/dev/null || echo "No analytics notes to push" + # Push analytics anchor tag + git push origin agentblame-analytics-anchor 2>/dev/null || echo "No analytics tag to push" `; /** * Install GitHub Actions workflow for handling squash/rebase merges + * Always overwrites to ensure the latest version is installed */ export async function installGitHubAction(repoRoot: string): Promise { const workflowDir = path.join(repoRoot, ".github", "workflows"); const workflowPath = path.join(workflowDir, "agentblame.yml"); try { - // Check if workflow already exists - if (fs.existsSync(workflowPath)) { - const existing = await fs.promises.readFile(workflowPath, "utf8"); - if (existing.includes("Agent Blame") || existing.includes("agentblame")) { - return true; // Already installed - } - } - // Create workflows directory if it doesn't exist await fs.promises.mkdir(workflowDir, { recursive: true }); - // Write the workflow file + // Always write the latest workflow file await fs.promises.writeFile(workflowPath, GITHUB_WORKFLOW_CONTENT, "utf8"); return true; diff --git a/packages/cli/src/lib/types.ts b/packages/cli/src/lib/types.ts index a3c4b80..9be66e4 100644 --- a/packages/cli/src/lib/types.ts +++ b/packages/cli/src/lib/types.ts @@ -11,7 +11,7 @@ /** * AI provider that generated the code */ -export type AiProvider = "cursor" | "claude_code"; +export type AiProvider = "cursor" | "claudeCode"; /** * Attribution category - we only track AI-generated code @@ -39,7 +39,7 @@ export type MatchType = export interface CapturedLine { content: string; hash: string; - hash_normalized: string; + hashNormalized: string; } // ============================================================================= @@ -51,16 +51,16 @@ export interface CapturedLine { */ export interface DiffHunk { path: string; - start_line: number; - end_line: number; + startLine: number; + endLine: number; content: string; - content_hash: string; - content_hash_normalized: string; + contentHash: string; + contentHashNormalized: string; lines: Array<{ - line_number: number; + lineNumber: number; content: string; hash: string; - hash_normalized: string; + hashNormalized: string; }>; } @@ -69,21 +69,21 @@ export interface DiffHunk { */ export interface DeletedBlock { path: string; - start_line: number; + startLine: number; lines: string[]; - normalized_content: string; + normalizedContent: string; } /** * A detected move operation */ export interface MoveMapping { - from_path: string; - from_start_line: number; - to_path: string; - to_start_line: number; - line_count: number; - normalized_content: string; + fromPath: string; + fromStartLine: number; + toPath: string; + toStartLine: number; + lineCount: number; + normalizedContent: string; } // ============================================================================= @@ -99,8 +99,8 @@ export interface LineAttribution { provider: AiProvider; model: string | null; confidence: number; - match_type: MatchType; - content_hash: string; + matchType: MatchType; + contentHash: string; } /** @@ -108,13 +108,13 @@ export interface LineAttribution { */ export interface RangeAttribution { path: string; - start_line: number; - end_line: number; + startLine: number; + endLine: number; provider: AiProvider; model: string | null; confidence: number; - match_type: MatchType; - content_hash: string; + matchType: MatchType; + contentHash: string; } /** @@ -123,8 +123,8 @@ export interface RangeAttribution { export interface MatchResult { sha: string; attributions: RangeAttribution[]; - unmatched_lines: number; - total_lines: number; + unmatchedLines: number; + totalLines: number; } // ============================================================================= @@ -132,21 +132,21 @@ export interface MatchResult { // ============================================================================= /** - * Git notes format for storing attribution + * Git notes format for storing attribution (version 2 with camelCase) */ export interface GitNotesAttribution { - version: 1; + version: 2; timestamp: string; attributions: Array<{ path: string; - start_line: number; - end_line: number; + startLine: number; + endLine: number; category: AttributionCategory; provider: AiProvider; model: string | null; confidence: number; - match_type: MatchType; - content_hash: string; + matchType: MatchType; + contentHash: string; }>; } @@ -165,3 +165,70 @@ export interface GitState { cherryPickHead: string | null; bisectLog: boolean; } + +// ============================================================================= +// Analytics Types (Repository-wide aggregates) +// ============================================================================= + +/** + * Provider breakdown for analytics + */ +export interface ProviderBreakdown { + cursor?: number; + claudeCode?: number; +} + +/** + * Model breakdown for analytics (model name -> line count) + */ +export type ModelBreakdown = Record; + +/** + * Per-contributor analytics + */ +export interface ContributorStats { + totalLines: number; + aiLines: number; + providers: ProviderBreakdown; + models: ModelBreakdown; + prCount: number; +} + +/** + * PR history entry + */ +export interface PRHistoryEntry { + date: string; // ISO date (YYYY-MM-DD) + pr: number; + title?: string; + author: string; + added: number; + removed: number; + aiLines: number; + providers?: ProviderBreakdown; + models?: ModelBreakdown; +} + +/** + * Repository-wide analytics summary + */ +export interface AnalyticsSummary { + totalLines: number; + aiLines: number; + humanLines: number; + providers: ProviderBreakdown; + models: ModelBreakdown; + updated: string; +} + +/** + * Analytics note format (stored as git note on analytics tag) + * Version 2: Analytics with camelCase field names + * Version 1: Attribution notes (per-commit, different format) + */ +export interface AnalyticsNote { + version: 2; + summary: AnalyticsSummary; + contributors: Record; + history: PRHistoryEntry[]; +} diff --git a/packages/cli/src/post-merge.ts b/packages/cli/src/post-merge.ts new file mode 100644 index 0000000..f1e3cf7 --- /dev/null +++ b/packages/cli/src/post-merge.ts @@ -0,0 +1,1002 @@ +#!/usr/bin/env bun +/** + * Agent Blame - Transfer Notes Action + * + * Transfers git notes from PR commits to merge/squash/rebase commits. + * Runs as part of GitHub Actions workflow after PR merge. + * + * Environment variables (set by GitHub Actions): + * PR_NUMBER - The PR number + * PR_TITLE - The PR title + * BASE_REF - Target branch (e.g., main) + * BASE_SHA - Base commit SHA before merge + * HEAD_SHA - Last commit SHA on feature branch + * MERGE_SHA - The merge commit SHA (for merge/squash) + */ + +import { execSync, spawnSync } from "node:child_process"; +import type { + GitNotesAttribution, + AnalyticsNote, + PRHistoryEntry, + ProviderBreakdown, + ModelBreakdown, + ContributorStats, + AiProvider, +} from "./lib"; + +// Get environment variables +const PR_NUMBER = process.env.PR_NUMBER || ""; +const PR_TITLE = process.env.PR_TITLE || ""; +const BASE_SHA = process.env.BASE_SHA || ""; +const HEAD_SHA = process.env.HEAD_SHA || ""; +const MERGE_SHA = process.env.MERGE_SHA || ""; +const PR_AUTHOR = process.env.PR_AUTHOR || "unknown"; + +// Analytics notes ref (separate from attribution notes) +const ANALYTICS_REF = "refs/notes/agentblame-analytics"; +// We store analytics on the repo's first commit (root) +const ANALYTICS_ANCHOR = "agentblame-analytics-anchor"; + +type MergeType = "merge_commit" | "squash" | "rebase"; + +type NoteAttribution = GitNotesAttribution["attributions"][number]; + +function run(cmd: string): string { + try { + return execSync(cmd, { encoding: "utf8" }).trim(); + } catch { + return ""; + } +} + +function log(msg: string): void { + console.log(`[agentblame] ${msg}`); +} + +/** + * Detect what type of merge was performed + */ +function detectMergeType(): MergeType { + // Get the merge commit + const mergeCommit = MERGE_SHA; + if (!mergeCommit) { + log("No merge commit SHA, assuming rebase"); + return "rebase"; + } + + // Check number of parents + const parents = run(`git rev-list --parents -n 1 ${mergeCommit}`).split(" "); + const parentCount = parents.length - 1; // First element is the commit itself + + if (parentCount > 1) { + // Multiple parents = merge commit + log("Detected: Merge commit (multiple parents)"); + return "merge_commit"; + } + + // Single parent - could be squash or rebase + // Check if commit message contains PR number (squash pattern) + const commitMsg = run(`git log -1 --format=%s ${mergeCommit}`); + if (commitMsg.includes(`#${PR_NUMBER}`) || commitMsg.includes(PR_TITLE)) { + log("Detected: Squash merge (single commit with PR reference)"); + return "squash"; + } + + log("Detected: Rebase merge"); + return "rebase"; +} + +/** + * Get all commits that were in the PR (between base and head) + */ +function getPRCommits(): string[] { + // Get commits that were in the feature branch but not in base + const output = run(`git rev-list ${BASE_SHA}..${HEAD_SHA}`); + if (!output) return []; + return output.split("\n").filter(Boolean); +} + +/** + * Read agentblame note from a commit + */ +function readNote(sha: string): GitNotesAttribution | null { + const note = run(`git notes --ref=refs/notes/agentblame show ${sha} 2>/dev/null`); + if (!note) return null; + try { + return JSON.parse(note); + } catch { + return null; + } +} + +/** + * Write agentblame note to a commit + */ +function writeNote(sha: string, attribution: GitNotesAttribution): boolean { + const noteJson = JSON.stringify(attribution); + try { + // Use spawnSync with array args to avoid shell injection + const result = spawnSync( + "git", + ["notes", "--ref=refs/notes/agentblame", "add", "-f", "-m", noteJson, sha], + { encoding: "utf8" }, + ); + if (result.status !== 0) { + log(`Failed to write note to ${sha}: ${result.stderr}`); + return false; + } + return true; + } catch (err) { + log(`Failed to write note to ${sha}: ${err}`); + return false; + } +} + +/** + * Attribution with its original content for containment matching + */ +interface AttributionWithContent extends NoteAttribution { + originalContent: string; +} + +/** + * Collect all attributions from PR commits, including original content + * + * The contentHash in attributions is the hash of the FIRST line in the range. + * We need to find that line in the commit's diff to extract the full content. + */ +function collectPRAttributions(prCommits: string[]): { + byHash: Map; + withContent: AttributionWithContent[]; +} { + const byHash = new Map(); + const withContent: AttributionWithContent[] = []; + + for (const sha of prCommits) { + const note = readNote(sha); + if (!note?.attributions) continue; + + // Get the commit's diff with per-line hashes + const hunks = getCommitHunks(sha); + + // Build a map from per-line contentHash to line data + // Also build a map from path+lineNumber to content for range extraction + const linesByHash = new Map(); + const linesByLocation = new Map(); + + for (const hunk of hunks) { + for (const line of hunk.lines) { + linesByHash.set(line.contentHash, { + path: hunk.path, + lineNumber: line.lineNumber, + content: line.content, + }); + linesByLocation.set(`${hunk.path}:${line.lineNumber}`, line.content); + } + } + + for (const attr of note.attributions) { + const hash = attr.contentHash; + if (!byHash.has(hash)) { + byHash.set(hash, []); + } + byHash.get(hash)?.push(attr); + + // Extract the full content for this attribution range + // The contentHash is for the first line; we need to get all lines in the range + const rangeLines: string[] = []; + for (let lineNum = attr.startLine; lineNum <= attr.endLine; lineNum++) { + const lineContent = linesByLocation.get(`${attr.path}:${lineNum}`); + if (lineContent !== undefined) { + rangeLines.push(lineContent); + } + } + + if (rangeLines.length > 0) { + withContent.push({ ...attr, originalContent: rangeLines.join("\n") }); + } else { + // Fallback: try to find by hash (first line) + const lineData = linesByHash.get(hash); + if (lineData) { + withContent.push({ ...attr, originalContent: lineData.content }); + } + } + } + } + + return { byHash, withContent }; +} + +/** + * Line-level data from a diff + */ +interface DiffLine { + lineNumber: number; + content: string; + contentHash: string; +} + +/** + * Hunk with line-level data + */ +interface DiffHunk { + path: string; + startLine: number; + endLine: number; + content: string; + contentHash: string; + lines: DiffLine[]; +} + +/** + * Get the diff of a commit and extract content with per-line hashes + * This matches the behavior of lib/git/gitDiff.ts parseDiff() + */ +function getCommitHunks(sha: string): DiffHunk[] { + const diff = run(`git diff-tree -p ${sha}`); + if (!diff) return []; + + const hunks: DiffHunk[] = []; + + let currentFile = ""; + let lineNumber = 0; + let hunkLines: DiffLine[] = []; + let startLine = 0; + + for (const line of diff.split("\n")) { + // New file header + if (line.startsWith("+++ b/")) { + // Save previous hunk + if (hunkLines.length > 0 && currentFile) { + const content = hunkLines.map((l) => l.content).join("\n"); + hunks.push({ + path: currentFile, + startLine, + endLine: startLine + hunkLines.length - 1, + content, + contentHash: computeHash(content), + lines: hunkLines, + }); + hunkLines = []; + } + currentFile = line.slice(6); + continue; + } + + // Hunk header + if (line.startsWith("@@")) { + // Save previous hunk + if (hunkLines.length > 0 && currentFile) { + const content = hunkLines.map((l) => l.content).join("\n"); + hunks.push({ + path: currentFile, + startLine, + endLine: startLine + hunkLines.length - 1, + content, + contentHash: computeHash(content), + lines: hunkLines, + }); + hunkLines = []; + } + + // Parse line number: @@ -old,count +new,count @@ + const match = line.match(/@@ -\d+(?:,\d+)? \+(\d+)/); + if (match) { + lineNumber = parseInt(match[1], 10); + startLine = lineNumber; + } + continue; + } + + // Added line + if (line.startsWith("+") && !line.startsWith("+++")) { + if (hunkLines.length === 0) { + startLine = lineNumber; + } + const content = line.slice(1); + hunkLines.push({ + lineNumber, + content, + contentHash: computeHash(content), + }); + lineNumber++; + continue; + } + + // Context or removed line + if (!line.startsWith("-")) { + // Save previous hunk if we hit a non-added line + if (hunkLines.length > 0 && currentFile) { + const content = hunkLines.map((l) => l.content).join("\n"); + hunks.push({ + path: currentFile, + startLine, + endLine: startLine + hunkLines.length - 1, + content, + contentHash: computeHash(content), + lines: hunkLines, + }); + hunkLines = []; + } + lineNumber++; + } + } + + // Save last hunk + if (hunkLines.length > 0 && currentFile) { + const content = hunkLines.map((l) => l.content).join("\n"); + hunks.push({ + path: currentFile, + startLine, + endLine: startLine + hunkLines.length - 1, + content, + contentHash: computeHash(content), + lines: hunkLines, + }); + } + + return hunks; +} + +/** + * Compute SHA256 hash of content + */ +function computeHash(content: string): string { + const crypto = require("node:crypto"); + return `sha256:${crypto.createHash("sha256").update(content).digest("hex")}`; +} + +/** + * Find attributions whose content is contained within the hunk content + * Returns attributions with calculated precise line numbers + */ +function findContainedAttributions( + hunk: { path: string; startLine: number; content: string }, + attributions: AttributionWithContent[], +): NoteAttribution[] { + const results: NoteAttribution[] = []; + + for (const attr of attributions) { + // Check if file paths match + const attrFileName = attr.path.split("/").pop(); + const hunkFileName = hunk.path.split("/").pop(); + const sameFile = + attrFileName === hunkFileName || + attr.path.endsWith(hunk.path) || + hunk.path.endsWith(attrFileName || ""); + + if (!sameFile) continue; + + // Check if AI content is contained in the hunk + const aiContent = attr.originalContent.trim(); + const hunkContent = hunk.content; + + if (!hunkContent.includes(aiContent)) continue; + + // Calculate precise line numbers + const offset = hunkContent.indexOf(aiContent); + let startLine = hunk.startLine; + + if (offset > 0) { + const contentBeforeAI = hunkContent.slice(0, offset); + const linesBeforeAI = contentBeforeAI.split("\n").length - 1; + startLine = hunk.startLine + linesBeforeAI; + } + + const aiLineCount = aiContent.split("\n").length; + const endLine = startLine + aiLineCount - 1; + + // Create clean attribution without originalContent + const { originalContent: _, ...cleanAttr } = attr; + results.push({ + ...cleanAttr, + path: hunk.path, + startLine: startLine, + endLine: endLine, + }); + + log( + ` Contained match: ${hunk.path}:${startLine}-${endLine} (${attr.provider})`, + ); + } + + return results; +} + +/** + * Transfer notes for a squash merge + */ +function handleSquashMerge(prCommits: string[]): void { + log( + `Transferring notes from ${prCommits.length} PR commits to squash commit ${MERGE_SHA}`, + ); + + // Collect all attributions from PR commits + const { byHash, withContent } = collectPRAttributions(prCommits); + if (byHash.size === 0) { + log("No attributions found in PR commits"); + return; + } + + log( + `Found ${byHash.size} unique content hashes, ${withContent.length} with content`, + ); + + // Get hunks from the squash commit (with per-line hashes) + const hunks = getCommitHunks(MERGE_SHA); + log(`Squash commit has ${hunks.length} hunks`); + + // Build a map of per-line hashes in the squash commit + const squashLinesByHash = new Map(); + for (const hunk of hunks) { + for (const line of hunk.lines) { + squashLinesByHash.set(line.contentHash, { + path: hunk.path, + lineNumber: line.lineNumber, + content: line.content, + }); + } + } + + // Match attributions to squash commit + const newAttributions: NoteAttribution[] = []; + const matchedContentHashes = new Set(); + + // First pass: exact line hash matches + for (const [hash, attrs] of byHash) { + const squashLine = squashLinesByHash.get(hash); + if (squashLine && attrs.length > 0) { + const attr = attrs[0]; + // For now, create single-line attribution + // TODO: could try to find consecutive matched lines and merge them + newAttributions.push({ + ...attr, + path: squashLine.path, + startLine: squashLine.lineNumber, + endLine: squashLine.lineNumber, + }); + matchedContentHashes.add(hash); + log( + ` Line hash match: ${squashLine.path}:${squashLine.lineNumber} (${attr.provider})`, + ); + } + } + + // Second pass: containment matching for multi-line attributions + for (const hunk of hunks) { + const unmatchedAttrs = withContent.filter( + (a) => !matchedContentHashes.has(a.contentHash), + ); + if (unmatchedAttrs.length === 0) continue; + + const containedMatches = findContainedAttributions(hunk, unmatchedAttrs); + for (const match of containedMatches) { + newAttributions.push(match); + matchedContentHashes.add(match.contentHash); + } + } + + if (newAttributions.length === 0) { + log("No attributions matched to squash commit"); + return; + } + + // Merge consecutive attributions with same provider + const mergedAttributions = mergeConsecutiveAttributions(newAttributions); + + // Write note to squash commit + const note: GitNotesAttribution = { + version: 2, + timestamp: new Date().toISOString(), + attributions: mergedAttributions, + }; + + if (writeNote(MERGE_SHA, note)) { + log(`✓ Attached ${mergedAttributions.length} attribution(s) to squash commit`); + } +} + +/** + * Merge consecutive attributions with the same provider into ranges + */ +function mergeConsecutiveAttributions(attrs: NoteAttribution[]): NoteAttribution[] { + if (attrs.length === 0) return []; + + // Sort by path, then by startLine + const sorted = [...attrs].sort((a, b) => { + if (a.path !== b.path) return a.path.localeCompare(b.path); + return a.startLine - b.startLine; + }); + + const merged: NoteAttribution[] = []; + let current = { ...sorted[0] }; + + for (let i = 1; i < sorted.length; i++) { + const next = sorted[i]; + // Check if consecutive and same provider + if ( + current.path === next.path && + current.endLine >= next.startLine - 1 && + current.provider === next.provider + ) { + // Merge: extend the range + current.endLine = Math.max(current.endLine, next.endLine); + current.confidence = Math.min(current.confidence, next.confidence); + } else { + merged.push(current); + current = { ...next }; + } + } + merged.push(current); + + return merged; +} + +/** + * Transfer notes for a rebase merge + */ +function handleRebaseMerge(prCommits: string[]): void { + log(`Handling rebase merge: ${prCommits.length} original commits`); + + // Collect all attributions from PR commits + const { byHash, withContent } = collectPRAttributions(prCommits); + if (byHash.size === 0) { + log("No attributions found in PR commits"); + return; + } + + // Find the new commits on target branch after the base + const newCommits = run(`git rev-list ${BASE_SHA}..HEAD`) + .split("\n") + .filter(Boolean); + log(`Found ${newCommits.length} new commits after rebase`); + + let totalTransferred = 0; + + for (const newSha of newCommits) { + const hunks = getCommitHunks(newSha); + const newAttributions: NoteAttribution[] = []; + const matchedContentHashes = new Set(); + + // Build a map of per-line hashes for this commit + const linesByHash = new Map(); + for (const hunk of hunks) { + for (const line of hunk.lines) { + linesByHash.set(line.contentHash, { + path: hunk.path, + lineNumber: line.lineNumber, + }); + } + } + + // First pass: exact line hash matches + for (const [hash, attrs] of byHash) { + const lineInfo = linesByHash.get(hash); + if (lineInfo && attrs.length > 0) { + const attr = attrs[0]; + newAttributions.push({ + ...attr, + path: lineInfo.path, + startLine: lineInfo.lineNumber, + endLine: lineInfo.lineNumber, + }); + matchedContentHashes.add(hash); + } + } + + // Second pass: containment matching + for (const hunk of hunks) { + const unmatchedAttrs = withContent.filter( + (a) => !matchedContentHashes.has(a.contentHash), + ); + if (unmatchedAttrs.length === 0) continue; + + const containedMatches = findContainedAttributions(hunk, unmatchedAttrs); + for (const match of containedMatches) { + newAttributions.push(match); + matchedContentHashes.add(match.contentHash); + } + } + + if (newAttributions.length > 0) { + // Merge consecutive attributions + const merged = mergeConsecutiveAttributions(newAttributions); + const note: GitNotesAttribution = { + version: 2, + timestamp: new Date().toISOString(), + attributions: merged, + }; + if (writeNote(newSha, note)) { + log( + ` ✓ ${newSha.slice(0, 7)}: ${merged.length} attribution(s)`, + ); + totalTransferred += merged.length; + } + } + } + + log( + `✓ Transferred ${totalTransferred} attribution(s) across ${newCommits.length} commits`, + ); +} + +// ============================================================================= +// Analytics Aggregation +// ============================================================================= + +/** + * Get the root commit SHA (first commit in repo) + */ +function getRootCommit(): string { + return run("git rev-list --max-parents=0 HEAD").split("\n")[0] || ""; +} + +/** + * Get or create the analytics anchor tag + * Returns the SHA the tag points to (root commit) + */ +function getOrCreateAnalyticsAnchor(): string { + // Check if tag exists + const existingTag = run(`git rev-parse ${ANALYTICS_ANCHOR} 2>/dev/null`); + if (existingTag) { + return existingTag; + } + + // Create tag on root commit + const rootSha = getRootCommit(); + if (!rootSha) { + log("Warning: Could not find root commit for analytics anchor"); + return ""; + } + + const result = spawnSync( + "git", + ["tag", ANALYTICS_ANCHOR, rootSha], + { encoding: "utf8" }, + ); + + if (result.status !== 0) { + log(`Warning: Could not create analytics anchor tag: ${result.stderr}`); + return ""; + } + + log(`Created analytics anchor tag at ${rootSha.slice(0, 7)}`); + return rootSha; +} + +/** + * Read existing analytics note + */ +function readAnalyticsNote(): AnalyticsNote | null { + const anchorSha = getOrCreateAnalyticsAnchor(); + if (!anchorSha) return null; + + const note = run( + `git notes --ref=${ANALYTICS_REF} show ${anchorSha} 2>/dev/null`, + ); + if (!note) return null; + + try { + const parsed = JSON.parse(note); + if (parsed.version === 2) { + return parsed as AnalyticsNote; + } + return null; + } catch { + return null; + } +} + +/** + * Write analytics note + */ +function writeAnalyticsNote(analytics: AnalyticsNote): boolean { + const anchorSha = getOrCreateAnalyticsAnchor(); + if (!anchorSha) return false; + + const noteJson = JSON.stringify(analytics); + const result = spawnSync( + "git", + ["notes", `--ref=${ANALYTICS_REF}`, "add", "-f", "-m", noteJson, anchorSha], + { encoding: "utf8" }, + ); + + if (result.status !== 0) { + log(`Failed to write analytics note: ${result.stderr}`); + return false; + } + + return true; +} + +/** + * Get PR diff stats (additions/deletions) + */ +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/); + + return { + additions: addMatch ? parseInt(addMatch[1], 10) : 0, + deletions: delMatch ? parseInt(delMatch[1], 10) : 0, + }; +} + +/** + * Aggregate PR statistics from attribution notes + */ +function aggregatePRStats( + attributions: NoteAttribution[], +): { + aiLines: number; + byProvider: ProviderBreakdown; + byModel: ModelBreakdown; +} { + let aiLines = 0; + const byProvider: ProviderBreakdown = {}; + const byModel: ModelBreakdown = {}; + + for (const attr of attributions) { + const lineCount = attr.endLine - attr.startLine + 1; + aiLines += lineCount; + + // Aggregate by provider + const provider = attr.provider as AiProvider; + byProvider[provider] = (byProvider[provider] || 0) + lineCount; + + // Aggregate by model + if (attr.model) { + byModel[attr.model] = (byModel[attr.model] || 0) + lineCount; + } + } + + return { aiLines, byProvider, byModel }; +} + +/** + * Merge provider breakdowns + */ +function mergeProviders( + a: ProviderBreakdown, + b: ProviderBreakdown, +): ProviderBreakdown { + const result: ProviderBreakdown = { ...a }; + for (const [key, value] of Object.entries(b)) { + const k = key as keyof ProviderBreakdown; + result[k] = (result[k] || 0) + (value || 0); + } + return result; +} + +/** + * Merge model breakdowns + */ +function mergeModels(a: ModelBreakdown, b: ModelBreakdown): ModelBreakdown { + const result: ModelBreakdown = { ...a }; + for (const [key, value] of Object.entries(b)) { + result[key] = (result[key] || 0) + value; + } + return result; +} + +/** + * Update analytics with current PR data + */ +function updateAnalytics( + existing: AnalyticsNote | null, + prAttributions: NoteAttribution[], +): AnalyticsNote { + const prStats = aggregatePRStats(prAttributions); + const diffStats = getPRDiffStats(); + const now = new Date().toISOString(); + const today = now.split("T")[0]; + + // Create history entry for this PR + const historyEntry: PRHistoryEntry = { + date: today, + pr: parseInt(PR_NUMBER, 10) || 0, + title: PR_TITLE.slice(0, 100), // Truncate long titles + author: PR_AUTHOR, + added: diffStats.additions, + removed: diffStats.deletions, + aiLines: prStats.aiLines, + providers: Object.keys(prStats.byProvider).length > 0 ? prStats.byProvider : undefined, + models: Object.keys(prStats.byModel).length > 0 ? prStats.byModel : undefined, + }; + + if (existing) { + // Update existing analytics + const newSummary = { + totalLines: existing.summary.totalLines + diffStats.additions, + aiLines: existing.summary.aiLines + prStats.aiLines, + humanLines: + existing.summary.humanLines + + (diffStats.additions - prStats.aiLines), + providers: mergeProviders( + existing.summary.providers, + prStats.byProvider, + ), + models: mergeModels(existing.summary.models, prStats.byModel), + updated: now, + }; + + // Update contributor stats + const contributors = { ...existing.contributors }; + if (!contributors[PR_AUTHOR]) { + contributors[PR_AUTHOR] = { + totalLines: 0, + aiLines: 0, + providers: {}, + models: {}, + prCount: 0, + }; + } + const authorStats = contributors[PR_AUTHOR]; + authorStats.totalLines += diffStats.additions; + authorStats.aiLines += prStats.aiLines; + authorStats.providers = mergeProviders( + authorStats.providers, + prStats.byProvider, + ); + authorStats.models = mergeModels(authorStats.models, prStats.byModel); + authorStats.prCount += 1; + + // Add to history (keep last 100 PRs) + const history = [historyEntry, ...existing.history].slice(0, 100); + + return { + version: 2, + summary: newSummary, + contributors, + history, + }; + } + + // Create new analytics + const contributors: Record = { + [PR_AUTHOR]: { + totalLines: diffStats.additions, + aiLines: prStats.aiLines, + providers: prStats.byProvider, + models: prStats.byModel, + prCount: 1, + }, + }; + + return { + version: 2, + summary: { + totalLines: diffStats.additions, + aiLines: prStats.aiLines, + humanLines: diffStats.additions - prStats.aiLines, + providers: prStats.byProvider, + models: prStats.byModel, + updated: now, + }, + contributors, + history: [historyEntry], + }; +} + +/** + * Collect all attributions from the merge result + */ +function collectMergeAttributions(mergeType: MergeType): NoteAttribution[] { + if (mergeType === "merge_commit") { + // For merge commits, notes survive on original commits + // Collect from all PR commits + const prCommits = getPRCommits(); + const allAttributions: NoteAttribution[] = []; + for (const sha of prCommits) { + const note = readNote(sha); + if (note?.attributions) { + allAttributions.push(...note.attributions); + } + } + return allAttributions; + } + + // For squash/rebase, read from the merge commit(s) + if (mergeType === "squash" && MERGE_SHA) { + const note = readNote(MERGE_SHA); + return note?.attributions || []; + } + + if (mergeType === "rebase") { + // Collect from all new commits after rebase + const newCommits = run(`git rev-list ${BASE_SHA}..HEAD`) + .split("\n") + .filter(Boolean); + const allAttributions: NoteAttribution[] = []; + for (const sha of newCommits) { + const note = readNote(sha); + if (note?.attributions) { + allAttributions.push(...note.attributions); + } + } + return allAttributions; + } + + return []; +} + +/** + * Update repository analytics after PR merge + */ +function updateRepositoryAnalytics(mergeType: MergeType): void { + log("Updating repository analytics..."); + + // Collect all attributions from this PR + const attributions = collectMergeAttributions(mergeType); + log(`Collected ${attributions.length} attributions from PR`); + + // Read existing analytics + const existing = readAnalyticsNote(); + if (existing) { + log( + `Found existing analytics: ${existing.history.length} PRs, ${existing.summary.totalLines} total lines`, + ); + } else { + log("No existing analytics found, creating new"); + } + + // Update analytics + const updated = updateAnalytics(existing, attributions); + + // Write updated analytics + if (writeAnalyticsNote(updated)) { + log( + `✓ Updated analytics: ${updated.summary.aiLines}/${updated.summary.totalLines} AI lines (${Math.round((updated.summary.aiLines / updated.summary.totalLines) * 100)}%)`, + ); + } +} + +/** + * Main entry point + */ +async function main(): Promise { + log("Agent Blame - Transfer Notes"); + log(`PR #${PR_NUMBER}: ${PR_TITLE}`); + log( + `Base: ${BASE_SHA.slice(0, 7)}, Head: ${HEAD_SHA.slice(0, 7)}, Merge: ${MERGE_SHA.slice(0, 7)}`, + ); + + // Detect merge type + const mergeType = detectMergeType(); + + if (mergeType === "merge_commit") { + log("Merge commit detected - notes survive automatically on original commits"); + // Still update analytics for merge commits + updateRepositoryAnalytics(mergeType); + log("Done"); + return; + } + + // Get PR commits + const prCommits = getPRCommits(); + if (prCommits.length === 0) { + log("No PR commits found"); + return; + } + + log(`Found ${prCommits.length} commits in PR`); + + if (mergeType === "squash") { + handleSquashMerge(prCommits); + } else if (mergeType === "rebase") { + handleRebaseMerge(prCommits); + } + + // Update repository analytics (runs for all merge types) + updateRepositoryAnalytics(mergeType); + + log("Done"); +} + +main().catch((err) => { + console.error("[agentblame] Error:", err); + process.exit(1); +}); diff --git a/packages/cli/src/process.ts b/packages/cli/src/process.ts index 49352c7..10b8413 100644 --- a/packages/cli/src/process.ts +++ b/packages/cli/src/process.ts @@ -11,6 +11,7 @@ import { getCommitMoves, buildMoveIndex, attachNote, + fetchNotesQuiet, getAgentBlameDirForRepo, type RangeAttribution, type LineAttribution, @@ -32,7 +33,7 @@ const c = { cyan: "\x1b[36m", yellow: "\x1b[33m", green: "\x1b[32m", - magenta: "\x1b[35m", + orange: "\x1b[38;5;166m", // Mesa Orange - matches gutter color blue: "\x1b[34m", }; @@ -78,11 +79,11 @@ function mergeConsecutiveLines(lines: LineAttribution[]): RangeAttribution[] { if ( currentRange && currentRange.path === line.path && - currentRange.end_line === line.line - 1 && + currentRange.endLine === line.line - 1 && currentRange.provider === line.provider && - currentRange.match_type === line.match_type + currentRange.matchType === line.matchType ) { - currentRange.end_line = line.line; + currentRange.endLine = line.line; currentRange.confidence = Math.min(currentRange.confidence, line.confidence); } else { if (currentRange) { @@ -90,13 +91,13 @@ function mergeConsecutiveLines(lines: LineAttribution[]): RangeAttribution[] { } currentRange = { path: line.path, - start_line: line.line, - end_line: line.line, + startLine: line.line, + endLine: line.line, provider: line.provider, model: line.model, confidence: line.confidence, - match_type: line.match_type, - content_hash: line.content_hash, + matchType: line.matchType, + contentHash: line.contentHash, }; } } @@ -137,13 +138,13 @@ async function matchCommit( let match = findLineMatch( line.content, line.hash, - line.hash_normalized, + line.hashNormalized, hunk.path ); // If no direct match, check for moved code if (!match) { - const moveKey = `${hunk.path}:${line.line_number}`; + const moveKey = `${hunk.path}:${line.lineNumber}`; const moveInfo = moveIndex.get(moveKey); if (moveInfo) { @@ -152,12 +153,12 @@ async function matchCommit( if (originalMatch) { lineAttributions.push({ path: hunk.path, - line: line.line_number, - provider: originalMatch.provider as "cursor" | "claude_code", + line: line.lineNumber, + provider: originalMatch.provider as "cursor" | "claudeCode", model: originalMatch.model, confidence: 0.85, - match_type: "move_detected", - content_hash: line.hash, + matchType: "move_detected", + contentHash: line.hash, }); // Track for marking as matched @@ -172,12 +173,12 @@ async function matchCommit( if (match) { lineAttributions.push({ path: hunk.path, - line: line.line_number, + line: line.lineNumber, provider: match.edit.provider, model: match.edit.model, confidence: match.confidence, - match_type: match.matchType, - content_hash: line.hash, + matchType: match.matchType, + contentHash: line.hash, }); // Track edit ID for marking as matched @@ -200,8 +201,8 @@ async function matchCommit( return { sha, attributions: rangeAttributions, - unmatched_lines: unmatchedLines, - total_lines: totalLines, + unmatchedLines, + totalLines, }; } @@ -236,6 +237,9 @@ export async function runProcess(sha?: string): Promise { const agentblameDir = getAgentBlameDirForRepo(repoRoot); setAgentBlameDir(agentblameDir); + // Fetch remote notes first to avoid push conflicts + await fetchNotesQuiet(repoRoot); + // Always resolve to actual SHA (not HEAD) let commitSha = sha || "HEAD"; const resolveResult = await runGit(repoRoot, ["rev-parse", commitSha]); @@ -248,9 +252,9 @@ export async function runProcess(sha?: string): Promise { const result = await processCommit(repoRoot, commitSha); // Calculate stats - const aiLines = result.total_lines - result.unmatched_lines; - const humanLines = result.unmatched_lines; - const aiPercent = result.total_lines > 0 ? Math.round((aiLines / result.total_lines) * 100) : 0; + const aiLines = result.totalLines - result.unmatchedLines; + const humanLines = result.unmatchedLines; + const aiPercent = result.totalLines > 0 ? Math.round((aiLines / result.totalLines) * 100) : 0; const humanPercent = 100 - aiPercent; const WIDTH = 72; @@ -288,8 +292,8 @@ export async function runProcess(sha?: string): Promise { const provider = attr.provider === "cursor" ? "Cursor" : "Claude"; const model = attr.model && attr.model !== "claude" ? attr.model : ""; const modelStr = model ? ` - ${model}` : ""; - const visibleText = ` ${attr.path}:${attr.start_line}-${attr.end_line} [${provider}${modelStr}]`; - const coloredText = ` ${c.blue}${attr.path}:${attr.start_line}-${attr.end_line}${c.reset} ${c.magenta}[${provider}${modelStr}]${c.reset}`; + const visibleText = ` ${attr.path}:${attr.startLine}-${attr.endLine} [${provider}${modelStr}]`; + const coloredText = ` ${c.blue}${attr.path}:${attr.startLine}-${attr.endLine}${c.reset} ${c.orange}[${provider}${modelStr}]${c.reset}`; console.log(`${border}${padRight(coloredText, visibleText.length)}${border}`); } console.log(`${c.dim}├${"─".repeat(WIDTH - 2)}┤${c.reset}`); @@ -304,11 +308,11 @@ export async function runProcess(sha?: string): Promise { console.log(`${border}${padRight(summaryHeader, summaryHeader.length)}${border}`); const barVisible = ` ${"█".repeat(aiBarWidth)}${"░".repeat(humanBarWidth)}`; - const barColored = ` ${c.magenta}${"█".repeat(aiBarWidth)}${c.reset}${c.dim}${"░".repeat(humanBarWidth)}${c.reset}`; + const barColored = ` ${c.orange}${"█".repeat(aiBarWidth)}${c.reset}${c.dim}${"░".repeat(humanBarWidth)}${c.reset}`; console.log(`${border}${padRight(barColored, barVisible.length)}${border}`); const statsVisible = ` AI: ${String(aiLines).padStart(3)} lines (${String(aiPercent).padStart(3)}%) Human: ${String(humanLines).padStart(3)} lines (${String(humanPercent).padStart(3)}%)`; - const statsColored = ` ${c.magenta}AI: ${String(aiLines).padStart(3)} lines (${String(aiPercent).padStart(3)}%)${c.reset} ${c.green}Human: ${String(humanLines).padStart(3)} lines (${String(humanPercent).padStart(3)}%)${c.reset}`; + const statsColored = ` ${c.orange}AI: ${String(aiLines).padStart(3)} lines (${String(aiPercent).padStart(3)}%)${c.reset} ${c.green}Human: ${String(humanLines).padStart(3)} lines (${String(humanPercent).padStart(3)}%)${c.reset}`; console.log(`${border}${padRight(statsColored, statsVisible.length)}${border}`); console.log(`${c.dim}└${"─".repeat(WIDTH - 2)}┘${c.reset}`); diff --git a/packages/cli/src/sync.ts b/packages/cli/src/sync.ts index 54c3367..af7b907 100644 --- a/packages/cli/src/sync.ts +++ b/packages/cli/src/sync.ts @@ -304,7 +304,7 @@ function collectPRAttributions( } for (const attr of note.attributions) { - const hash = attr.content_hash; + const hash = attr.contentHash; if (!byHash.has(hash)) { byHash.set(hash, []); } @@ -360,8 +360,8 @@ function findContainedAttributions( results.push({ ...cleanAttr, path: hunk.path, - start_line: startLine, - end_line: endLine, + startLine: startLine, + endLine: endLine, }); } @@ -400,25 +400,25 @@ function transferNotes( newAttributions.push({ ...attr, path: hunk.path, - start_line: hunk.startLine, - end_line: hunk.startLine + hunk.content.split("\n").length - 1, + startLine: hunk.startLine, + endLine: hunk.startLine + hunk.content.split("\n").length - 1, }); - matchedHashes.add(attr.content_hash); + matchedHashes.add(attr.contentHash); vlog(` Exact match: ${hunk.path}:${hunk.startLine}`, options); continue; } // Fallback: containment matching const unmatchedAttrs = withContent.filter( - (a) => !matchedHashes.has(a.content_hash), + (a) => !matchedHashes.has(a.contentHash), ); const containedMatches = findContainedAttributions(hunk, unmatchedAttrs); for (const match of containedMatches) { newAttributions.push(match); - matchedHashes.add(match.content_hash); + matchedHashes.add(match.contentHash); vlog( - ` Contained match: ${match.path}:${match.start_line}-${match.end_line}`, + ` Contained match: ${match.path}:${match.startLine}-${match.endLine}`, options, ); } @@ -434,7 +434,7 @@ function transferNotes( } const note: GitNotesAttribution = { - version: 1, + version: 2, timestamp: new Date().toISOString(), attributions: newAttributions, }; diff --git a/packages/cli/src/transfer-notes.ts b/packages/cli/src/transfer-notes.ts deleted file mode 100644 index 2bfb95c..0000000 --- a/packages/cli/src/transfer-notes.ts +++ /dev/null @@ -1,535 +0,0 @@ -#!/usr/bin/env bun -/** - * Agent Blame - Transfer Notes Action - * - * Transfers git notes from PR commits to merge/squash/rebase commits. - * Runs as part of GitHub Actions workflow after PR merge. - * - * Environment variables (set by GitHub Actions): - * PR_NUMBER - The PR number - * PR_TITLE - The PR title - * BASE_REF - Target branch (e.g., main) - * BASE_SHA - Base commit SHA before merge - * HEAD_SHA - Last commit SHA on feature branch - * MERGE_SHA - The merge commit SHA (for merge/squash) - */ - -import { execSync, spawnSync } from "node:child_process"; -import type { GitNotesAttribution } from "./lib"; - -// Get environment variables -const PR_NUMBER = process.env.PR_NUMBER || ""; -const PR_TITLE = process.env.PR_TITLE || ""; -const BASE_SHA = process.env.BASE_SHA || ""; -const HEAD_SHA = process.env.HEAD_SHA || ""; -const MERGE_SHA = process.env.MERGE_SHA || ""; - -type MergeType = "merge_commit" | "squash" | "rebase"; - -type NoteAttribution = GitNotesAttribution["attributions"][number]; - -function run(cmd: string): string { - try { - return execSync(cmd, { encoding: "utf8" }).trim(); - } catch { - return ""; - } -} - -function log(msg: string): void { - console.log(`[agentblame] ${msg}`); -} - -/** - * Detect what type of merge was performed - */ -function detectMergeType(): MergeType { - // Get the merge commit - const mergeCommit = MERGE_SHA; - if (!mergeCommit) { - log("No merge commit SHA, assuming rebase"); - return "rebase"; - } - - // Check number of parents - const parents = run(`git rev-list --parents -n 1 ${mergeCommit}`).split(" "); - const parentCount = parents.length - 1; // First element is the commit itself - - if (parentCount > 1) { - // Multiple parents = merge commit - log("Detected: Merge commit (multiple parents)"); - return "merge_commit"; - } - - // Single parent - could be squash or rebase - // Check if commit message contains PR number (squash pattern) - const commitMsg = run(`git log -1 --format=%s ${mergeCommit}`); - if (commitMsg.includes(`#${PR_NUMBER}`) || commitMsg.includes(PR_TITLE)) { - log("Detected: Squash merge (single commit with PR reference)"); - return "squash"; - } - - log("Detected: Rebase merge"); - return "rebase"; -} - -/** - * Get all commits that were in the PR (between base and head) - */ -function getPRCommits(): string[] { - // Get commits that were in the feature branch but not in base - const output = run(`git rev-list ${BASE_SHA}..${HEAD_SHA}`); - if (!output) return []; - return output.split("\n").filter(Boolean); -} - -/** - * Read agentblame note from a commit - */ -function readNote(sha: string): GitNotesAttribution | null { - const note = run(`git notes --ref=refs/notes/agentblame show ${sha} 2>/dev/null`); - if (!note) return null; - try { - return JSON.parse(note); - } catch { - return null; - } -} - -/** - * Write agentblame note to a commit - */ -function writeNote(sha: string, attribution: GitNotesAttribution): boolean { - const noteJson = JSON.stringify(attribution); - try { - // Use spawnSync with array args to avoid shell injection - const result = spawnSync( - "git", - ["notes", "--ref=refs/notes/agentblame", "add", "-f", "-m", noteJson, sha], - { encoding: "utf8" }, - ); - if (result.status !== 0) { - log(`Failed to write note to ${sha}: ${result.stderr}`); - return false; - } - return true; - } catch (err) { - log(`Failed to write note to ${sha}: ${err}`); - return false; - } -} - -/** - * Attribution with its original content for containment matching - */ -interface AttributionWithContent extends NoteAttribution { - originalContent: string; -} - -/** - * Collect all attributions from PR commits, including original content - */ -function collectPRAttributions(prCommits: string[]): { - byHash: Map; - withContent: AttributionWithContent[]; -} { - const byHash = new Map(); - const withContent: AttributionWithContent[] = []; - - for (const sha of prCommits) { - const note = readNote(sha); - if (!note?.attributions) continue; - - // Get the commit's diff to extract original content - const hunks = getCommitHunks(sha); - const hunksByHash = new Map(); - for (const hunk of hunks) { - hunksByHash.set(hunk.contentHash, hunk.content); - } - - for (const attr of note.attributions) { - const hash = attr.content_hash; - if (!byHash.has(hash)) { - byHash.set(hash, []); - } - byHash.get(hash)?.push(attr); - - // Store with original content for containment matching - const content = hunksByHash.get(hash) || ""; - if (content) { - withContent.push({ ...attr, originalContent: content }); - } - } - } - - return { byHash, withContent }; -} - -/** - * Get the diff of a commit and extract content hashes - */ -function getCommitHunks(sha: string): Array<{ - path: string; - startLine: number; - endLine: number; - content: string; - contentHash: string; -}> { - const diff = run(`git diff-tree -p ${sha}`); - if (!diff) return []; - - const hunks: Array<{ - path: string; - startLine: number; - endLine: number; - content: string; - contentHash: string; - }> = []; - - let currentFile = ""; - let lineNumber = 0; - let addedLines: string[] = []; - let startLine = 0; - - for (const line of diff.split("\n")) { - // New file header - if (line.startsWith("+++ b/")) { - // Save previous hunk - if (addedLines.length > 0 && currentFile) { - const content = addedLines.join("\n"); - const hash = computeHash(content); - hunks.push({ - path: currentFile, - startLine, - endLine: startLine + addedLines.length - 1, - content, - contentHash: hash, - }); - addedLines = []; - } - currentFile = line.slice(6); - continue; - } - - // Hunk header - if (line.startsWith("@@")) { - // Save previous hunk - if (addedLines.length > 0 && currentFile) { - const content = addedLines.join("\n"); - const hash = computeHash(content); - hunks.push({ - path: currentFile, - startLine, - endLine: startLine + addedLines.length - 1, - content, - contentHash: hash, - }); - addedLines = []; - } - - // Parse line number: @@ -old,count +new,count @@ - const match = line.match(/@@ -\d+(?:,\d+)? \+(\d+)/); - if (match) { - lineNumber = parseInt(match[1], 10); - startLine = lineNumber; - } - continue; - } - - // Added line - if (line.startsWith("+") && !line.startsWith("+++")) { - if (addedLines.length === 0) { - startLine = lineNumber; - } - addedLines.push(line.slice(1)); - lineNumber++; - continue; - } - - // Context or removed line - if (!line.startsWith("-")) { - // Save previous hunk if we hit a non-added line - if (addedLines.length > 0 && currentFile) { - const content = addedLines.join("\n"); - const hash = computeHash(content); - hunks.push({ - path: currentFile, - startLine, - endLine: startLine + addedLines.length - 1, - content, - contentHash: hash, - }); - addedLines = []; - } - lineNumber++; - } - } - - // Save last hunk - if (addedLines.length > 0 && currentFile) { - const content = addedLines.join("\n"); - const hash = computeHash(content); - hunks.push({ - path: currentFile, - startLine, - endLine: startLine + addedLines.length - 1, - content, - contentHash: hash, - }); - } - - return hunks; -} - -/** - * Compute SHA256 hash of content - */ -function computeHash(content: string): string { - const crypto = require("node:crypto"); - return `sha256:${crypto.createHash("sha256").update(content).digest("hex")}`; -} - -/** - * Find attributions whose content is contained within the hunk content - * Returns attributions with calculated precise line numbers - */ -function findContainedAttributions( - hunk: { path: string; startLine: number; content: string }, - attributions: AttributionWithContent[], -): NoteAttribution[] { - const results: NoteAttribution[] = []; - - for (const attr of attributions) { - // Check if file paths match - const attrFileName = attr.path.split("/").pop(); - const hunkFileName = hunk.path.split("/").pop(); - const sameFile = - attrFileName === hunkFileName || - attr.path.endsWith(hunk.path) || - hunk.path.endsWith(attrFileName || ""); - - if (!sameFile) continue; - - // Check if AI content is contained in the hunk - const aiContent = attr.originalContent.trim(); - const hunkContent = hunk.content; - - if (!hunkContent.includes(aiContent)) continue; - - // Calculate precise line numbers - const offset = hunkContent.indexOf(aiContent); - let startLine = hunk.startLine; - - if (offset > 0) { - const contentBeforeAI = hunkContent.slice(0, offset); - const linesBeforeAI = contentBeforeAI.split("\n").length - 1; - startLine = hunk.startLine + linesBeforeAI; - } - - const aiLineCount = aiContent.split("\n").length; - const endLine = startLine + aiLineCount - 1; - - // Create clean attribution without originalContent - const { originalContent: _, ...cleanAttr } = attr; - results.push({ - ...cleanAttr, - path: hunk.path, - start_line: startLine, - end_line: endLine, - }); - - log( - ` Contained match: ${hunk.path}:${startLine}-${endLine} (${attr.provider})`, - ); - } - - return results; -} - -/** - * Transfer notes for a squash merge - */ -function handleSquashMerge(prCommits: string[]): void { - log( - `Transferring notes from ${prCommits.length} PR commits to squash commit ${MERGE_SHA}`, - ); - - // Collect all attributions from PR commits - const { byHash, withContent } = collectPRAttributions(prCommits); - if (byHash.size === 0) { - log("No attributions found in PR commits"); - return; - } - - log( - `Found ${byHash.size} unique content hashes, ${withContent.length} with content`, - ); - - // Get hunks from the squash commit - const hunks = getCommitHunks(MERGE_SHA); - log(`Squash commit has ${hunks.length} hunks`); - - // Match attributions to hunks - const newAttributions: NoteAttribution[] = []; - const matchedContentHashes = new Set(); - - for (const hunk of hunks) { - // First try exact hash match - const attrs = byHash.get(hunk.contentHash); - if (attrs && attrs.length > 0) { - const attr = attrs[0]; - newAttributions.push({ - ...attr, - path: hunk.path, - start_line: hunk.startLine, - end_line: hunk.endLine, - }); - matchedContentHashes.add(attr.content_hash); - log( - ` Exact match: ${hunk.path}:${hunk.startLine}-${hunk.endLine} (${attr.provider})`, - ); - continue; - } - - // Fallback: check if any AI content is contained within this hunk - const unmatchedAttrs = withContent.filter( - (a) => !matchedContentHashes.has(a.content_hash), - ); - const containedMatches = findContainedAttributions(hunk, unmatchedAttrs); - - for (const match of containedMatches) { - newAttributions.push(match); - matchedContentHashes.add(match.content_hash); - } - } - - if (newAttributions.length === 0) { - log("No attributions matched to squash commit"); - return; - } - - // Write note to squash commit - const note: GitNotesAttribution = { - version: 1, - timestamp: new Date().toISOString(), - attributions: newAttributions, - }; - - if (writeNote(MERGE_SHA, note)) { - log(`✓ Attached ${newAttributions.length} attribution(s) to squash commit`); - } -} - -/** - * Transfer notes for a rebase merge - */ -function handleRebaseMerge(prCommits: string[]): void { - log(`Handling rebase merge: ${prCommits.length} original commits`); - - // Collect all attributions from PR commits - const { byHash, withContent } = collectPRAttributions(prCommits); - if (byHash.size === 0) { - log("No attributions found in PR commits"); - return; - } - - // Find the new commits on target branch after the base - const newCommits = run(`git rev-list ${BASE_SHA}..HEAD`) - .split("\n") - .filter(Boolean); - log(`Found ${newCommits.length} new commits after rebase`); - - let totalTransferred = 0; - - for (const newSha of newCommits) { - const hunks = getCommitHunks(newSha); - const newAttributions: NoteAttribution[] = []; - const matchedContentHashes = new Set(); - - for (const hunk of hunks) { - // First try exact hash match - const attrs = byHash.get(hunk.contentHash); - if (attrs && attrs.length > 0) { - const attr = attrs[0]; - newAttributions.push({ - ...attr, - path: hunk.path, - start_line: hunk.startLine, - end_line: hunk.endLine, - }); - matchedContentHashes.add(attr.content_hash); - continue; - } - - // Fallback: containment matching - const unmatchedAttrs = withContent.filter( - (a) => !matchedContentHashes.has(a.content_hash), - ); - const containedMatches = findContainedAttributions(hunk, unmatchedAttrs); - - for (const match of containedMatches) { - newAttributions.push(match); - matchedContentHashes.add(match.content_hash); - } - } - - if (newAttributions.length > 0) { - const note: GitNotesAttribution = { - version: 1, - timestamp: new Date().toISOString(), - attributions: newAttributions, - }; - if (writeNote(newSha, note)) { - log( - ` ✓ ${newSha.slice(0, 7)}: ${newAttributions.length} attribution(s)`, - ); - totalTransferred += newAttributions.length; - } - } - } - - log( - `✓ Transferred ${totalTransferred} attribution(s) across ${newCommits.length} commits`, - ); -} - -/** - * Main entry point - */ -async function main(): Promise { - log("Agent Blame - Transfer Notes"); - log(`PR #${PR_NUMBER}: ${PR_TITLE}`); - log( - `Base: ${BASE_SHA.slice(0, 7)}, Head: ${HEAD_SHA.slice(0, 7)}, Merge: ${MERGE_SHA.slice(0, 7)}`, - ); - - // Detect merge type - const mergeType = detectMergeType(); - - if (mergeType === "merge_commit") { - log("Merge commit detected - notes survive automatically, nothing to do"); - return; - } - - // Get PR commits - const prCommits = getPRCommits(); - if (prCommits.length === 0) { - log("No PR commits found"); - return; - } - - log(`Found ${prCommits.length} commits in PR`); - - if (mergeType === "squash") { - handleSquashMerge(prCommits); - } else if (mergeType === "rebase") { - handleRebaseMerge(prCommits); - } - - log("Done"); -} - -main().catch((err) => { - console.error("[agentblame] Error:", err); - process.exit(1); -});