Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commit5c83cccunknown_key

feat(intel+mobile): competitive intelligence engine + mobile app scaffold (WIP)

feat(intel+mobile): competitive intelligence engine + mobile app scaffold (WIP)

Competitive intelligence engine:
- drizzle/0038_competitive_intel.sql: new tables competitor_reports +
  intel_scan_runs for storing weekly competitor analysis
- src/lib/competitive-intel.ts: fetches RSS/HTML changelogs from GitHub,
  GitLab, Bitbucket, Linear, Vercel, Render; analyses with Claude Sonnet
  to identify feature gaps; admin trigger + scheduled scan support

React Native mobile app (Expo):
- mobile/package.json: Expo ~52, React Navigation, Zustand, SecureStore
- mobile/src/api/client.ts: base fetch wrapper with PAT Bearer auth
- mobile/src/api/auth.ts: token validation against /api/users/me
- mobile/src/api/repos.ts: repo list/detail API calls
- mobile/src/store/authStore.ts: Zustand auth state + SecureStore persistence
- mobile/src/store/settingsStore.ts: host URL + preferences
- mobile/src/theme/colors.ts: dark theme matching web (#0d1117 base)
- mobile/src/theme/typography.ts: type scale

MCP server + remaining mobile screens still building (agents running).

https://claude.ai/code/session_01HkSzgS2aJpKqv1B9t7o9Cb
Claude committed on April 24, 2026Parent: eb3b81a
14 files changed+124205c83ccc96bae7274ff2299b64bc955afa844964b
14 changed files+1242−0
Addeddrizzle/0038_competitive_intel.sql+45−0View fileUnifiedSplit
1-- Competitive Intelligence Engine — weekly competitor monitoring + gap analysis.
2--
3-- competitor_reports one row per (competitor, week). Stores raw changelog
4-- content, Claude-extracted features shipped, gaps vs
5-- Gluecron, and a short summary.
6-- intel_scan_runs audit log of every scan job invocation — useful for
7-- showing "last scanned at" in the admin UI.
8--
9-- `CREATE TABLE IF NOT EXISTS` + `CREATE INDEX IF NOT EXISTS` throughout so
10-- reruns are idempotent.
11
12--> statement-breakpoint
13CREATE TABLE IF NOT EXISTS "competitor_reports" (
14 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
15 "competitor" text NOT NULL,
16 "report_date" date NOT NULL,
17 "raw_content" text NOT NULL,
18 "features_shipped" jsonb NOT NULL DEFAULT '[]',
19 "gaps_identified" jsonb NOT NULL DEFAULT '[]',
20 "summary" text NOT NULL DEFAULT '',
21 "model_used" text NOT NULL DEFAULT 'claude-sonnet-4-6',
22 "created_at" timestamptz NOT NULL DEFAULT now()
23);
24
25--> statement-breakpoint
26CREATE INDEX IF NOT EXISTS "competitor_reports_competitor"
27 ON "competitor_reports"("competitor");
28
29--> statement-breakpoint
30CREATE INDEX IF NOT EXISTS "competitor_reports_date"
31 ON "competitor_reports"("report_date" DESC);
32
33--> statement-breakpoint
34CREATE UNIQUE INDEX IF NOT EXISTS "competitor_reports_unique"
35 ON "competitor_reports"("competitor", "report_date");
36
37--> statement-breakpoint
38CREATE TABLE IF NOT EXISTS "intel_scan_runs" (
39 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
40 "started_at" timestamptz NOT NULL DEFAULT now(),
41 "completed_at" timestamptz,
42 "status" text NOT NULL DEFAULT 'running',
43 "competitors_scanned" integer NOT NULL DEFAULT 0,
44 "error" text
45);
Addedmobile/.env.example+8−0View fileUnifiedSplit
1# Gluecron Mobile App — Environment Variables
2# Copy this file to .env and fill in values
3
4# Default Gluecron host URL (can be overridden in the app's Settings screen)
5EXPO_PUBLIC_DEFAULT_HOST=https://gluecron.com
6
7# EAS project ID (required for EAS Build)
8# EAS_PROJECT_ID=your-eas-project-id
Addedmobile/app.json+23−0View fileUnifiedSplit
1{
2 "expo": {
3 "name": "Gluecron",
4 "slug": "gluecron",
5 "version": "1.0.0",
6 "orientation": "portrait",
7 "icon": "./assets/icon.png",
8 "userInterfaceStyle": "dark",
9 "splash": {
10 "backgroundColor": "#0d1117"
11 },
12 "ios": {
13 "supportsTablet": true,
14 "bundleIdentifier": "com.gluecron.app"
15 },
16 "android": {
17 "adaptiveIcon": {
18 "backgroundColor": "#0d1117"
19 },
20 "package": "com.gluecron.app"
21 }
22 }
23}
Addedmobile/babel.config.js+17−0View fileUnifiedSplit
1module.exports = function (api) {
2 api.cache(true);
3 return {
4 presets: ['babel-preset-expo'],
5 plugins: [
6 [
7 'module-resolver',
8 {
9 root: ['./src'],
10 alias: {
11 '@': './src',
12 },
13 },
14 ],
15 ],
16 };
17};
Addedmobile/package.json+31−0View fileUnifiedSplit
1{
2 "name": "gluecron-mobile",
3 "version": "1.0.0",
4 "main": "expo-router/entry",
5 "scripts": {
6 "start": "expo start",
7 "android": "expo start --android",
8 "ios": "expo start --ios",
9 "build:android": "eas build --platform android",
10 "build:ios": "eas build --platform ios"
11 },
12 "dependencies": {
13 "expo": "~52.0.0",
14 "expo-status-bar": "~2.0.0",
15 "expo-secure-store": "~14.0.0",
16 "react": "18.3.1",
17 "react-native": "0.76.5",
18 "@react-navigation/native": "^6.1.18",
19 "@react-navigation/native-stack": "^6.11.0",
20 "@react-navigation/bottom-tabs": "^6.6.1",
21 "react-native-safe-area-context": "4.12.0",
22 "react-native-screens": "~4.1.0",
23 "zustand": "^5.0.0",
24 "react-native-markdown-display": "^7.0.0-alpha.2"
25 },
26 "devDependencies": {
27 "@babel/core": "^7.25.0",
28 "@types/react": "~18.3.12",
29 "typescript": "^5.3.0"
30 }
31}
Addedmobile/src/api/auth.ts+68−0View fileUnifiedSplit
1import { fetchJSON, saveToken, removeToken, PAT_STORE_KEY } from './client';
2import * as SecureStore from 'expo-secure-store';
3import { useSettingsStore } from '../store/settingsStore';
4import type { AuthUser } from '../store/authStore';
5
6export interface MeResponse {
7 id: number;
8 username: string;
9 email: string;
10 avatarUrl: string | null;
11 bio: string | null;
12 createdAt: string;
13}
14
15/**
16 * Validates a PAT token against the given host by calling GET /api/users/me.
17 * The host is temporarily set in the settings store before the call.
18 * Returns the user profile on success; throws ApiError on failure.
19 */
20export async function validateToken(host: string, token: string): Promise<AuthUser> {
21 // Temporarily point the store at the target host so client.ts builds the
22 // correct URL. The final host is persisted by the caller (useAuth hook).
23 useSettingsStore.getState().setHost(host);
24
25 // Provide the token inline since it is not yet saved to SecureStore.
26 const response = await fetch(`${host.replace(/\/$/, '')}/api/users/me`, {
27 headers: {
28 Authorization: `Bearer ${token}`,
29 Accept: 'application/json',
30 },
31 });
32
33 if (!response.ok) {
34 let msg = `HTTP ${response.status}`;
35 try {
36 const body = (await response.json()) as Record<string, unknown>;
37 if (typeof body.error === 'string') msg = body.error;
38 } catch {
39 // ignore parse failures
40 }
41 throw new Error(msg);
42 }
43
44 const data = (await response.json()) as MeResponse;
45 return {
46 id: data.id,
47 username: data.username,
48 email: data.email,
49 avatarUrl: data.avatarUrl ?? null,
50 bio: data.bio ?? null,
51 createdAt: data.createdAt,
52 };
53}
54
55/** Persists the token to SecureStore. */
56export async function persistToken(token: string): Promise<void> {
57 await saveToken(token);
58}
59
60/** Removes the saved token from SecureStore. */
61export async function clearToken(): Promise<void> {
62 await removeToken();
63}
64
65/** Retrieves the current user profile from the API. */
66export async function fetchMe(): Promise<AuthUser> {
67 return fetchJSON<AuthUser>('/api/users/me');
68}
Addedmobile/src/api/client.ts+166−0View fileUnifiedSplit
1import * as SecureStore from 'expo-secure-store';
2import { useSettingsStore } from '../store/settingsStore';
3
4export const PAT_STORE_KEY = 'gluecron_pat';
5
6/** Retrieves the saved PAT from SecureStore. Returns null if none saved. */
7export async function getSavedToken(): Promise<string | null> {
8 return SecureStore.getItemAsync(PAT_STORE_KEY);
9}
10
11/** Saves a PAT to SecureStore. */
12export async function saveToken(token: string): Promise<void> {
13 await SecureStore.setItemAsync(PAT_STORE_KEY, token);
14}
15
16/** Removes the saved PAT from SecureStore. */
17export async function removeToken(): Promise<void> {
18 await SecureStore.deleteItemAsync(PAT_STORE_KEY);
19}
20
21/** Build the full URL for a given API path. */
22function buildUrl(path: string): string {
23 const host = useSettingsStore.getState().host;
24 const normalizedPath = path.startsWith('/') ? path : `/${path}`;
25 return `${host}${normalizedPath}`;
26}
27
28/** Build common headers, injecting the Bearer token if one is saved. */
29async function buildHeaders(extra?: Record<string, string>): Promise<Record<string, string>> {
30 const token = await getSavedToken();
31 const headers: Record<string, string> = {
32 'Content-Type': 'application/json',
33 Accept: 'application/json',
34 ...extra,
35 };
36 if (token) {
37 headers['Authorization'] = `Bearer ${token}`;
38 }
39 return headers;
40}
41
42export class ApiError extends Error {
43 constructor(
44 public readonly status: number,
45 message: string,
46 public readonly body?: unknown,
47 ) {
48 super(message);
49 this.name = 'ApiError';
50 }
51}
52
53/** Performs a GET (or custom method) request and parses the JSON response. */
54export async function fetchJSON<T>(path: string, init?: RequestInit): Promise<T> {
55 const headers = await buildHeaders(init?.headers as Record<string, string> | undefined);
56 const response = await fetch(buildUrl(path), {
57 ...init,
58 headers,
59 });
60
61 if (!response.ok) {
62 let body: unknown;
63 try {
64 body = await response.json();
65 } catch {
66 body = await response.text();
67 }
68 const message =
69 typeof body === 'object' && body !== null && 'error' in body
70 ? String((body as Record<string, unknown>).error)
71 : `HTTP ${response.status}`;
72 throw new ApiError(response.status, message, body);
73 }
74
75 return response.json() as Promise<T>;
76}
77
78/** Performs a POST request with a JSON body. */
79export async function postJSON<T>(path: string, body: unknown): Promise<T> {
80 const headers = await buildHeaders();
81 const response = await fetch(buildUrl(path), {
82 method: 'POST',
83 headers,
84 body: JSON.stringify(body),
85 });
86
87 if (!response.ok) {
88 let errorBody: unknown;
89 try {
90 errorBody = await response.json();
91 } catch {
92 errorBody = await response.text();
93 }
94 const message =
95 typeof errorBody === 'object' &&
96 errorBody !== null &&
97 'error' in errorBody
98 ? String((errorBody as Record<string, unknown>).error)
99 : `HTTP ${response.status}`;
100 throw new ApiError(response.status, message, errorBody);
101 }
102
103 return response.json() as Promise<T>;
104}
105
106/** Performs a PATCH request with a JSON body. */
107export async function patchJSON<T>(path: string, body: unknown): Promise<T> {
108 const headers = await buildHeaders();
109 const response = await fetch(buildUrl(path), {
110 method: 'PATCH',
111 headers,
112 body: JSON.stringify(body),
113 });
114
115 if (!response.ok) {
116 let errorBody: unknown;
117 try {
118 errorBody = await response.json();
119 } catch {
120 errorBody = await response.text();
121 }
122 const message =
123 typeof errorBody === 'object' &&
124 errorBody !== null &&
125 'error' in errorBody
126 ? String((errorBody as Record<string, unknown>).error)
127 : `HTTP ${response.status}`;
128 throw new ApiError(response.status, message, errorBody);
129 }
130
131 return response.json() as Promise<T>;
132}
133
134/** Performs a DELETE request. */
135export async function deleteRequest(path: string): Promise<void> {
136 const headers = await buildHeaders();
137 const response = await fetch(buildUrl(path), {
138 method: 'DELETE',
139 headers,
140 });
141
142 if (!response.ok) {
143 throw new ApiError(response.status, `HTTP ${response.status}`);
144 }
145}
146
147/** Executes a GraphQL query against /api/graphql. */
148export async function graphql<T>(
149 query: string,
150 variables?: Record<string, unknown>,
151): Promise<T> {
152 const result = await postJSON<{ data?: T; errors?: Array<{ message: string }> }>(
153 '/api/graphql',
154 { query, variables },
155 );
156
157 if (result.errors && result.errors.length > 0) {
158 throw new Error(result.errors.map((e) => e.message).join('; '));
159 }
160
161 if (result.data === undefined) {
162 throw new Error('GraphQL response missing data field');
163 }
164
165 return result.data;
166}
Addedmobile/src/api/repos.ts+148−0View fileUnifiedSplit
1import { fetchJSON, postJSON } from './client';
2
3export interface Repository {
4 id: number;
5 name: string;
6 fullName: string;
7 description: string | null;
8 isPrivate: boolean;
9 isArchived: boolean;
10 isFork: boolean;
11 defaultBranch: string;
12 language: string | null;
13 starCount: number;
14 forkCount: number;
15 openIssueCount: number;
16 ownerUsername: string;
17 updatedAt: string;
18 createdAt: string;
19}
20
21export interface TreeEntry {
22 name: string;
23 type: 'blob' | 'tree';
24 mode: string;
25 sha: string;
26 path: string;
27}
28
29export interface FileContent {
30 name: string;
31 path: string;
32 sha: string;
33 content: string;
34 encoding: string;
35 size: number;
36}
37
38export interface CommitEntry {
39 sha: string;
40 message: string;
41 authorName: string;
42 authorEmail: string;
43 authorDate: string;
44 committerName: string;
45 committerDate: string;
46 parentShas: string[];
47}
48
49export interface BranchInfo {
50 name: string;
51 sha: string;
52 isDefault: boolean;
53 isProtected: boolean;
54}
55
56export interface SearchResult {
57 file: string;
58 line: number;
59 content: string;
60 sha: string;
61}
62
63export interface RepoStats {
64 commits: number;
65 branches: number;
66 tags: number;
67 contributors: number;
68}
69
70/** List repositories for a user. */
71export async function listUserRepos(username: string): Promise<Repository[]> {
72 return fetchJSON<Repository[]>(`/api/users/${encodeURIComponent(username)}/repos`);
73}
74
75/** List all public repos (explore). */
76export async function listPublicRepos(page = 1): Promise<Repository[]> {
77 return fetchJSON<Repository[]>(`/api/repos?page=${page}`);
78}
79
80/** Get a single repository. */
81export async function getRepo(owner: string, repo: string): Promise<Repository> {
82 return fetchJSON<Repository>(`/api/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`);
83}
84
85/** List the file tree for a repo at a given ref and path. */
86export async function getTree(
87 owner: string,
88 repo: string,
89 ref = 'HEAD',
90 path = '',
91): Promise<TreeEntry[]> {
92 const encodedPath = path ? `/${encodeURIComponent(path)}` : '';
93 return fetchJSON<TreeEntry[]>(
94 `/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/tree/${encodeURIComponent(ref)}${encodedPath}?json=1`,
95 );
96}
97
98/** Get raw file content. */
99export async function getFileContent(
100 owner: string,
101 repo: string,
102 ref: string,
103 path: string,
104): Promise<string> {
105 const response = await fetch(
106 `${require('../store/settingsStore').useSettingsStore.getState().host}/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/raw/${encodeURIComponent(ref)}/${path}`,
107 {
108 headers: { Accept: 'text/plain' },
109 },
110 );
111 if (!response.ok) throw new Error(`HTTP ${response.status}`);
112 return response.text();
113}
114
115/** List commits on a branch. */
116export async function listCommits(
117 owner: string,
118 repo: string,
119 branch = 'HEAD',
120 page = 1,
121): Promise<CommitEntry[]> {
122 return fetchJSON<CommitEntry[]>(
123 `/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/commits?branch=${encodeURIComponent(branch)}&page=${page}&json=1`,
124 );
125}
126
127/** List branches. */
128export async function listBranches(owner: string, repo: string): Promise<BranchInfo[]> {
129 return fetchJSON<BranchInfo[]>(
130 `/api/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/branches`,
131 );
132}
133
134/** Star a repo. */
135export async function starRepo(owner: string, repo: string): Promise<void> {
136 await postJSON(`/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/star`, {});
137}
138
139/** Search code in a repo. */
140export async function searchCode(
141 owner: string,
142 repo: string,
143 query: string,
144): Promise<SearchResult[]> {
145 return fetchJSON<SearchResult[]>(
146 `/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/search?q=${encodeURIComponent(query)}&json=1`,
147 );
148}
Addedmobile/src/store/authStore.ts+28−0View fileUnifiedSplit
1import { create } from 'zustand';
2
3export interface AuthUser {
4 id: number;
5 username: string;
6 email: string;
7 avatarUrl: string | null;
8 bio: string | null;
9 createdAt: string;
10}
11
12interface AuthState {
13 isAuthenticated: boolean;
14 user: AuthUser | null;
15 token: string | null;
16 setAuth: (user: AuthUser, token: string) => void;
17 clearAuth: () => void;
18}
19
20export const useAuthStore = create<AuthState>((set) => ({
21 isAuthenticated: false,
22 user: null,
23 token: null,
24 setAuth: (user: AuthUser, token: string) =>
25 set({ isAuthenticated: true, user, token }),
26 clearAuth: () =>
27 set({ isAuthenticated: false, user: null, token: null }),
28}));
Addedmobile/src/store/settingsStore.ts+17−0View fileUnifiedSplit
1import { create } from 'zustand';
2
3interface SettingsState {
4 host: string;
5 setHost: (host: string) => void;
6 notificationsEnabled: boolean;
7 setNotificationsEnabled: (enabled: boolean) => void;
8}
9
10export const useSettingsStore = create<SettingsState>((set) => ({
11 host: process.env.EXPO_PUBLIC_DEFAULT_HOST ?? 'https://gluecron.com',
12 setHost: (host: string) =>
13 set({ host: host.replace(/\/$/, '') }),
14 notificationsEnabled: true,
15 setNotificationsEnabled: (enabled: boolean) =>
16 set({ notificationsEnabled: enabled }),
17}));
Addedmobile/src/theme/colors.ts+16−0View fileUnifiedSplit
1export const colors = {
2 bg: '#0d1117',
3 bgSecondary: '#161b22',
4 bgTertiary: '#21262d',
5 border: '#30363d',
6 text: '#e6edf3',
7 textMuted: '#8b949e',
8 textLink: '#58a6ff',
9 accent: '#238636', // green — gates passing
10 accentRed: '#da3633',
11 accentYellow: '#d29922',
12 accentPurple: '#bc8cff', // AI features
13 accentBlue: '#58a6ff',
14} as const;
15
16export type ColorKey = keyof typeof colors;
Addedmobile/src/theme/typography.ts+60−0View fileUnifiedSplit
1import { StyleSheet } from 'react-native';
2import { colors } from './colors';
3
4export const typography = StyleSheet.create({
5 h1: {
6 fontSize: 24,
7 fontWeight: '700',
8 color: colors.text,
9 lineHeight: 32,
10 },
11 h2: {
12 fontSize: 20,
13 fontWeight: '600',
14 color: colors.text,
15 lineHeight: 28,
16 },
17 h3: {
18 fontSize: 16,
19 fontWeight: '600',
20 color: colors.text,
21 lineHeight: 24,
22 },
23 body: {
24 fontSize: 14,
25 fontWeight: '400',
26 color: colors.text,
27 lineHeight: 22,
28 },
29 bodySmall: {
30 fontSize: 12,
31 fontWeight: '400',
32 color: colors.textMuted,
33 lineHeight: 18,
34 },
35 mono: {
36 fontSize: 13,
37 fontFamily: 'monospace',
38 color: colors.text,
39 lineHeight: 20,
40 },
41 monoSmall: {
42 fontSize: 11,
43 fontFamily: 'monospace',
44 color: colors.textMuted,
45 lineHeight: 16,
46 },
47 label: {
48 fontSize: 12,
49 fontWeight: '500',
50 color: colors.textMuted,
51 letterSpacing: 0.5,
52 textTransform: 'uppercase',
53 },
54 link: {
55 fontSize: 14,
56 fontWeight: '400',
57 color: colors.textLink,
58 lineHeight: 22,
59 },
60});
Addedmobile/tsconfig.json+19−0View fileUnifiedSplit
1{
2 "extends": "expo/tsconfig.base",
3 "compilerOptions": {
4 "strict": true,
5 "baseUrl": ".",
6 "paths": {
7 "@/*": ["src/*"]
8 },
9 "jsx": "react-native",
10 "allowSyntheticDefaultImports": true,
11 "esModuleInterop": true,
12 "resolveJsonModule": true,
13 "moduleResolution": "bundler",
14 "target": "ES2020",
15 "lib": ["ES2020"],
16 "skipLibCheck": true
17 },
18 "include": ["src", "*.ts", "*.tsx"]
19}
Addedsrc/lib/competitive-intel.ts+596−0View fileUnifiedSplit
1/**
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}
0597