diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index f12f7fb..bb01550 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -15,11 +15,14 @@ env:
jobs:
deploy:
runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ pull-requests: write
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
- name: Setup Node.js
- uses: actions/setup-node@v4
+ uses: actions/setup-node@v5
with:
node-version: 22
cache: npm
@@ -47,7 +50,7 @@ jobs:
- name: Comment PR with Preview URL
if: github.event_name == 'pull_request' && env.VERCEL_TOKEN != '' && env.VERCEL_ORG_ID != '' && env.VERCEL_PROJECT_ID != ''
- uses: actions/github-script@v7
+ uses: actions/github-script@v8
with:
script: |
github.rest.issues.createComment({
diff --git a/README.md b/README.md
index c9175d3..43ff5c0 100644
--- a/README.md
+++ b/README.md
@@ -208,6 +208,7 @@ Required environment variables:
| `ACTIVITY_INGEST_SECRET` | Bearer secret for the activity collector endpoint |
| `RESEND_API_KEY` | Optional Resend API key for claim and approval emails |
| `EMAIL_FROM` | Sender on a domain verified by Resend |
+| `COSMOS_WATCHLIST_CONTAINER` | Optional private watchlist container name (default: `watchlists`) |
| `CRON_SECRET` | Bearer token used by Vercel Cron for the weekly digest endpoint |
| `EMAIL_PREFERENCE_SECRET` | HMAC secret for weekly-email unsubscribe links; defaults to `SESSION_SECRET` |
diff --git a/app/api/watchlist/developers/route.js b/app/api/watchlist/developers/route.js
new file mode 100644
index 0000000..dbc1770
--- /dev/null
+++ b/app/api/watchlist/developers/route.js
@@ -0,0 +1,54 @@
+import { NextResponse } from 'next/server';
+import { getSession } from '../../../../lib/auth.js';
+import {
+ followEntity,
+ getWatchlist,
+ normalizeDeveloperFollow,
+ unfollowEntity,
+} from '../../../../lib/watchlist-store.js';
+
+export async function GET() {
+ const session = await getSession();
+ if (!session?.login) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
+
+ try {
+ const watchlist = await getWatchlist(session.login);
+ return NextResponse.json(
+ { developers: watchlist.follows.developers },
+ { headers: { 'Cache-Control': 'no-store' } },
+ );
+ } catch (error) {
+ console.error('Developer watchlist query failed:', error.message);
+ return NextResponse.json({ error: 'Unable to load followed developers' }, { status: 503 });
+ }
+}
+
+async function updateFollow(request, remove) {
+ const session = await getSession();
+ if (!session?.login) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
+
+ let login;
+ try {
+ const body = await request.json();
+ login = normalizeDeveloperFollow(body.login);
+ const watchlist = remove
+ ? await unfollowEntity(session.login, 'developers', login)
+ : await followEntity(session.login, 'developers', login);
+ return NextResponse.json({ developers: watchlist.follows.developers });
+ } catch (error) {
+ const validationError = /Invalid GitHub login|own profile|follow limit/.test(error.message);
+ if (!validationError) console.error('Developer watchlist update failed:', error.message);
+ return NextResponse.json(
+ { error: validationError ? error.message : 'Unable to update followed developers' },
+ { status: validationError ? 400 : 500 },
+ );
+ }
+}
+
+export async function POST(request) {
+ return updateFollow(request, false);
+}
+
+export async function DELETE(request) {
+ return updateFollow(request, true);
+}
\ No newline at end of file
diff --git a/app/page.jsx b/app/page.jsx
index 380febf..c9e6310 100644
--- a/app/page.jsx
+++ b/app/page.jsx
@@ -482,6 +482,7 @@ export default function Home() {
onClose={handleCloseDetail}
onCardGenerated={recordCardActivity}
claimedLogins={claimedLogins}
+ user={user}
openCardOnMount={cardRequest > 0}
claimSuccess={cardContext === 'claim'}
/>
diff --git a/components/DetailPanel.jsx b/components/DetailPanel.jsx
index bb8918b..728b18f 100644
--- a/components/DetailPanel.jsx
+++ b/components/DetailPanel.jsx
@@ -10,9 +10,11 @@ import { classifyAgent } from '../lib/agent-class.js';
import { AI_TOOLS } from '../lib/ai-profile.js';
import SpecialTags from './SpecialTags.jsx';
-export default function DetailPanel({ dev, onClose, onCardGenerated, claimedLogins, openCardOnMount = false, claimSuccess = false }) {
+export default function DetailPanel({ dev, onClose, onCardGenerated, claimedLogins, user, openCardOnMount = false, claimSuccess = false }) {
const [fullData, setFullData] = useState(null);
const [showCard, setShowCard] = useState(false);
+ const [followState, setFollowState] = useState('idle');
+ const [followError, setFollowError] = useState('');
const radarRef = useRef(null);
const heatmapRef = useRef(null);
const langRef = useRef(null);
@@ -27,6 +29,49 @@ export default function DetailPanel({ dev, onClose, onCardGenerated, claimedLogi
if (openCardOnMount) setShowCard(true);
}, [openCardOnMount]);
+ useEffect(() => {
+ if (!user || user.login.toLowerCase() === dev.login.toLowerCase()) return;
+ let cancelled = false;
+ setFollowState('loading');
+ fetch('/api/watchlist/developers', { cache: 'no-store' })
+ .then(async response => {
+ if (!response.ok) throw new Error('Unable to load follows');
+ const result = await response.json();
+ if (!cancelled) {
+ setFollowState(result.developers.includes(dev.login.toLowerCase()) ? 'following' : 'not-following');
+ }
+ })
+ .catch(() => {
+ if (!cancelled) setFollowState('not-following');
+ });
+ return () => { cancelled = true; };
+ }, [dev.login, user]);
+
+ const handleFollow = async () => {
+ if (!user) {
+ window.location.assign('/api/auth/github');
+ return;
+ }
+ const wasFollowing = followState === 'following';
+ setFollowState('saving');
+ setFollowError('');
+ try {
+ const response = await fetch('/api/watchlist/developers', {
+ method: wasFollowing ? 'DELETE' : 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ login: dev.login }),
+ });
+ const result = await response.json();
+ if (!response.ok) throw new Error(result.error || 'Unable to update follow');
+ const following = result.developers.includes(dev.login.toLowerCase());
+ setFollowState(following ? 'following' : 'not-following');
+ track(following ? 'developer_followed' : 'developer_unfollowed', { login: dev.login });
+ } catch (error) {
+ setFollowState(wasFollowing ? 'following' : 'not-following');
+ setFollowError(error.message);
+ }
+ };
+
// Fetch full details on mount
useEffect(() => {
let cancelled = false;
@@ -129,6 +174,20 @@ export default function DetailPanel({ dev, onClose, onCardGenerated, claimedLogi
{SCORE_METHODOLOGY.short}
+ {user?.login.toLowerCase() !== dev.login.toLowerCase() && (
+
+ )}
GitHub ↗
{merged.soUserId && (
StackOverflow ↗
@@ -146,6 +205,7 @@ export default function DetailPanel({ dev, onClose, onCardGenerated, claimedLogi
Generate Identity Card
+ {followError && {followError}
}
diff --git a/docs/prd/developer-watchlists.md b/docs/prd/developer-watchlists.md
new file mode 100644
index 0000000..fd5e853
--- /dev/null
+++ b/docs/prd/developer-watchlists.md
@@ -0,0 +1,66 @@
+# PRD: Developer Watchlists
+
+**Status:** MVP implementation
+**Issue:** [#113](https://github.com/sajeetharan/devglobe/issues/113)
+**Priority:** P0
+**Depends on:** [#127](https://github.com/sajeetharan/devglobe/issues/127)
+**Last updated:** 2026-08-16
+
+## Summary
+
+DevGlobe will let signed-in users privately follow developers from a profile. These explicit follows seed the personalized activity feed and future opt-in digest, creating a useful record of what changed in a developer's network.
+
+## Problem
+
+Discovery currently ends when a profile is closed. Users cannot retain interesting developers or return to changes from people they care about, so each visit starts from scratch.
+
+## Goals
+
+- Let a signed-in user follow or unfollow a public developer from the profile panel.
+- Keep watchlists private and scoped to the authenticated GitHub login.
+- Feed existing personalization through `follows.developers` without duplicating storage.
+- Normalize GitHub logins, prevent self-following, and bound the MVP list to 100 developers.
+- Measure follow and unfollow intent for retention analysis.
+
+## Non-goals
+
+- Public follower counts or social popularity rankings.
+- Following private or pending profiles through a direct public UI.
+- Push or email notifications in this slice; those are covered by #22 and #165.
+- Full management UI for projects, languages, and countries.
+
+## User experience
+
+- A signed-in visitor sees **Follow** on another developer's profile.
+- Activating it updates in place to **Following**. Activating **Following** unfollows.
+- A signed-out visitor sees **Follow** and is sent to GitHub sign-in when activating it.
+- The control is hidden on the user's own profile.
+- Loading and failed updates do not optimistically claim success.
+
+## API
+
+- `GET /api/watchlist/developers` returns `{ developers: string[] }` for the authenticated user.
+- `POST /api/watchlist/developers` with `{ login }` follows a developer.
+- `DELETE /api/watchlist/developers` with `{ login }` unfollows a developer.
+- All methods return `401` without a valid session and responses are never publicly cached.
+
+## Data and privacy
+
+One private document is stored per authenticated login in the `watchlists` container, partitioned by `/id`. Developer logins are canonical lowercase strings. The watchlist is never included in public profile, search, card, or MCP responses.
+
+Existing documents are replaced with an ETag precondition so concurrent feed read-state and follow updates fail rather than silently overwriting each other.
+
+## Success metrics
+
+- Percentage of signed-in users following at least one and at least three developers.
+- Follow conversion from developer profile views.
+- D7 retention difference between followers and non-followers.
+- Personalized-feed opens among users with follows.
+
+## Acceptance criteria
+
+- Follow state persists across sessions when Cosmos DB is configured.
+- Duplicate casing cannot create duplicate follows.
+- Self-follow, invalid logins, and more than 100 follows are rejected.
+- The existing personalized feed matches newly followed developers.
+- Store tests, the full test suite, and production build pass.
\ No newline at end of file
diff --git a/lib/watchlist-store.js b/lib/watchlist-store.js
index 82fbd06..d41e5be 100644
--- a/lib/watchlist-store.js
+++ b/lib/watchlist-store.js
@@ -4,6 +4,29 @@ const memoryWatchlists = new Map();
const FOLLOW_CATEGORIES = ['developers', 'projects', 'languages', 'countries'];
const MUTE_ENTITY_TYPES = ['developer', 'project', 'language', 'country'];
+export const MAX_DEVELOPER_FOLLOWS = 100;
+
+export function normalizeDeveloperFollow(value) {
+ const login = String(value || '').trim().replace(/^@/, '').toLowerCase();
+ if (!/^[a-z\d](?:[a-z\d]|-(?=[a-z\d])){0,38}$/.test(login)) {
+ throw new Error('Invalid GitHub login');
+ }
+ return login;
+}
+
+export function updateDeveloperFollows(follows, value, { remove = false, ownerLogin = '' } = {}) {
+ const login = normalizeDeveloperFollow(value);
+ const owner = ownerLogin ? normalizeDeveloperFollow(ownerLogin) : '';
+ if (!remove && login === owner) throw new Error('You cannot follow your own profile');
+
+ const current = new Set((follows || []).map(normalizeDeveloperFollow));
+ if (remove) current.delete(login);
+ else current.add(login);
+ if (current.size > MAX_DEVELOPER_FOLLOWS) {
+ throw new Error(`Developer follow limit is ${MAX_DEVELOPER_FOLLOWS}`);
+ }
+ return [...current];
+}
function getWatchlistContainer() {
return getCosmosContainer(process.env.COSMOS_WATCHLIST_CONTAINER || 'watchlists');
@@ -14,6 +37,7 @@ function emptyWatchlist(login) {
id: login,
login,
documentType: 'watchlist',
+ schemaVersion: 1,
follows: { developers: [], projects: [], languages: [], countries: [] },
mutes: { entities: [], eventTypes: [] },
readState: { readThrough: null, readIds: [] },
@@ -39,12 +63,22 @@ export async function getWatchlist(login) {
}
async function saveWatchlist(watchlist) {
- const document = { ...watchlist, updatedAt: new Date().toISOString() };
+ const document = { ...watchlist, schemaVersion: 1, updatedAt: new Date().toISOString() };
const container = getWatchlistContainer();
if (!container) {
memoryWatchlists.set(document.login, document);
return document;
}
+ if (watchlist._etag) {
+ const etag = watchlist._etag;
+ for (const key of Object.keys(document)) {
+ if (key.startsWith('_')) delete document[key];
+ }
+ const { resource } = await container.item(document.id, document.id).replace(document, {
+ accessCondition: { type: 'IfMatch', condition: etag },
+ });
+ return resource;
+ }
const { resource } = await container.items.upsert(document);
return resource;
}
@@ -54,6 +88,10 @@ export async function followEntity(login, category, value) {
throw new Error(`Invalid follow category: ${category}`);
}
const watchlist = await getWatchlist(login);
+ if (category === 'developers') {
+ watchlist.follows.developers = updateDeveloperFollows(watchlist.follows.developers, value, { ownerLogin: login });
+ return saveWatchlist(watchlist);
+ }
const set = new Set(watchlist.follows[category]);
set.add(value);
watchlist.follows[category] = [...set];
@@ -65,6 +103,10 @@ export async function unfollowEntity(login, category, value) {
throw new Error(`Invalid follow category: ${category}`);
}
const watchlist = await getWatchlist(login);
+ if (category === 'developers') {
+ watchlist.follows.developers = updateDeveloperFollows(watchlist.follows.developers, value, { remove: true });
+ return saveWatchlist(watchlist);
+ }
watchlist.follows[category] = watchlist.follows[category].filter(entry => entry !== value);
return saveWatchlist(watchlist);
}
diff --git a/package.json b/package.json
index 481fc4c..0d1d3e3 100644
--- a/package.json
+++ b/package.json
@@ -21,6 +21,7 @@
"rescore-developers": "node scripts/rescore-developers.js",
"populate-special-tags": "node scripts/populate-special-tags.js",
"setup-activity-container": "node scripts/setup-activity-container.js",
+ "setup-watchlist-container": "node scripts/setup-watchlist-container.js",
"setup-introductions-container": "node scripts/setup-introductions-container.js",
"setup-contacts-container": "node scripts/setup-contacts-container.js",
"create-agent-key": "node scripts/create-agent-key.js",
diff --git a/scripts/setup-watchlist-container.js b/scripts/setup-watchlist-container.js
new file mode 100644
index 0000000..c4a3df1
--- /dev/null
+++ b/scripts/setup-watchlist-container.js
@@ -0,0 +1,27 @@
+import 'dotenv/config';
+import { CosmosClient } from '@azure/cosmos';
+
+const endpoint = process.env.COSMOS_ENDPOINT?.trim();
+const key = process.env.COSMOS_KEY?.trim();
+const databaseId = process.env.COSMOS_DATABASE || 'devglobe';
+const containerId = process.env.COSMOS_WATCHLIST_CONTAINER || 'watchlists';
+
+if (!endpoint || !key) {
+ console.error('COSMOS_ENDPOINT and COSMOS_KEY are required.');
+ process.exit(1);
+}
+
+const client = new CosmosClient({ endpoint, key });
+const database = client.database(databaseId);
+const { resource, statusCode } = await database.containers.createIfNotExists({
+ id: containerId,
+ partitionKey: { paths: ['/id'], kind: 'Hash' },
+ indexingPolicy: {
+ indexingMode: 'consistent',
+ automatic: true,
+ includedPaths: [],
+ excludedPaths: [{ path: '/*' }],
+ },
+});
+
+console.log(`${statusCode === 201 ? 'Created' : 'Verified'} private container ${databaseId}/${resource.id}.`);
\ No newline at end of file
diff --git a/styles/main.css b/styles/main.css
index 9a3e314..c6a2ce7 100644
--- a/styles/main.css
+++ b/styles/main.css
@@ -2047,6 +2047,8 @@ body {
.detail-header__links {
display: flex;
+ align-items: center;
+ flex-wrap: wrap;
gap: 8px;
margin-top: 8px;
}
@@ -2061,6 +2063,38 @@ body {
text-decoration: underline;
}
+.btn--follow {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ min-height: 32px;
+ padding: 5px 12px;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ background: var(--overlay-subtle);
+ color: var(--text-primary);
+ font: 600 12px var(--font);
+ cursor: pointer;
+}
+
+.btn--follow:hover:not(:disabled),
+.btn--follow-active {
+ border-color: rgba(46, 164, 79, 0.55);
+ background: rgba(46, 164, 79, 0.14);
+ color: #2ea44f;
+}
+
+.btn--follow:disabled {
+ cursor: wait;
+ opacity: 0.65;
+}
+
+.detail-header__follow-error {
+ margin-top: 6px;
+ color: #ef4444;
+ font-size: 11px;
+}
+
.ai-collaboration {
margin: 0 0 24px;
padding: 16px;
diff --git a/tests/watchlist-store.test.js b/tests/watchlist-store.test.js
new file mode 100644
index 0000000..22078d7
--- /dev/null
+++ b/tests/watchlist-store.test.js
@@ -0,0 +1,26 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import {
+ MAX_DEVELOPER_FOLLOWS,
+ normalizeDeveloperFollow,
+ updateDeveloperFollows,
+} from '../lib/watchlist-store.js';
+
+test('normalizes developer follows and removes duplicate casing', () => {
+ assert.equal(normalizeDeveloperFollow(' @OctoCat '), 'octocat');
+ assert.deepEqual(updateDeveloperFollows(['OctoCat'], '@octocat', { ownerLogin: 'viewer' }), ['octocat']);
+});
+
+test('rejects invalid and self follows', () => {
+ assert.throws(() => normalizeDeveloperFollow('invalid--login'), /Invalid GitHub login/);
+ assert.throws(
+ () => updateDeveloperFollows([], 'Viewer', { ownerLogin: 'viewer' }),
+ /own profile/,
+ );
+});
+
+test('removes follows and enforces the developer follow limit', () => {
+ assert.deepEqual(updateDeveloperFollows(['octocat'], 'OCTOCAT', { remove: true }), []);
+ const follows = Array.from({ length: MAX_DEVELOPER_FOLLOWS }, (_, index) => `dev-${index}`);
+ assert.throws(() => updateDeveloperFollows(follows, 'one-more'), /follow limit/);
+});
\ No newline at end of file