Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '18'
node-version: '24'
cache: 'npm'

- name: Install dependencies
Expand All @@ -34,7 +34,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '18'
node-version: '24'
cache: 'npm'

- name: Install dependencies
Expand All @@ -52,7 +52,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '18'
node-version: '24'
cache: 'npm'

- name: Install dependencies
Expand All @@ -75,7 +75,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '18'
node-version: '24'
cache: 'npm'

- name: Install dependencies
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ WINDSURF.md

# Legacy local design / planning artifacts
.superpowers/
.mockups/
docs/superpowers/
icon-concepts.html
*-450.png
Expand Down
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]

### Security
- GitHub sign-in sessions now stay in Chrome session storage only instead of persisting to local extension storage
- GitHub sign-in sessions now persist in local extension storage for the current Chrome profile instead of clearing at browser shutdown
- Added validation for the GitHub device-flow verification URL before opening a browser tab
- Tightened remote image handling for activity avatars and extension page CSP rules

Expand Down
10 changes: 4 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ Best for people who follow several repos and want one local review queue instead
- Chrome or another Chromium-based browser that supports Manifest V3 extensions
- A GitHub account to connect during setup
- No separate hosted DevWatch account or backend service
- GitHub sign-in lasts for the current browser session and is cleared when that session ends
- GitHub sign-in persists in the current Chrome profile until you disconnect it or reset DevWatch

## Installation

Expand Down Expand Up @@ -101,10 +101,6 @@ Settings are split into a few practical jobs:
- Tune filters, notifications, refresh interval, snooze behavior, and item expiry
- Change theme/color theme, export or import settings, and enable advanced repo-limit options

<div align="center">
<img src="screenshots/settings-page.png" alt="Settings page for configuring repositories" width="600">
</div>

## Typical Workflow

Here's what using the extension looks like day-to-day:
Expand All @@ -123,7 +119,7 @@ That said, this project has not gone through a formal accessibility audit or doc

## Privacy & Security Notes

The extension talks directly to GitHub's API and does not use a separate analytics or sync backend. It stores settings and cached activity in Chrome extension storage, while the current GitHub auth session stays in Chrome session storage so it is not persisted to disk. Legacy encrypted auth data from older builds is cleared when accessed.
The extension talks directly to GitHub's API and does not use a separate analytics or sync backend. It stores settings, cached activity, and the current GitHub auth session in Chrome extension storage for the current browser profile. Legacy encrypted auth data from older builds is cleared when accessed.

