diff --git a/README.md b/README.md index 576f8ec..a4e89d5 100644 --- a/README.md +++ b/README.md @@ -226,6 +226,21 @@ This hook will: - Show success/failure messages - Continue with the regular push even if notes fail to push +## Chrome Extension + +A Chrome extension is available to view git notes directly on GitHub commit pages. See [`chrome-extension/`](chrome-extension/) for details. + +### Features +- 🔢 Shows badge indicator when commits have notes +- 📝 Displays notes in a popup when clicked +- 🏷️ Supports multiple notes refs with tabs +- ⚡ Works with any public repository + +### Installation +1. Load the extension from `chrome-extension/` directory +2. Navigate to any GitHub commit page +3. Click the extension icon to view notes + --- **⚠️ Security Notice**: cnotes executes as a Claude Code hook handler. Ensure your installation is secure and trusted. diff --git a/chrome-extension/README.md b/chrome-extension/README.md new file mode 100644 index 0000000..af3a525 --- /dev/null +++ b/chrome-extension/README.md @@ -0,0 +1,128 @@ +# GitHub Git Notes Viewer + +A Chrome extension that displays git notes on GitHub commit pages via a popup interface. Click the extension icon while viewing a GitHub commit to see all available git notes. + +## Features + +- 🔍 Works on any GitHub commit page - just click the extension icon +- 🔢 Shows badge with note count on commits that have notes +- 📝 Displays all available git notes refs for a commit +- 🏷️ Supports multiple notes refs with tabbed interface +- 🎨 Clean popup UI with GitHub-style design +- ⚡ Caches results to minimize API calls +- 🚫 No authentication required (public repos only) + +## Installation + +### From Source (Development) + +1. Clone this repository: + ```bash + git clone https://github.com/imjasonh/chrome-github-notes.git + cd chrome-github-notes + ``` + +2. Generate icon files from the SVG (optional): + ```bash + # Using ImageMagick or similar tool + convert icons/icon.svg -resize 16x16 icons/icon-16.png + convert icons/icon.svg -resize 48x48 icons/icon-48.png + convert icons/icon.svg -resize 128x128 icons/icon-128.png + ``` + +3. Load the extension in Chrome: + - Open Chrome and navigate to `chrome://extensions` + - Enable "Developer mode" in the top right + - Click "Load unpacked" + - Select the `chrome-github-notes` directory + +## Usage + +1. Navigate to any GitHub commit page (e.g., `https://github.com/owner/repo/commit/abc123`) +2. If the commit has notes, you'll see a blue badge on the extension icon showing the count +3. Click the extension icon to view the notes +4. Click on tabs to switch between different notes refs if multiple exist + +The extension automatically checks for notes when you visit GitHub commit pages and displays a badge indicator when notes are found. + +## How It Works + +The extension uses the GitHub API to fetch git notes: + +1. Detects when you're on a GitHub commit page +2. Extracts the repository and commit information from the URL +3. Makes API calls to fetch available notes refs +4. For each ref, fetches the notes content for the specific commit +5. Displays the notes in a formatted view + +## API Rate Limits + +- **Unauthenticated**: 60 requests per hour per IP address +- The extension caches results for 5 minutes to minimize API usage +- Rate limit errors are displayed clearly to the user + +## Supported Note Formats + +The extension recognizes and formats several note types: + +### cnotes Format +If notes were created by [cnotes](https://github.com/imjasonh/cnotes), they're displayed with: +- Session information +- Claude version +- Tools used +- Formatted conversation excerpts + +### JSON Format +JSON notes are pretty-printed with syntax highlighting + +### Plain Text +Plain text notes are displayed in a monospace font + +## Development + +### Project Structure +``` +chrome-github-notes/ +├── manifest.json # Extension manifest (V3) +├── background.js # Service worker for API calls +├── popup.html # Extension popup interface +├── popup.js # Popup logic and UI handling +├── styles.css # Popup styles +├── utils.js # Utility functions (placeholder) +└── icons/ # Extension icons +``` + +### Key Components + +- **popup.js**: Detects current tab URL and displays notes in popup +- **background.js**: Handles GitHub API calls to bypass CORS restrictions +- **styles.css**: Clean popup UI with GitHub-inspired design + +## Limitations + +- Only works with public repositories (no authentication) +- Limited to 60 API requests per hour +- Notes must be pushed to GitHub (`git push origin refs/notes/*`) +- Only displays notes on individual commit pages + +## Future Enhancements + +- [ ] OAuth authentication for private repos and higher rate limits +- [ ] Display notes on commit list pages +- [ ] User preferences and configuration +- [ ] Export functionality +- [ ] Support for more note formats +- [ ] Integration with git notes management tools + +## Contributing + +Contributions are welcome! Please feel free to submit issues or pull requests. + +## License + +MIT License - see LICENSE file for details + +## Related Projects + +- [cnotes](https://github.com/imjasonh/cnotes) - Git notes for Claude conversations +- [git-notes](https://git-scm.com/docs/git-notes) - Git notes documentation \ No newline at end of file diff --git a/chrome-extension/background.js b/chrome-extension/background.js new file mode 100644 index 0000000..76ecda5 --- /dev/null +++ b/chrome-extension/background.js @@ -0,0 +1,279 @@ +// Background service worker for GitHub Git Notes Viewer +// Handles API calls to bypass CORS restrictions and monitors tabs for notes + +// Cache for API responses (5 minutes TTL) +const cache = new Map(); +const CACHE_TTL = 5 * 60 * 1000; // 5 minutes + +// Track tab states +const tabStates = new Map(); + +// Listen for tab updates +chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { + if (changeInfo.status === 'complete' && tab.url) { + checkTabForNotes(tabId, tab.url); + } +}); + +// Listen for tab activation +chrome.tabs.onActivated.addListener(async (activeInfo) => { + const tab = await chrome.tabs.get(activeInfo.tabId); + if (tab.url) { + checkTabForNotes(activeInfo.tabId, tab.url); + } +}); + +// Check if a tab is on a GitHub commit page and has notes +async function checkTabForNotes(tabId, url) { + const commitMatch = url.match(/^https:\/\/github\.com\/([^\/]+)\/([^\/]+)\/commit\/([0-9a-f]+)/i); + + if (!commitMatch) { + // Not a commit page, clear badge + chrome.action.setBadgeText({ tabId, text: '' }); + tabStates.delete(tabId); + return; + } + + const [, owner, repo, commitSha] = commitMatch; + const cacheKey = `${owner}/${repo}/${commitSha}`; + + // Check if we already know about this tab + const tabState = tabStates.get(tabId); + if (tabState && tabState.cacheKey === cacheKey && tabState.checked) { + return; + } + + try { + // Check cache first + const cached = cache.get(cacheKey); + let noteCount = 0; + + if (cached && Date.now() - cached.timestamp < CACHE_TTL) { + noteCount = Object.keys(cached.data).length; + } else { + // Fetch notes to check if any exist + const notesRefs = await fetchNotesRefs(owner, repo); + + for (const ref of notesRefs) { + const refName = ref.replace('refs/notes/', ''); + const hasNotes = await checkCommitHasNotes(owner, repo, commitSha, refName); + if (hasNotes) { + noteCount++; + } + } + } + + // Update badge + if (noteCount > 0) { + chrome.action.setBadgeText({ tabId, text: noteCount.toString() }); + chrome.action.setBadgeBackgroundColor({ tabId, color: '#0366d6' }); + } else { + chrome.action.setBadgeText({ tabId, text: '' }); + } + + // Save state + tabStates.set(tabId, { + cacheKey, + noteCount, + checked: true + }); + + } catch (error) { + console.error('Error checking for notes:', error); + // Don't show badge on error + chrome.action.setBadgeText({ tabId, text: '' }); + } +} + +// Quick check if a commit has notes without fetching content +async function checkCommitHasNotes(owner, repo, commitSha, notesRef) { + try { + const refResponse = await fetch( + `https://api.github.com/repos/${owner}/${repo}/git/refs/notes/${notesRef}` + ); + + if (!refResponse.ok) { + return false; + } + + const refData = await refResponse.json(); + const notesSha = refData.object.sha; + + // Get the tree + const commitResponse = await fetch( + `https://api.github.com/repos/${owner}/${repo}/git/commits/${notesSha}` + ); + + if (!commitResponse.ok) { + return false; + } + + const commitData = await commitResponse.json(); + const treeSha = commitData.tree.sha; + + const treeResponse = await fetch( + `https://api.github.com/repos/${owner}/${repo}/git/trees/${treeSha}` + ); + + if (!treeResponse.ok) { + return false; + } + + const treeData = await treeResponse.json(); + + // Check if this commit exists in the tree + return treeData.tree.some(item => + item.path.startsWith(commitSha) || commitSha.startsWith(item.path.substring(0, 7)) + ); + + } catch (error) { + console.error(`Error checking notes for ref ${notesRef}:`, error); + return false; + } +} + +// Listen for messages from content script +chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { + if (request.action === 'fetchGitNotes') { + handleFetchGitNotes(request, sendResponse); + return true; // Will respond asynchronously + } +}); + +async function handleFetchGitNotes(request, sendResponse) { + const { owner, repo, commitSha } = request; + const cacheKey = `${owner}/${repo}/${commitSha}`; + + // Check cache first + const cached = cache.get(cacheKey); + if (cached && Date.now() - cached.timestamp < CACHE_TTL) { + sendResponse({ success: true, data: cached.data }); + return; + } + + try { + // Fetch all available notes refs + const notesRefs = await fetchNotesRefs(owner, repo); + const allNotes = {}; + + for (const ref of notesRefs) { + const refName = ref.replace('refs/notes/', ''); + const notes = await fetchNotesForCommit(owner, repo, commitSha, refName); + if (notes) { + allNotes[refName] = notes; + } + } + + // Cache the result + cache.set(cacheKey, { + data: allNotes, + timestamp: Date.now() + }); + + sendResponse({ success: true, data: allNotes }); + } catch (error) { + console.error('Error fetching git notes:', error); + sendResponse({ + success: false, + error: error.message, + rateLimitRemaining: error.rateLimitRemaining + }); + } +} + +async function fetchNotesRefs(owner, repo) { + const response = await fetch(`https://api.github.com/repos/${owner}/${repo}/git/refs`); + + if (!response.ok) { + throw createError(response); + } + + const refs = await response.json(); + return refs + .filter(ref => ref.ref.startsWith('refs/notes/')) + .map(ref => ref.ref); +} + +async function fetchNotesForCommit(owner, repo, commitSha, notesRef) { + try { + // Step 1: Get the notes reference + const refResponse = await fetch( + `https://api.github.com/repos/${owner}/${repo}/git/refs/notes/${notesRef}` + ); + + if (!refResponse.ok) { + if (refResponse.status === 404) { + return null; // No notes in this ref + } + throw createError(refResponse); + } + + const refData = await refResponse.json(); + const notesSha = refData.object.sha; + + // Step 2: Get the commit object + const commitResponse = await fetch( + `https://api.github.com/repos/${owner}/${repo}/git/commits/${notesSha}` + ); + + if (!commitResponse.ok) { + throw createError(commitResponse); + } + + const commitData = await commitResponse.json(); + const treeSha = commitData.tree.sha; + + // Step 3: Get the tree + const treeResponse = await fetch( + `https://api.github.com/repos/${owner}/${repo}/git/trees/${treeSha}` + ); + + if (!treeResponse.ok) { + throw createError(treeResponse); + } + + const treeData = await treeResponse.json(); + + // Find the blob for this commit (handle short SHAs) + const blob = treeData.tree.find(item => + item.path.startsWith(commitSha) || commitSha.startsWith(item.path.substring(0, 7)) + ); + + if (!blob) { + return null; // No notes for this commit + } + + // Step 4: Get the blob content + const blobResponse = await fetch( + `https://api.github.com/repos/${owner}/${repo}/git/blobs/${blob.sha}` + ); + + if (!blobResponse.ok) { + throw createError(blobResponse); + } + + const blobData = await blobResponse.json(); + + // Decode base64 content + const content = atob(blobData.content); + + // Try to parse as JSON + try { + return JSON.parse(content); + } catch { + // Return as plain text if not JSON + return { content }; + } + + } catch (error) { + console.error(`Error fetching notes for ref ${notesRef}:`, error); + throw error; + } +} + +function createError(response) { + const error = new Error(`GitHub API error: ${response.status} ${response.statusText}`); + error.status = response.status; + error.rateLimitRemaining = response.headers.get('X-RateLimit-Remaining'); + return error; +} \ No newline at end of file diff --git a/chrome-extension/icons/icon-128.png b/chrome-extension/icons/icon-128.png new file mode 100644 index 0000000..f3c5d13 --- /dev/null +++ b/chrome-extension/icons/icon-128.png @@ -0,0 +1,2 @@ +Placeholder for 128x128 icon. +Please generate from icon.svg or create your own 128x128 PNG icon. \ No newline at end of file diff --git a/chrome-extension/icons/icon-16.png b/chrome-extension/icons/icon-16.png new file mode 100644 index 0000000..e904f01 --- /dev/null +++ b/chrome-extension/icons/icon-16.png @@ -0,0 +1,2 @@ +Placeholder for 16x16 icon. +Please generate from icon.svg or create your own 16x16 PNG icon. \ No newline at end of file diff --git a/chrome-extension/icons/icon-48.png b/chrome-extension/icons/icon-48.png new file mode 100644 index 0000000..10d6b33 --- /dev/null +++ b/chrome-extension/icons/icon-48.png @@ -0,0 +1,2 @@ +Placeholder for 48x48 icon. +Please generate from icon.svg or create your own 48x48 PNG icon. \ No newline at end of file diff --git a/chrome-extension/icons/icon.svg b/chrome-extension/icons/icon.svg new file mode 100644 index 0000000..6c5eb1f --- /dev/null +++ b/chrome-extension/icons/icon.svg @@ -0,0 +1,24 @@ + \ No newline at end of file diff --git a/chrome-extension/manifest.json b/chrome-extension/manifest.json new file mode 100644 index 0000000..e26cb2a --- /dev/null +++ b/chrome-extension/manifest.json @@ -0,0 +1,29 @@ +{ + "manifest_version": 3, + "name": "GitHub Git Notes Viewer", + "version": "1.0.0", + "description": "Display git notes on GitHub commit pages", + "icons": { + "16": "icons/icon-16.png", + "48": "icons/icon-48.png", + "128": "icons/icon-128.png" + }, + "permissions": [ + "activeTab", + "tabs" + ], + "host_permissions": [ + "https://api.github.com/*" + ], + "background": { + "service_worker": "background.js" + }, + "action": { + "default_popup": "popup.html", + "default_icon": { + "16": "icons/icon-16.png", + "48": "icons/icon-48.png", + "128": "icons/icon-128.png" + } + } +} \ No newline at end of file diff --git a/chrome-extension/popup.html b/chrome-extension/popup.html new file mode 100644 index 0000000..8b8c5da --- /dev/null +++ b/chrome-extension/popup.html @@ -0,0 +1,25 @@ + + +
+ + + + +${JSON.stringify(noteData, null, 2)}`;
+ }
+
+ // Plain text
+ const content = noteData.content || noteData;
+ return `${escapeHtml(content)}`;
+}
+
+function formatCnotesNote(data) {
+ let html = '