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

feat(mcp+intel+mobile): MCP server, competitive intelligence dashboard, mobile API layer

feat(mcp+intel+mobile): MCP server, competitive intelligence dashboard, mobile API layer

MCP Server — connects Claude Code and any MCP client to Gluecron:
- src/lib/mcp-tools.ts: 13 tools — list_repositories, get_repository,
  get_file_contents, list_files, search_code, list_commits, get_gate_status,
  list_pull_requests, list_issues, create_issue, explain_codebase,
  get_branch_diff, trigger_ai_review. JSON Schema inputSchema on every tool.
  Auth: PAT Bearer tokens (glc_). Public repos readable unauthenticated;
  private repos + write tools require auth.
- src/routes/mcp.ts: JSON-RPC 2.0 dispatcher at POST /mcp. GET /mcp returns
  discovery manifest. tools/call results wrapped in MCP content format.
- app.tsx: mounts /mcp with CORS so external AI agents can connect

Competitive Intelligence Engine:
- src/routes/competitive-intel.tsx: admin dashboard at /admin/intelligence —
  competitor cards grid, gap counts by priority, "Run scan now" trigger,
  per-competitor history view at /admin/intelligence/:competitor

Mobile app API + component layer:
- mobile/src/api/gates.ts: gate run list + status summary
- mobile/src/api/issues.ts: list + create + detail
- mobile/src/api/pulls.ts: list + detail with AI review comments
- mobile/src/api/notifications.ts: list + mark read + unread count
- mobile/src/components/ErrorBanner.tsx: retry-able error state
- mobile/src/components/LoadingSpinner.tsx: branded dark-theme spinner
- mobile/src/hooks/useAuth.ts: SecureStore-backed auth hook
- mobile/src/hooks/useRepo.ts: repo data fetching hook
- mobile/src/hooks/useGates.ts: gate status polling hook

