Skip to content

Commit cef06e6

Browse files
Add GitHub Stars lead tracking to web admin (#3692)
* Add GitHub Stars lead tracking to web admin - Add /admin/stars/ dashboard page for tracking GitHub stargazers - Add API routes for fetching stars, researching leads via OpenRouter AI - Add Supabase migration for github_star_leads table - Add Stars nav link to admin header Co-Authored-By: john@hyprnote.com <john@hyprnote.com> * Fix typecheck errors: use postgres tagged templates, remove unused React import, add auto-generated routeTree Co-Authored-By: john@hyprnote.com <john@hyprnote.com> * Restore stars dashboard component (dev server had overwritten it) Co-Authored-By: john@hyprnote.com <john@hyprnote.com> * Rename to Char Admin, add separator, add RLS policy for github_star_leads Co-Authored-By: john@hyprnote.com <john@hyprnote.com> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: john@hyprnote.com <john@hyprnote.com>
1 parent a8c7943 commit cef06e6

File tree

9 files changed

+1119
-3
lines changed

9 files changed

+1119
-3
lines changed
Lines changed: 381 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,381 @@
1+
import postgres from "postgres";
2+
3+
import { env, requireEnv } from "@/env";
4+
5+
function getSql() {
6+
return postgres(requireEnv(env.DATABASE_URL, "DATABASE_URL"), {
7+
prepare: false,
8+
});
9+
}
10+
11+
export interface StarLead {
12+
id: number;
13+
github_username: string;
14+
github_id: number | null;
15+
avatar_url: string | null;
16+
profile_url: string | null;
17+
bio: string | null;
18+
event_type: string;
19+
repo_name: string;
20+
name: string | null;
21+
company: string | null;
22+
is_match: boolean | null;
23+
score: number | null;
24+
reasoning: string | null;
25+
researched_at: string | null;
26+
event_at: string;
27+
created_at: string;
28+
}
29+
30+
export async function listStarLeads(options?: {
31+
limit?: number;
32+
offset?: number;
33+
researchedOnly?: boolean;
34+
}): Promise<{ leads: StarLead[]; total: number }> {
35+
const sql = getSql();
36+
const limit = options?.limit ?? 50;
37+
const offset = options?.offset ?? 0;
38+
39+
const countResult = options?.researchedOnly
40+
? await sql`SELECT COUNT(*) as count FROM public.github_star_leads WHERE researched_at IS NOT NULL`
41+
: await sql`SELECT COUNT(*) as count FROM public.github_star_leads`;
42+
const total = parseInt(String(countResult[0].count), 10);
43+
44+
const rows = options?.researchedOnly
45+
? await sql`SELECT * FROM public.github_star_leads WHERE researched_at IS NOT NULL ORDER BY COALESCE(score, -1) DESC, created_at DESC LIMIT ${limit} OFFSET ${offset}`
46+
: await sql`SELECT * FROM public.github_star_leads ORDER BY COALESCE(score, -1) DESC, created_at DESC LIMIT ${limit} OFFSET ${offset}`;
47+
48+
return { leads: rows as unknown as StarLead[], total };
49+
}
50+
51+
interface GitHubUser {
52+
login: string;
53+
id: number;
54+
avatar_url: string;
55+
html_url: string;
56+
type: string;
57+
}
58+
59+
interface GitHubEvent {
60+
type: string;
61+
actor: {
62+
login: string;
63+
id: number;
64+
avatar_url: string;
65+
url: string;
66+
};
67+
repo: {
68+
name: string;
69+
};
70+
created_at: string;
71+
}
72+
73+
export async function fetchGitHubStargazers(): Promise<{
74+
added: number;
75+
total: number;
76+
}> {
77+
const sql = getSql();
78+
let added = 0;
79+
let page = 1;
80+
const perPage = 100;
81+
82+
while (true) {
83+
const response = await fetch(
84+
`https://api.github.com/repos/fastrepl/hyprnote/stargazers?per_page=${perPage}&page=${page}`,
85+
{
86+
headers: {
87+
Accept: "application/vnd.github.star+json",
88+
"User-Agent": "hyprnote-admin",
89+
},
90+
},
91+
);
92+
93+
if (!response.ok) break;
94+
95+
const stargazers: Array<{ starred_at: string; user: GitHubUser }> =
96+
await response.json();
97+
if (stargazers.length === 0) break;
98+
99+
for (const s of stargazers) {
100+
if (s.user.type === "Bot") continue;
101+
102+
const result = await sql`
103+
INSERT INTO public.github_star_leads (github_username, github_id, avatar_url, profile_url, event_type, repo_name, event_at)
104+
VALUES (${s.user.login}, ${s.user.id}, ${s.user.avatar_url}, ${s.user.html_url}, 'star', 'fastrepl/hyprnote', ${s.starred_at})
105+
ON CONFLICT (github_username) DO UPDATE SET
106+
avatar_url = EXCLUDED.avatar_url,
107+
github_id = EXCLUDED.github_id
108+
RETURNING id`;
109+
110+
if (result.length > 0) {
111+
added++;
112+
}
113+
}
114+
115+
if (stargazers.length < perPage) break;
116+
page++;
117+
}
118+
119+
const countResult =
120+
await sql`SELECT COUNT(*) as count FROM public.github_star_leads`;
121+
122+
return { added, total: parseInt(String(countResult[0].count), 10) };
123+
}
124+
125+
export async function fetchGitHubActivity(): Promise<{
126+
added: number;
127+
total: number;
128+
}> {
129+
const sql = getSql();
130+
let added = 0;
131+
132+
const response = await fetch(
133+
"https://api.github.com/orgs/fastrepl/events?per_page=100",
134+
{
135+
headers: {
136+
Accept: "application/vnd.github.v3+json",
137+
"User-Agent": "hyprnote-admin",
138+
},
139+
},
140+
);
141+
142+
if (!response.ok) {
143+
return { added: 0, total: 0 };
144+
}
145+
146+
const events: GitHubEvent[] = await response.json();
147+
148+
const eventTypeMap: Record<string, string> = {
149+
WatchEvent: "star",
150+
ForkEvent: "fork",
151+
IssuesEvent: "issue",
152+
PullRequestEvent: "pr",
153+
IssueCommentEvent: "comment",
154+
PushEvent: "push",
155+
CreateEvent: "create",
156+
};
157+
158+
for (const event of events) {
159+
const eventType = eventTypeMap[event.type] || event.type;
160+
if (!event.actor.login) continue;
161+
162+
const userResponse = await fetch(
163+
`https://api.github.com/users/${event.actor.login}`,
164+
{
165+
headers: {
166+
Accept: "application/vnd.github.v3+json",
167+
"User-Agent": "hyprnote-admin",
168+
},
169+
},
170+
);
171+
172+
let bio: string | null = null;
173+
if (userResponse.ok) {
174+
const userData = await userResponse.json();
175+
bio = userData.bio;
176+
}
177+
178+
const profileUrl = `https://github.com/${event.actor.login}`;
179+
180+
await sql`
181+
INSERT INTO public.github_star_leads (github_username, github_id, avatar_url, profile_url, bio, event_type, repo_name, event_at)
182+
VALUES (${event.actor.login}, ${event.actor.id}, ${event.actor.avatar_url}, ${profileUrl}, ${bio}, ${eventType}, ${event.repo.name}, ${event.created_at})
183+
ON CONFLICT (github_username) DO UPDATE SET
184+
avatar_url = EXCLUDED.avatar_url,
185+
bio = COALESCE(EXCLUDED.bio, github_star_leads.bio),
186+
event_type = EXCLUDED.event_type,
187+
event_at = GREATEST(EXCLUDED.event_at, github_star_leads.event_at)`;
188+
added++;
189+
}
190+
191+
const countResult =
192+
await sql`SELECT COUNT(*) as count FROM public.github_star_leads`;
193+
194+
return { added, total: parseInt(String(countResult[0].count), 10) };
195+
}
196+
197+
const RESEARCH_PROMPT = `You are an assistant to the founders of Hyprnote.
198+
199+
Hyprnote is a privacy-first AI notepad for meetings — it runs transcription and summarization locally on-device, without bots or cloud recording. Think of it as the "anti-Otter.ai" for professionals who care about privacy.
200+
201+
I'm sending you data about a GitHub user who interacted with our repository (starred, forked, opened an issue, etc). Your job is to exhaustively research this person using the information provided to determine if they are:
202+
203+
1. A potential customer (someone who would benefit from Hyprnote)
204+
2. A potential hire (talented developer who could contribute to Hyprnote)
205+
3. A potential community contributor
206+
207+
Hyprnote's ideal customer profile:
208+
1. Professional who has frequent meetings (sales, consulting, recruiting, healthcare, legal, journalism, engineering management)
209+
2. Privacy-conscious — works with sensitive data
210+
3. Tech-savvy enough to appreciate local AI / on-device processing
211+
4. Uses a Mac (our primary platform)
212+
213+
Hyprnote's ideal hire profile:
214+
1. Strong Rust and/or TypeScript developer
215+
2. Experience with audio processing, ML/AI, or desktop apps (Tauri/Electron)
216+
3. Open source contributor
217+
4. Passionate about privacy and local-first software
218+
219+
Return your final response in JSON only with the following schema:
220+
{
221+
"name": string,
222+
"company": string,
223+
"match": boolean,
224+
"score": number,
225+
"reasoning": string
226+
}
227+
228+
- The score field is from 0 to 100.
229+
- The company is where they currently work, or "" if unknown.
230+
- For the "reasoning" field, write in Markdown. Include newlines where appropriate.
231+
- If the person works at Hyprnote (fastrepl), there is no match and the score is 0.
232+
- Focus on whether they'd be a good customer, hire, or contributor.`;
233+
234+
export async function researchLead(
235+
username: string,
236+
openrouterApiKey: string,
237+
): Promise<{
238+
success: boolean;
239+
lead?: StarLead;
240+
error?: string;
241+
}> {
242+
const sql = getSql();
243+
244+
const existing =
245+
await sql`SELECT * FROM public.github_star_leads WHERE github_username = ${username}`;
246+
247+
if (existing.length === 0) {
248+
return { success: false, error: "User not found in leads table" };
249+
}
250+
251+
const lead = existing[0] as unknown as StarLead;
252+
253+
const profileResponse = await fetch(
254+
`https://api.github.com/users/${username}`,
255+
{
256+
headers: {
257+
Accept: "application/vnd.github.v3+json",
258+
"User-Agent": "hyprnote-admin",
259+
},
260+
},
261+
);
262+
263+
let profileData: Record<string, string | number | null> = {};
264+
if (profileResponse.ok) {
265+
profileData = await profileResponse.json();
266+
}
267+
268+
const reposResponse = await fetch(
269+
`https://api.github.com/users/${username}/repos?sort=stars&per_page=10`,
270+
{
271+
headers: {
272+
Accept: "application/vnd.github.v3+json",
273+
"User-Agent": "hyprnote-admin",
274+
},
275+
},
276+
);
277+
278+
let topRepos: Array<{
279+
name: string;
280+
description: string | null;
281+
language: string | null;
282+
stargazers_count: number;
283+
}> = [];
284+
if (reposResponse.ok) {
285+
topRepos = await reposResponse.json();
286+
}
287+
288+
const userInfo = `GitHub Username: ${username}
289+
Name: ${profileData.name || "Unknown"}
290+
Bio: ${profileData.bio || "N/A"}
291+
Company: ${profileData.company || "N/A"}
292+
Location: ${profileData.location || "N/A"}
293+
Blog/Website: ${profileData.blog || "N/A"}
294+
Twitter: ${profileData.twitter_username || "N/A"}
295+
Public Repos: ${profileData.public_repos || 0}
296+
Followers: ${profileData.followers || 0}
297+
Following: ${profileData.following || 0}
298+
Profile URL: https://github.com/${username}
299+
Event Type: ${lead.event_type} on ${lead.repo_name}
300+
301+
Top Repositories:
302+
${topRepos
303+
.slice(0, 5)
304+
.map(
305+
(r) =>
306+
`- ${r.name}: ${r.description || "No description"} (${r.language || "Unknown"}, ${r.stargazers_count} stars)`,
307+
)
308+
.join("\n")}`;
309+
310+
const llmResponse = await fetch(
311+
"https://openrouter.ai/api/v1/chat/completions",
312+
{
313+
method: "POST",
314+
headers: {
315+
Authorization: `Bearer ${openrouterApiKey}`,
316+
"Content-Type": "application/json",
317+
},
318+
body: JSON.stringify({
319+
model: "openai/gpt-4o-mini",
320+
messages: [
321+
{ role: "system", content: RESEARCH_PROMPT },
322+
{
323+
role: "user",
324+
content: `Research this GitHub user:\n\n${userInfo}`,
325+
},
326+
],
327+
temperature: 0.3,
328+
response_format: { type: "json_object" },
329+
}),
330+
},
331+
);
332+
333+
if (!llmResponse.ok) {
334+
const errText = await llmResponse.text();
335+
return { success: false, error: `OpenRouter API error: ${errText}` };
336+
}
337+
338+
const llmData = await llmResponse.json();
339+
const content = llmData.choices?.[0]?.message?.content;
340+
341+
if (!content) {
342+
return { success: false, error: "No response from LLM" };
343+
}
344+
345+
let parsed: {
346+
name: string;
347+
company: string;
348+
match: boolean;
349+
score: number;
350+
reasoning: string;
351+
};
352+
try {
353+
parsed = JSON.parse(content);
354+
} catch {
355+
return {
356+
success: false,
357+
error: `Failed to parse LLM response: ${content}`,
358+
};
359+
}
360+
361+
const parsedName = parsed.name || "";
362+
const parsedCompany = parsed.company || "";
363+
const parsedReasoning = parsed.reasoning || "";
364+
const parsedBio = profileData.bio ? String(profileData.bio) : null;
365+
366+
await sql`
367+
UPDATE public.github_star_leads SET
368+
name = ${parsedName},
369+
company = ${parsedCompany},
370+
is_match = ${parsed.match},
371+
score = ${parsed.score},
372+
reasoning = ${parsedReasoning},
373+
bio = COALESCE(bio, ${parsedBio}),
374+
researched_at = NOW()
375+
WHERE github_username = ${username}`;
376+
377+
const updated =
378+
await sql`SELECT * FROM public.github_star_leads WHERE github_username = ${username}`;
379+
380+
return { success: true, lead: updated[0] as unknown as StarLead };
381+
}

0 commit comments

Comments
 (0)