- **Direct network access** - Requests go to `api.github.com` for activity checks, `github.com` for OAuth device-flow sign-in, and `registry.npmjs.org` only when you use package-name lookup
- **Scoped browser permissions** - The manifest asks for `storage`, `alarms`, and `notifications`
Expand Down Expand Up @@ -187,6 +183,8 @@ npm test
npm run build
```

For the same full local validation pass used in this repo before packaging or release prep, run `npm run validate`.

The automated checks cover shared logic, UI behavior, and a range of mocked extension flows. They do not replace manual testing in Chrome for permissions, service worker lifecycle behavior, or end-to-end interactions against live GitHub data.

Jest enforces minimum global coverage thresholds of 47% lines, 46% branches, and 44% functions. That is a floor for the suite, not a claim of exhaustive coverage.
Expand Down
17 changes: 9 additions & 8 deletions background.js
Original file line number Diff line number Diff line change
Expand Up @@ -109,11 +109,6 @@ async function checkGitHubActivity() {
'unmutedRepos'
]);

if (!githubToken) {
console.warn('[DevWatch] No GitHub connection found. Please connect GitHub in settings.');
return;
}

if (!watchedRepos || watchedRepos.length === 0) {
console.warn('[DevWatch] No repositories being watched. Please add repos in settings.');
return;
Expand Down Expand Up @@ -192,7 +187,9 @@ async function checkGitHubActivity() {

async function fetchRepoActivity(repo, token, since, filters) {
const activities = [];
const headers = createHeaders(token);
const headers = token
? createHeaders(token)
: { 'Accept': 'application/vnd.github.v3+json' };

async function fetchWithRateLimit(url) {
try {
Expand Down Expand Up @@ -312,9 +309,13 @@ async function fetchRepoActivity(repo, token, since, filters) {
if (error.message.includes('401')) {
userMessage = 'GitHub sign-in expired or was revoked. Reconnect GitHub in settings.';
} else if (error.message.includes('403')) {
userMessage = 'Access denied or rate limit exceeded.';
userMessage = token
? 'Access denied or rate limit exceeded.'
: 'Anonymous GitHub access hit the rate limit or this repo requires GitHub sign-in.';
} else if (error.message.includes('404')) {
userMessage = 'Repository not found or access denied.';
userMessage = token
? 'Repository not found or access denied.'
: 'Repository not found publicly. If it is private, connect GitHub and try again.';
} else if (error.message.includes('Network error')) {
userMessage = 'Network connection error. Please check your internet connection.';
}
Expand Down
44 changes: 29 additions & 15 deletions icon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified icons/icon128.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified icons/icon16.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified icons/icon32.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified icons/icon48.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
111 changes: 12 additions & 99 deletions options/controllers/import-controller.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { STORAGE_CONFIG } from '../../shared/config.js';
import { getAccessToken, getSyncItem, setWatchedRepos } from '../../shared/storage-helpers.js';
import { createHeaders } from '../../shared/github-api.js';
import {
fetchGitHubRepoSource,
getGitHubRepoSourceConfig
} from '../../shared/github-repo-sources.js';
import { getRepoFullName, normalizeWatchedRepoRecord } from '../../shared/repo-service.js';
import { escapeHtml, unescapeHtml } from '../../shared/sanitize.js';
import { formatDateVerbose } from '../../shared/utils.js';
Expand Down Expand Up @@ -72,23 +75,16 @@ export async function openImportModal(type, watchedRepos) {
importModalState.type = type;
const modal = document.getElementById('importModal');
const title = document.getElementById('importModalTitle');
const sourceConfig = getGitHubRepoSourceConfig(type);

const titles = {
watched: 'Import Watched Repositories',
starred: 'Import Starred Repositories',
participating: 'Import Participating Repositories',
mine: 'Import My Repositories'
};

title.textContent = titles[type] || 'Import Repositories';
title.textContent = sourceConfig.modalTitle;
importModalState.previousFocusElement = document.activeElement;

modal.classList.add('show');
document.getElementById('importLoadingState').style.display = 'flex';
document.getElementById('importLoadingState').classList.remove('hidden');
const reposList = document.getElementById('importReposList');
reposList.classList.add('hidden');
reposList.style.display = 'none';
document.getElementById('importErrorState').style.display = 'none';
document.getElementById('importErrorState').classList.add('hidden');

setupModalFocusTrap(modal);

Expand All @@ -98,7 +94,7 @@ export async function openImportModal(type, watchedRepos) {
}, 100);

try {
const repos = await fetchReposFromGitHub(type, token);
const repos = await fetchGitHubRepoSource(type, token);

const alreadyAdded = new Set(
(watchedRepos || [])
Expand All @@ -113,100 +109,17 @@ export async function openImportModal(type, watchedRepos) {

importModalState.filteredRepos = [...importModalState.repos];

document.getElementById('importLoadingState').style.display = 'none';
document.getElementById('importLoadingState').classList.add('hidden');

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Hide import loading pane with a stronger display override

After this change, openImportModal hides #importLoadingState by adding hidden, but in options/options.css the utility rule .hidden { display: none; } is declared before .import-loading { display: flex; }, so the later .import-loading rule wins and the loading pane remains visible even after repos are fetched. In practice, successful imports render the repo list while the spinner block is still shown, which regresses the modal state transition.

Useful? React with 👍 / 👎.

const reposList = document.getElementById('importReposList');
reposList.classList.remove('hidden');
reposList.style.display = 'block';
renderImportReposList();
} catch (error) {
document.getElementById('importLoadingState').style.display = 'none';
document.getElementById('importErrorState').style.display = 'block';
document.getElementById('importLoadingState').classList.add('hidden');
document.getElementById('importErrorState').classList.remove('hidden');
document.getElementById('importErrorMessage').textContent = error.message || 'Failed to fetch repositories';
}
}

async function fetchReposFromGitHub(type, token) {
const headers = createHeaders(token);
let allRepos = [];
let page = 1;
const perPage = 100;
const MAX_PAGES = 100; // Prevent infinite loops (10,000 repos max)
const MAX_REPOS = 10000; // Hard limit on total repos

const endpoints = {
watched: 'https://api.github.com/user/subscriptions',
starred: 'https://api.github.com/user/starred',
participating: 'https://api.github.com/user/repos?affiliation=collaborator,organization_member&sort=pushed',
mine: 'https://api.github.com/user/repos?type=all&sort=updated'
};

const url = endpoints[type];
if (!url) {
throw new Error(`Invalid import type: ${type}`);
}

let hasMorePages = true;
let startTime = Date.now();
const TIMEOUT_MS = 60000; // 60 second timeout

while (hasMorePages && page <= MAX_PAGES && allRepos.length < MAX_REPOS) {
// Check for timeout to prevent hanging
if (Date.now() - startTime > TIMEOUT_MS) {
break;
}

// Build URL safely with URLSearchParams
const urlObj = new URL(url);
urlObj.searchParams.set('per_page', perPage.toString());
urlObj.searchParams.set('page', page.toString());

const response = await fetch(urlObj.toString(), {
headers
});

if (!response.ok) {
if (response.status === 401) {
throw new Error('GitHub sign-in expired or was revoked');
} else if (response.status === 403) {
throw new Error('Rate limit exceeded or insufficient permissions');
} else {
throw new Error(`GitHub API error: ${response.status}`);
}
}

const repos = await response.json();
if (!Array.isArray(repos) || repos.length === 0) {
hasMorePages = false;
break;
}

const transformed = repos.map(repo => ({
fullName: repo.full_name,
description: repo.description || 'No description provided',
language: repo.language || 'Unknown',
stars: repo.stargazers_count || 0,
forks: repo.forks_count || 0,
updatedAt: repo.updated_at || repo.pushed_at
}));

allRepos.push(...transformed);

// Stop if we've hit the max repos limit
if (allRepos.length >= MAX_REPOS) {
break;
}

const linkHeader = response.headers.get('Link');
if (!linkHeader || !linkHeader.includes('rel="next"')) {
hasMorePages = false;
} else {
page++;
}
}

return allRepos;
}

export function closeImportModal() {
const modal = document.getElementById('importModal');
modal.classList.remove('show');
Expand Down
Loading