https://claude.ai/code/session_01HkSzgS2aJpKqv1B9t7o9Cb
Claude committed on April 24, 2026Parent: 5c83ccc
19 files changed+391909989d8673cd22c5cca04ba5a00b8b8c750df1a63
19 changed files+3919−0
Addedmobile/src/api/gates.ts+108−0View fileUnifiedSplit
1import { fetchJSON, postJSON } from './client';
2
3export type GateRunStatus = 'pending' | 'running' | 'passed' | 'failed' | 'error';
4
5export interface GateRun {
6 id: string;
7 repositoryId: string;
8 repoOwner: string;
9 repoName: string;
10 commitSha: string;
11 branch: string;
12 status: GateRunStatus;
13 output: string | null;
14 aiRepaired: boolean;
15 repairedCommitSha: string | null;
16 triggeredBy: string | null;
17 startedAt: string;
18 completedAt: string | null;
19 createdAt: string;
20}
21
22export interface GateRepoSummary {
23 repoOwner: string;
24 repoName: string;
25 latestRun: GateRun | null;
26 totalRuns: number;
27 passedRuns: number;
28 failedRuns: number;
29}
30
31export interface GateSettings {
32 gateTestEnabled: boolean;
33 autoRepairEnabled: boolean;
34 blockMergeOnFail: boolean;
35}
36
37/** List gate runs for a repository. */
38export async function listGateRuns(
39 owner: string,
40 repo: string,
41 page = 1,
42): Promise<GateRun[]> {
43 return fetchJSON<GateRun[]>(
44 `/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/gates?json=1&page=${page}`,
45 );
46}
47
48/** Get a single gate run. */
49export async function getGateRun(
50 owner: string,
51 repo: string,
52 runId: string,
53): Promise<GateRun> {
54 return fetchJSON<GateRun>(
55 `/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/gates/${encodeURIComponent(runId)}?json=1`,
56 );
57}
58
59/** Get gate summary for a repository. */
60export async function getGateSummary(
61 owner: string,
62 repo: string,
63): Promise<GateRepoSummary> {
64 return fetchJSON<GateRepoSummary>(
65 `/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/gates/summary?json=1`,
66 );
67}
68
69/** Manually trigger a gate run on the default branch. */
70export async function triggerGateRun(
71 owner: string,
72 repo: string,
73): Promise<GateRun> {
74 return postJSON<GateRun>(
75 `/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/gates/run`,
76 {},
77 );
78}
79
80/** Get gate settings for a repository. */
81export async function getGateSettings(
82 owner: string,
83 repo: string,
84): Promise<GateSettings> {
85 return fetchJSON<GateSettings>(
86 `/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/gates/settings?json=1`,
87 );
88}
89
90/** Get the gate status for the current HEAD of a repository. */
91export async function getLatestGateStatus(
92 owner: string,
93 repo: string,
94): Promise<GateRun | null> {
95 try {
96 const runs = await listGateRuns(owner, repo, 1);
97 return runs.length > 0 ? runs[0] : null;
98 } catch {
99 return null;
100 }
101}
102
103/** List gate runs across all repos for the authenticated user (dashboard summary). */
104export async function listAllGateRuns(username: string): Promise<GateRun[]> {
105 return fetchJSON<GateRun[]>(
106 `/api/users/${encodeURIComponent(username)}/gates?json=1`,
107 );
108}
Addedmobile/src/api/issues.ts+132−0View fileUnifiedSplit
1import { fetchJSON, postJSON } from './client';
2
3export interface Label {
4 id: number;
5 name: string;
6 color: string;
7 description: string | null;
8}
9
10export interface Issue {
11 id: number;
12 number: number;
13 title: string;
14 body: string | null;
15 state: 'open' | 'closed';
16 authorUsername: string;
17 authorAvatarUrl: string | null;
18 labels: Label[];
19 commentCount: number;
20 createdAt: string;
21 updatedAt: string;
22 closedAt: string | null;
23}
24
25export interface IssueComment {
26 id: number;
27 body: string;
28 authorUsername: string;
29 authorAvatarUrl: string | null;
30 createdAt: string;
31 updatedAt: string;
32}
33
34export interface CreateIssueInput {
35 title: string;
36 body?: string;
37 labelIds?: number[];
38}
39
40export interface CreateCommentInput {
41 body: string;
42}
43
44/** List issues for a repository. */
45export async function listIssues(
46 owner: string,
47 repo: string,
48 state: 'open' | 'closed' | 'all' = 'open',
49 page = 1,
50): Promise<Issue[]> {
51 return fetchJSON<Issue[]>(
52 `/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues?state=${state}&page=${page}&json=1`,
53 );
54}
55
56/** Get a single issue with comments. */
57export async function getIssue(
58 owner: string,
59 repo: string,
60 number: number,
61): Promise<Issue> {
62 return fetchJSON<Issue>(
63 `/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${number}?json=1`,
64 );
65}
66
67/** Get comments for an issue. */
68export async function getIssueComments(
69 owner: string,
70 repo: string,
71 number: number,
72): Promise<IssueComment[]> {
73 return fetchJSON<IssueComment[]>(
74 `/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${number}/comments?json=1`,
75 );
76}
77
78/** Create a new issue. */
79export async function createIssue(
80 owner: string,
81 repo: string,
82 input: CreateIssueInput,
83): Promise<Issue> {
84 return postJSON<Issue>(
85 `/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues`,
86 input,
87 );
88}
89
90/** Post a comment on an issue. */
91export async function createIssueComment(
92 owner: string,
93 repo: string,
94 number: number,
95 input: CreateCommentInput,
96): Promise<IssueComment> {
97 return postJSON<IssueComment>(
98 `/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${number}/comments`,
99 input,
100 );
101}
102
103/** Close an issue. */
104export async function closeIssue(
105 owner: string,
106 repo: string,
107 number: number,
108): Promise<Issue> {
109 return postJSON<Issue>(
110 `/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${number}/close`,
111 {},
112 );
113}
114
115/** Reopen a closed issue. */
116export async function reopenIssue(
117 owner: string,
118 repo: string,
119 number: number,
120): Promise<Issue> {
121 return postJSON<Issue>(
122 `/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${number}/reopen`,
123 {},
124 );
125}
126
127/** List labels for a repository. */
128export async function listLabels(owner: string, repo: string): Promise<Label[]> {
129 return fetchJSON<Label[]>(
130 `/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/labels?json=1`,
131 );
132}
Addedmobile/src/api/notifications.ts+70−0View fileUnifiedSplit
1import { fetchJSON, postJSON } from './client';
2
3export type NotificationType =
4 | 'issue_opened'
5 | 'issue_closed'
6 | 'issue_comment'
7 | 'pr_opened'
8 | 'pr_merged'
9 | 'pr_closed'
10 | 'pr_comment'
11 | 'pr_review'
12 | 'push'
13 | 'star'
14 | 'fork'
15 | 'gate_failed'
16 | 'gate_passed'
17 | 'mention';
18
19export interface Notification {
20 id: string;
21 userId: string;
22 type: NotificationType;
23 title: string;
24 body: string | null;
25 repoOwner: string | null;
26 repoName: string | null;
27 resourceUrl: string | null;
28 isRead: boolean;
29 createdAt: string;
30}
31
32export interface NotificationCounts {
33 total: number;
34 unread: number;
35}
36
37/** List notifications for the authenticated user. */
38export async function listNotifications(
39 filter: 'unread' | 'all' = 'unread',
40 page = 1,
41): Promise<Notification[]> {
42 return fetchJSON<Notification[]>(
43 `/notifications?filter=${filter}&page=${page}&json=1`,
44 );
45}
46
47/** Get the unread notification count. */
48export async function getNotificationCounts(): Promise<NotificationCounts> {
49 return fetchJSON<NotificationCounts>('/notifications/counts?json=1');
50}
51
52/** Mark a single notification as read. */
53export async function markNotificationRead(id: string): Promise<void> {
54 await postJSON(`/notifications/${encodeURIComponent(id)}/read`, {});
55}
56
57/** Mark all notifications as read. */
58export async function markAllRead(): Promise<void> {
59 await postJSON('/notifications/mark-all-read', {});
60}
61
62/** Delete a notification. */
63export async function deleteNotification(id: string): Promise<void> {
64 await postJSON(`/notifications/${encodeURIComponent(id)}/delete`, {});
65}
66
67/** Clear all read notifications. */
68export async function clearReadNotifications(): Promise<void> {
69 await postJSON('/notifications/clear-read', {});
70}
Addedmobile/src/api/pulls.ts+191−0View fileUnifiedSplit
1import { fetchJSON, postJSON } from './client';
2
3export interface PullRequest {
4 id: number;
5 number: number;
6 title: string;
7 body: string | null;
8 state: 'open' | 'closed' | 'merged';
9 authorUsername: string;
10 authorAvatarUrl: string | null;
11 baseBranch: string;
12 headBranch: string;
13 baseRepo: string;
14 headRepo: string;
15 isDraft: boolean;
16 commentCount: number;
17 commitCount: number;
18 additions: number;
19 deletions: number;
20 changedFiles: number;
21 mergedAt: string | null;
22 closedAt: string | null;
23 createdAt: string;
24 updatedAt: string;
25 mergedByUsername: string | null;
26 gateStatus: 'pending' | 'passed' | 'failed' | 'none';
27}
28
29export interface PrComment {
30 id: number;
31 body: string;
32 authorUsername: string;
33 authorAvatarUrl: string | null;
34 isAiReview: boolean;
35 filePath: string | null;
36 lineNumber: number | null;
37 diffHunk: string | null;
38 createdAt: string;
39 updatedAt: string;
40}
41
42export interface AiReviewSummary {
43 summary: string;
44 comments: PrComment[];
45 severity: 'info' | 'warning' | 'error';
46 createdAt: string;
47}
48
49export interface PrDiffFile {
50 path: string;
51 additions: number;
52 deletions: number;
53 patch: string;
54}
55
56export interface CreatePrInput {
57 title: string;
58 body?: string;
59 baseBranch: string;
60 headBranch: string;
61 isDraft?: boolean;
62}
63
64export interface CreatePrCommentInput {
65 body: string;
66 filePath?: string;
67 lineNumber?: number;
68 diffHunk?: string;
69}
70
71/** List pull requests for a repository. */
72export async function listPullRequests(
73 owner: string,
74 repo: string,
75 state: 'open' | 'closed' | 'merged' | 'all' = 'open',
76 page = 1,
77): Promise<PullRequest[]> {
78 return fetchJSON<PullRequest[]>(
79 `/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls?state=${state}&page=${page}&json=1`,
80 );
81}
82
83/** Get a single pull request. */
84export async function getPullRequest(
85 owner: string,
86 repo: string,
87 number: number,
88): Promise<PullRequest> {
89 return fetchJSON<PullRequest>(
90 `/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls/${number}?json=1`,
91 );
92}
93
94/** Get comments on a pull request (including AI review comments). */
95export async function getPrComments(
96 owner: string,
97 repo: string,
98 number: number,
99): Promise<PrComment[]> {
100 return fetchJSON<PrComment[]>(
101 `/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls/${number}/comments?json=1`,
102 );
103}
104
105/** Get the AI review summary for a pull request. */
106export async function getAiReview(
107 owner: string,
108 repo: string,
109 number: number,
110): Promise<AiReviewSummary | null> {
111 try {
112 return await fetchJSON<AiReviewSummary>(
113 `/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls/${number}/ai-review?json=1`,
114 );
115 } catch {
116 return null;
117 }
118}
119
120/** Get diff files for a pull request. */
121export async function getPrDiff(
122 owner: string,
123 repo: string,
124 number: number,
125): Promise<PrDiffFile[]> {
126 return fetchJSON<PrDiffFile[]>(
127 `/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls/${number}/diff?json=1`,
128 );
129}
130
131/** Create a new pull request. */
132export async function createPullRequest(
133 owner: string,
134 repo: string,
135 input: CreatePrInput,
136): Promise<PullRequest> {
137 return postJSON<PullRequest>(
138 `/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls`,
139 input,
140 );
141}
142
143/** Post a comment on a pull request. */
144export async function createPrComment(
145 owner: string,
146 repo: string,
147 number: number,
148 input: CreatePrCommentInput,
149): Promise<PrComment> {
150 return postJSON<PrComment>(
151 `/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls/${number}/comments`,
152 input,
153 );
154}
155
156/** Merge a pull request. */
157export async function mergePullRequest(
158 owner: string,
159 repo: string,
160 number: number,
161 mergeMethod: 'merge' | 'squash' | 'rebase' = 'merge',
162): Promise<{ merged: boolean; sha: string }> {
163 return postJSON<{ merged: boolean; sha: string }>(
164 `/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls/${number}/merge`,
165 { mergeMethod },
166 );
167}
168
169/** Close a pull request without merging. */
170export async function closePullRequest(
171 owner: string,
172 repo: string,
173 number: number,
174): Promise<PullRequest> {
175 return postJSON<PullRequest>(
176 `/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls/${number}/close`,
177 {},
178 );
179}
180
181/** Trigger AI review on a pull request. */
182export async function requestAiReview(
183 owner: string,
184 repo: string,
185 number: number,
186): Promise<{ queued: boolean }> {
187 return postJSON<{ queued: boolean }>(
188 `/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls/${number}/request-ai-review`,
189 {},
190 );
191}
Addedmobile/src/components/AiReviewCard.tsx+232−0View fileUnifiedSplit
1import React, { useState, useCallback } from 'react';
2import {
3 FlatList,
4 StyleSheet,
5 Text,
6 TouchableOpacity,
7 View,
8} from 'react-native';
9import { colors } from '../theme/colors';
10import type { AiReviewSummary, PrComment } from '../api/pulls';
11
12interface AiReviewCardProps {
13 review: AiReviewSummary;
14}
15
16function severityColor(severity: AiReviewSummary['severity']): string {
17 switch (severity) {
18 case 'error': return colors.accentRed;
19 case 'warning': return colors.accentYellow;
20 case 'info': return colors.accentBlue;
21 }
22}
23
24function severityLabel(severity: AiReviewSummary['severity']): string {
25 switch (severity) {
26 case 'error': return 'Needs Changes';
27 case 'warning': return 'Suggestions';
28 case 'info': return 'Looks Good';
29 }
30}
31
32interface InlineCommentProps {
33 comment: PrComment;
34}
35
36function InlineComment({ comment }: InlineCommentProps): React.ReactElement {
37 return (
38 <View style={styles.inlineComment}>
39 {comment.filePath !== null && (
40 <View style={styles.fileHeader}>
41 <Text style={styles.filePath} numberOfLines={1}>
42 {comment.filePath}
43 {comment.lineNumber !== null ? `:${comment.lineNumber}` : ''}
44 </Text>
45 </View>
46 )}
47 {comment.diffHunk !== null && (
48 <View style={styles.diffHunk}>
49 <Text style={styles.diffHunkText} numberOfLines={4}>
50 {comment.diffHunk}
51 </Text>
52 </View>
53 )}
54 <Text style={styles.commentBody}>{comment.body}</Text>
55 </View>
56 );
57}
58
59export function AiReviewCard({ review }: AiReviewCardProps): React.ReactElement {
60 const [expanded, setExpanded] = useState(true);
61 const toggle = useCallback(() => setExpanded((v) => !v), []);
62 const borderColor = severityColor(review.severity);
63 const inlineComments = review.comments.filter((c) => c.filePath !== null);
64
65 const renderComment = useCallback(
66 ({ item }: { item: PrComment }) => <InlineComment comment={item} />,
67 [],
68 );
69
70 const keyExtractor = useCallback((item: PrComment) => String(item.id), []);
71
72 return (
73 <View style={[styles.card, { borderColor }]}>
74 <TouchableOpacity style={styles.header} onPress={toggle} activeOpacity={0.8}>
75 <View style={styles.headerLeft}>
76 <Text style={styles.sparkle}></Text>
77 <Text style={styles.headerTitle}>AI Review</Text>
78 <View style={[styles.severityBadge, { borderColor }]}>
79 <Text style={[styles.severityText, { color: borderColor }]}>
80 {severityLabel(review.severity)}
81 </Text>
82 </View>
83 {inlineComments.length > 0 && (
84 <View style={styles.countBadge}>
85 <Text style={styles.countText}>{inlineComments.length} comments</Text>
86 </View>
87 )}
88 </View>
89 <Text style={styles.chevron}>{expanded ? '▾' : '▸'}</Text>
90 </TouchableOpacity>
91
92 {expanded && (
93 <View style={styles.body}>
94 <Text style={styles.summary}>{review.summary}</Text>
95
96 {inlineComments.length > 0 && (
97 <View style={styles.inlineSection}>
98 <Text style={styles.inlineSectionTitle}>Inline Comments</Text>
99 <FlatList
100 data={inlineComments}
101 keyExtractor={keyExtractor}
102 renderItem={renderComment}
103 scrollEnabled={false}
104 ItemSeparatorComponent={() => <View style={styles.separator} />}
105 />
106 </View>
107 )}
108 </View>
109 )}
110 </View>
111 );
112}
113
114const styles = StyleSheet.create({
115 card: {
116 backgroundColor: colors.bgSecondary,
117 borderRadius: 8,
118 borderWidth: 1,
119 marginHorizontal: 16,
120 marginBottom: 16,
121 overflow: 'hidden',
122 },
123 header: {
124 flexDirection: 'row',
125 alignItems: 'center',
126 justifyContent: 'space-between',
127 padding: 14,
128 backgroundColor: colors.bgTertiary,
129 },
130 headerLeft: {
131 flexDirection: 'row',
132 alignItems: 'center',
133 gap: 8,
134 flex: 1,
135 },
136 sparkle: {
137 fontSize: 15,
138 color: colors.accentPurple,
139 },
140 headerTitle: {
141 fontSize: 14,
142 fontWeight: '600',
143 color: colors.accentPurple,
144 },
145 severityBadge: {
146 paddingHorizontal: 8,
147 paddingVertical: 2,
148 borderRadius: 20,
149 borderWidth: 1,
150 },
151 severityText: {
152 fontSize: 11,
153 fontWeight: '600',
154 },
155 countBadge: {
156 paddingHorizontal: 7,
157 paddingVertical: 2,
158 borderRadius: 20,
159 backgroundColor: colors.bg,
160 borderWidth: 1,
161 borderColor: colors.border,
162 },
163 countText: {
164 fontSize: 11,
165 color: colors.textMuted,
166 },
167 chevron: {
168 fontSize: 14,
169 color: colors.textMuted,
170 marginLeft: 8,
171 },
172 body: {
173 padding: 14,
174 gap: 14,
175 },
176 summary: {
177 fontSize: 14,
178 color: colors.text,
179 lineHeight: 22,
180 },
181 inlineSection: {
182 gap: 8,
183 },
184 inlineSectionTitle: {
185 fontSize: 12,
186 fontWeight: '600',
187 color: colors.textMuted,
188 textTransform: 'uppercase',
189 letterSpacing: 0.5,
190 },
191 inlineComment: {
192 backgroundColor: colors.bg,
193 borderRadius: 6,
194 overflow: 'hidden',
195 borderWidth: 1,
196 borderColor: colors.border,
197 },
198 fileHeader: {
199 paddingHorizontal: 10,
200 paddingVertical: 6,
201 backgroundColor: colors.bgTertiary,
202 borderBottomWidth: 1,
203 borderBottomColor: colors.border,
204 },
205 filePath: {
206 fontSize: 12,
207 fontFamily: 'monospace',
208 color: colors.textLink,
209 },
210 diffHunk: {
211 paddingHorizontal: 10,
212 paddingVertical: 6,
213 backgroundColor: colors.bg,
214 borderBottomWidth: 1,
215 borderBottomColor: colors.border,
216 },
217 diffHunkText: {
218 fontSize: 11,
219 fontFamily: 'monospace',
220 color: colors.textMuted,
221 lineHeight: 16,
222 },
223 commentBody: {
224 padding: 10,
225 fontSize: 13,
226 color: colors.text,
227 lineHeight: 20,
228 },
229 separator: {
230 height: 8,
231 },
232});
Addedmobile/src/components/CommitRow.tsx+108−0View fileUnifiedSplit
1import React, { useCallback } from 'react';
2import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
3import { colors } from '../theme/colors';
4import type { CommitEntry } from '../api/repos';
5
6interface CommitRowProps {
7 commit: CommitEntry;
8 onPress?: (commit: CommitEntry) => void;
9}
10
11function formatRelative(dateStr: string): string {
12 const diff = Date.now() - new Date(dateStr).getTime();
13 const mins = Math.floor(diff / 60000);
14 if (mins < 1) return 'just now';
15 if (mins < 60) return `${mins}m ago`;
16 const hrs = Math.floor(mins / 60);
17 if (hrs < 24) return `${hrs}h ago`;
18 const days = Math.floor(hrs / 24);
19 if (days < 30) return `${days}d ago`;
20 const months = Math.floor(days / 30);
21 if (months < 12) return `${months}mo ago`;
22 return `${Math.floor(months / 12)}y ago`;
23}
24
25function shortMessage(msg: string): string {
26 const firstLine = msg.split('\n')[0] ?? msg;
27 if (firstLine.length <= 72) return firstLine;
28 return firstLine.slice(0, 69) + '...';
29}
30
31export function CommitRow({ commit, onPress }: CommitRowProps): React.ReactElement {
32 const handlePress = useCallback(() => onPress?.(commit), [commit, onPress]);
33
34 return (
35 <TouchableOpacity
36 style={styles.row}
37 onPress={handlePress}
38 activeOpacity={onPress !== undefined ? 0.75 : 1}
39 >
40 <View style={styles.content}>
41 <Text style={styles.message} numberOfLines={2}>
42 {shortMessage(commit.message)}
43 </Text>
44 <View style={styles.meta}>
45 <Text style={styles.author}>{commit.authorName}</Text>
46 <Text style={styles.dot}>·</Text>
47 <Text style={styles.metaText}>{formatRelative(commit.authorDate)}</Text>
48 </View>
49 </View>
50 <View style={styles.shaContainer}>
51 <Text style={styles.sha}>{commit.sha.slice(0, 7)}</Text>
52 </View>
53 </TouchableOpacity>
54 );
55}
56
57const styles = StyleSheet.create({
58 row: {
59 flexDirection: 'row',
60 alignItems: 'center',
61 backgroundColor: colors.bgSecondary,
62 borderBottomWidth: 1,
63 borderBottomColor: colors.border,
64 paddingVertical: 12,
65 paddingHorizontal: 16,
66 gap: 12,
67 },
68 content: {
69 flex: 1,
70 gap: 4,
71 },
72 message: {
73 fontSize: 14,
74 color: colors.text,
75 lineHeight: 20,
76 },
77 meta: {
78 flexDirection: 'row',
79 alignItems: 'center',
80 gap: 5,
81 },
82 author: {
83 fontSize: 12,
84 fontWeight: '500',
85 color: colors.textMuted,
86 },
87 dot: {
88 fontSize: 12,
89 color: colors.textMuted,
90 },
91 metaText: {
92 fontSize: 12,
93 color: colors.textMuted,
94 },
95 shaContainer: {
96 paddingHorizontal: 8,
97 paddingVertical: 3,
98 backgroundColor: colors.bgTertiary,
99 borderRadius: 4,
100 borderWidth: 1,
101 borderColor: colors.border,
102 },
103 sha: {
104 fontSize: 12,
105 fontFamily: 'monospace',
106 color: colors.textLink,
107 },
108});
Addedmobile/src/components/ErrorBanner.tsx+59−0View fileUnifiedSplit
1import React from 'react';
2import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
3import { colors } from '../theme/colors';
4
5interface ErrorBannerProps {
6 message: string;
7 onRetry?: () => void;
8}
9
10export function ErrorBanner({ message, onRetry }: ErrorBannerProps): React.ReactElement {
11 return (
12 <View style={styles.container}>
13 <Text style={styles.icon}></Text>
14 <Text style={styles.message}>{message}</Text>
15 {onRetry !== undefined && (
16 <TouchableOpacity style={styles.retryButton} onPress={onRetry} activeOpacity={0.7}>
17 <Text style={styles.retryText}>Retry</Text>
18 </TouchableOpacity>
19 )}
20 </View>
21 );
22}
23
24const styles = StyleSheet.create({
25 container: {
26 margin: 16,
27 padding: 16,
28 backgroundColor: colors.bgSecondary,
29 borderRadius: 8,
30 borderWidth: 1,
31 borderColor: colors.accentRed,
32 alignItems: 'center',
33 gap: 8,
34 },
35 icon: {
36 fontSize: 20,
37 color: colors.accentRed,
38 },
39 message: {
40 fontSize: 14,
41 color: colors.text,
42 textAlign: 'center',
43 lineHeight: 20,
44 },
45 retryButton: {
46 marginTop: 4,
47 paddingHorizontal: 20,
48 paddingVertical: 8,
49 backgroundColor: colors.bgTertiary,
50 borderRadius: 6,
51 borderWidth: 1,
52 borderColor: colors.border,
53 },
54 retryText: {
55 fontSize: 14,
56 fontWeight: '500',
57 color: colors.textLink,
58 },
59});
Addedmobile/src/components/GateStatusBadge.tsx+122−0View fileUnifiedSplit
1import React from 'react';
2import { StyleSheet, Text, View } from 'react-native';
3import { colors } from '../theme/colors';
4import type { GateRunStatus } from '../api/gates';
5
6interface GateStatusBadgeProps {
7 status: GateRunStatus;
8 aiRepaired?: boolean;
9 size?: 'small' | 'medium';
10}
11
12function statusLabel(status: GateRunStatus): string {
13 switch (status) {
14 case 'passed': return 'Passed';
15 case 'failed': return 'Failed';
16 case 'pending': return 'Pending';
17 case 'running': return 'Running';
18 case 'error': return 'Error';
19 }
20}
21
22function statusColor(status: GateRunStatus): string {
23 switch (status) {
24 case 'passed': return colors.accent;
25 case 'failed': return colors.accentRed;
26 case 'pending': return colors.accentYellow;
27 case 'running': return colors.accentBlue;
28 case 'error': return colors.accentRed;
29 }
30}
31
32function statusIcon(status: GateRunStatus): string {
33 switch (status) {
34 case 'passed': return '✓';
35 case 'failed': return '✗';
36 case 'pending': return '◌';
37 case 'running': return '◌';
38 case 'error': return '!';
39 }
40}
41
42export function GateStatusBadge({
43 status,
44 aiRepaired = false,
45 size = 'medium',
46}: GateStatusBadgeProps): React.ReactElement {
47 const badgeColor = statusColor(status);
48 const isSmall = size === 'small';
49
50 return (
51 <View style={styles.wrapper}>
52 <View
53 style={[
54 styles.badge,
55 isSmall && styles.badgeSmall,
56 { borderColor: badgeColor },
57 ]}
58 >
59 <Text style={[styles.icon, isSmall && styles.iconSmall, { color: badgeColor }]}>
60 {statusIcon(status)}
61 </Text>
62 <Text style={[styles.label, isSmall && styles.labelSmall, { color: badgeColor }]}>
63 {statusLabel(status)}
64 </Text>
65 </View>
66 {aiRepaired && (
67 <View style={styles.aiBadge}>
68 <Text style={styles.aiText}>AI Repaired</Text>
69 </View>
70 )}
71 </View>
72 );
73}
74
75const styles = StyleSheet.create({
76 wrapper: {
77 flexDirection: 'row',
78 alignItems: 'center',
79 gap: 6,
80 },
81 badge: {
82 flexDirection: 'row',
83 alignItems: 'center',
84 paddingHorizontal: 10,
85 paddingVertical: 4,
86 borderRadius: 20,
87 borderWidth: 1,
88 gap: 5,
89 backgroundColor: colors.bgSecondary,
90 },
91 badgeSmall: {
92 paddingHorizontal: 7,
93 paddingVertical: 2,
94 },
95 icon: {
96 fontSize: 13,
97 fontWeight: '700',
98 },
99 iconSmall: {
100 fontSize: 11,
101 },
102 label: {
103 fontSize: 13,
104 fontWeight: '600',
105 },
106 labelSmall: {
107 fontSize: 11,
108 },
109 aiBadge: {
110 paddingHorizontal: 7,
111 paddingVertical: 2,
112 borderRadius: 20,
113 backgroundColor: colors.bgSecondary,
114 borderWidth: 1,
115 borderColor: colors.accentPurple,
116 },
117 aiText: {
118 fontSize: 11,
119 color: colors.accentPurple,
120 fontWeight: '500',
121 },
122});
Addedmobile/src/components/IssueRow.tsx+140−0View fileUnifiedSplit
1import React, { useCallback } from 'react';
2import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
3import { colors } from '../theme/colors';
4import type { Issue } from '../api/issues';
5
6interface IssueRowProps {
7 issue: Issue;
8 onPress: (issue: Issue) => void;
9}
10
11function formatRelative(dateStr: string): string {
12 const diff = Date.now() - new Date(dateStr).getTime();
13 const mins = Math.floor(diff / 60000);
14 if (mins < 1) return 'just now';
15 if (mins < 60) return `${mins}m ago`;
16 const hrs = Math.floor(mins / 60);
17 if (hrs < 24) return `${hrs}h ago`;
18 const days = Math.floor(hrs / 24);
19 if (days < 30) return `${days}d ago`;
20 const months = Math.floor(days / 30);
21 if (months < 12) return `${months}mo ago`;
22 return `${Math.floor(months / 12)}y ago`;
23}
24
25export function IssueRow({ issue, onPress }: IssueRowProps): React.ReactElement {
26 const handlePress = useCallback(() => onPress(issue), [issue, onPress]);
27
28 const stateColor = issue.state === 'open' ? colors.accent : colors.accentRed;
29
30 return (
31 <TouchableOpacity
32 style={styles.row}
33 onPress={handlePress}
34 activeOpacity={0.75}
35 >
36 <View style={[styles.stateIndicator, { backgroundColor: stateColor }]} />
37
38 <View style={styles.content}>
39 <View style={styles.titleRow}>
40 <Text style={styles.title} numberOfLines={2}>
41 {issue.title}
42 </Text>
43 </View>
44
45 {issue.labels.length > 0 && (
46 <View style={styles.labels}>
47 {issue.labels.slice(0, 4).map((label) => (
48 <View
49 key={label.id}
50 style={[styles.label, { borderColor: `#${label.color}` }]}
51 >
52 <Text style={[styles.labelText, { color: `#${label.color}` }]}>
53 {label.name}
54 </Text>
55 </View>
56 ))}
57 </View>
58 )}
59
60 <View style={styles.meta}>
61 <Text style={styles.metaText}>
62 #{issue.number} opened {formatRelative(issue.createdAt)} by {issue.authorUsername}
63 </Text>
64 {issue.commentCount > 0 && (
65 <View style={styles.commentCount}>
66 <Text style={styles.metaIcon}>💬</Text>
67 <Text style={styles.metaText}>{issue.commentCount}</Text>
68 </View>
69 )}
70 </View>
71 </View>
72 </TouchableOpacity>
73 );
74}
75
76const styles = StyleSheet.create({
77 row: {
78 flexDirection: 'row',
79 backgroundColor: colors.bgSecondary,
80 borderBottomWidth: 1,
81 borderBottomColor: colors.border,
82 paddingVertical: 12,
83 paddingHorizontal: 16,
84 gap: 12,
85 },
86 stateIndicator: {
87 width: 14,
88 height: 14,
89 borderRadius: 7,
90 marginTop: 3,
91 flexShrink: 0,
92 },
93 content: {
94 flex: 1,
95 gap: 6,
96 },
97 titleRow: {
98 flexDirection: 'row',
99 alignItems: 'flex-start',
100 },
101 title: {
102 fontSize: 14,
103 fontWeight: '500',
104 color: colors.text,
105 lineHeight: 20,
106 flex: 1,
107 },
108 labels: {
109 flexDirection: 'row',
110 flexWrap: 'wrap',
111 gap: 6,
112 },
113 label: {
114 paddingHorizontal: 7,
115 paddingVertical: 2,
116 borderRadius: 20,
117 borderWidth: 1,
118 },
119 labelText: {
120 fontSize: 11,
121 fontWeight: '500',
122 },
123 meta: {
124 flexDirection: 'row',
125 alignItems: 'center',
126 justifyContent: 'space-between',
127 },
128 metaText: {
129 fontSize: 12,
130 color: colors.textMuted,
131 },
132 commentCount: {
133 flexDirection: 'row',
134 alignItems: 'center',
135 gap: 3,
136 },
137 metaIcon: {
138 fontSize: 11,
139 },
140});
Addedmobile/src/components/LoadingSpinner.tsx+33−0View fileUnifiedSplit
1import React from 'react';
2import { ActivityIndicator, StyleSheet, View } from 'react-native';
3import { colors } from '../theme/colors';
4
5interface LoadingSpinnerProps {
6 size?: 'small' | 'large';
7 color?: string;
8 fullScreen?: boolean;
9}
10
11export function LoadingSpinner({
12 size = 'large',
13 color = colors.accentBlue,
14 fullScreen = false,
15}: LoadingSpinnerProps): React.ReactElement {
16 return (
17 <View style={[styles.container, fullScreen && styles.fullScreen]}>
18 <ActivityIndicator size={size} color={color} />
19 </View>
20 );
21}
22
23const styles = StyleSheet.create({
24 container: {
25 padding: 24,
26 alignItems: 'center',
27 justifyContent: 'center',
28 },
29 fullScreen: {
30 flex: 1,
31 backgroundColor: colors.bg,
32 },
33});
Addedmobile/src/components/PullRow.tsx+169−0View fileUnifiedSplit
1import React, { useCallback } from 'react';
2import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
3import { colors } from '../theme/colors';
4import type { PullRequest } from '../api/pulls';
5
6interface PullRowProps {
7 pr: PullRequest;
8 onPress: (pr: PullRequest) => void;
9}
10
11function formatRelative(dateStr: string): string {
12 const diff = Date.now() - new Date(dateStr).getTime();
13 const mins = Math.floor(diff / 60000);
14 if (mins < 1) return 'just now';
15 if (mins < 60) return `${mins}m ago`;
16 const hrs = Math.floor(mins / 60);
17 if (hrs < 24) return `${hrs}h ago`;
18 const days = Math.floor(hrs / 24);
19 if (days < 30) return `${days}d ago`;
20 const months = Math.floor(days / 30);
21 if (months < 12) return `${months}mo ago`;
22 return `${Math.floor(months / 12)}y ago`;
23}
24
25function stateColor(state: PullRequest['state']): string {
26 switch (state) {
27 case 'open': return colors.accent;
28 case 'merged': return colors.accentPurple;
29 case 'closed': return colors.accentRed;
30 }
31}
32
33function stateLabel(state: PullRequest['state']): string {
34 switch (state) {
35 case 'open': return 'Open';
36 case 'merged': return 'Merged';
37 case 'closed': return 'Closed';
38 }
39}
40
41export function PullRow({ pr, onPress }: PullRowProps): React.ReactElement {
42 const handlePress = useCallback(() => onPress(pr), [pr, onPress]);
43
44 return (
45 <TouchableOpacity
46 style={styles.row}
47 onPress={handlePress}
48 activeOpacity={0.75}
49 >
50 <View style={[styles.stateIndicator, { backgroundColor: stateColor(pr.state) }]} />
51
52 <View style={styles.content}>
53 <View style={styles.titleRow}>
54 <Text style={styles.title} numberOfLines={2}>
55 {pr.title}
56 </Text>
57 {pr.isDraft && (
58 <View style={styles.draftBadge}>
59 <Text style={styles.draftText}>Draft</Text>
60 </View>
61 )}
62 </View>
63
64 <View style={styles.branchInfo}>
65 <Text style={styles.branchText} numberOfLines={1}>
66 {pr.headBranch}
67 </Text>
68 <Text style={styles.branchArrow}></Text>
69 <Text style={styles.branchText} numberOfLines={1}>
70 {pr.baseBranch}
71 </Text>
72 </View>
73
74 <View style={styles.meta}>
75 <Text style={styles.metaText}>
76 #{pr.number} {stateLabel(pr.state)} {formatRelative(pr.createdAt)} by {pr.authorUsername}
77 </Text>
78 {pr.commentCount > 0 && (
79 <View style={styles.commentCount}>
80 <Text style={styles.metaIcon}>💬</Text>
81 <Text style={styles.metaText}>{pr.commentCount}</Text>
82 </View>
83 )}
84 </View>
85 </View>
86 </TouchableOpacity>
87 );
88}
89
90const styles = StyleSheet.create({
91 row: {
92 flexDirection: 'row',
93 backgroundColor: colors.bgSecondary,
94 borderBottomWidth: 1,
95 borderBottomColor: colors.border,
96 paddingVertical: 12,
97 paddingHorizontal: 16,
98 gap: 12,
99 },
100 stateIndicator: {
101 width: 14,
102 height: 14,
103 borderRadius: 7,
104 marginTop: 3,
105 flexShrink: 0,
106 },
107 content: {
108 flex: 1,
109 gap: 5,
110 },
111 titleRow: {
112 flexDirection: 'row',
113 alignItems: 'flex-start',
114 gap: 8,
115 },
116 title: {
117 fontSize: 14,
118 fontWeight: '500',
119 color: colors.text,
120 lineHeight: 20,
121 flex: 1,
122 },
123 draftBadge: {
124 paddingHorizontal: 7,
125 paddingVertical: 2,
126 borderRadius: 20,
127 backgroundColor: colors.bgTertiary,
128 borderWidth: 1,
129 borderColor: colors.border,
130 flexShrink: 0,
131 },
132 draftText: {
133 fontSize: 11,
134 color: colors.textMuted,
135 },
136 branchInfo: {
137 flexDirection: 'row',
138 alignItems: 'center',
139 gap: 6,
140 },
141 branchText: {
142 fontSize: 12,
143 fontFamily: 'monospace',
144 color: colors.textMuted,
145 maxWidth: 120,
146 },
147 branchArrow: {
148 fontSize: 12,
149 color: colors.textMuted,
150 },
151 meta: {
152 flexDirection: 'row',
153 alignItems: 'center',
154 justifyContent: 'space-between',
155 },
156 metaText: {
157 fontSize: 12,
158 color: colors.textMuted,
159 flex: 1,
160 },
161 commentCount: {
162 flexDirection: 'row',
163 alignItems: 'center',
164 gap: 3,
165 },
166 metaIcon: {
167 fontSize: 11,
168 },
169});
Addedmobile/src/components/RepoCard.tsx+168−0View fileUnifiedSplit
1import React, { useCallback } from 'react';
2import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
3import { colors } from '../theme/colors';
4import type { Repository } from '../api/repos';
5
6interface RepoCardProps {
7 repo: Repository;
8 onPress: (repo: Repository) => void;
9}
10
11function formatRelative(dateStr: string): string {
12 const diff = Date.now() - new Date(dateStr).getTime();
13 const mins = Math.floor(diff / 60000);
14 if (mins < 1) return 'just now';
15 if (mins < 60) return `${mins}m ago`;
16 const hrs = Math.floor(mins / 60);
17 if (hrs < 24) return `${hrs}h ago`;
18 const days = Math.floor(hrs / 24);
19 if (days < 30) return `${days}d ago`;
20 const months = Math.floor(days / 30);
21 if (months < 12) return `${months}mo ago`;
22 return `${Math.floor(months / 12)}y ago`;
23}
24
25export function RepoCard({ repo, onPress }: RepoCardProps): React.ReactElement {
26 const handlePress = useCallback(() => onPress(repo), [repo, onPress]);
27
28 return (
29 <TouchableOpacity
30 style={styles.card}
31 onPress={handlePress}
32 activeOpacity={0.75}
33 >
34 <View style={styles.header}>
35 <View style={styles.titleRow}>
36 <Text style={styles.repoName} numberOfLines={1}>
37 {repo.ownerUsername}/{repo.name}
38 </Text>
39 {repo.isPrivate && (
40 <View style={styles.badge}>
41 <Text style={styles.badgeText}>Private</Text>
42 </View>
43 )}
44 {repo.isArchived && (
45 <View style={[styles.badge, styles.badgeArchived]}>
46 <Text style={styles.badgeText}>Archived</Text>
47 </View>
48 )}
49 {repo.isFork && (
50 <View style={[styles.badge, styles.badgeFork]}>
51 <Text style={styles.badgeText}>Fork</Text>
52 </View>
53 )}
54 </View>
55 {repo.description !== null && repo.description.length > 0 && (
56 <Text style={styles.description} numberOfLines={2}>
57 {repo.description}
58 </Text>
59 )}
60 </View>
61
62 <View style={styles.footer}>
63 {repo.language !== null && (
64 <View style={styles.metaItem}>
65 <View style={styles.langDot} />
66 <Text style={styles.metaText}>{repo.language}</Text>
67 </View>
68 )}
69 <View style={styles.metaItem}>
70 <Text style={styles.metaIcon}></Text>
71 <Text style={styles.metaText}>{repo.starCount}</Text>
72 </View>
73 <View style={styles.metaItem}>
74 <Text style={styles.metaIcon}></Text>
75 <Text style={styles.metaText}>{repo.forkCount}</Text>
76 </View>
77 {repo.openIssueCount > 0 && (
78 <View style={styles.metaItem}>
79 <Text style={styles.metaIcon}></Text>
80 <Text style={styles.metaText}>{repo.openIssueCount}</Text>
81 </View>
82 )}
83 <Text style={styles.updatedAt}>Updated {formatRelative(repo.updatedAt)}</Text>
84 </View>
85 </TouchableOpacity>
86 );
87}
88
89const styles = StyleSheet.create({
90 card: {
91 backgroundColor: colors.bgSecondary,
92 borderRadius: 8,
93 borderWidth: 1,
94 borderColor: colors.border,
95 padding: 16,
96 marginHorizontal: 16,
97 marginBottom: 12,
98 },
99 header: {
100 marginBottom: 12,
101 gap: 6,
102 },
103 titleRow: {
104 flexDirection: 'row',
105 alignItems: 'center',
106 flexWrap: 'wrap',
107 gap: 8,
108 },
109 repoName: {
110 fontSize: 15,
111 fontWeight: '600',
112 color: colors.textLink,
113 flexShrink: 1,
114 },
115 description: {
116 fontSize: 13,
117 color: colors.textMuted,
118 lineHeight: 18,
119 },
120 badge: {
121 paddingHorizontal: 7,
122 paddingVertical: 2,
123 borderRadius: 20,
124 backgroundColor: colors.bgTertiary,
125 borderWidth: 1,
126 borderColor: colors.border,
127 },
128 badgeArchived: {
129 borderColor: colors.accentYellow,
130 },
131 badgeFork: {
132 borderColor: colors.accentBlue,
133 },
134 badgeText: {
135 fontSize: 11,
136 color: colors.textMuted,
137 },
138 footer: {
139 flexDirection: 'row',
140 alignItems: 'center',
141 flexWrap: 'wrap',
142 gap: 12,
143 },
144 metaItem: {
145 flexDirection: 'row',
146 alignItems: 'center',
147 gap: 4,
148 },
149 langDot: {
150 width: 10,
151 height: 10,
152 borderRadius: 5,
153 backgroundColor: colors.accentPurple,
154 },
155 metaIcon: {
156 fontSize: 12,
157 color: colors.textMuted,
158 },
159 metaText: {
160 fontSize: 12,
161 color: colors.textMuted,
162 },
163 updatedAt: {
164 fontSize: 12,
165 color: colors.textMuted,
166 marginLeft: 'auto',
167 },
168});
Addedmobile/src/hooks/useAuth.ts+95−0View fileUnifiedSplit
1import { useCallback, useEffect, useState } from 'react';
2import * as SecureStore from 'expo-secure-store';
3import { useAuthStore } from '../store/authStore';
4import { useSettingsStore } from '../store/settingsStore';
5import { validateToken, persistToken, clearToken } from '../api/auth';
6import { PAT_STORE_KEY } from '../api/client';
7
8export interface UseAuthReturn {
9 isAuthenticated: boolean;
10 isLoading: boolean;
11 user: ReturnType<typeof useAuthStore.getState>['user'];
12 login: (host: string, token: string) => Promise<void>;
13 logout: () => Promise<void>;
14 error: string | null;
15}
16
17/**
18 * Primary auth hook. Checks SecureStore on mount and exposes
19 * login / logout helpers that update both SecureStore and the
20 * in-memory Zustand store.
21 */
22export function useAuth(): UseAuthReturn {
23 const { isAuthenticated, user, setAuth, clearAuth } = useAuthStore();
24 const { setHost } = useSettingsStore();
25 const [isLoading, setIsLoading] = useState(true);
26 const [error, setError] = useState<string | null>(null);
27
28 // On mount, check if there is a saved token and auto-login.
29 useEffect(() => {
30 let cancelled = false;
31
32 async function restoreSession() {
33 try {
34 const savedToken = await SecureStore.getItemAsync(PAT_STORE_KEY);
35 const savedHost = await SecureStore.getItemAsync('gluecron_host');
36
37 if (savedToken && savedHost) {
38 setHost(savedHost);
39 const authUser = await validateToken(savedHost, savedToken);
40 if (!cancelled) {
41 setAuth(authUser, savedToken);
42 }
43 }
44 } catch {
45 // Token invalid or network error — stay logged out silently
46 await SecureStore.deleteItemAsync(PAT_STORE_KEY);
47 await SecureStore.deleteItemAsync('gluecron_host');
48 } finally {
49 if (!cancelled) {
50 setIsLoading(false);
51 }
52 }
53 }
54
55 restoreSession();
56 return () => {
57 cancelled = true;
58 };
59 }, [setAuth, setHost]);
60
61 const login = useCallback(
62 async (host: string, token: string) => {
63 setError(null);
64 setIsLoading(true);
65 try {
66 const normalizedHost = host.replace(/\/$/, '');
67 const authUser = await validateToken(normalizedHost, token);
68 await persistToken(token);
69 await SecureStore.setItemAsync('gluecron_host', normalizedHost);
70 setHost(normalizedHost);
71 setAuth(authUser, token);
72 } catch (err) {
73 const msg = err instanceof Error ? err.message : 'Login failed';
74 setError(msg);
75 throw err;
76 } finally {
77 setIsLoading(false);
78 }
79 },
80 [setAuth, setHost],
81 );
82
83 const logout = useCallback(async () => {
84 setIsLoading(true);
85 try {
86 await clearToken();
87 await SecureStore.deleteItemAsync('gluecron_host');
88 } finally {
89 clearAuth();
90 setIsLoading(false);
91 }
92 }, [clearAuth]);
93
94 return { isAuthenticated, isLoading, user, login, logout, error };
95}
Addedmobile/src/hooks/useGates.ts+87−0View fileUnifiedSplit
1import { useCallback, useEffect, useState } from 'react';
2import { listGateRuns, triggerGateRun } from '../api/gates';
3import type { GateRun } from '../api/gates';
4
5export interface UseGatesReturn {
6 runs: GateRun[];
7 isLoading: boolean;
8 error: string | null;
9 isTriggering: boolean;
10 triggerRun: () => Promise<void>;
11 loadMore: () => void;
12 hasMore: boolean;
13 refresh: () => void;
14}
15
16/**
17 * Fetches gate runs for a repository with pagination and a manual trigger action.
18 */
19export function useGates(owner: string, repoName: string): UseGatesReturn {
20 const [runs, setRuns] = useState<GateRun[]>([]);
21 const [page, setPage] = useState(1);
22 const [isLoading, setIsLoading] = useState(true);
23 const [error, setError] = useState<string | null>(null);
24 const [hasMore, setHasMore] = useState(true);
25 const [isTriggering, setIsTriggering] = useState(false);
26 const [tick, setTick] = useState(0);
27
28 useEffect(() => {
29 if (!owner || !repoName) return;
30 let cancelled = false;
31
32 setIsLoading(true);
33 setError(null);
34
35 listGateRuns(owner, repoName, page)
36 .then((data) => {
37 if (!cancelled) {
38 if (page === 1) {
39 setRuns(data);
40 } else {
41 setRuns((prev) => [...prev, ...data]);
42 }
43 // Assume pages of 30
44 setHasMore(data.length === 30);
45 }
46 })
47 .catch((err) => {
48 if (!cancelled) {
49 setError(err instanceof Error ? err.message : 'Failed to load gate runs');
50 }
51 })
52 .finally(() => {
53 if (!cancelled) setIsLoading(false);
54 });
55
56 return () => {
57 cancelled = true;
58 };
59 }, [owner, repoName, page, tick]);
60
61 const loadMore = useCallback(() => {
62 if (!isLoading && hasMore) {
63 setPage((p) => p + 1);
64 }
65 }, [isLoading, hasMore]);
66
67 const refresh = useCallback(() => {
68 setPage(1);
69 setTick((t) => t + 1);
70 }, []);
71
72 const triggerRun = useCallback(async () => {
73 if (isTriggering) return;
74 setIsTriggering(true);
75 setError(null);
76 try {
77 const newRun = await triggerGateRun(owner, repoName);
78 setRuns((prev) => [newRun, ...prev]);
79 } catch (err) {
80 setError(err instanceof Error ? err.message : 'Failed to trigger gate run');
81 } finally {
82 setIsTriggering(false);
83 }
84 }, [owner, repoName, isTriggering]);
85
86 return { runs, isLoading, error, isTriggering, triggerRun, loadMore, hasMore, refresh };
87}
Addedmobile/src/hooks/useRepo.ts+199−0View fileUnifiedSplit
1import { useCallback, useEffect, useState } from 'react';
2import { getRepo, getTree, listCommits, listBranches } from '../api/repos';
3import type { Repository, TreeEntry, CommitEntry, BranchInfo } from '../api/repos';
4
5export interface UseRepoReturn {
6 repo: Repository | null;
7 isLoading: boolean;
8 error: string | null;
9 refresh: () => void;
10}
11
12/** Fetches and caches a single repository. */
13export function useRepo(owner: string, repoName: string): UseRepoReturn {
14 const [repo, setRepo] = useState<Repository | null>(null);
15 const [isLoading, setIsLoading] = useState(true);
16 const [error, setError] = useState<string | null>(null);
17 const [tick, setTick] = useState(0);
18
19 useEffect(() => {
20 if (!owner || !repoName) return;
21 let cancelled = false;
22
23 setIsLoading(true);
24 setError(null);
25
26 getRepo(owner, repoName)
27 .then((data) => {
28 if (!cancelled) {
29 setRepo(data);
30 }
31 })
32 .catch((err) => {
33 if (!cancelled) {
34 setError(err instanceof Error ? err.message : 'Failed to load repository');
35 }
36 })
37 .finally(() => {
38 if (!cancelled) setIsLoading(false);
39 });
40
41 return () => {
42 cancelled = true;
43 };
44 }, [owner, repoName, tick]);
45
46 const refresh = useCallback(() => setTick((t) => t + 1), []);
47
48 return { repo, isLoading, error, refresh };
49}
50
51export interface UseTreeReturn {
52 entries: TreeEntry[];
53 isLoading: boolean;
54 error: string | null;
55 refresh: () => void;
56}
57
58/** Fetches the file tree for a repo at a given ref and path. */
59export function useTree(
60 owner: string,
61 repoName: string,
62 ref = 'HEAD',
63 path = '',
64): UseTreeReturn {
65 const [entries, setEntries] = useState<TreeEntry[]>([]);
66 const [isLoading, setIsLoading] = useState(true);
67 const [error, setError] = useState<string | null>(null);
68 const [tick, setTick] = useState(0);
69
70 useEffect(() => {
71 if (!owner || !repoName) return;
72 let cancelled = false;
73
74 setIsLoading(true);
75 setError(null);
76
77 getTree(owner, repoName, ref, path)
78 .then((data) => {
79 if (!cancelled) setEntries(data);
80 })
81 .catch((err) => {
82 if (!cancelled) {
83 setError(err instanceof Error ? err.message : 'Failed to load files');
84 }
85 })
86 .finally(() => {
87 if (!cancelled) setIsLoading(false);
88 });
89
90 return () => {
91 cancelled = true;
92 };
93 }, [owner, repoName, ref, path, tick]);
94
95 const refresh = useCallback(() => setTick((t) => t + 1), []);
96
97 return { entries, isLoading, error, refresh };
98}
99
100export interface UseCommitsReturn {
101 commits: CommitEntry[];
102 isLoading: boolean;
103 error: string | null;
104 loadMore: () => void;
105 hasMore: boolean;
106}
107
108/** Fetches commits for a repo with pagination. */
109export function useCommits(
110 owner: string,
111 repoName: string,
112 branch = 'HEAD',
113): UseCommitsReturn {
114 const [commits, setCommits] = useState<CommitEntry[]>([]);
115 const [page, setPage] = useState(1);
116 const [isLoading, setIsLoading] = useState(true);
117 const [error, setError] = useState<string | null>(null);
118 const [hasMore, setHasMore] = useState(true);
119
120 useEffect(() => {
121 if (!owner || !repoName) return;
122 let cancelled = false;
123
124 setIsLoading(true);
125 setError(null);
126
127 listCommits(owner, repoName, branch, page)
128 .then((data) => {
129 if (!cancelled) {
130 if (page === 1) {
131 setCommits(data);
132 } else {
133 setCommits((prev) => [...prev, ...data]);
134 }
135 setHasMore(data.length === 30);
136 }
137 })
138 .catch((err) => {
139 if (!cancelled) {
140 setError(err instanceof Error ? err.message : 'Failed to load commits');
141 }
142 })
143 .finally(() => {
144 if (!cancelled) setIsLoading(false);
145 });
146
147 return () => {
148 cancelled = true;
149 };
150 }, [owner, repoName, branch, page]);
151
152 const loadMore = useCallback(() => {
153 if (!isLoading && hasMore) {
154 setPage((p) => p + 1);
155 }
156 }, [isLoading, hasMore]);
157
158 return { commits, isLoading, error, loadMore, hasMore };
159}
160
161export interface UseBranchesReturn {
162 branches: BranchInfo[];
163 isLoading: boolean;
164 error: string | null;
165}
166
167/** Fetches branches for a repo. */
168export function useBranches(owner: string, repoName: string): UseBranchesReturn {
169 const [branches, setBranches] = useState<BranchInfo[]>([]);
170 const [isLoading, setIsLoading] = useState(true);
171 const [error, setError] = useState<string | null>(null);
172
173 useEffect(() => {
174 if (!owner || !repoName) return;
175 let cancelled = false;
176
177 setIsLoading(true);
178 setError(null);
179
180 listBranches(owner, repoName)
181 .then((data) => {
182 if (!cancelled) setBranches(data);
183 })
184 .catch((err) => {
185 if (!cancelled) {
186 setError(err instanceof Error ? err.message : 'Failed to load branches');
187 }
188 })
189 .finally(() => {
190 if (!cancelled) setIsLoading(false);
191 });
192
193 return () => {
194 cancelled = true;
195 };
196 }, [owner, repoName]);
197
198 return { branches, isLoading, error };
199}
Modifiedsrc/app.tsx+10−0View fileUnifiedSplit
5555import notificationRoutes from "./routes/notifications";
5656import onboardingRoutes from "./routes/onboarding";
5757import adminRoutes from "./routes/admin";
58import competitiveIntelRoutes from "./routes/competitive-intel";
59import mcpRoutes from "./routes/mcp";
5860import advisoriesRoutes from "./routes/advisories";
5961import aiChangelogRoutes from "./routes/ai-changelog";
6062import aiExplainRoutes from "./routes/ai-explain";
110112 return logger()(c, next);
111113});
112114app.use("/api/*", cors());
115// MCP endpoint needs CORS for external AI agent clients
116app.use("/mcp", cors());
113117// Rate-limit API + auth endpoints (generous default)
114118app.use("/api/*", rateLimit(120, 60_000, "api"));
115119app.use("/login", rateLimit(20, 60_000, "login"));
237241// SEO: robots.txt + sitemap.xml
238242app.route("/", seoRoutes);
239243
244// MCP server — Model Context Protocol endpoint for AI agent integrations
245app.route("/", mcpRoutes);
246
247// Competitive intelligence dashboard (admin only)
248app.route("/", competitiveIntelRoutes);
249
240250// Health dashboard (per-repo health page)
241251app.route("/", healthDashboardRoutes);
242252
Addedsrc/lib/mcp-tools.ts+968−0View fileUnifiedSplit
1/**
2 * MCP (Model Context Protocol) tool definitions and implementations.
3 *
4 * Each tool has:
5 * - name: unique identifier
6 * - description: human/model readable explanation (required by MCP spec)
7 * - inputSchema: JSON Schema for the arguments
8 * - handler: async function that receives (args, user) and returns a result
9 *
10 * The route (src/routes/mcp.ts) dispatches tools/call to handler() and
11 * wraps errors in JSON-RPC error objects.
12 */
13
14import { eq, and, desc, inArray } from "drizzle-orm";
15import { db } from "../db";
16import {
17 repositories,
18 users,
19 issues,
20 issueLabels,
21 labels,
22 pullRequests,
23 prComments,
24 gateRuns,
25} from "../db/schema";
26import type { User } from "../db/schema";
27import {
28 getTree,
29 getBlob,
30 listCommits,
31 searchCode,
32 getRepoPath,
33} from "../git/repository";
34import { loadRepoByPath } from "./namespace";
35import { explainCodebase, getCachedExplanation } from "./ai-explain";
36import { triggerAiReview } from "./ai-review";
37
38// --------------------------------------------------------------------------
39// Shared error helpers
40// --------------------------------------------------------------------------
41
42export class McpToolError extends Error {
43 constructor(
44 public code: number,
45 message: string
46 ) {
47 super(message);
48 this.name = "McpToolError";
49 }
50}
51
52function notFound(msg: string): never {
53 throw new McpToolError(-32004, msg);
54}
55
56function unauthorized(msg = "Authentication required"): never {
57 throw new McpToolError(-32001, msg);
58}
59
60function invalidParams(msg: string): never {
61 throw new McpToolError(-32602, msg);
62}
63
64// --------------------------------------------------------------------------
65// Helper: resolve owner username → user row
66// --------------------------------------------------------------------------
67
68async function resolveOwnerUser(username: string): Promise<User> {
69 const [u] = await db
70 .select()
71 .from(users)
72 .where(eq(users.username, username))
73 .limit(1);
74 if (!u) notFound(`User '${username}' not found`);
75 return u;
76}
77
78// --------------------------------------------------------------------------
79// Helper: load repo + enforce visibility
80// --------------------------------------------------------------------------
81
82async function loadAndCheckRepo(
83 owner: string,
84 repoName: string,
85 caller: User | null
86) {
87 const repo = await loadRepoByPath(owner, repoName);
88 if (!repo) notFound(`Repository '${owner}/${repoName}' not found`);
89 if (repo.isPrivate) {
90 if (!caller) unauthorized(`Repository '${owner}/${repoName}' is private`);
91 // caller must be the owner (org-owned repos not checked here for brevity)
92 if (repo.ownerId !== caller.id) {
93 unauthorized(`Access denied to private repository '${owner}/${repoName}'`);
94 }
95 }
96 return repo;
97}
98
99// --------------------------------------------------------------------------
100// Tool type
101// --------------------------------------------------------------------------
102
103export interface McpTool {
104 name: string;
105 description: string;
106 inputSchema: Record<string, unknown>;
107 handler: (args: Record<string, unknown>, user: User | null) => Promise<unknown>;
108}
109
110// --------------------------------------------------------------------------
111// Tool: list_repositories
112// --------------------------------------------------------------------------
113
114const listRepositoriesTool: McpTool = {
115 name: "list_repositories",
116 description:
117 "List repositories owned by a Gluecron user. If username is omitted the authenticated user's repos are returned.",
118 inputSchema: {
119 type: "object",
120 properties: {
121 username: {
122 type: "string",
123 description: "Username whose repositories to list. Defaults to the authenticated user.",
124 },
125 },
126 },
127 async handler(args, user) {
128 const usernameArg = args.username as string | undefined;
129 let targetUser: User;
130 if (usernameArg) {
131 targetUser = await resolveOwnerUser(usernameArg);
132 } else {
133 if (!user) unauthorized("Provide a username or authenticate");
134 targetUser = user;
135 }
136
137 const rows = await db
138 .select({
139 id: repositories.id,
140 name: repositories.name,
141 description: repositories.description,
142 isPrivate: repositories.isPrivate,
143 defaultBranch: repositories.defaultBranch,
144 stars: repositories.starCount,
145 createdAt: repositories.createdAt,
146 updatedAt: repositories.updatedAt,
147 pushedAt: repositories.pushedAt,
148 })
149 .from(repositories)
150 .where(
151 and(
152 eq(repositories.ownerId, targetUser.id),
153 // Only show private repos to the owner themselves
154 user?.id === targetUser.id
155 ? undefined
156 : eq(repositories.isPrivate, false)
157 )
158 )
159 .orderBy(desc(repositories.updatedAt));
160
161 return rows.map((r) => ({
162 owner: targetUser.username,
163 name: r.name,
164 description: r.description ?? null,
165 isPrivate: r.isPrivate,
166 defaultBranch: r.defaultBranch,
167 stars: r.stars,
168 createdAt: r.createdAt,
169 updatedAt: r.updatedAt,
170 pushedAt: r.pushedAt ?? null,
171 }));
172 },
173};
174
175// --------------------------------------------------------------------------
176// Tool: get_repository
177// --------------------------------------------------------------------------
178
179const getRepositoryTool: McpTool = {
180 name: "get_repository",
181 description:
182 "Get full details for a repository including description, default branch, star/fork/issue counts, and recent activity.",
183 inputSchema: {
184 type: "object",
185 properties: {
186 owner: { type: "string", description: "Repository owner username" },
187 name: { type: "string", description: "Repository name" },
188 },
189 required: ["owner", "name"],
190 },
191 async handler(args, user) {
192 const owner = args.owner as string;
193 const repoName = args.name as string;
194 if (!owner || !repoName) invalidParams("owner and name are required");
195
196 const repo = await loadAndCheckRepo(owner, repoName, user);
197
198 // Recent activity
199 const activity = await db
200 .select()
201 .from(
202 (await import("../db/schema")).activityFeed
203 )
204 .where(eq((await import("../db/schema")).activityFeed.repositoryId, repo.id))
205 .orderBy(desc((await import("../db/schema")).activityFeed.createdAt))
206 .limit(10);
207
208 return {
209 id: repo.id,
210 owner,
211 name: repo.name,
212 description: repo.description ?? null,
213 isPrivate: repo.isPrivate,
214 isArchived: repo.isArchived,
215 isTemplate: repo.isTemplate,
216 defaultBranch: repo.defaultBranch,
217 stars: repo.starCount,
218 forks: repo.forkCount,
219 openIssues: repo.issueCount,
220 createdAt: repo.createdAt,
221 updatedAt: repo.updatedAt,
222 pushedAt: repo.pushedAt ?? null,
223 recentActivity: activity.map((a) => ({
224 action: a.action,
225 targetType: a.targetType ?? null,
226 targetId: a.targetId ?? null,
227 createdAt: a.createdAt,
228 })),
229 };
230 },
231};
232
233// --------------------------------------------------------------------------
234// Tool: get_file_contents
235// --------------------------------------------------------------------------
236
237const getFileContentsTool: McpTool = {
238 name: "get_file_contents",
239 description: "Get the contents of a file in a repository at a given ref (branch, tag, or commit SHA).",
240 inputSchema: {
241 type: "object",
242 properties: {
243 owner: { type: "string", description: "Repository owner username" },
244 repo: { type: "string", description: "Repository name" },
245 path: { type: "string", description: "File path within the repository" },
246 ref: {
247 type: "string",
248 description: "Branch, tag, or commit SHA. Defaults to the repository default branch.",
249 },
250 },
251 required: ["owner", "repo", "path"],
252 },
253 async handler(args, user) {
254 const owner = args.owner as string;
255 const repoName = args.repo as string;
256 const filePath = args.path as string;
257 if (!owner || !repoName || !filePath) invalidParams("owner, repo, and path are required");
258
259 const repo = await loadAndCheckRepo(owner, repoName, user);
260 const ref = (args.ref as string | undefined) || repo.defaultBranch;
261
262 const blob = await getBlob(owner, repoName, ref, filePath);
263 if (!blob) notFound(`File '${filePath}' not found at ref '${ref}'`);
264
265 if (blob.isBinary) {
266 return {
267 content: "",
268 encoding: "binary",
269 size: blob.size,
270 path: filePath,
271 isBinary: true,
272 };
273 }
274
275 return {
276 content: blob.content,
277 encoding: "utf8",
278 size: blob.size,
279 path: filePath,
280 isBinary: false,
281 };
282 },
283};
284
285// --------------------------------------------------------------------------
286// Tool: list_files
287// --------------------------------------------------------------------------
288
289const listFilesTool: McpTool = {
290 name: "list_files",
291 description: "List files and directories in a repository at a given path and ref.",
292 inputSchema: {
293 type: "object",
294 properties: {
295 owner: { type: "string", description: "Repository owner username" },
296 repo: { type: "string", description: "Repository name" },
297 path: {
298 type: "string",
299 description: "Directory path to list. Defaults to the root directory.",
300 },
301 ref: {
302 type: "string",
303 description: "Branch, tag, or commit SHA. Defaults to the repository default branch.",
304 },
305 },
306 required: ["owner", "repo"],
307 },
308 async handler(args, user) {
309 const owner = args.owner as string;
310 const repoName = args.repo as string;
311 if (!owner || !repoName) invalidParams("owner and repo are required");
312
313 const repo = await loadAndCheckRepo(owner, repoName, user);
314 const ref = (args.ref as string | undefined) || repo.defaultBranch;
315 const treePath = (args.path as string | undefined) || "";
316
317 const entries = await getTree(owner, repoName, ref, treePath);
318
319 return entries.map((e) => {
320 const entryPath = treePath ? `${treePath}/${e.name}` : e.name;
321 return {
322 name: e.name,
323 path: entryPath,
324 type: e.type === "tree" ? "dir" : "file",
325 size: e.type === "blob" ? (e.size ?? null) : null,
326 sha: e.sha,
327 };
328 });
329 },
330};
331
332// --------------------------------------------------------------------------
333// Tool: search_code
334// --------------------------------------------------------------------------
335
336const searchCodeTool: McpTool = {
337 name: "search_code",
338 description: "Search for a string or pattern in the source code of a repository.",
339 inputSchema: {
340 type: "object",
341 properties: {
342 owner: { type: "string", description: "Repository owner username" },
343 repo: { type: "string", description: "Repository name" },
344 query: { type: "string", description: "Search query string (passed to git grep)" },
345 ref: {
346 type: "string",
347 description: "Branch, tag, or commit SHA. Defaults to the repository default branch.",
348 },
349 },
350 required: ["owner", "repo", "query"],
351 },
352 async handler(args, user) {
353 const owner = args.owner as string;
354 const repoName = args.repo as string;
355 const query = args.query as string;
356 if (!owner || !repoName || !query) invalidParams("owner, repo, and query are required");
357
358 const repo = await loadAndCheckRepo(owner, repoName, user);
359 const ref = (args.ref as string | undefined) || repo.defaultBranch;
360
361 const matches = await searchCode(owner, repoName, ref, query);
362
363 return matches.map((m) => ({
364 file: m.file,
365 line: m.lineNum,
366 content: m.line,
367 }));
368 },
369};
370
371// --------------------------------------------------------------------------
372// Tool: list_commits
373// --------------------------------------------------------------------------
374
375const listCommitsTool: McpTool = {
376 name: "list_commits",
377 description: "List recent commits on a branch of a repository.",
378 inputSchema: {
379 type: "object",
380 properties: {
381 owner: { type: "string", description: "Repository owner username" },
382 repo: { type: "string", description: "Repository name" },
383 branch: {
384 type: "string",
385 description: "Branch name. Defaults to the repository default branch.",
386 },
387 limit: {
388 type: "number",
389 description: "Maximum number of commits to return (1–100). Defaults to 20.",
390 minimum: 1,
391 maximum: 100,
392 },
393 },
394 required: ["owner", "repo"],
395 },
396 async handler(args, user) {
397 const owner = args.owner as string;
398 const repoName = args.repo as string;
399 if (!owner || !repoName) invalidParams("owner and repo are required");
400
401 const repo = await loadAndCheckRepo(owner, repoName, user);
402 const branch = (args.branch as string | undefined) || repo.defaultBranch;
403 const limit = Math.min(100, Math.max(1, (args.limit as number | undefined) ?? 20));
404
405 const commits = await listCommits(owner, repoName, branch, limit);
406
407 return commits.map((c) => ({
408 sha: c.sha,
409 message: c.message,
410 author: c.author,
411 authorEmail: c.authorEmail,
412 date: c.date,
413 parentShas: c.parentShas,
414 }));
415 },
416};
417
418// --------------------------------------------------------------------------
419// Tool: get_gate_status
420// --------------------------------------------------------------------------
421
422const getGateStatusTool: McpTool = {
423 name: "get_gate_status",
424 description:
425 "Get the current gate status for a repository — the latest result for each configured gate (GateTest, AI Review, Secret Scan, etc.).",
426 inputSchema: {
427 type: "object",
428 properties: {
429 owner: { type: "string", description: "Repository owner username" },
430 repo: { type: "string", description: "Repository name" },
431 },
432 required: ["owner", "repo"],
433 },
434 async handler(args, user) {
435 const owner = args.owner as string;
436 const repoName = args.repo as string;
437 if (!owner || !repoName) invalidParams("owner and repo are required");
438
439 const repo = await loadAndCheckRepo(owner, repoName, user);
440
441 // Get all gate runs ordered newest-first and deduplicate by gate name
442 const runs = await db
443 .select()
444 .from(gateRuns)
445 .where(eq(gateRuns.repositoryId, repo.id))
446 .orderBy(desc(gateRuns.createdAt))
447 .limit(200);
448
449 // Latest run per gate name
450 const latestByGate = new Map<string, typeof runs[0]>();
451 for (const run of runs) {
452 if (!latestByGate.has(run.gateName)) {
453 latestByGate.set(run.gateName, run);
454 }
455 }
456
457 const gates = Array.from(latestByGate.values()).map((r) => ({
458 name: r.gateName,
459 status: r.status,
460 summary: r.summary ?? null,
461 commitSha: r.commitSha,
462 updatedAt: r.completedAt ?? r.createdAt,
463 }));
464
465 // Derive overall status
466 let overall: "green" | "red" | "pending" = "green";
467 for (const g of gates) {
468 if (g.status === "failed") { overall = "red"; break; }
469 if (g.status === "pending" || g.status === "running") overall = "pending";
470 }
471 if (gates.length === 0) overall = "pending";
472
473 return { overall, gates };
474 },
475};
476
477// --------------------------------------------------------------------------
478// Tool: list_pull_requests
479// --------------------------------------------------------------------------
480
481const listPullRequestsTool: McpTool = {
482 name: "list_pull_requests",
483 description: "List pull requests for a repository, optionally filtered by state.",
484 inputSchema: {
485 type: "object",
486 properties: {
487 owner: { type: "string", description: "Repository owner username" },
488 repo: { type: "string", description: "Repository name" },
489 state: {
490 type: "string",
491 enum: ["open", "closed", "merged"],
492 description: "Filter by state. Defaults to 'open'.",
493 },
494 },
495 required: ["owner", "repo"],
496 },
497 async handler(args, user) {
498 const owner = args.owner as string;
499 const repoName = args.repo as string;
500 if (!owner || !repoName) invalidParams("owner and repo are required");
501
502 const repo = await loadAndCheckRepo(owner, repoName, user);
503 const state = (args.state as string | undefined) || "open";
504
505 const prs = await db
506 .select({
507 id: pullRequests.id,
508 number: pullRequests.number,
509 title: pullRequests.title,
510 state: pullRequests.state,
511 authorId: pullRequests.authorId,
512 baseBranch: pullRequests.baseBranch,
513 headBranch: pullRequests.headBranch,
514 createdAt: pullRequests.createdAt,
515 mergedAt: pullRequests.mergedAt,
516 })
517 .from(pullRequests)
518 .where(
519 and(
520 eq(pullRequests.repositoryId, repo.id),
521 eq(pullRequests.state, state)
522 )
523 )
524 .orderBy(desc(pullRequests.createdAt))
525 .limit(50);
526
527 if (prs.length === 0) return [];
528
529 // Collect author user data
530 const authorIds = [...new Set(prs.map((p) => p.authorId))];
531 const authorRows = await db
532 .select({ id: users.id, username: users.username })
533 .from(users)
534 .where(inArray(users.id, authorIds));
535 const authorMap = new Map(authorRows.map((u) => [u.id, u.username]));
536
537 // Latest AI review comment per PR
538 const prIds = prs.map((p) => p.id);
539 const aiComments = await db
540 .select({
541 pullRequestId: prComments.pullRequestId,
542 body: prComments.body,
543 createdAt: prComments.createdAt,
544 })
545 .from(prComments)
546 .where(
547 and(
548 inArray(prComments.pullRequestId, prIds),
549 eq(prComments.isAiReview, true)
550 )
551 )
552 .orderBy(desc(prComments.createdAt));
553
554 // Latest AI comment per PR
555 const aiReviewMap = new Map<string, string>();
556 for (const c of aiComments) {
557 if (!aiReviewMap.has(c.pullRequestId)) {
558 aiReviewMap.set(c.pullRequestId, c.body);
559 }
560 }
561
562 return prs.map((p) => ({
563 number: p.number,
564 title: p.title,
565 state: p.state,
566 author: authorMap.get(p.authorId) ?? null,
567 baseBranch: p.baseBranch,
568 headBranch: p.headBranch,
569 createdAt: p.createdAt,
570 mergedAt: p.mergedAt ?? null,
571 aiReviewSummary: aiReviewMap.get(p.id) ?? null,
572 }));
573 },
574};
575
576// --------------------------------------------------------------------------
577// Tool: list_issues
578// --------------------------------------------------------------------------
579
580const listIssuesTool: McpTool = {
581 name: "list_issues",
582 description: "List issues for a repository, optionally filtered by state.",
583 inputSchema: {
584 type: "object",
585 properties: {
586 owner: { type: "string", description: "Repository owner username" },
587 repo: { type: "string", description: "Repository name" },
588 state: {
589 type: "string",
590 enum: ["open", "closed"],
591 description: "Filter by state. Defaults to 'open'.",
592 },
593 },
594 required: ["owner", "repo"],
595 },
596 async handler(args, user) {
597 const owner = args.owner as string;
598 const repoName = args.repo as string;
599 if (!owner || !repoName) invalidParams("owner and repo are required");
600
601 const repo = await loadAndCheckRepo(owner, repoName, user);
602 const state = (args.state as string | undefined) || "open";
603
604 const issueRows = await db
605 .select({
606 id: issues.id,
607 number: issues.number,
608 title: issues.title,
609 state: issues.state,
610 authorId: issues.authorId,
611 createdAt: issues.createdAt,
612 })
613 .from(issues)
614 .where(
615 and(
616 eq(issues.repositoryId, repo.id),
617 eq(issues.state, state)
618 )
619 )
620 .orderBy(desc(issues.createdAt))
621 .limit(50);
622
623 if (issueRows.length === 0) return [];
624
625 // Collect author usernames
626 const authorIds = [...new Set(issueRows.map((i) => i.authorId))];
627 const authorRows = await db
628 .select({ id: users.id, username: users.username })
629 .from(users)
630 .where(inArray(users.id, authorIds));
631 const authorMap = new Map(authorRows.map((u) => [u.id, u.username]));
632
633 // Load labels for all issues in one join
634 const issueIds = issueRows.map((i) => i.id);
635 const labelJoinRows = await db
636 .select({
637 issueId: issueLabels.issueId,
638 labelName: labels.name,
639 labelColor: labels.color,
640 })
641 .from(issueLabels)
642 .innerJoin(labels, eq(issueLabels.labelId, labels.id))
643 .where(inArray(issueLabels.issueId, issueIds));
644
645 // Group labels by issue
646 const labelsMap = new Map<string, Array<{ name: string; color: string }>>();
647 for (const row of labelJoinRows) {
648 const existing = labelsMap.get(row.issueId) ?? [];
649 existing.push({ name: row.labelName, color: row.labelColor });
650 labelsMap.set(row.issueId, existing);
651 }
652
653 return issueRows.map((i) => ({
654 number: i.number,
655 title: i.title,
656 state: i.state,
657 author: authorMap.get(i.authorId) ?? null,
658 labels: labelsMap.get(i.id) ?? [],
659 createdAt: i.createdAt,
660 }));
661 },
662};
663
664// --------------------------------------------------------------------------
665// Tool: create_issue
666// --------------------------------------------------------------------------
667
668const createIssueTool: McpTool = {
669 name: "create_issue",
670 description: "Create a new issue in a repository. Requires authentication.",
671 inputSchema: {
672 type: "object",
673 properties: {
674 owner: { type: "string", description: "Repository owner username" },
675 repo: { type: "string", description: "Repository name" },
676 title: { type: "string", description: "Issue title" },
677 body: { type: "string", description: "Issue body (Markdown)" },
678 labels: {
679 type: "array",
680 items: { type: "string" },
681 description: "Label names to apply to the issue",
682 },
683 },
684 required: ["owner", "repo", "title"],
685 },
686 async handler(args, user) {
687 if (!user) unauthorized("Creating issues requires authentication");
688
689 const owner = args.owner as string;
690 const repoName = args.repo as string;
691 const title = args.title as string;
692 if (!owner || !repoName || !title) invalidParams("owner, repo, and title are required");
693 if (title.trim().length === 0) invalidParams("title cannot be empty");
694
695 const repo = await loadAndCheckRepo(owner, repoName, user);
696 const body = (args.body as string | undefined) ?? null;
697 const labelNames = (args.labels as string[] | undefined) ?? [];
698
699 // Insert issue
700 const [newIssue] = await db
701 .insert(issues)
702 .values({
703 repositoryId: repo.id,
704 authorId: user.id,
705 title: title.trim(),
706 body,
707 state: "open",
708 })
709 .returning({ id: issues.id, number: issues.number });
710
711 if (!newIssue) throw new McpToolError(-32603, "Failed to create issue");
712
713 // Apply labels if any
714 if (labelNames.length > 0) {
715 const labelRows = await db
716 .select({ id: labels.id, name: labels.name })
717 .from(labels)
718 .where(
719 and(
720 eq(labels.repositoryId, repo.id),
721 inArray(labels.name, labelNames)
722 )
723 );
724
725 if (labelRows.length > 0) {
726 await db.insert(issueLabels).values(
727 labelRows.map((l) => ({ issueId: newIssue.id, labelId: l.id }))
728 );
729 }
730 }
731
732 // Bump issue count on repo (best-effort)
733 db.update(repositories)
734 .set({ issueCount: repo.issueCount + 1 })
735 .where(eq(repositories.id, repo.id))
736 .catch(() => {});
737
738 return {
739 number: newIssue.number,
740 url: `/${owner}/${repoName}/issues/${newIssue.number}`,
741 };
742 },
743};
744
745// --------------------------------------------------------------------------
746// Tool: explain_codebase
747// --------------------------------------------------------------------------
748
749const explainCodebaseTool: McpTool = {
750 name: "explain_codebase",
751 description:
752 "Generate (or return a cached) AI-powered explanation of what a repository does, its architecture, and key files. Results are cached per commit SHA.",
753 inputSchema: {
754 type: "object",
755 properties: {
756 owner: { type: "string", description: "Repository owner username" },
757 repo: { type: "string", description: "Repository name" },
758 },
759 required: ["owner", "repo"],
760 },
761 async handler(args, user) {
762 const owner = args.owner as string;
763 const repoName = args.repo as string;
764 if (!owner || !repoName) invalidParams("owner and repo are required");
765
766 const repo = await loadAndCheckRepo(owner, repoName, user);
767
768 // Get latest commit SHA on default branch
769 const commits = await listCommits(owner, repoName, repo.defaultBranch, 1);
770 const commitSha = commits[0]?.sha ?? "HEAD";
771
772 // Check cache first
773 const cached = await getCachedExplanation(repo.id, commitSha);
774 if (cached) {
775 return {
776 explanation: cached.markdown,
777 summary: cached.summary,
778 generatedAt: new Date().toISOString(),
779 cached: true,
780 };
781 }
782
783 // Generate fresh
784 const result = await explainCodebase({
785 owner,
786 repo: repoName,
787 repositoryId: repo.id,
788 commitSha,
789 });
790
791 return {
792 explanation: result.markdown,
793 summary: result.summary,
794 generatedAt: new Date().toISOString(),
795 cached: result.cached,
796 };
797 },
798};
799
800// --------------------------------------------------------------------------
801// Tool: get_branch_diff
802// --------------------------------------------------------------------------
803
804const getBranchDiffTool: McpTool = {
805 name: "get_branch_diff",
806 description:
807 "Get the diff between two branches or refs, including per-file addition/deletion stats and the raw diff (truncated to 50 KB).",
808 inputSchema: {
809 type: "object",
810 properties: {
811 owner: { type: "string", description: "Repository owner username" },
812 repo: { type: "string", description: "Repository name" },
813 base: { type: "string", description: "Base branch or ref" },
814 head: { type: "string", description: "Head branch or ref" },
815 },
816 required: ["owner", "repo", "base", "head"],
817 },
818 async handler(args, user) {
819 const owner = args.owner as string;
820 const repoName = args.repo as string;
821 const base = args.base as string;
822 const head = args.head as string;
823 if (!owner || !repoName || !base || !head) {
824 invalidParams("owner, repo, base, and head are required");
825 }
826
827 const repo = await loadAndCheckRepo(owner, repoName, user);
828 const repoDir = getRepoPath(owner, repoName);
829
830 // Raw diff
831 const diffProc = Bun.spawn(["git", "diff", `${base}...${head}`], {
832 cwd: repoDir,
833 stdout: "pipe",
834 stderr: "pipe",
835 });
836 const rawDiffFull = await new Response(diffProc.stdout).text();
837 await diffProc.exited;
838
839 // Numstat for per-file summary
840 const statProc = Bun.spawn(
841 ["git", "diff", "--numstat", `${base}...${head}`],
842 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
843 );
844 const numstatRaw = await new Response(statProc.stdout).text();
845 await statProc.exited;
846
847 const files = numstatRaw
848 .trim()
849 .split("\n")
850 .filter(Boolean)
851 .map((line) => {
852 const [add, del, filePath] = line.split("\t");
853 return {
854 path: filePath ?? "",
855 additions: add === "-" ? 0 : parseInt(add, 10) || 0,
856 deletions: del === "-" ? 0 : parseInt(del, 10) || 0,
857 };
858 });
859
860 const MAX_DIFF_BYTES = 50 * 1024; // 50 KB
861 const rawDiff =
862 rawDiffFull.length > MAX_DIFF_BYTES
863 ? rawDiffFull.slice(0, MAX_DIFF_BYTES) + "\n... (diff truncated at 50 KB)"
864 : rawDiffFull;
865
866 return { files, rawDiff };
867 },
868};
869
870// --------------------------------------------------------------------------
871// Tool: trigger_ai_review
872// --------------------------------------------------------------------------
873
874const triggerAiReviewTool: McpTool = {
875 name: "trigger_ai_review",
876 description:
877 "Trigger an AI code review for a pull request. The caller must be authenticated and must be the repository owner or the PR author.",
878 inputSchema: {
879 type: "object",
880 properties: {
881 owner: { type: "string", description: "Repository owner username" },
882 repo: { type: "string", description: "Repository name" },
883 prNumber: { type: "number", description: "Pull request number" },
884 },
885 required: ["owner", "repo", "prNumber"],
886 },
887 async handler(args, user) {
888 if (!user) unauthorized("Triggering AI review requires authentication");
889
890 const owner = args.owner as string;
891 const repoName = args.repo as string;
892 const prNumber = args.prNumber as number;
893 if (!owner || !repoName || !prNumber) {
894 invalidParams("owner, repo, and prNumber are required");
895 }
896
897 const repo = await loadAndCheckRepo(owner, repoName, user);
898
899 // Load PR
900 const [pr] = await db
901 .select()
902 .from(pullRequests)
903 .where(
904 and(
905 eq(pullRequests.repositoryId, repo.id),
906 eq(pullRequests.number, prNumber)
907 )
908 )
909 .limit(1);
910
911 if (!pr) notFound(`Pull request #${prNumber} not found`);
912
913 // Authorization: repo owner or PR author
914 if (user.id !== repo.ownerId && user.id !== pr.authorId) {
915 unauthorized("Only the repository owner or PR author can trigger an AI review");
916 }
917
918 // Fire-and-forget
919 triggerAiReview(
920 owner,
921 repoName,
922 pr.id,
923 pr.title,
924 pr.body ?? "",
925 pr.baseBranch,
926 pr.headBranch
927 ).catch((err) => {
928 console.error("[mcp] triggerAiReview error:", err);
929 });
930
931 return { queued: true, prId: pr.id };
932 },
933};
934
935// --------------------------------------------------------------------------
936// Tool registry
937// --------------------------------------------------------------------------
938
939export const MCP_TOOLS: McpTool[] = [
940 listRepositoriesTool,
941 getRepositoryTool,
942 getFileContentsTool,
943 listFilesTool,
944 searchCodeTool,
945 listCommitsTool,
946 getGateStatusTool,
947 listPullRequestsTool,
948 listIssuesTool,
949 createIssueTool,
950 explainCodebaseTool,
951 getBranchDiffTool,
952 triggerAiReviewTool,
953];
954
955export const MCP_TOOL_MAP = new Map<string, McpTool>(
956 MCP_TOOLS.map((t) => [t.name, t])
957);
958
959/**
960 * Serialise a tool for the tools/list response.
961 */
962export function serializeTool(tool: McpTool) {
963 return {
964 name: tool.name,
965 description: tool.description,
966 inputSchema: tool.inputSchema,
967 };
968}
Addedsrc/routes/competitive-intel.tsx+738−0View fileUnifiedSplit
1/**
2 * Competitive Intelligence Engine — Admin UI.
3 *
4 * GET /admin/intelligence — dashboard (latest report per competitor)
5 * POST /admin/intelligence/scan — trigger a new scan (fire-and-forget)
6 * GET /admin/intelligence/:competitor — history for one competitor
7 *
8 * All routes gated by `isSiteAdmin`.
9 */
10
11import { Hono } from "hono";
12import { softAuth } from "../middleware/auth";
13import type { AuthEnv } from "../middleware/auth";
14import { isSiteAdmin } from "../lib/admin";
15import { Layout } from "../views/layout";
16import {
17 runIntelligenceScan,
18 getLatestReports,
19 getReportHistory,
20 getLastScanRun,
21 COMPETITORS,
22 type CompetitorReport,
23 type GapIdentified,
24 type FeatureShipped,
25} from "../lib/competitive-intel";
26
27const competitiveIntel = new Hono<AuthEnv>();
28competitiveIntel.use("*", softAuth);
29
30// ---------------------------------------------------------------------------
31// Auth gate helper (mirrors admin.tsx pattern)
32// ---------------------------------------------------------------------------
33
34async function gate(c: any): Promise<{ user: any } | Response> {
35 const user = c.get("user");
36 if (!user) return c.redirect("/login?next=/admin/intelligence");
37 if (!(await isSiteAdmin(user.id))) {
38 return c.html(
39 <Layout title="Forbidden" user={user}>
40 <div class="empty-state">
41 <h2>403 — Not a site admin</h2>
42 <p>You don't have permission to view this page.</p>
43 </div>
44 </Layout>,
45 403
46 );
47 }
48 return { user };
49}
50
51// ---------------------------------------------------------------------------
52// Priority badge helper
53// ---------------------------------------------------------------------------
54
55function PriorityBadge({ priority }: { priority: "high" | "medium" | "low" }) {
56 const styles: Record<string, string> = {
57 high: "background:#f85149;color:#fff",
58 medium: "background:#d29922;color:#0d1117",
59 low: "background:#30363d;color:#8b949e",
60 };
61 return (
62 <span
63 style={`display:inline-block;padding:2px 8px;border-radius:3px;font-size:11px;font-weight:600;text-transform:uppercase;${styles[priority] ?? styles.low}`}
64 >
65 {priority}
66 </span>
67 );
68}
69
70// ---------------------------------------------------------------------------
71// Competitor display name helper
72// ---------------------------------------------------------------------------
73
74function competitorName(id: string): string {
75 const c = COMPETITORS.find((c) => c.id === id);
76 return c ? c.name : id;
77}
78
79// ---------------------------------------------------------------------------
80// Format a date string nicely
81// ---------------------------------------------------------------------------
82
83function fmtDate(d: string | Date | null | undefined): string {
84 if (!d) return "—";
85 try {
86 return new Date(d as string).toLocaleDateString("en-US", {
87 year: "numeric",
88 month: "short",
89 day: "numeric",
90 });
91 } catch {
92 return String(d);
93 }
94}
95
96// ---------------------------------------------------------------------------
97// Compute gap priority counts across all reports
98// ---------------------------------------------------------------------------
99
100function countGapsByPriority(reports: CompetitorReport[]) {
101 let high = 0;
102 let medium = 0;
103 let low = 0;
104 for (const r of reports) {
105 const gaps = (r.gapsIdentified ?? []) as GapIdentified[];
106 for (const g of gaps) {
107 if (g.priority === "high") high++;
108 else if (g.priority === "medium") medium++;
109 else low++;
110 }
111 }
112 return { high, medium, low };
113}
114
115// ---------------------------------------------------------------------------
116// GET /admin/intelligence — dashboard
117// ---------------------------------------------------------------------------
118
119competitiveIntel.get("/admin/intelligence", async (c) => {
120 const g = await gate(c);
121 if (g instanceof Response) return g;
122 const { user } = g;
123
124 const [reports, lastRun] = await Promise.all([
125 getLatestReports(),
126 getLastScanRun(),
127 ]);
128
129 const scanStarted = c.req.query("scan") === "started";
130 const gapCounts = countGapsByPriority(reports);
131
132 // Map reports by competitor id for easy lookup
133 const reportMap = new Map<string, CompetitorReport>(
134 reports.map((r) => [r.competitor, r])
135 );
136
137 return c.html(
138 <Layout title="Competitive Intelligence — Admin" user={user}>
139 <div style="max-width:1100px;margin:0 auto;padding:24px 16px">
140 {/* Header */}
141 <div
142 style="display:flex;justify-content:space-between;align-items:flex-start;flex-wrap:wrap;gap:12px;margin-bottom:8px"
143 >
144 <div>
145 <h1 style="margin:0;font-size:22px;font-weight:700">
146 Competitive Intelligence
147 </h1>
148 <p style="color:var(--text-muted);font-size:13px;margin-top:4px">
149 Weekly gap analysis — what competitors are shipping vs. what
150 Gluecron has.
151 </p>
152 </div>
153 <div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
154 <a href="/admin" class="btn btn-sm">
155 Back to Admin
156 </a>
157 <form method="post" action="/admin/intelligence/scan">
158 <button
159 type="submit"
160 class="btn btn-primary btn-sm"
161 onclick="this.disabled=true;this.textContent='Scanning…';this.form.submit()"
162 >
163 Run scan now
164 </button>
165 </form>
166 </div>
167 </div>
168
169 {/* Scan started banner */}
170 {scanStarted && (
171 <div
172 class="auth-success"
173 style="margin-bottom:16px;font-size:13px"
174 >
175 Scan started in the background. Refresh in a few minutes to see
176 updated reports.
177 </div>
178 )}
179
180 {/* Last scan status */}
181 {lastRun && (
182 <div
183 style="background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius);padding:10px 16px;margin-bottom:20px;font-size:13px;display:flex;gap:16px;flex-wrap:wrap;align-items:center"
184 >
185 <span>
186 <span style="color:var(--text-muted)">Last scan:</span>{" "}
187 {fmtDate(lastRun.startedAt as unknown as string)}
188 </span>
189 <span>
190 <span style="color:var(--text-muted)">Status:</span>{" "}
191 <span
192 style={
193 lastRun.status === "completed"
194 ? "color:var(--green);font-weight:600"
195 : lastRun.status === "failed"
196 ? "color:var(--red);font-weight:600"
197 : "color:var(--yellow);font-weight:600"
198 }
199 >
200 {lastRun.status}
201 </span>
202 </span>
203 <span>
204 <span style="color:var(--text-muted)">Reports created:</span>{" "}
205 {lastRun.competitorsScanned}
206 </span>
207 {lastRun.error && (
208 <span style="color:var(--red);font-size:12px">
209 {lastRun.error}
210 </span>
211 )}
212 </div>
213 )}
214
215 {/* Gap summary counts */}
216 <div
217 style="display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:12px;margin-bottom:24px"
218 >
219 <div
220 style="background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius);padding:14px;text-align:center"
221 >
222 <div style="font-size:26px;font-weight:700;color:var(--red)">
223 {gapCounts.high}
224 </div>
225 <div
226 style="font-size:11px;text-transform:uppercase;color:var(--text-muted);margin-top:2px"
227 >
228 High-priority gaps
229 </div>
230 </div>
231 <div
232 style="background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius);padding:14px;text-align:center"
233 >
234 <div style="font-size:26px;font-weight:700;color:var(--yellow)">
235 {gapCounts.medium}
236 </div>
237 <div
238 style="font-size:11px;text-transform:uppercase;color:var(--text-muted);margin-top:2px"
239 >
240 Medium-priority gaps
241 </div>
242 </div>
243 <div
244 style="background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius);padding:14px;text-align:center"
245 >
246 <div style="font-size:26px;font-weight:700;color:var(--text-muted)">
247 {gapCounts.low}
248 </div>
249 <div
250 style="font-size:11px;text-transform:uppercase;color:var(--text-muted);margin-top:2px"
251 >
252 Low-priority gaps
253 </div>
254 </div>
255 <div
256 style="background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius);padding:14px;text-align:center"
257 >
258 <div style="font-size:26px;font-weight:700">
259 {reports.length}
260 </div>
261 <div
262 style="font-size:11px;text-transform:uppercase;color:var(--text-muted);margin-top:2px"
263 >
264 Competitors tracked
265 </div>
266 </div>
267 </div>
268
269 {/* Competitor cards grid */}
270 {reports.length === 0 ? (
271 <div
272 style="background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius);padding:48px;text-align:center;color:var(--text-muted)"
273 >
274 <p style="font-size:15px;margin-bottom:8px">No reports yet.</p>
275 <p style="font-size:13px">
276 Click "Run scan now" to fetch the latest competitor changelogs and
277 analyse gaps with Claude.
278 </p>
279 </div>
280 ) : (
281 <div
282 style="display:grid;grid-template-columns:repeat(auto-fill,minmax(340px,1fr));gap:16px"
283 >
284 {COMPETITORS.map((comp) => {
285 const report = reportMap.get(comp.id);
286 return (
287 <div
288 style="background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius);overflow:hidden;display:flex;flex-direction:column"
289 >
290 {/* Card header */}
291 <div
292 style="padding:14px 16px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center"
293 >
294 <div>
295 <span style="font-weight:700;font-size:15px">
296 {comp.name}
297 </span>
298 {report && (
299 <span
300 style="margin-left:8px;font-size:12px;color:var(--text-muted)"
301 >
302 {fmtDate(report.reportDate as unknown as string)}
303 </span>
304 )}
305 </div>
306 <a
307 href={`/admin/intelligence/${comp.id}`}
308 style="font-size:12px;color:var(--text-link)"
309 >
310 History
311 </a>
312 </div>
313
314 {/* Card body */}
315 <div style="padding:14px 16px;flex:1;display:flex;flex-direction:column;gap:12px">
316 {!report ? (
317 <p style="font-size:13px;color:var(--text-muted)">
318 No report available. Run a scan to populate.
319 </p>
320 ) : (
321 <>
322 {/* Summary */}
323 {report.summary && (
324 <p style="font-size:13px;color:var(--text-muted);line-height:1.55">
325 {report.summary}
326 </p>
327 )}
328
329 {/* Gaps — high priority first */}
330 {(report.gapsIdentified as GapIdentified[]).length >
331 0 && (
332 <div>
333 <div
334 style="font-size:11px;font-weight:600;text-transform:uppercase;color:var(--text-muted);margin-bottom:6px;letter-spacing:0.05em"
335 >
336 Gaps identified (
337 {
338 (report.gapsIdentified as GapIdentified[])
339 .length
340 }
341 )
342 </div>
343 <ul
344 style="list-style:none;display:flex;flex-direction:column;gap:6px"
345 >
346 {(report.gapsIdentified as GapIdentified[])
347 .sort((a, b) => {
348 const order = {
349 high: 0,
350 medium: 1,
351 low: 2,
352 };
353 return (
354 (order[a.priority] ?? 3) -
355 (order[b.priority] ?? 3)
356 );
357 })
358 .slice(0, 5)
359 .map((gap) => (
360 <li
361 style="display:flex;align-items:flex-start;gap:8px;font-size:13px"
362 >
363 <PriorityBadge priority={gap.priority} />
364 <span style="color:var(--text)">
365 {gap.feature}
366 </span>
367 </li>
368 ))}
369 {(report.gapsIdentified as GapIdentified[])
370 .length > 5 && (
371 <li
372 style="font-size:12px;color:var(--text-muted)"
373 >
374 +
375 {(report.gapsIdentified as GapIdentified[])
376 .length - 5}{" "}
377 more —{" "}
378 <a
379 href={`/admin/intelligence/${comp.id}`}
380 style="color:var(--text-link)"
381 >
382 see all
383 </a>
384 </li>
385 )}
386 </ul>
387 </div>
388 )}
389
390 {/* Features shipped */}
391 {(report.featuresShipped as FeatureShipped[]).length >
392 0 && (
393 <div>
394 <div
395 style="font-size:11px;font-weight:600;text-transform:uppercase;color:var(--text-muted);margin-bottom:6px;letter-spacing:0.05em"
396 >
397 Features shipped (
398 {
399 (report.featuresShipped as FeatureShipped[])
400 .length
401 }
402 )
403 </div>
404 <ul
405 style="list-style:none;display:flex;flex-direction:column;gap:4px"
406 >
407 {(report.featuresShipped as FeatureShipped[])
408 .slice(0, 4)
409 .map((f) => (
410 <li style="font-size:13px;color:var(--text-muted)">
411 {f.url ? (
412 <a
413 href={f.url}
414 target="_blank"
415 rel="noopener noreferrer"
416 style="color:var(--text-link)"
417 >
418 {f.title}
419 </a>
420 ) : (
421 <span style="color:var(--text)">
422 {f.title}
423 </span>
424 )}
425 </li>
426 ))}
427 {(report.featuresShipped as FeatureShipped[])
428 .length > 4 && (
429 <li
430 style="font-size:12px;color:var(--text-muted)"
431 >
432 +
433 {(report.featuresShipped as FeatureShipped[])
434 .length - 4}{" "}
435 more
436 </li>
437 )}
438 </ul>
439 </div>
440 )}
441 </>
442 )}
443 </div>
444
445 {/* Card footer */}
446 <div
447 style="padding:10px 16px;border-top:1px solid var(--border);display:flex;justify-content:space-between;align-items:center"
448 >
449 <a
450 href={comp.changelogUrl}
451 target="_blank"
452 rel="noopener noreferrer"
453 style="font-size:12px;color:var(--text-muted)"
454 >
455 Changelog ↗
456 </a>
457 <a
458 href={`/admin/intelligence/${comp.id}`}
459 class="btn btn-sm"
460 style="font-size:12px"
461 >
462 View history
463 </a>
464 </div>
465 </div>
466 );
467 })}
468 </div>
469 )}
470 </div>
471 </Layout>
472 );
473});
474
475// ---------------------------------------------------------------------------
476// POST /admin/intelligence/scan — trigger scan (fire-and-forget)
477// ---------------------------------------------------------------------------
478
479competitiveIntel.post("/admin/intelligence/scan", async (c) => {
480 const g = await gate(c);
481 if (g instanceof Response) return g;
482
483 // Fire-and-forget — do NOT await
484 runIntelligenceScan().catch((err) => {
485 console.error("[competitive-intel] background scan error:", err);
486 });
487
488 return c.redirect("/admin/intelligence?scan=started");
489});
490
491// ---------------------------------------------------------------------------
492// GET /admin/intelligence/:competitor — history for one competitor
493// ---------------------------------------------------------------------------
494
495competitiveIntel.get("/admin/intelligence/:competitor", async (c) => {
496 const g = await gate(c);
497 if (g instanceof Response) return g;
498 const { user } = g;
499
500 const competitorId = c.req.param("competitor");
501 const knownCompetitor = COMPETITORS.find((x) => x.id === competitorId);
502
503 if (!knownCompetitor) {
504 return c.html(
505 <Layout title="Not Found" user={user}>
506 <div class="empty-state">
507 <h2>404</h2>
508 <p>Competitor not found: {competitorId}</p>
509 <a href="/admin/intelligence" style="margin-top:12px;display:inline-block">
510 Back to Intelligence
511 </a>
512 </div>
513 </Layout>,
514 404
515 );
516 }
517
518 const history = await getReportHistory(competitorId, 20);
519 const name = knownCompetitor.name;
520
521 return c.html(
522 <Layout title={`${name} — Intelligence History`} user={user}>
523 <div style="max-width:960px;margin:0 auto;padding:24px 16px">
524 {/* Header */}
525 <div
526 style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:8px;margin-bottom:20px"
527 >
528 <div>
529 <h1 style="font-size:22px;font-weight:700;margin:0">
530 {name} — Intelligence History
531 </h1>
532 <p style="color:var(--text-muted);font-size:13px;margin-top:4px">
533 {history.length} report{history.length === 1 ? "" : "s"} on
534 record.{" "}
535 <a
536 href={knownCompetitor.changelogUrl}
537 target="_blank"
538 rel="noopener noreferrer"
539 style="color:var(--text-link)"
540 >
541 View live changelog ↗
542 </a>
543 </p>
544 </div>
545 <a href="/admin/intelligence" class="btn btn-sm">
546 Back to Dashboard
547 </a>
548 </div>
549
550 {/* Timeline */}
551 {history.length === 0 ? (
552 <div
553 style="background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius);padding:48px;text-align:center;color:var(--text-muted)"
554 >
555 <p>No reports for {name} yet. Run a scan to generate one.</p>
556 </div>
557 ) : (
558 <div style="display:flex;flex-direction:column;gap:20px">
559 {history.map((report, idx) => {
560 const gaps = (report.gapsIdentified ?? []) as GapIdentified[];
561 const features = (
562 report.featuresShipped ?? []
563 ) as FeatureShipped[];
564 const highGaps = gaps.filter((g) => g.priority === "high");
565 const medGaps = gaps.filter((g) => g.priority === "medium");
566 const lowGaps = gaps.filter((g) => g.priority === "low");
567
568 return (
569 <div
570 style="background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius);overflow:hidden"
571 >
572 {/* Report header */}
573 <div
574 style="padding:14px 16px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:8px"
575 >
576 <div style="display:flex;align-items:center;gap:12px">
577 <span
578 style="background:var(--accent);color:#fff;font-size:11px;font-weight:600;padding:2px 8px;border-radius:3px"
579 >
580 {idx === 0 ? "LATEST" : `#${idx + 1}`}
581 </span>
582 <span style="font-weight:600;font-size:15px">
583 Week of{" "}
584 {fmtDate(report.reportDate as unknown as string)}
585 </span>
586 </div>
587 <div
588 style="display:flex;gap:8px;font-size:12px;color:var(--text-muted)"
589 >
590 <span>
591 <span style="color:var(--red);font-weight:600">
592 {highGaps.length}
593 </span>{" "}
594 high
595 </span>
596 <span>
597 <span style="color:var(--yellow);font-weight:600">
598 {medGaps.length}
599 </span>{" "}
600 medium
601 </span>
602 <span>
603 <span style="color:var(--text-muted);font-weight:600">
604 {lowGaps.length}
605 </span>{" "}
606 low
607 </span>
608 <span>{features.length} features shipped</span>
609 </div>
610 </div>
611
612 {/* Report body */}
613 <div
614 style="padding:16px;display:grid;grid-template-columns:1fr 1fr;gap:20px"
615 >
616 {/* Left: summary + gaps */}
617 <div>
618 {report.summary && (
619 <div style="margin-bottom:14px">
620 <div
621 style="font-size:11px;font-weight:600;text-transform:uppercase;color:var(--text-muted);margin-bottom:6px;letter-spacing:0.05em"
622 >
623 Summary
624 </div>
625 <p
626 style="font-size:13px;color:var(--text-muted);line-height:1.6"
627 >
628 {report.summary}
629 </p>
630 </div>
631 )}
632
633 {gaps.length > 0 && (
634 <div>
635 <div
636 style="font-size:11px;font-weight:600;text-transform:uppercase;color:var(--text-muted);margin-bottom:8px;letter-spacing:0.05em"
637 >
638 Gaps identified ({gaps.length})
639 </div>
640 <ul
641 style="list-style:none;display:flex;flex-direction:column;gap:8px"
642 >
643 {[...highGaps, ...medGaps, ...lowGaps].map(
644 (gap) => (
645 <li style="font-size:13px">
646 <div
647 style="display:flex;align-items:flex-start;gap:8px;margin-bottom:2px"
648 >
649 <PriorityBadge priority={gap.priority} />
650 <span
651 style="color:var(--text);font-weight:500"
652 >
653 {gap.feature}
654 </span>
655 </div>
656 {gap.notes && (
657 <p
658 style="font-size:12px;color:var(--text-muted);margin-top:3px;padding-left:4px;line-height:1.5"
659 >
660 {gap.notes}
661 </p>
662 )}
663 </li>
664 )
665 )}
666 </ul>
667 </div>
668 )}
669 </div>
670
671 {/* Right: features shipped */}
672 <div>
673 {features.length > 0 && (
674 <div>
675 <div
676 style="font-size:11px;font-weight:600;text-transform:uppercase;color:var(--text-muted);margin-bottom:8px;letter-spacing:0.05em"
677 >
678 Features shipped ({features.length})
679 </div>
680 <ul
681 style="list-style:none;display:flex;flex-direction:column;gap:10px"
682 >
683 {features.map((f) => (
684 <li style="font-size:13px">
685 <div style="font-weight:500;color:var(--text);margin-bottom:2px">
686 {f.url ? (
687 <a
688 href={f.url}
689 target="_blank"
690 rel="noopener noreferrer"
691 style="color:var(--text-link)"
692 >
693 {f.title}
694 </a>
695 ) : (
696 f.title
697 )}
698 </div>
699 {f.description && (
700 <p
701 style="font-size:12px;color:var(--text-muted);line-height:1.5"
702 >
703 {f.description}
704 </p>
705 )}
706 </li>
707 ))}
708 </ul>
709 </div>
710 )}
711
712 {features.length === 0 && gaps.length === 0 && (
713 <p style="font-size:13px;color:var(--text-muted)">
714 No structured data extracted for this report.
715 </p>
716 )}
717 </div>
718 </div>
719 </div>
720 );
721 })}
722 </div>
723 )}
724 </div>
725
726 {/* Mobile: single-column layout for the report body grid */}
727 <style>{`
728 @media (max-width: 640px) {
729 .intel-report-body {
730 grid-template-columns: 1fr !important;
731 }
732 }
733 `}</style>
734 </Layout>
735 );
736});
737
738export default competitiveIntel;
Addedsrc/routes/mcp.ts+290−0View fileUnifiedSplit
1/**
2 * MCP (Model Context Protocol) HTTP endpoint — 2024-11-05 spec.
3 *
4 * Single endpoint: POST /mcp
5 * Discovery: GET /mcp
6 *
7 * Authentication: Bearer token in the Authorization header.
8 * - Tokens prefixed `glc_` → PAT (api_tokens table)
9 * - Tokens prefixed `glct_` → OAuth access token (oauth_access_tokens table)
10 * - No token → unauthenticated (only certain read-only methods are allowed)
11 *
12 * JSON-RPC 2.0 methods:
13 * initialize
14 * notifications/initialized
15 * tools/list
16 * tools/call
17 * resources/list
18 */
19
20import { Hono } from "hono";
21import { eq } from "drizzle-orm";
22import { db } from "../db";
23import { apiTokens, users, oauthAccessTokens } from "../db/schema";
24import type { User } from "../db/schema";
25import { sha256Hex } from "../lib/oauth";
26import {
27 MCP_TOOLS,
28 MCP_TOOL_MAP,
29 McpToolError,
30 serializeTool,
31} from "../lib/mcp-tools";
32
33const mcp = new Hono();
34
35// --------------------------------------------------------------------------
36// Constants
37// --------------------------------------------------------------------------
38
39const PROTOCOL_VERSION = "2024-11-05";
40const SERVER_INFO = { name: "gluecron", version: "1.0.0" };
41
42// JSON-RPC error codes
43const E_PARSE = -32700;
44const E_INVALID_REQUEST = -32600;
45const E_METHOD_NOT_FOUND = -32601;
46const E_INVALID_PARAMS = -32602;
47const E_INTERNAL = -32603;
48const E_UNAUTHORIZED = -32001;
49const E_NOT_FOUND = -32004;
50
51// Methods that do NOT require authentication
52const PUBLIC_METHODS = new Set([
53 "initialize",
54 "notifications/initialized",
55 "tools/list",
56 "resources/list",
57]);
58
59// --------------------------------------------------------------------------
60// Auth helpers (inline — no middleware, raw handler)
61// --------------------------------------------------------------------------
62
63async function loadUserFromPat(token: string): Promise<User | null> {
64 if (!token.startsWith("glc_")) return null;
65 try {
66 const hash = await sha256Hex(token);
67 const [row] = await db
68 .select()
69 .from(apiTokens)
70 .where(eq(apiTokens.tokenHash, hash))
71 .limit(1);
72 if (!row) return null;
73 if (row.expiresAt && new Date(row.expiresAt) < new Date()) return null;
74 const [user] = await db
75 .select()
76 .from(users)
77 .where(eq(users.id, row.userId))
78 .limit(1);
79 if (!user) return null;
80 // Best-effort: update lastUsedAt
81 db.update(apiTokens)
82 .set({ lastUsedAt: new Date() })
83 .where(eq(apiTokens.id, row.id))
84 .catch(() => {});
85 return user;
86 } catch {
87 return null;
88 }
89}
90
91async function loadUserFromOauthBearer(token: string): Promise<User | null> {
92 if (!token.startsWith("glct_")) return null;
93 try {
94 const hash = await sha256Hex(token);
95 const [row] = await db
96 .select()
97 .from(oauthAccessTokens)
98 .where(eq(oauthAccessTokens.accessTokenHash, hash))
99 .limit(1);
100 if (!row) return null;
101 if (row.revokedAt) return null;
102 if (new Date(row.expiresAt) < new Date()) return null;
103 const [user] = await db
104 .select()
105 .from(users)
106 .where(eq(users.id, row.userId))
107 .limit(1);
108 if (!user) return null;
109 db.update(oauthAccessTokens)
110 .set({ lastUsedAt: new Date() })
111 .where(eq(oauthAccessTokens.id, row.id))
112 .catch(() => {});
113 return user;
114 } catch {
115 return null;
116 }
117}
118
119/**
120 * Extract the authenticated user (or null) from the Authorization header.
121 * Accepts both PAT (`glc_`) and OAuth bearer (`glct_`) tokens.
122 */
123async function resolveUser(authHeader: string | undefined): Promise<User | null> {
124 if (!authHeader) return null;
125 const lower = authHeader.toLowerCase();
126 if (!lower.startsWith("bearer ")) return null;
127 const token = authHeader.slice(7).trim();
128 if (!token) return null;
129
130 if (token.startsWith("glct_")) return loadUserFromOauthBearer(token);
131 if (token.startsWith("glc_")) return loadUserFromPat(token);
132 return null;
133}
134
135// --------------------------------------------------------------------------
136// JSON-RPC helpers
137// --------------------------------------------------------------------------
138
139type JsonRpcId = string | number | null;
140
141function rpcOk(id: JsonRpcId, result: unknown) {
142 return { jsonrpc: "2.0", id, result };
143}
144
145function rpcError(id: JsonRpcId, code: number, message: string, data?: unknown) {
146 const error: Record<string, unknown> = { code, message };
147 if (data !== undefined) error.data = data;
148 return { jsonrpc: "2.0", id, error };
149}
150
151// --------------------------------------------------------------------------
152// Method handlers
153// --------------------------------------------------------------------------
154
155function handleInitialize() {
156 return {
157 protocolVersion: PROTOCOL_VERSION,
158 capabilities: {
159 tools: {},
160 resources: { subscribe: false },
161 },
162 serverInfo: SERVER_INFO,
163 };
164}
165
166function handleToolsList() {
167 return { tools: MCP_TOOLS.map(serializeTool) };
168}
169
170function handleResourcesList() {
171 return { resources: [] };
172}
173
174async function handleToolsCall(
175 params: Record<string, unknown>,
176 user: User | null
177): Promise<unknown> {
178 const toolName = params.name as string | undefined;
179 if (!toolName) {
180 throw new McpToolError(E_INVALID_PARAMS, "params.name is required for tools/call");
181 }
182
183 const tool = MCP_TOOL_MAP.get(toolName);
184 if (!tool) {
185 throw new McpToolError(E_NOT_FOUND, `Unknown tool: '${toolName}'`);
186 }
187
188 const args = (params.arguments ?? {}) as Record<string, unknown>;
189 const result = await tool.handler(args, user);
190 return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
191}
192
193// --------------------------------------------------------------------------
194// POST /mcp — main JSON-RPC dispatcher
195// --------------------------------------------------------------------------
196
197mcp.post("/mcp", async (c) => {
198 // Resolve authenticated user (may be null for public requests)
199 const authHeader = c.req.header("authorization");
200 const user = await resolveUser(authHeader);
201
202 // Parse body
203 let body: Record<string, unknown>;
204 try {
205 body = await c.req.json();
206 } catch {
207 return c.json(rpcError(null, E_PARSE, "Parse error: invalid JSON"), 200);
208 }
209
210 // Validate JSON-RPC envelope
211 if (body.jsonrpc !== "2.0" || !body.method) {
212 return c.json(
213 rpcError(body.id as JsonRpcId ?? null, E_INVALID_REQUEST, "Invalid JSON-RPC request"),
214 200
215 );
216 }
217
218 const id = (body.id as JsonRpcId) ?? null;
219 const method = body.method as string;
220 const params = (body.params ?? {}) as Record<string, unknown>;
221
222 // Auth gate: tools/call that mutate or read private data require auth
223 // (public methods are allowed without a token)
224 if (!PUBLIC_METHODS.has(method) && method !== "tools/call") {
225 // Any unknown method — we'll return method-not-found below, not an auth error
226 }
227
228 // For tools/call: auth is enforced at the tool level (each tool decides)
229 // but we still need a user object passed through.
230
231 try {
232 let result: unknown;
233
234 switch (method) {
235 case "initialize":
236 result = handleInitialize();
237 break;
238
239 case "notifications/initialized":
240 // Client acknowledgement — no-op, return empty result
241 result = {};
242 break;
243
244 case "tools/list":
245 result = handleToolsList();
246 break;
247
248 case "tools/call":
249 result = await handleToolsCall(params, user);
250 break;
251
252 case "resources/list":
253 result = handleResourcesList();
254 break;
255
256 default:
257 return c.json(rpcError(id, E_METHOD_NOT_FOUND, `Method not found: '${method}'`), 200);
258 }
259
260 return c.json(rpcOk(id, result), 200);
261 } catch (err) {
262 if (err instanceof McpToolError) {
263 return c.json(rpcError(id, err.code, err.message), 200);
264 }
265 // Unexpected error
266 console.error("[mcp] unhandled error:", err);
267 return c.json(rpcError(id, E_INTERNAL, "Internal error"), 200);
268 }
269});
270
271// --------------------------------------------------------------------------
272// GET /mcp — server discovery (no auth required)
273// --------------------------------------------------------------------------
274
275mcp.get("/mcp", async (c) => {
276 return c.json({
277 name: "gluecron",
278 version: "1.0.0",
279 description: "AI-native code intelligence platform",
280 transport: "http",
281 endpoint: "/mcp",
282 protocolVersion: PROTOCOL_VERSION,
283 tools: MCP_TOOLS.map((t) => ({
284 name: t.name,
285 description: t.description,
286 })),
287 });
288});
289
290export default mcp;
0291