Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
Blame · Line-by-line history

competitive-intel.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

competitive-intel.tsBlame596 lines · 1 contributor
5c83cccClaude1/**
2 * Competitive Intelligence Engine.
3 *
4 * Monitors GitHub, GitLab, Bitbucket, Linear, Vercel, and Render changelogs
5 * weekly, uses Claude to analyse what they've shipped, and surfaces a
6 * "what are we missing?" gap report for the admin team.
7 *
8 * Key exports:
9 * runIntelligenceScan() — main orchestrator (call from admin UI / cron)
10 * getLatestReports() — one report per competitor, most recent
11 * getReportHistory() — historical reports for a single competitor
12 */
13
14import {
15 pgTable,
16 uuid,
17 text,
18 jsonb,
19 integer,
20 timestamp,
21 date,
22 index,
23 uniqueIndex,
24} from "drizzle-orm/pg-core";
25import { eq, desc, sql, and } from "drizzle-orm";
26import { db } from "../db";
27import { config } from "./config";
28import { getAnthropic, isAiAvailable, extractText, parseJsonResponse } from "./ai-client";
29
30// ---------------------------------------------------------------------------
31// Schema (defined inline because these are new tables not in schema.ts yet)
32// ---------------------------------------------------------------------------
33
34export const competitorReports = pgTable(
35 "competitor_reports",
36 {
37 id: uuid("id").primaryKey().defaultRandom(),
38 competitor: text("competitor").notNull(),
39 reportDate: date("report_date").notNull(),
40 rawContent: text("raw_content").notNull(),
41 featuresShipped: jsonb("features_shipped")
42 .notNull()
43 .$type<FeatureShipped[]>()
44 .default([]),
45 gapsIdentified: jsonb("gaps_identified")
46 .notNull()
47 .$type<GapIdentified[]>()
48 .default([]),
49 summary: text("summary").notNull().default(""),
50 modelUsed: text("model_used").notNull().default("claude-sonnet-4-6"),
51 createdAt: timestamp("created_at", { withTimezone: true })
52 .notNull()
53 .defaultNow(),
54 },
55 (table) => [
56 index("competitor_reports_competitor").on(table.competitor),
57 index("competitor_reports_date").on(table.reportDate),
58 uniqueIndex("competitor_reports_unique").on(
59 table.competitor,
60 table.reportDate
61 ),
62 ]
63);
64
65export const intelScanRuns = pgTable("intel_scan_runs", {
66 id: uuid("id").primaryKey().defaultRandom(),
67 startedAt: timestamp("started_at", { withTimezone: true })
68 .notNull()
69 .defaultNow(),
70 completedAt: timestamp("completed_at", { withTimezone: true }),
71 status: text("status").notNull().default("running"),
72 competitorsScanned: integer("competitors_scanned").notNull().default(0),
73 error: text("error"),
74});
75
76// ---------------------------------------------------------------------------
77// Types
78// ---------------------------------------------------------------------------
79
80export type FeatureShipped = {
81 title: string;
82 description: string;
83 url?: string;
84};
85
86export type GapIdentified = {
87 feature: string;
88 priority: "high" | "medium" | "low";
89 notes: string;
90};
91
92export type CompetitorReport = typeof competitorReports.$inferSelect;
93export type IntelScanRun = typeof intelScanRuns.$inferSelect;
94
95// ---------------------------------------------------------------------------
96// Competitor definitions
97// ---------------------------------------------------------------------------
98
99const COMPETITORS = [
100 {
101 id: "github",
102 name: "GitHub",
103 changelogUrl: "https://github.blog/changelog/",
104 rssUrl: "https://github.blog/changelog/feed/",
105 },
106 {
107 id: "gitlab",
108 name: "GitLab",
109 changelogUrl: "https://about.gitlab.com/releases/",
110 rssUrl: "https://about.gitlab.com/atom.xml",
111 },
112 {
113 id: "bitbucket",
114 name: "Bitbucket",
115 changelogUrl: "https://bitbucket.org/blog/",
116 rssUrl: "https://bitbucket.org/blog/rss",
117 },
118 {
119 id: "linear",
120 name: "Linear",
121 changelogUrl: "https://linear.app/changelog",
122 rssUrl: "https://linear.app/changelog/rss.xml",
123 },
124 {
125 id: "vercel",
126 name: "Vercel",
127 changelogUrl: "https://vercel.com/changelog",
128 rssUrl: null, // no RSS — scrape HTML
129 },
130 {
131 id: "render",
132 name: "Render",
133 changelogUrl: "https://render.com/changelog",
134 rssUrl: null, // no RSS — scrape HTML
135 },
136] as const;
137
138export { COMPETITORS };
139
140// ---------------------------------------------------------------------------
141// Helpers
142// ---------------------------------------------------------------------------
143
144/** Returns the ISO date string for the Monday of the current week. */
145function getMondayOfCurrentWeek(): string {
146 const now = new Date();
147 const day = now.getUTCDay(); // 0=Sun, 1=Mon, …
148 const diff = (day === 0 ? -6 : 1 - day);
149 const monday = new Date(now);
150 monday.setUTCDate(now.getUTCDate() + diff);
151 return monday.toISOString().slice(0, 10); // "YYYY-MM-DD"
152}
153
154/** Strip HTML tags from a string. */
155function stripHtml(html: string): string {
156 return html
157 .replace(/<script[\s\S]*?<\/script>/gi, " ")
158 .replace(/<style[\s\S]*?<\/style>/gi, " ")
159 .replace(/<[^>]+>/g, " ")
160 .replace(/&amp;/g, "&")
161 .replace(/&lt;/g, "<")
162 .replace(/&gt;/g, ">")
163 .replace(/&quot;/g, '"')
164 .replace(/&#39;/g, "'")
165 .replace(/&nbsp;/g, " ")
166 .replace(/\s{2,}/g, " ")
167 .trim();
168}
169
170/**
171 * Parse RSS/Atom XML by hand using regex.
172 * Returns up to `limit` entries as plain text chunks.
173 */
174function parseRssItems(xml: string, limit = 20): string {
175 const items: string[] = [];
176
177 // Match <item> … </item> (RSS) or <entry> … </entry> (Atom)
178 const itemRe = /<(?:item|entry)[\s>]([\s\S]*?)<\/(?:item|entry)>/gi;
179 let match: RegExpExecArray | null;
180
181 while ((match = itemRe.exec(xml)) !== null && items.length < limit) {
182 const chunk = match[1];
183
184 // title — try CDATA first, then plain
185 const titleMatch =
186 chunk.match(/<title[^>]*><!\[CDATA\[([\s\S]*?)\]\]><\/title>/i) ||
187 chunk.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
188 const title = titleMatch ? stripHtml(titleMatch[1]).trim() : "";
189
190 // description / summary / content
191 const descMatch =
192 chunk.match(
193 /<(?:description|summary|content(?::[a-z]+)?)[^>]*><!\[CDATA\[([\s\S]*?)\]\]><\/(?:description|summary|content(?::[a-z]+)?)>/i
194 ) ||
195 chunk.match(
196 /<(?:description|summary|content(?::[a-z]+)?)[^>]*>([\s\S]*?)<\/(?:description|summary|content(?::[a-z]+)?)>/i
197 );
198 const desc = descMatch ? stripHtml(descMatch[1]).trim() : "";
199
200 // link
201 const linkMatch =
202 chunk.match(/<link[^>]*href="([^"]+)"/i) ||
203 chunk.match(/<link[^>]*>(https?:\/\/[^\s<]+)<\/link>/i);
204 const link = linkMatch ? linkMatch[1].trim() : "";
205
206 if (title || desc) {
207 const parts = [title && `Title: ${title}`, desc && `Summary: ${desc.slice(0, 500)}`, link && `URL: ${link}`].filter(Boolean);
208 items.push(parts.join("\n"));
209 }
210 }
211
212 return items.join("\n\n---\n\n");
213}
214
215// ---------------------------------------------------------------------------
216// Core functions
217// ---------------------------------------------------------------------------
218
219/**
220 * Fetch recent changelog content for a competitor.
221 * Tries RSS first (if available), falls back to HTML scrape.
222 * Never throws — returns empty string on any error.
223 */
224export async function fetchCompetitorContent(competitor: (typeof COMPETITORS)[number]): Promise<string> {
225 const timeout = 15_000;
226
227 // ---- Try RSS/Atom feed ----
228 if (competitor.rssUrl) {
229 try {
230 const ctrl = new AbortController();
231 const timer = setTimeout(() => ctrl.abort(), timeout);
232 const res = await fetch(competitor.rssUrl, {
233 signal: ctrl.signal,
234 headers: { "User-Agent": "Gluecron-Intel-Bot/1.0" },
235 });
236 clearTimeout(timer);
237 if (res.ok) {
238 const xml = await res.text();
239 const parsed = parseRssItems(xml, 20);
240 if (parsed.trim().length > 50) return parsed;
241 }
242 } catch {
243 // fall through to HTML scrape
244 }
245 }
246
247 // ---- Fall back to HTML changelog page ----
248 try {
249 const ctrl = new AbortController();
250 const timer = setTimeout(() => ctrl.abort(), timeout);
251 const res = await fetch(competitor.changelogUrl, {
252 signal: ctrl.signal,
253 headers: { "User-Agent": "Gluecron-Intel-Bot/1.0" },
254 });
255 clearTimeout(timer);
256 if (res.ok) {
257 const html = await res.text();
258 const text = stripHtml(html);
259 return text.slice(0, 8000);
260 }
261 } catch {
262 // fall through
263 }
264
265 return "";
266}
267
268/**
269 * Hardcoded summary of Gluecron's existing features.
270 * Used as context when asking Claude to identify gaps.
271 */
272export function getGluecronFeatureSummary(): string {
273 return `
274Gluecron is an AI-native GitHub-alternative platform. It currently ships the following features:
275
276CORE GIT HOSTING
277- Bare git repository hosting with Smart HTTP (clone, push, pull)
278- Branch management, tags, commits, file browser, diffs, blame
279- Syntax highlighting for 40+ languages; Markdown rendering
280- Raw file serving; semantic code search (Voyage AI embeddings)
281
282AI FEATURES
283- AI code review on every pull request (Claude-powered, inline annotations)
284- AI merge conflict resolver (auto-repair on push)
285- Spec-to-PR: describe a feature → Claude writes the code + opens a PR
286- AI commit message generation
287- AI PR summary / changelog generation
288- AI code explain (explain any file or diff in plain English)
289- AI test generation
290- GateTest integration: push-time security + quality gate enforcement
291- Auto-repair pipeline: lint/type/test failures auto-fixed by Claude
292- AI-powered dep-updater (automated dependency PRs)
293
294COLLABORATION
295- Issue tracker with labels, comments, open/close/reopen
296- Pull requests with reviews, inline comments, close/merge
297- Branch comparison view
298- Activity feed per repository
299- Discussions (threaded, category-based)
300- Wikis per repository
301- Gists (standalone code snippets)
302- Projects (kanban board)
303
304PLATFORM
305- Webhooks (HMAC-signed, per-event filtering, delivery log)
306- Personal access tokens (SHA-256 hashed)
307- OAuth 2.0 provider (developer apps can request scopes)
308- Two-factor authentication (TOTP)
309- Passkeys / WebAuthn
310- SSH key management
311- Organization accounts with team-level permissions
312- Repository forking with fork count tracking
313- Repository stars
314- Repository topics / tags for discoverability
315- Explore / discover public repos
316- Contributors graph + commit activity
317- Repo settings (description, visibility, delete)
318- Branch protection rules (require PR, green gates, approvals)
319- Repository templates
320- Repository archival
321- Site admin panel (user management, flags, audit log)
322- Email digests + mention/assign notifications
323
324DEPLOYMENT & PACKAGING
325- GitHub Pages-style static site hosting (Pages)
326- Package registry (Packages)
327- Workflow / CI engine (YAML-based, similar to GitHub Actions)
328- Deployment environments (staging, production)
329- Deploy webhooks to external services (Render, Fly.io, etc.)
330- Workflow secrets management (AES-256-GCM)
331- Workflow artifact storage
332`.trim();
333}
334
335type AnalysisResult = {
336 featuresShipped: FeatureShipped[];
337 gapsIdentified: GapIdentified[];
338 summary: string;
339};
340
341/**
342 * Send competitor changelog content to Claude for analysis.
343 * Returns structured gap report. Never throws.
344 */
345export async function analyzeWithClaude(
346 competitor: (typeof COMPETITORS)[number],
347 rawContent: string,
348 existingGluecronFeatures: string
349): Promise<AnalysisResult> {
350 const empty: AnalysisResult = {
351 featuresShipped: [],
352 gapsIdentified: [],
353 summary: "Analysis unavailable.",
354 };
355
356 if (!isAiAvailable()) return empty;
357 if (!rawContent.trim()) return empty;
358
359 try {
360 const anthropic = getAnthropic();
361
362 const systemPrompt = `You are a product intelligence analyst for Gluecron, an AI-native GitHub-alternative platform.
363
364Your job is to analyse a competitor's recent changelog and identify:
3651. What features the competitor has shipped recently
3662. Which of those features Gluecron does NOT already have (gaps)
367
368When rating gap priority:
369- HIGH: Core developer workflow features that many users would miss; features that give the competitor a meaningful advantage
370- MEDIUM: Nice-to-have additions that improve UX or cover secondary use-cases
371- LOW: Niche, very org-specific, or features that few users would care about
372
373Always respond with ONLY valid JSON matching this exact schema (no surrounding prose):
374{
375 "featuresShipped": [
376 { "title": "string", "description": "string", "url": "string or omit" }
377 ],
378 "gapsIdentified": [
379 { "feature": "string", "priority": "high|medium|low", "notes": "string" }
380 ],
381 "summary": "2-sentence overall summary of what this competitor is focused on and what the biggest threat to Gluecron is."
382}`;
383
384 const userPrompt = `Competitor: ${competitor.name}
385Changelog URL: ${competitor.changelogUrl}
386
387--- GLUECRON EXISTING FEATURES ---
388${existingGluecronFeatures}
389
390--- ${competitor.name.toUpperCase()} RECENT CHANGELOG ---
391${rawContent.slice(0, 12000)}
392
393Analyse the changelog above. List all features shipped. Then identify gaps — features the competitor has that Gluecron does NOT already have based on the list above. Rate each gap's priority. Return only JSON.`;
394
395 const message = await anthropic.messages.create({
396 model: "claude-sonnet-4-6",
397 max_tokens: 4096,
398 messages: [{ role: "user", content: userPrompt }],
399 system: systemPrompt,
400 });
401
402 const text = extractText(message);
403 const parsed = parseJsonResponse<AnalysisResult>(text);
404
405 if (!parsed) return empty;
406
407 return {
408 featuresShipped: Array.isArray(parsed.featuresShipped)
409 ? parsed.featuresShipped
410 : [],
411 gapsIdentified: Array.isArray(parsed.gapsIdentified)
412 ? parsed.gapsIdentified
413 : [],
414 summary:
415 typeof parsed.summary === "string" && parsed.summary.trim()
416 ? parsed.summary.trim()
417 : "Analysis unavailable.",
418 };
419 } catch (err) {
420 console.error(`[competitive-intel] Claude analysis failed for ${competitor.name}:`, err);
421 return empty;
422 }
423}
424
425/**
426 * Main orchestrator. Run this from the admin UI or a scheduled job.
427 * Never throws — returns a summary object even on partial failure.
428 */
429export async function runIntelligenceScan(): Promise<{
430 success: boolean;
431 reportsCreated: number;
432 errors: string[];
433}> {
434 const errors: string[] = [];
435 let reportsCreated = 0;
436 let runId: string | null = null;
437
438 // Create a scan run record
439 try {
440 const [run] = await db
441 .insert(intelScanRuns)
442 .values({ status: "running" })
443 .returning({ id: intelScanRuns.id });
444 runId = run?.id ?? null;
445 } catch (err) {
446 console.error("[competitive-intel] Failed to create scan run record:", err);
447 // Continue anyway
448 }
449
450 const weekOf = getMondayOfCurrentWeek();
451 const gluecronFeatures = getGluecronFeatureSummary();
452
453 for (const competitor of COMPETITORS) {
454 try {
455 // Check if a report already exists for this week
456 const [existing] = await db
457 .select({ id: competitorReports.id })
458 .from(competitorReports)
459 .where(
460 and(
461 eq(competitorReports.competitor, competitor.id),
462 eq(competitorReports.reportDate, weekOf)
463 )
464 )
465 .limit(1);
466
467 if (existing) {
468 console.log(`[competitive-intel] Skipping ${competitor.name} — report for ${weekOf} already exists`);
469 continue;
470 }
471
472 // Fetch content
473 console.log(`[competitive-intel] Fetching ${competitor.name}...`);
474 const rawContent = await fetchCompetitorContent(competitor);
475
476 if (!rawContent.trim()) {
477 errors.push(`${competitor.name}: no content fetched`);
478 console.warn(`[competitive-intel] ${competitor.name}: empty content, skipping`);
479 continue;
480 }
481
482 // Analyse with Claude
483 console.log(`[competitive-intel] Analysing ${competitor.name} with Claude...`);
484 const analysis = await analyzeWithClaude(competitor, rawContent, gluecronFeatures);
485
486 // Insert report
487 await db.insert(competitorReports).values({
488 competitor: competitor.id,
489 reportDate: weekOf,
490 rawContent,
491 featuresShipped: analysis.featuresShipped,
492 gapsIdentified: analysis.gapsIdentified,
493 summary: analysis.summary,
494 modelUsed: "claude-sonnet-4-6",
495 });
496
497 reportsCreated++;
498 console.log(`[competitive-intel] ${competitor.name}: report created (${analysis.featuresShipped.length} features, ${analysis.gapsIdentified.length} gaps)`);
499 } catch (err) {
500 const msg = `${competitor.name}: ${err instanceof Error ? err.message : String(err)}`;
501 errors.push(msg);
502 console.error(`[competitive-intel] Error processing ${competitor.name}:`, err);
503 }
504 }
505
506 // Update scan run record
507 if (runId) {
508 try {
509 await db
510 .update(intelScanRuns)
511 .set({
512 completedAt: new Date(),
513 status: errors.length === COMPETITORS.length ? "failed" : "completed",
514 competitorsScanned: reportsCreated,
515 error: errors.length > 0 ? errors.slice(0, 5).join("; ") : null,
516 })
517 .where(eq(intelScanRuns.id, runId));
518 } catch (err) {
519 console.error("[competitive-intel] Failed to update scan run record:", err);
520 }
521 }
522
523 return {
524 success: reportsCreated > 0 || errors.length === 0,
525 reportsCreated,
526 errors,
527 };
528}
529
530/**
531 * Returns the most recent report for each competitor.
532 */
533export async function getLatestReports(): Promise<CompetitorReport[]> {
534 try {
535 // Use a DISTINCT ON query to get the latest report per competitor
536 const rows = await db.execute(sql`
537 SELECT DISTINCT ON (competitor)
538 id, competitor, report_date, raw_content,
539 features_shipped, gaps_identified, summary,
540 model_used, created_at
541 FROM competitor_reports
542 ORDER BY competitor, report_date DESC
543 `);
544
545 return (rows.rows as CompetitorReport[]).map((r) => ({
546 ...r,
547 featuresShipped: (r.featuresShipped ?? []) as FeatureShipped[],
548 gapsIdentified: (r.gapsIdentified ?? []) as GapIdentified[],
549 }));
550 } catch (err) {
551 console.error("[competitive-intel] getLatestReports:", err);
552 return [];
553 }
554}
555
556/**
557 * Returns historical reports for one competitor, most recent first.
558 */
559export async function getReportHistory(
560 competitor: string,
561 limit = 12
562): Promise<CompetitorReport[]> {
563 try {
564 const rows = await db
565 .select()
566 .from(competitorReports)
567 .where(eq(competitorReports.competitor, competitor))
568 .orderBy(desc(competitorReports.reportDate))
569 .limit(limit);
570
571 return rows.map((r) => ({
572 ...r,
573 featuresShipped: (r.featuresShipped ?? []) as FeatureShipped[],
574 gapsIdentified: (r.gapsIdentified ?? []) as GapIdentified[],
575 }));
576 } catch (err) {
577 console.error("[competitive-intel] getReportHistory:", err);
578 return [];
579 }
580}
581
582/**
583 * Returns the most recent scan run record.
584 */
585export async function getLastScanRun(): Promise<IntelScanRun | null> {
586 try {
587 const [row] = await db
588 .select()
589 .from(intelScanRuns)
590 .orderBy(desc(intelScanRuns.startedAt))
591 .limit(1);
592 return row ?? null;
593 } catch {
594 return null;
595 }
596}