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

Merge pull request #101 from ccantynz-alt/claude/platform-analysis-roadmap-1nUGL

Merge pull request #101 from ccantynz-alt/claude/platform-analysis-roadmap-1nUGL

Claude/platform analysis roadmap 1n ugl
CC LABS App committed on June 7, 2026Parents: 5f245bf 9d7a803
92 files changed+20066983df3979559686e3814b321e612b2e74529ad09de
92 changed files+20066−98
Addeddrizzle/0088_releases_changelog.sql+22−0View fileUnifiedSplit
1-- Migration 0077: ensure the releases table and its index exist.
2-- The table was first created in 0001_green_ecosystem.sql; this migration
3-- is a no-op guard so the AI Changelog Generator feature can declare its
4-- own dependency cleanly, and so fresh deploys from a stripped-down seed
5-- always get the table even if 0001 is re-run in a different order.
6
7CREATE TABLE IF NOT EXISTS releases (
8 id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
9 repository_id uuid NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
10 author_id uuid NOT NULL REFERENCES users(id),
11 tag text NOT NULL,
12 name text NOT NULL,
13 body text,
14 target_commit text NOT NULL,
15 is_draft boolean DEFAULT false NOT NULL,
16 is_prerelease boolean DEFAULT false NOT NULL,
17 created_at timestamp DEFAULT now() NOT NULL,
18 published_at timestamp,
19 UNIQUE(repository_id, tag)
20);
21
22CREATE INDEX IF NOT EXISTS releases_repo ON releases(repository_id, created_at DESC);
Addeddrizzle/0089_notifications.sql+20−0View fileUnifiedSplit
1-- Notification Center (Block A7 follow-up).
2--
3-- The initial 0001 migration created the `notifications` table with the
4-- `kind` / `read_at` column set used by src/lib/notify.ts. The inbox
5-- UI (src/routes/notifications.tsx) was later built against the
6-- schema-extensions variant which uses `type` / `is_read` / `actor_id`.
7-- This migration adds the missing columns so both surfaces operate on the
8-- same physical table without a breaking rename.
9--
10-- All columns are additive (IF NOT EXISTS) so this migration is idempotent
11-- and safe to run against databases that already have these columns.
12
13ALTER TABLE "notifications"
14 ADD COLUMN IF NOT EXISTS "type" text,
15 ADD COLUMN IF NOT EXISTS "is_read" boolean NOT NULL DEFAULT false,
16 ADD COLUMN IF NOT EXISTS "actor_id" uuid REFERENCES "users"("id") ON DELETE SET NULL;
17
18-- Index used by the inbox page to find unread notifications quickly.
19CREATE INDEX IF NOT EXISTS "notifications_user_unread_is_read"
20 ON "notifications" ("user_id", "is_read", "created_at" DESC);
Addeddrizzle/0090_milestones.sql+8−0View fileUnifiedSplit
1-- Migration 0077: Add milestone_id to issues table
2-- The milestones table and pull_requests.milestone_id already exist (migration 0001).
3-- This migration adds milestone_id to the issues table.
4
5ALTER TABLE "issues" ADD COLUMN IF NOT EXISTS "milestone_id" uuid REFERENCES "milestones"("id") ON DELETE SET NULL;
6
7--> statement-breakpoint
8CREATE INDEX IF NOT EXISTS "issues_milestone" ON "issues" ("milestone_id");
Addeddrizzle/0091_codeowners_review_requests.sql+15−0View fileUnifiedSplit
1-- Migration 0077: CODEOWNERS auto-assign + required reviews before merge
2-- Adds pr_review_requests table for tracking requested reviewers per PR.
3-- (branch_protection table already exists from an earlier migration.)
4
5CREATE TABLE IF NOT EXISTS pr_review_requests (
6 id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
7 pr_id uuid NOT NULL REFERENCES pull_requests(id) ON DELETE CASCADE,
8 reviewer_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
9 requested_by uuid REFERENCES users(id),
10 created_at timestamp DEFAULT now(),
11 UNIQUE(pr_id, reviewer_id)
12);
13
14CREATE INDEX IF NOT EXISTS pr_review_requests_pr ON pr_review_requests(pr_id);
15CREATE INDEX IF NOT EXISTS pr_review_requests_reviewer ON pr_review_requests(reviewer_id);
Addeddrizzle/0092_dep_updater.sql+5−0View fileUnifiedSplit
1-- Migration 0077: AI dependency auto-updater
2-- Adds dep_updater_enabled flag to repositories.
3-- dep_update_runs table already exists (from Block D2).
4
5ALTER TABLE repositories ADD COLUMN IF NOT EXISTS dep_updater_enabled boolean NOT NULL DEFAULT false;
Addeddrizzle/0093_incident_hooks.sql+10−0View fileUnifiedSplit
1-- Migration 0077: Incident hook configs for PagerDuty/Datadog/Opsgenie/generic
2CREATE TABLE IF NOT EXISTS incident_hook_configs (
3 id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
4 user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
5 repo_id uuid NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
6 provider text NOT NULL, -- 'pagerduty' | 'datadog' | 'opsgenie' | 'generic'
7 secret_hash text NOT NULL, -- SHA-256 of the webhook secret for HMAC validation
8 created_at timestamp DEFAULT now(),
9 UNIQUE(repo_id, provider)
10);
Addeddrizzle/0094_enterprise_sso.sql+47−0View fileUnifiedSplit
1-- Migration 0077: Enterprise SSO (SAML 2.0 + OIDC per-org) and SCIM user provisioning
2-- Org-level SSO configs (distinct from site-wide sso_config / Block I10)
3
4CREATE TABLE IF NOT EXISTS org_sso_configs (
5 id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
6 org_id uuid NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
7 provider text NOT NULL DEFAULT 'saml', -- 'saml' | 'oidc'
8 -- SAML fields
9 idp_entity_id text,
10 idp_sso_url text,
11 idp_certificate text, -- PEM cert from IdP
12 sp_entity_id text, -- our entity ID (computed)
13 -- OIDC fields
14 oidc_client_id text,
15 oidc_client_secret text,
16 oidc_discovery_url text,
17 -- Common
18 domain_hint text, -- e.g. "acme.com" — auto-routes users from this domain to SSO
19 attribute_mapping jsonb DEFAULT '{"email":"email","name":"name","username":"preferred_username"}',
20 enabled boolean NOT NULL DEFAULT false,
21 created_at timestamp DEFAULT now(),
22 updated_at timestamp DEFAULT now(),
23 UNIQUE(org_id)
24);
25
26CREATE INDEX IF NOT EXISTS org_sso_configs_domain ON org_sso_configs(domain_hint) WHERE domain_hint IS NOT NULL;
27
28CREATE TABLE IF NOT EXISTS scim_tokens (
29 id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
30 org_id uuid NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
31 token_hash text NOT NULL UNIQUE, -- SHA-256 of the token
32 created_by uuid NOT NULL REFERENCES users(id),
33 created_at timestamp DEFAULT now(),
34 last_used_at timestamp
35);
36
37CREATE TABLE IF NOT EXISTS org_sso_sessions (
38 id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
39 user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
40 org_id uuid NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
41 idp_session_id text,
42 created_at timestamp DEFAULT now(),
43 expires_at timestamp NOT NULL
44);
45
46CREATE INDEX IF NOT EXISTS org_sso_sessions_user ON org_sso_sessions(user_id);
47CREATE INDEX IF NOT EXISTS org_sso_sessions_expires ON org_sso_sessions(expires_at);
Addeddrizzle/0095_cloud_deploys.sql+32−0View fileUnifiedSplit
1-- Migration 0077: Multi-cloud deploy integration
2-- Adds cloud_deploy_configs and cloud_deployments tables for push-triggered
3-- deploys to Fly.io, Railway, Render, Vercel, Netlify, and generic webhooks.
4
5CREATE TABLE IF NOT EXISTS cloud_deploy_configs (
6 id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
7 repo_id uuid NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
8 provider text NOT NULL, -- 'fly' | 'railway' | 'render' | 'vercel' | 'netlify' | 'webhook'
9 provider_app_id text NOT NULL, -- Fly app name, Railway service ID, Render service ID, Vercel project ID, webhook URL
10 api_token_encrypted text NOT NULL, -- AES-256-GCM encrypted via SERVER_TARGETS_KEY
11 trigger_branch text NOT NULL DEFAULT 'main',
12 enabled boolean NOT NULL DEFAULT true,
13 created_at timestamp DEFAULT now()
14);
15
16CREATE TABLE IF NOT EXISTS cloud_deployments (
17 id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
18 config_id uuid NOT NULL REFERENCES cloud_deploy_configs(id) ON DELETE CASCADE,
19 repo_id uuid NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
20 commit_sha text NOT NULL,
21 status text NOT NULL DEFAULT 'pending', -- pending | running | success | failed | cancelled
22 provider_deploy_id text, -- provider's deployment ID for tracking
23 log_url text,
24 deploy_url text, -- live URL after success
25 error_message text,
26 started_at timestamp DEFAULT now(),
27 completed_at timestamp,
28 duration_ms integer
29);
30
31CREATE INDEX IF NOT EXISTS cloud_deployments_repo ON cloud_deployments(repo_id, started_at DESC);
32CREATE INDEX IF NOT EXISTS cloud_deployments_config ON cloud_deployments(config_id, started_at DESC);
Addedmobile/App.tsx+13−0View fileUnifiedSplit
1import React from 'react';
2import { StatusBar } from 'expo-status-bar';
3import { SafeAreaProvider } from 'react-native-safe-area-context';
4import { RootNavigator } from './src/navigation/RootNavigator';
5
6export default function App() {
7 return (
8 <SafeAreaProvider>
9 <StatusBar style="light" backgroundColor="#08090f" />
10 <RootNavigator />
11 </SafeAreaProvider>
12 );
13}
Addedmobile/app.json+23−0View fileUnifiedSplit
1{
2 "expo": {
3 "name": "Gluecron",
4 "slug": "gluecron",
5 "version": "1.0.0",
6 "orientation": "portrait",
7 "icon": "./assets/icon.png",
8 "userInterfaceStyle": "dark",
9 "splash": {
10 "backgroundColor": "#08090f"
11 },
12 "ios": {
13 "supportsTablet": true,
14 "bundleIdentifier": "com.gluecron.app"
15 },
16 "android": {
17 "adaptiveIcon": {
18 "backgroundColor": "#08090f"
19 },
20 "package": "com.gluecron.app"
21 }
22 }
23}
Addedmobile/babel.config.js+6−0View fileUnifiedSplit
1module.exports = function (api) {
2 api.cache(true);
3 return {
4 presets: ['babel-preset-expo'],
5 };
6};
Addedmobile/package.json+28−0View fileUnifiedSplit
1{
2 "name": "gluecron-mobile",
3 "version": "1.0.0",
4 "main": "expo/AppEntry.js",
5 "scripts": {
6 "start": "expo start",
7 "android": "expo start --android",
8 "ios": "expo start --ios"
9 },
10 "dependencies": {
11 "expo": "~51.0.0",
12 "expo-status-bar": "~1.12.1",
13 "expo-secure-store": "~13.0.2",
14 "@react-navigation/native": "^6.1.17",
15 "@react-navigation/native-stack": "^6.9.26",
16 "@react-navigation/bottom-tabs": "^6.5.20",
17 "react-native-screens": "~3.31.1",
18 "react-native-safe-area-context": "4.10.5",
19 "react": "18.2.0",
20 "react-native": "0.74.3"
21 },
22 "devDependencies": {
23 "@babel/core": "^7.24.0",
24 "@types/react": "~18.2.79",
25 "@types/react-native": "~0.73.0",
26 "typescript": "~5.3.3"
27 }
28}
Addedmobile/src/api/client.ts+306−0View fileUnifiedSplit
1export interface User {
2 id: string;
3 username: string;
4 email: string;
5 displayName: string | null;
6 bio: string | null;
7 avatarUrl: string | null;
8 createdAt: string;
9}
10
11export interface Repository {
12 id: string;
13 name: string;
14 description: string | null;
15 isPrivate: boolean;
16 starCount: number;
17 forkCount: number;
18 issueCount: number;
19 defaultBranch: string;
20 language: string | null;
21 updatedAt: string;
22 createdAt: string;
23 ownerId: string;
24}
25
26export interface Commit {
27 sha: string;
28 message: string;
29 author: {
30 name: string;
31 email: string;
32 date: string;
33 };
34 committer: {
35 name: string;
36 email: string;
37 date: string;
38 };
39}
40
41export interface TreeEntry {
42 name: string;
43 path: string;
44 type: 'blob' | 'tree';
45 sha: string;
46 size?: number;
47 mode: string;
48}
49
50export interface FileContent {
51 name: string;
52 path: string;
53 sha: string;
54 size: number;
55 content: string;
56 encoding: string;
57 type: string;
58}
59
60export interface Issue {
61 id: string;
62 number: number;
63 title: string;
64 body: string | null;
65 state: 'open' | 'closed';
66 authorId: string;
67 createdAt: string;
68 updatedAt: string;
69 closedAt: string | null;
70 commentCount?: number;
71 labels?: Label[];
72}
73
74export interface IssueComment {
75 id: string;
76 body: string;
77 authorId: string;
78 createdAt: string;
79 updatedAt: string;
80 author?: { username: string; displayName: string | null };
81}
82
83export interface Label {
84 id: string;
85 name: string;
86 color: string;
87 description: string | null;
88}
89
90export interface PullRequest {
91 id: string;
92 number: number;
93 title: string;
94 body: string | null;
95 state: 'open' | 'closed' | 'merged';
96 baseBranch: string;
97 headBranch: string;
98 authorId: string;
99 createdAt: string;
100 updatedAt: string;
101 mergedAt: string | null;
102 closedAt: string | null;
103}
104
105export interface PullComment {
106 id: string;
107 body: string;
108 authorId: string;
109 filePath: string | null;
110 line: number | null;
111 createdAt: string;
112 isAiReview: boolean;
113 author?: { username: string; displayName: string | null };
114}
115
116export interface ActivityEvent {
117 id: string;
118 type: string;
119 payload: Record<string, unknown>;
120 createdAt: string;
121 repositoryId: string;
122}
123
124export interface Branch {
125 name: string;
126 sha: string;
127 isDefault: boolean;
128}
129
130export interface LoginResponse {
131 user: { id: string; username: string; email: string };
132 token: string;
133}
134
135class ApiError extends Error {
136 constructor(
137 public status: number,
138 public statusText: string,
139 public body?: unknown,
140 ) {
141 super(`${status} ${statusText}`);
142 this.name = 'ApiError';
143 }
144}
145
146let _baseUrl = 'https://gluecron.com';
147
148export function setBaseUrl(url: string) {
149 _baseUrl = url.replace(/\/$/, '');
150}
151
152export function getBaseUrl() {
153 return _baseUrl;
154}
155
156class GluecronClient {
157 private token: string | null = null;
158
159 setToken(token: string) {
160 this.token = token;
161 }
162
163 clearToken() {
164 this.token = null;
165 }
166
167 getToken() {
168 return this.token;
169 }
170
171 private async request<T>(path: string, options?: RequestInit): Promise<T> {
172 const url = `${_baseUrl}${path}`;
173 const headers: Record<string, string> = {
174 'Content-Type': 'application/json',
175 };
176 if (this.token) {
177 headers['Authorization'] = `Bearer ${this.token}`;
178 }
179 if (options?.headers) {
180 Object.assign(headers, options.headers);
181 }
182
183 const res = await fetch(url, {
184 ...options,
185 headers,
186 });
187
188 if (!res.ok) {
189 let body: unknown;
190 try {
191 body = await res.json();
192 } catch {
193 body = undefined;
194 }
195 throw new ApiError(res.status, res.statusText, body);
196 }
197
198 return res.json() as Promise<T>;
199 }
200
201 // ── Auth ────────────────────────────────────────────────────────────────────
202
203 async login(username: string, password: string): Promise<LoginResponse> {
204 return this.request<LoginResponse>('/api/auth/login', {
205 method: 'POST',
206 body: JSON.stringify({ username, password }),
207 });
208 }
209
210 async getMe(): Promise<User> {
211 return this.request<User>('/api/v2/user');
212 }
213
214 // ── Repos ───────────────────────────────────────────────────────────────────
215
216 async listUserRepos(username: string, sort = 'updated'): Promise<Repository[]> {
217 return this.request<Repository[]>(`/api/v2/users/${encodeURIComponent(username)}/repos?sort=${sort}`);
218 }
219
220 async getRepo(owner: string, repo: string): Promise<Repository> {
221 return this.request<Repository>(`/api/v2/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`);
222 }
223
224 async listBranches(owner: string, repo: string): Promise<Branch[]> {
225 return this.request<Branch[]>(`/api/v2/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/branches`);
226 }
227
228 async listCommits(owner: string, repo: string, branch?: string, page = 1, limit = 30): Promise<Commit[]> {
229 const params = new URLSearchParams({ page: String(page), limit: String(limit) });
230 if (branch) params.set('branch', branch);
231 return this.request<Commit[]>(`/api/v2/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/commits?${params}`);
232 }
233
234 async getFileTree(owner: string, repo: string, ref = 'HEAD', path?: string): Promise<TreeEntry[]> {
235 const params = new URLSearchParams();
236 if (path) params.set('path', path);
237 return this.request<TreeEntry[]>(`/api/v2/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/tree/${encodeURIComponent(ref)}?${params}`);
238 }
239
240 async getFileContent(owner: string, repo: string, filePath: string, ref = 'HEAD'): Promise<FileContent> {
241 return this.request<FileContent>(`/api/v2/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/contents/${filePath}?ref=${encodeURIComponent(ref)}`);
242 }
243
244 async getRepoActivity(owner: string, repo: string, limit = 30): Promise<ActivityEvent[]> {
245 return this.request<ActivityEvent[]>(`/api/v2/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/activity?limit=${limit}`);
246 }
247
248 // ── Issues ──────────────────────────────────────────────────────────────────
249
250 async listIssues(owner: string, repo: string, state: 'open' | 'closed' | 'all' = 'open', page = 1, limit = 30): Promise<Issue[]> {
251 const params = new URLSearchParams({ state, page: String(page), limit: String(limit) });
252 return this.request<Issue[]>(`/api/v2/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues?${params}`);
253 }
254
255 async getIssue(owner: string, repo: string, number: number): Promise<{ issue: Issue; comments: IssueComment[] }> {
256 return this.request<{ issue: Issue; comments: IssueComment[] }>(`/api/v2/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${number}`);
257 }
258
259 async createIssue(owner: string, repo: string, title: string, body: string): Promise<Issue> {
260 return this.request<Issue>(`/api/v2/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues`, {
261 method: 'POST',
262 body: JSON.stringify({ title, body }),
263 });
264 }
265
266 async createIssueComment(owner: string, repo: string, number: number, body: string): Promise<IssueComment> {
267 return this.request<IssueComment>(`/api/v2/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${number}/comments`, {
268 method: 'POST',
269 body: JSON.stringify({ body }),
270 });
271 }
272
273 async updateIssueState(owner: string, repo: string, number: number, state: 'open' | 'closed'): Promise<Issue> {
274 return this.request<Issue>(`/api/v2/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${number}`, {
275 method: 'PATCH',
276 body: JSON.stringify({ state }),
277 });
278 }
279
280 // ── Pull Requests ────────────────────────────────────────────────────────────
281
282 async listPulls(owner: string, repo: string, state: 'open' | 'closed' | 'merged' | 'all' = 'open', page = 1, limit = 30): Promise<PullRequest[]> {
283 const params = new URLSearchParams({ state, page: String(page), limit: String(limit) });
284 return this.request<PullRequest[]>(`/api/v2/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls?${params}`);
285 }
286
287 async getPull(owner: string, repo: string, number: number): Promise<{ pull: PullRequest; comments: PullComment[] }> {
288 return this.request<{ pull: PullRequest; comments: PullComment[] }>(`/api/v2/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls/${number}`);
289 }
290
291 async createPull(owner: string, repo: string, title: string, body: string, head: string, base: string): Promise<PullRequest> {
292 return this.request<PullRequest>(`/api/v2/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls`, {
293 method: 'POST',
294 body: JSON.stringify({ title, body, head, base }),
295 });
296 }
297
298 // ── User repos shorthand ────────────────────────────────────────────────────
299
300 async listMyRepos(username: string): Promise<Repository[]> {
301 return this.listUserRepos(username, 'updated');
302 }
303}
304
305export const api = new GluecronClient();
306export { ApiError };
Addedmobile/src/components/CommitRow.tsx+76−0View fileUnifiedSplit
1import React from 'react';
2import { View, Text, StyleSheet } from 'react-native';
3import { colors } from '../theme/colors';
4import { fontSizes, fontWeights, fonts } from '../theme/typography';
5import { type Commit } from '../api/client';
6
7interface Props {
8 commit: Commit;
9}
10
11function timeAgo(dateStr: string): string {
12 const diff = Date.now() - new Date(dateStr).getTime();
13 const minutes = Math.floor(diff / 60000);
14 if (minutes < 60) return `${minutes}m ago`;
15 const hours = Math.floor(minutes / 60);
16 if (hours < 24) return `${hours}h ago`;
17 const days = Math.floor(hours / 24);
18 if (days < 30) return `${days}d ago`;
19 return `${Math.floor(days / 30)}mo ago`;
20}
21
22export function CommitRow({ commit }: Props) {
23 const shortSha = commit.sha.slice(0, 7);
24 const subject = commit.message.split('\n')[0];
25
26 return (
27 <View style={styles.row}>
28 <View style={styles.content}>
29 <Text style={styles.message} numberOfLines={2}>
30 {subject}
31 </Text>
32 <View style={styles.meta}>
33 <Text style={styles.sha}>{shortSha}</Text>
34 <Text style={styles.author}>{commit.author.name}</Text>
35 <Text style={styles.time}>{timeAgo(commit.author.date)}</Text>
36 </View>
37 </View>
38 </View>
39 );
40}
41
42const styles = StyleSheet.create({
43 row: {
44 padding: 12,
45 borderBottomWidth: 1,
46 borderBottomColor: colors.border,
47 },
48 content: {
49 gap: 4,
50 },
51 message: {
52 color: colors.text,
53 fontSize: fontSizes.sm,
54 fontWeight: fontWeights.regular,
55 lineHeight: 18,
56 },
57 meta: {
58 flexDirection: 'row',
59 alignItems: 'center',
60 gap: 8,
61 },
62 sha: {
63 color: colors.accent,
64 fontSize: fontSizes.xs,
65 fontFamily: fonts.mono,
66 fontWeight: fontWeights.medium,
67 },
68 author: {
69 color: colors.textMuted,
70 fontSize: fontSizes.xs,
71 },
72 time: {
73 color: colors.textMuted,
74 fontSize: fontSizes.xs,
75 },
76});
Addedmobile/src/components/DiffViewer.tsx+90−0View fileUnifiedSplit
1import React from 'react';
2import { View, Text, ScrollView, StyleSheet } from 'react-native';
3import { colors } from '../theme/colors';
4import { fontSizes, fonts } from '../theme/typography';
5
6interface DiffLine {
7 type: 'add' | 'remove' | 'context' | 'header';
8 content: string;
9 lineNo?: number;
10}
11
12interface Props {
13 diff: string;
14}
15
16function parseDiff(diff: string): DiffLine[] {
17 const lines: DiffLine[] = [];
18 for (const raw of diff.split('\n')) {
19 if (raw.startsWith('@@')) {
20 lines.push({ type: 'header', content: raw });
21 } else if (raw.startsWith('+') && !raw.startsWith('+++')) {
22 lines.push({ type: 'add', content: raw.slice(1) });
23 } else if (raw.startsWith('-') && !raw.startsWith('---')) {
24 lines.push({ type: 'remove', content: raw.slice(1) });
25 } else {
26 lines.push({ type: 'context', content: raw.startsWith(' ') ? raw.slice(1) : raw });
27 }
28 }
29 return lines;
30}
31
32function lineStyle(type: DiffLine['type']) {
33 switch (type) {
34 case 'add': return { bg: 'rgba(52,211,153,0.10)', prefix: '+', color: colors.green };
35 case 'remove': return { bg: 'rgba(248,113,113,0.10)', prefix: '-', color: colors.red };
36 case 'header': return { bg: 'rgba(140,109,255,0.10)', prefix: '', color: colors.accent };
37 default: return { bg: 'transparent', prefix: ' ', color: colors.textMuted };
38 }
39}
40
41export function DiffViewer({ diff }: Props) {
42 const lines = parseDiff(diff);
43
44 return (
45 <ScrollView horizontal style={styles.scroll} showsHorizontalScrollIndicator={false}>
46 <View style={styles.container}>
47 {lines.map((line, i) => {
48 const s = lineStyle(line.type);
49 return (
50 <View key={i} style={[styles.line, { backgroundColor: s.bg }]}>
51 <Text style={[styles.prefix, { color: s.color }]}>{s.prefix}</Text>
52 <Text style={[styles.code, { color: s.color }]}>
53 {line.content || ' '}
54 </Text>
55 </View>
56 );
57 })}
58 </View>
59 </ScrollView>
60 );
61}
62
63const styles = StyleSheet.create({
64 scroll: {
65 flex: 1,
66 backgroundColor: colors.bgSecondary,
67 borderRadius: 8,
68 },
69 container: {
70 padding: 4,
71 minWidth: '100%',
72 },
73 line: {
74 flexDirection: 'row',
75 paddingHorizontal: 4,
76 paddingVertical: 1,
77 borderRadius: 2,
78 },
79 prefix: {
80 fontSize: fontSizes.xs,
81 fontFamily: fonts.mono,
82 width: 14,
83 textAlign: 'center',
84 },
85 code: {
86 fontSize: fontSizes.xs,
87 fontFamily: fonts.mono,
88 lineHeight: 18,
89 },
90});
Addedmobile/src/components/EmptyState.tsx+48−0View fileUnifiedSplit
1import React from 'react';
2import { View, Text, StyleSheet } from 'react-native';
3import { colors } from '../theme/colors';
4import { fontSizes } from '../theme/typography';
5
6interface Props {
7 title: string;
8 subtitle?: string;
9 icon?: string;
10}
11
12export function EmptyState({ title, subtitle, icon = '~' }: Props) {
13 return (
14 <View style={styles.container}>
15 <Text style={styles.icon}>{icon}</Text>
16 <Text style={styles.title}>{title}</Text>
17 {subtitle && <Text style={styles.subtitle}>{subtitle}</Text>}
18 </View>
19 );
20}
21
22const styles = StyleSheet.create({
23 container: {
24 flex: 1,
25 alignItems: 'center',
26 justifyContent: 'center',
27 padding: 32,
28 backgroundColor: colors.bg,
29 },
30 icon: {
31 fontSize: 40,
32 color: colors.textMuted,
33 marginBottom: 12,
34 },
35 title: {
36 color: colors.text,
37 fontSize: fontSizes.md,
38 fontWeight: '600',
39 textAlign: 'center',
40 marginBottom: 8,
41 },
42 subtitle: {
43 color: colors.textMuted,
44 fontSize: fontSizes.sm,
45 textAlign: 'center',
46 lineHeight: 20,
47 },
48});
Addedmobile/src/components/ErrorState.tsx+57−0View fileUnifiedSplit
1import React from 'react';
2import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
3import { colors } from '../theme/colors';
4import { fontSizes } from '../theme/typography';
5
6interface Props {
7 message: string;
8 onRetry?: () => void;
9}
10
11export function ErrorState({ message, onRetry }: Props) {
12 return (
13 <View style={styles.container}>
14 <Text style={styles.icon}>!</Text>
15 <Text style={styles.message}>{message}</Text>
16 {onRetry && (
17 <TouchableOpacity style={styles.retryBtn} onPress={onRetry} activeOpacity={0.75}>
18 <Text style={styles.retryText}>Retry</Text>
19 </TouchableOpacity>
20 )}
21 </View>
22 );
23}
24
25const styles = StyleSheet.create({
26 container: {
27 flex: 1,
28 alignItems: 'center',
29 justifyContent: 'center',
30 padding: 32,
31 backgroundColor: colors.bg,
32 },
33 icon: {
34 fontSize: 40,
35 color: colors.red,
36 marginBottom: 12,
37 fontWeight: '700',
38 },
39 message: {
40 color: colors.textMuted,
41 fontSize: fontSizes.base,
42 textAlign: 'center',
43 lineHeight: 22,
44 marginBottom: 20,
45 },
46 retryBtn: {
47 backgroundColor: colors.accentDim,
48 borderRadius: 8,
49 paddingHorizontal: 20,
50 paddingVertical: 10,
51 },
52 retryText: {
53 color: colors.accent,
54 fontSize: fontSizes.base,
55 fontWeight: '600',
56 },
57});
Addedmobile/src/components/IssueRow.tsx+127−0View fileUnifiedSplit
1import React from 'react';
2import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
3import { colors } from '../theme/colors';
4import { fontSizes, fontWeights } from '../theme/typography';
5import { type Issue } from '../api/client';
6
7interface Props {
8 issue: Issue;
9 onPress: () => void;
10}
11
12function timeAgo(dateStr: string): string {
13 const diff = Date.now() - new Date(dateStr).getTime();
14 const minutes = Math.floor(diff / 60000);
15 if (minutes < 60) return `${minutes}m ago`;
16 const hours = Math.floor(minutes / 60);
17 if (hours < 24) return `${hours}h ago`;
18 const days = Math.floor(hours / 24);
19 if (days < 30) return `${days}d ago`;
20 return `${Math.floor(days / 30)}mo ago`;
21}
22
23export function IssueRow({ issue, onPress }: Props) {
24 const isOpen = issue.state === 'open';
25
26 return (
27 <TouchableOpacity style={styles.row} onPress={onPress} activeOpacity={0.75}>
28 <View style={[styles.dot, { backgroundColor: isOpen ? colors.green : colors.textMuted }]} />
29
30 <View style={styles.content}>
31 <Text style={styles.title} numberOfLines={2}>
32 {issue.title}
33 </Text>
34
35 <View style={styles.meta}>
36 <Text style={styles.number}>#{issue.number}</Text>
37 <Text style={styles.time}>opened {timeAgo(issue.createdAt)}</Text>
38 {typeof issue.commentCount === 'number' && issue.commentCount > 0 && (
39 <View style={styles.commentBadge}>
40 <Text style={styles.commentText}>◯ {issue.commentCount}</Text>
41 </View>
42 )}
43 </View>
44
45 {issue.labels && issue.labels.length > 0 && (
46 <View style={styles.labels}>
47 {issue.labels.slice(0, 3).map((label) => (
48 <View
49 key={label.id}
50 style={[styles.label, { backgroundColor: `#${label.color}22`, borderColor: `#${label.color}` }]}
51 >
52 <Text style={[styles.labelText, { color: `#${label.color}` }]}>{label.name}</Text>
53 </View>
54 ))}
55 </View>
56 )}
57 </View>
58 </TouchableOpacity>
59 );
60}
61
62const styles = StyleSheet.create({
63 row: {
64 flexDirection: 'row',
65 alignItems: 'flex-start',
66 padding: 14,
67 borderBottomWidth: 1,
68 borderBottomColor: colors.border,
69 gap: 12,
70 },
71 dot: {
72 width: 8,
73 height: 8,
74 borderRadius: 4,
75 marginTop: 5,
76 flexShrink: 0,
77 },
78 content: {
79 flex: 1,
80 gap: 4,
81 },
82 title: {
83 color: colors.text,
84 fontSize: fontSizes.base,
85 fontWeight: fontWeights.medium,
86 lineHeight: 20,
87 },
88 meta: {
89 flexDirection: 'row',
90 alignItems: 'center',
91 gap: 8,
92 flexWrap: 'wrap',
93 },
94 number: {
95 color: colors.textMuted,
96 fontSize: fontSizes.xs,
97 },
98 time: {
99 color: colors.textMuted,
100 fontSize: fontSizes.xs,
101 },
102 commentBadge: {
103 flexDirection: 'row',
104 alignItems: 'center',
105 gap: 3,
106 },
107 commentText: {
108 color: colors.textMuted,
109 fontSize: fontSizes.xs,
110 },
111 labels: {
112 flexDirection: 'row',
113 flexWrap: 'wrap',
114 gap: 4,
115 marginTop: 4,
116 },
117 label: {
118 borderRadius: 10,
119 paddingHorizontal: 8,
120 paddingVertical: 2,
121 borderWidth: 1,
122 },
123 labelText: {
124 fontSize: fontSizes.xs,
125 fontWeight: fontWeights.medium,
126 },
127});
Addedmobile/src/components/LoadingSpinner.tsx+37−0View fileUnifiedSplit
1import React from 'react';
2import { ActivityIndicator, View, StyleSheet } from 'react-native';
3import { colors } from '../theme/colors';
4
5interface Props {
6 size?: 'small' | 'large';
7 fullScreen?: boolean;
8}
9
10export function LoadingSpinner({ size = 'large', fullScreen = false }: Props) {
11 if (fullScreen) {
12 return (
13 <View style={styles.fullScreen}>
14 <ActivityIndicator size={size} color={colors.accent} />
15 </View>
16 );
17 }
18 return (
19 <View style={styles.inline}>
20 <ActivityIndicator size={size} color={colors.accent} />
21 </View>
22 );
23}
24
25const styles = StyleSheet.create({
26 fullScreen: {
27 flex: 1,
28 alignItems: 'center',
29 justifyContent: 'center',
30 backgroundColor: colors.bg,
31 },
32 inline: {
33 padding: 24,
34 alignItems: 'center',
35 justifyContent: 'center',
36 },
37});
Addedmobile/src/components/NotifRow.tsx+134−0View fileUnifiedSplit
1import React from 'react';
2import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
3import { colors } from '../theme/colors';
4import { fontSizes, fontWeights } from '../theme/typography';
5import { type Notification } from '../hooks/useNotifications';
6
7interface Props {
8 notification: Notification;
9 onPress?: () => void;
10}
11
12function timeAgo(dateStr: string): string {
13 const diff = Date.now() - new Date(dateStr).getTime();
14 const minutes = Math.floor(diff / 60000);
15 if (minutes < 60) return `${minutes}m ago`;
16 const hours = Math.floor(minutes / 60);
17 if (hours < 24) return `${hours}h ago`;
18 const days = Math.floor(hours / 24);
19 return `${days}d ago`;
20}
21
22function typeIcon(type: string): string {
23 if (type.startsWith('issue')) return '●';
24 if (type.startsWith('pr')) return '⑂';
25 if (type === 'push') return '↑';
26 if (type === 'star') return '★';
27 return '◈';
28}
29
30function typeColor(type: string): string {
31 if (type.startsWith('issue')) return colors.green;
32 if (type.startsWith('pr')) return colors.accent;
33 if (type === 'push') return colors.blue;
34 if (type === 'star') return colors.yellow;
35 return colors.textMuted;
36}
37
38export function NotifRow({ notification, onPress }: Props) {
39 const icon = typeIcon(notification.type);
40 const iconColor = typeColor(notification.type);
41
42 return (
43 <TouchableOpacity style={[styles.row, !notification.read && styles.unread]} onPress={onPress} activeOpacity={0.75}>
44 <View style={styles.iconWrap}>
45 <Text style={[styles.icon, { color: iconColor }]}>{icon}</Text>
46 </View>
47
48 <View style={styles.content}>
49 <View style={styles.header}>
50 <Text style={styles.title} numberOfLines={1}>
51 {notification.title}
52 </Text>
53 {!notification.read && <View style={styles.unreadDot} />}
54 </View>
55 {notification.subtitle ? (
56 <Text style={styles.subtitle} numberOfLines={1}>{notification.subtitle}</Text>
57 ) : null}
58 <View style={styles.footer}>
59 {notification.repoOwner && notification.repoName && (
60 <Text style={styles.repo}>{notification.repoOwner}/{notification.repoName}</Text>
61 )}
62 <Text style={styles.time}>{timeAgo(notification.createdAt)}</Text>
63 </View>
64 </View>
65 </TouchableOpacity>
66 );
67}
68
69const styles = StyleSheet.create({
70 row: {
71 flexDirection: 'row',
72 alignItems: 'flex-start',
73 padding: 14,
74 borderBottomWidth: 1,
75 borderBottomColor: colors.border,
76 gap: 12,
77 },
78 unread: {
79 backgroundColor: colors.bgSecondary,
80 },
81 iconWrap: {
82 width: 28,
83 height: 28,
84 borderRadius: 14,
85 backgroundColor: colors.bgSurface,
86 alignItems: 'center',
87 justifyContent: 'center',
88 flexShrink: 0,
89 },
90 icon: {
91 fontSize: 14,
92 },
93 content: {
94 flex: 1,
95 gap: 3,
96 },
97 header: {
98 flexDirection: 'row',
99 alignItems: 'center',
100 gap: 6,
101 },
102 title: {
103 color: colors.text,
104 fontSize: fontSizes.base,
105 fontWeight: fontWeights.medium,
106 flex: 1,
107 },
108 unreadDot: {
109 width: 7,
110 height: 7,
111 borderRadius: 4,
112 backgroundColor: colors.accent,
113 flexShrink: 0,
114 },
115 subtitle: {
116 color: colors.textMuted,
117 fontSize: fontSizes.sm,
118 },
119 footer: {
120 flexDirection: 'row',
121 alignItems: 'center',
122 gap: 8,
123 marginTop: 2,
124 },
125 repo: {
126 color: colors.textMuted,
127 fontSize: fontSizes.xs,
128 flex: 1,
129 },
130 time: {
131 color: colors.textMuted,
132 fontSize: fontSizes.xs,
133 },
134});
Addedmobile/src/components/PullRow.tsx+122−0View fileUnifiedSplit
1import React from 'react';
2import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
3import { colors } from '../theme/colors';
4import { fontSizes, fontWeights } from '../theme/typography';
5import { type PullRequest } from '../api/client';
6
7interface Props {
8 pull: PullRequest;
9 onPress: () => void;
10}
11
12function timeAgo(dateStr: string): string {
13 const diff = Date.now() - new Date(dateStr).getTime();
14 const minutes = Math.floor(diff / 60000);
15 if (minutes < 60) return `${minutes}m ago`;
16 const hours = Math.floor(minutes / 60);
17 if (hours < 24) return `${hours}h ago`;
18 const days = Math.floor(hours / 24);
19 if (days < 30) return `${days}d ago`;
20 return `${Math.floor(days / 30)}mo ago`;
21}
22
23function stateColor(state: string): string {
24 switch (state) {
25 case 'open': return colors.green;
26 case 'merged': return colors.accent;
27 case 'closed': return colors.red;
28 default: return colors.textMuted;
29 }
30}
31
32function stateLabel(state: string): string {
33 switch (state) {
34 case 'open': return 'Open';
35 case 'merged': return 'Merged';
36 case 'closed': return 'Closed';
37 default: return state;
38 }
39}
40
41export function PullRow({ pull, onPress }: Props) {
42 return (
43 <TouchableOpacity style={styles.row} onPress={onPress} activeOpacity={0.75}>
44 <View style={[styles.stateDot, { backgroundColor: stateColor(pull.state) }]} />
45
46 <View style={styles.content}>
47 <Text style={styles.title} numberOfLines={2}>
48 {pull.title}
49 </Text>
50
51 <View style={styles.meta}>
52 <Text style={styles.number}>#{pull.number}</Text>
53 <View style={[styles.stateBadge, { backgroundColor: stateColor(pull.state) + '22', borderColor: stateColor(pull.state) }]}>
54 <Text style={[styles.stateText, { color: stateColor(pull.state) }]}>{stateLabel(pull.state)}</Text>
55 </View>
56 <Text style={styles.branches}>
57 {pull.headBranch} → {pull.baseBranch}
58 </Text>
59 </View>
60
61 <Text style={styles.time}>opened {timeAgo(pull.createdAt)}</Text>
62 </View>
63 </TouchableOpacity>
64 );
65}
66
67const styles = StyleSheet.create({
68 row: {
69 flexDirection: 'row',
70 alignItems: 'flex-start',
71 padding: 14,
72 borderBottomWidth: 1,
73 borderBottomColor: colors.border,
74 gap: 12,
75 },
76 stateDot: {
77 width: 8,
78 height: 8,
79 borderRadius: 4,
80 marginTop: 5,
81 flexShrink: 0,
82 },
83 content: {
84 flex: 1,
85 gap: 4,
86 },
87 title: {
88 color: colors.text,
89 fontSize: fontSizes.base,
90 fontWeight: fontWeights.medium,
91 lineHeight: 20,
92 },
93 meta: {
94 flexDirection: 'row',
95 alignItems: 'center',
96 gap: 8,
97 flexWrap: 'wrap',
98 },
99 number: {
100 color: colors.textMuted,
101 fontSize: fontSizes.xs,
102 },
103 stateBadge: {
104 borderRadius: 4,
105 paddingHorizontal: 6,
106 paddingVertical: 2,
107 borderWidth: 1,
108 },
109 stateText: {
110 fontSize: fontSizes.xs,
111 fontWeight: fontWeights.medium,
112 },
113 branches: {
114 color: colors.textMuted,
115 fontSize: fontSizes.xs,
116 fontFamily: 'monospace',
117 },
118 time: {
119 color: colors.textMuted,
120 fontSize: fontSizes.xs,
121 },
122});
Addedmobile/src/components/RepoCard.tsx+164−0View fileUnifiedSplit
1import React from 'react';
2import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
3import { colors } from '../theme/colors';
4import { fontSizes, fontWeights } from '../theme/typography';
5import { type Repository } from '../api/client';
6
7interface Props {
8 repo: Repository;
9 ownerUsername?: string;
10 onPress: () => void;
11}
12
13const LANG_COLORS: Record<string, string> = {
14 TypeScript: '#3178c6',
15 JavaScript: '#f1e05a',
16 Python: '#3572A5',
17 Go: '#00ADD8',
18 Rust: '#dea584',
19 Ruby: '#701516',
20 Java: '#b07219',
21 'C++': '#f34b7d',
22 C: '#555555',
23 Swift: '#F05138',
24 Kotlin: '#A97BFF',
25 PHP: '#4F5D95',
26 'C#': '#239120',
27 Shell: '#89e051',
28};
29
30function timeAgo(dateStr: string): string {
31 const diff = Date.now() - new Date(dateStr).getTime();
32 const seconds = Math.floor(diff / 1000);
33 if (seconds < 60) return 'just now';
34 const minutes = Math.floor(seconds / 60);
35 if (minutes < 60) return `${minutes}m ago`;
36 const hours = Math.floor(minutes / 60);
37 if (hours < 24) return `${hours}h ago`;
38 const days = Math.floor(hours / 24);
39 if (days < 30) return `${days}d ago`;
40 const months = Math.floor(days / 30);
41 return `${months}mo ago`;
42}
43
44export function RepoCard({ repo, ownerUsername, onPress }: Props) {
45 const langColor = repo.language ? (LANG_COLORS[repo.language] ?? colors.textMuted) : colors.textMuted;
46
47 return (
48 <TouchableOpacity style={styles.card} onPress={onPress} activeOpacity={0.75}>
49 <View style={styles.header}>
50 <Text style={styles.name} numberOfLines={1}>
51 {ownerUsername ? `${ownerUsername}/` : ''}
52 <Text style={styles.repoName}>{repo.name}</Text>
53 </Text>
54 {repo.isPrivate && (
55 <View style={styles.privateBadge}>
56 <Text style={styles.privateBadgeText}>private</Text>
57 </View>
58 )}
59 </View>
60
61 {repo.description ? (
62 <Text style={styles.desc} numberOfLines={2}>
63 {repo.description}
64 </Text>
65 ) : null}
66
67 <View style={styles.meta}>
68 {repo.language && (
69 <View style={styles.langRow}>
70 <View style={[styles.langDot, { backgroundColor: langColor }]} />
71 <Text style={styles.metaText}>{repo.language}</Text>
72 </View>
73 )}
74 <View style={styles.metaItem}>
75 <Text style={styles.metaIcon}></Text>
76 <Text style={styles.metaText}>{repo.starCount}</Text>
77 </View>
78 <View style={styles.metaItem}>
79 <Text style={styles.metaIcon}></Text>
80 <Text style={styles.metaText}>{repo.forkCount}</Text>
81 </View>
82 <Text style={styles.metaTime}>{timeAgo(repo.updatedAt)}</Text>
83 </View>
84 </TouchableOpacity>
85 );
86}
87
88const styles = StyleSheet.create({
89 card: {
90 backgroundColor: colors.bgSurface,
91 borderRadius: 10,
92 padding: 14,
93 marginBottom: 10,
94 borderWidth: 1,
95 borderColor: colors.border,
96 },
97 header: {
98 flexDirection: 'row',
99 alignItems: 'center',
100 marginBottom: 6,
101 gap: 8,
102 },
103 name: {
104 flex: 1,
105 fontSize: fontSizes.base,
106 color: colors.textMuted,
107 fontWeight: fontWeights.regular,
108 },
109 repoName: {
110 color: colors.accent,
111 fontWeight: fontWeights.semibold,
112 },
113 privateBadge: {
114 backgroundColor: colors.accentDim,
115 borderRadius: 4,
116 paddingHorizontal: 6,
117 paddingVertical: 2,
118 },
119 privateBadgeText: {
120 color: colors.accent,
121 fontSize: fontSizes.xs,
122 fontWeight: fontWeights.medium,
123 },
124 desc: {
125 color: colors.textMuted,
126 fontSize: fontSizes.sm,
127 lineHeight: 18,
128 marginBottom: 10,
129 },
130 meta: {
131 flexDirection: 'row',
132 alignItems: 'center',
133 gap: 12,
134 flexWrap: 'wrap',
135 },
136 langRow: {
137 flexDirection: 'row',
138 alignItems: 'center',
139 gap: 4,
140 },
141 langDot: {
142 width: 10,
143 height: 10,
144 borderRadius: 5,
145 },
146 metaItem: {
147 flexDirection: 'row',
148 alignItems: 'center',
149 gap: 3,
150 },
151 metaIcon: {
152 color: colors.textMuted,
153 fontSize: fontSizes.xs,
154 },
155 metaText: {
156 color: colors.textMuted,
157 fontSize: fontSizes.xs,
158 },
159 metaTime: {
160 color: colors.textMuted,
161 fontSize: fontSizes.xs,
162 marginLeft: 'auto',
163 },
164});
Addedmobile/src/hooks/useAuth.ts+101−0View fileUnifiedSplit
1import { useState, useEffect, useCallback } from 'react';
2import { api, type User } from '../api/client';
3import {
4 saveToken,
5 loadToken,
6 clearAll,
7 saveUser,
8 loadUser,
9 saveHost,
10 loadHost,
11} from '../store/auth';
12import { setBaseUrl } from '../api/client';
13
14export interface AuthState {
15 user: User | null;
16 token: string | null;
17 loading: boolean;
18 error: string | null;
19}
20
21export function useAuth() {
22 const [state, setState] = useState<AuthState>({
23 user: null,
24 token: null,
25 loading: true,
26 error: null,
27 });
28
29 // Hydrate from secure storage on mount
30 useEffect(() => {
31 (async () => {
32 try {
33 const [token, user, host] = await Promise.all([
34 loadToken(),
35 loadUser(),
36 loadHost(),
37 ]);
38 if (host) {
39 setBaseUrl(host);
40 }
41 if (token && user) {
42 setState({ user, token, loading: false, error: null });
43 } else {
44 setState({ user: null, token: null, loading: false, error: null });
45 }
46 } catch {
47 setState({ user: null, token: null, loading: false, error: null });
48 }
49 })();
50 }, []);
51
52 const login = useCallback(async (usernameOrToken: string, passwordOrEmpty?: string, host?: string) => {
53 setState((s) => ({ ...s, loading: true, error: null }));
54 try {
55 if (host) {
56 setBaseUrl(host);
57 await saveHost(host);
58 }
59
60 let token: string;
61 let user: User;
62
63 // PAT token login: if passwordOrEmpty is empty, treat usernameOrToken as raw token
64 if (!passwordOrEmpty) {
65 token = usernameOrToken;
66 api.setToken(token);
67 user = await api.getMe();
68 } else {
69 // Username + password login
70 const res = await api.login(usernameOrToken, passwordOrEmpty);
71 token = res.token;
72 api.setToken(token);
73 user = await api.getMe();
74 }
75
76 await Promise.all([saveToken(token), saveUser(user)]);
77 setState({ user, token, loading: false, error: null });
78 } catch (err: unknown) {
79 const msg = err instanceof Error ? err.message : 'Login failed';
80 setState((s) => ({ ...s, loading: false, error: msg }));
81 throw err;
82 }
83 }, []);
84
85 const logout = useCallback(async () => {
86 await clearAll();
87 setState({ user: null, token: null, loading: false, error: null });
88 }, []);
89
90 const clearError = useCallback(() => {
91 setState((s) => ({ ...s, error: null }));
92 }, []);
93
94 return {
95 ...state,
96 isAuthenticated: !!state.token && !!state.user,
97 login,
98 logout,
99 clearError,
100 };
101}
Addedmobile/src/hooks/useIssues.ts+68−0View fileUnifiedSplit
1import { useState, useEffect, useCallback } from 'react';
2import { api, type Issue, type IssueComment } from '../api/client';
3
4export function useIssues(owner: string | null, repo: string | null, state: 'open' | 'closed' | 'all' = 'open') {
5 const [issues, setIssues] = useState<Issue[]>([]);
6 const [loading, setLoading] = useState(false);
7 const [error, setError] = useState<string | null>(null);
8
9 const fetch = useCallback(async () => {
10 if (!owner || !repo) return;
11 setLoading(true);
12 setError(null);
13 try {
14 const data = await api.listIssues(owner, repo, state);
15 setIssues(data);
16 } catch (err) {
17 setError(err instanceof Error ? err.message : 'Failed to load issues');
18 } finally {
19 setLoading(false);
20 }
21 }, [owner, repo, state]);
22
23 useEffect(() => {
24 fetch();
25 }, [fetch]);
26
27 return { issues, loading, error, refresh: fetch };
28}
29
30export function useIssue(owner: string | null, repo: string | null, number: number | null) {
31 const [issue, setIssue] = useState<Issue | null>(null);
32 const [comments, setComments] = useState<IssueComment[]>([]);
33 const [loading, setLoading] = useState(false);
34 const [error, setError] = useState<string | null>(null);
35 const [submitting, setSubmitting] = useState(false);
36
37 const fetch = useCallback(async () => {
38 if (!owner || !repo || number === null) return;
39 setLoading(true);
40 setError(null);
41 try {
42 const data = await api.getIssue(owner, repo, number);
43 setIssue(data.issue);
44 setComments(data.comments);
45 } catch (err) {
46 setError(err instanceof Error ? err.message : 'Failed to load issue');
47 } finally {
48 setLoading(false);
49 }
50 }, [owner, repo, number]);
51
52 useEffect(() => {
53 fetch();
54 }, [fetch]);
55
56 const addComment = useCallback(async (body: string) => {
57 if (!owner || !repo || number === null) return;
58 setSubmitting(true);
59 try {
60 const comment = await api.createIssueComment(owner, repo, number, body);
61 setComments((prev) => [...prev, comment]);
62 } finally {
63 setSubmitting(false);
64 }
65 }, [owner, repo, number]);
66
67 return { issue, comments, loading, error, submitting, refresh: fetch, addComment };
68}
Addedmobile/src/hooks/useNotifications.ts+109−0View fileUnifiedSplit
1import { useState, useCallback } from 'react';
2import { api, type ActivityEvent } from '../api/client';
3
4export interface Notification {
5 id: string;
6 type: string;
7 title: string;
8 subtitle: string;
9 read: boolean;
10 createdAt: string;
11 repoOwner?: string;
12 repoName?: string;
13}
14
15function activityToNotification(event: ActivityEvent & { repoOwner?: string; repoName?: string }): Notification {
16 const type = event.type || 'activity';
17 const payload = event.payload as Record<string, unknown>;
18
19 let title = 'Activity';
20 let subtitle = '';
21
22 switch (type) {
23 case 'push':
24 title = 'Push';
25 subtitle = `${payload.ref ?? 'branch'} updated`;
26 break;
27 case 'issue.opened':
28 title = 'Issue opened';
29 subtitle = String(payload.title ?? '');
30 break;
31 case 'issue.closed':
32 title = 'Issue closed';
33 subtitle = String(payload.title ?? '');
34 break;
35 case 'pr.opened':
36 title = 'PR opened';
37 subtitle = String(payload.title ?? '');
38 break;
39 case 'pr.merged':
40 title = 'PR merged';
41 subtitle = String(payload.title ?? '');
42 break;
43 case 'star':
44 title = 'New star';
45 subtitle = String(payload.username ?? 'Someone') + ' starred the repo';
46 break;
47 default:
48 title = type.replace(/[._]/g, ' ');
49 subtitle = '';
50 }
51
52 return {
53 id: event.id,
54 type,
55 title,
56 subtitle,
57 read: false,
58 createdAt: event.createdAt,
59 repoOwner: event.repoOwner,
60 repoName: event.repoName,
61 };
62}
63
64// Notifications are synthesized from activity feeds across user repos.
65// The Gluecron REST API doesn't expose a dedicated /notifications endpoint,
66// so we aggregate from the repos the user cares about.
67export function useNotifications(repos: Array<{ owner: string; name: string }>) {
68 const [notifications, setNotifications] = useState<Notification[]>([]);
69 const [loading, setLoading] = useState(false);
70 const [error, setError] = useState<string | null>(null);
71
72 const fetch = useCallback(async () => {
73 if (repos.length === 0) return;
74 setLoading(true);
75 setError(null);
76 try {
77 const results = await Promise.allSettled(
78 repos.slice(0, 5).map((r) =>
79 api.getRepoActivity(r.owner, r.name, 10).then((events) =>
80 events.map((e) => activityToNotification({ ...e, repoOwner: r.owner, repoName: r.name }))
81 )
82 )
83 );
84
85 const allNotifs: Notification[] = [];
86 for (const r of results) {
87 if (r.status === 'fulfilled') {
88 allNotifs.push(...r.value);
89 }
90 }
91
92 // Sort newest first
93 allNotifs.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
94 setNotifications(allNotifs.slice(0, 50));
95 } catch (err) {
96 setError(err instanceof Error ? err.message : 'Failed to load notifications');
97 } finally {
98 setLoading(false);
99 }
100 }, [repos]);
101
102 const markAllRead = useCallback(() => {
103 setNotifications((prev) => prev.map((n) => ({ ...n, read: true })));
104 }, []);
105
106 const unreadCount = notifications.filter((n) => !n.read).length;
107
108 return { notifications, loading, error, refresh: fetch, markAllRead, unreadCount };
109}
Addedmobile/src/hooks/usePulls.ts+56−0View fileUnifiedSplit
1import { useState, useEffect, useCallback } from 'react';
2import { api, type PullRequest, type PullComment } from '../api/client';
3
4export function usePulls(owner: string | null, repo: string | null, state: 'open' | 'closed' | 'merged' | 'all' = 'open') {
5 const [pulls, setPulls] = useState<PullRequest[]>([]);
6 const [loading, setLoading] = useState(false);
7 const [error, setError] = useState<string | null>(null);
8
9 const fetch = useCallback(async () => {
10 if (!owner || !repo) return;
11 setLoading(true);
12 setError(null);
13 try {
14 const data = await api.listPulls(owner, repo, state);
15 setPulls(data);
16 } catch (err) {
17 setError(err instanceof Error ? err.message : 'Failed to load pull requests');
18 } finally {
19 setLoading(false);
20 }
21 }, [owner, repo, state]);
22
23 useEffect(() => {
24 fetch();
25 }, [fetch]);
26
27 return { pulls, loading, error, refresh: fetch };
28}
29
30export function usePull(owner: string | null, repo: string | null, number: number | null) {
31 const [pull, setPull] = useState<PullRequest | null>(null);
32 const [comments, setComments] = useState<PullComment[]>([]);
33 const [loading, setLoading] = useState(false);
34 const [error, setError] = useState<string | null>(null);
35
36 const fetch = useCallback(async () => {
37 if (!owner || !repo || number === null) return;
38 setLoading(true);
39 setError(null);
40 try {
41 const data = await api.getPull(owner, repo, number);
42 setPull(data.pull);
43 setComments(data.comments);
44 } catch (err) {
45 setError(err instanceof Error ? err.message : 'Failed to load pull request');
46 } finally {
47 setLoading(false);
48 }
49 }, [owner, repo, number]);
50
51 useEffect(() => {
52 fetch();
53 }, [fetch]);
54
55 return { pull, comments, loading, error, refresh: fetch };
56}
Addedmobile/src/hooks/useRepo.ts+140−0View fileUnifiedSplit
1import { useState, useEffect, useCallback } from 'react';
2import { api, type Repository, type Commit, type TreeEntry, type FileContent, type Branch } from '../api/client';
3
4export function useUserRepos(username: string | null) {
5 const [repos, setRepos] = useState<Repository[]>([]);
6 const [loading, setLoading] = useState(false);
7 const [error, setError] = useState<string | null>(null);
8
9 const fetch = useCallback(async () => {
10 if (!username) return;
11 setLoading(true);
12 setError(null);
13 try {
14 const data = await api.listUserRepos(username);
15 setRepos(data);
16 } catch (err) {
17 setError(err instanceof Error ? err.message : 'Failed to load repos');
18 } finally {
19 setLoading(false);
20 }
21 }, [username]);
22
23 useEffect(() => {
24 fetch();
25 }, [fetch]);
26
27 return { repos, loading, error, refresh: fetch };
28}
29
30export function useRepo(owner: string | null, repo: string | null) {
31 const [data, setData] = useState<Repository | null>(null);
32 const [loading, setLoading] = useState(false);
33 const [error, setError] = useState<string | null>(null);
34
35 const fetch = useCallback(async () => {
36 if (!owner || !repo) return;
37 setLoading(true);
38 setError(null);
39 try {
40 const result = await api.getRepo(owner, repo);
41 setData(result);
42 } catch (err) {
43 setError(err instanceof Error ? err.message : 'Failed to load repo');
44 } finally {
45 setLoading(false);
46 }
47 }, [owner, repo]);
48
49 useEffect(() => {
50 fetch();
51 }, [fetch]);
52
53 return { repo: data, loading, error, refresh: fetch };
54}
55
56export function useCommits(owner: string | null, repoName: string | null, branch?: string) {
57 const [commits, setCommits] = useState<Commit[]>([]);
58 const [loading, setLoading] = useState(false);
59 const [error, setError] = useState<string | null>(null);
60
61 const fetch = useCallback(async () => {
62 if (!owner || !repoName) return;
63 setLoading(true);
64 setError(null);
65 try {
66 const data = await api.listCommits(owner, repoName, branch);
67 setCommits(data);
68 } catch (err) {
69 setError(err instanceof Error ? err.message : 'Failed to load commits');
70 } finally {
71 setLoading(false);
72 }
73 }, [owner, repoName, branch]);
74
75 useEffect(() => {
76 fetch();
77 }, [fetch]);
78
79 return { commits, loading, error, refresh: fetch };
80}
81
82export function useFileTree(owner: string | null, repoName: string | null, ref: string, path?: string) {
83 const [tree, setTree] = useState<TreeEntry[]>([]);
84 const [loading, setLoading] = useState(false);
85 const [error, setError] = useState<string | null>(null);
86
87 const fetch = useCallback(async () => {
88 if (!owner || !repoName) return;
89 setLoading(true);
90 setError(null);
91 try {
92 const data = await api.getFileTree(owner, repoName, ref, path);
93 setTree(data);
94 } catch (err) {
95 setError(err instanceof Error ? err.message : 'Failed to load file tree');
96 } finally {
97 setLoading(false);
98 }
99 }, [owner, repoName, ref, path]);
100
101 useEffect(() => {
102 fetch();
103 }, [fetch]);
104
105 return { tree, loading, error, refresh: fetch };
106}
107
108export function useFileContent(owner: string, repoName: string, filePath: string, ref = 'HEAD') {
109 const [file, setFile] = useState<FileContent | null>(null);
110 const [loading, setLoading] = useState(false);
111 const [error, setError] = useState<string | null>(null);
112
113 useEffect(() => {
114 setLoading(true);
115 setError(null);
116 api.getFileContent(owner, repoName, filePath, ref)
117 .then(setFile)
118 .catch((err) => setError(err instanceof Error ? err.message : 'Failed to load file'))
119 .finally(() => setLoading(false));
120 }, [owner, repoName, filePath, ref]);
121
122 return { file, loading, error };
123}
124
125export function useBranches(owner: string | null, repoName: string | null) {
126 const [branches, setBranches] = useState<Branch[]>([]);
127 const [loading, setLoading] = useState(false);
128 const [error, setError] = useState<string | null>(null);
129
130 useEffect(() => {
131 if (!owner || !repoName) return;
132 setLoading(true);
133 api.listBranches(owner, repoName)
134 .then(setBranches)
135 .catch((err) => setError(err instanceof Error ? err.message : 'Failed to load branches'))
136 .finally(() => setLoading(false));
137 }, [owner, repoName]);
138
139 return { branches, loading, error };
140}
Addedmobile/src/navigation/AuthContext.ts+16−0View fileUnifiedSplit
1import { createContext } from 'react';
2import { type User } from '../api/client';
3
4export interface AuthContextValue {
5 user: User | null;
6 token: string | null;
7 login: (tokenOrUsername: string, password?: string, host?: string) => Promise<void>;
8 logout: () => Promise<void>;
9}
10
11export const AuthContext = createContext<AuthContextValue>({
12 user: null,
13 token: null,
14 login: async () => {},
15 logout: async () => {},
16});
Addedmobile/src/navigation/MainTabNavigator.tsx+132−0View fileUnifiedSplit
1import React from 'react';
2import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
3import { createNativeStackNavigator } from '@react-navigation/native-stack';
4import { Text } from 'react-native';
5import { colors } from '../theme/colors';
6import { fontSizes } from '../theme/typography';
7import { type MainStackParamList } from './types';
8
9// Re-export for convenience (navigators that don't import screens can still get the type)
10export type { MainStackParamList };
11
12import { DashboardScreen } from '../screens/DashboardScreen';
13import { RepoListScreen } from '../screens/RepoListScreen';
14import { RepoDetailScreen } from '../screens/RepoDetailScreen';
15import { FileViewerScreen } from '../screens/FileViewerScreen';
16import { IssueListScreen } from '../screens/IssueListScreen';
17import { IssueDetailScreen } from '../screens/IssueDetailScreen';
18import { PullListScreen } from '../screens/PullListScreen';
19import { PullDetailScreen } from '../screens/PullDetailScreen';
20import { NotificationsScreen } from '../screens/NotificationsScreen';
21import { SettingsScreen } from '../screens/SettingsScreen';
22
23const Stack = createNativeStackNavigator<MainStackParamList>();
24const Tab = createBottomTabNavigator();
25
26// ─── Stack navigators (one per tab root) ────────────────────────────────────
27
28function DashboardStack() {
29 return (
30 <Stack.Navigator screenOptions={stackScreenOptions}>
31 <Stack.Screen name="Dashboard" component={DashboardScreen} options={{ title: 'Dashboard' }} />
32 <Stack.Screen name="RepoDetail" component={RepoDetailScreen} options={({ route }) => ({ title: route.params.repo })} />
33 <Stack.Screen name="FileViewer" component={FileViewerScreen} options={({ route }) => ({ title: route.params.path.split('/').pop() ?? 'File' })} />
34 <Stack.Screen name="IssueList" component={IssueListScreen} options={{ title: 'Issues' }} />
35 <Stack.Screen name="IssueDetail" component={IssueDetailScreen} options={({ route }) => ({ title: `#${route.params.number}` })} />
36 <Stack.Screen name="PullList" component={PullListScreen} options={{ title: 'Pull Requests' }} />
37 <Stack.Screen name="PullDetail" component={PullDetailScreen} options={({ route }) => ({ title: `PR #${route.params.number}` })} />
38 {/* Dummy screens required by type — never actually navigated to from this stack */}
39 <Stack.Screen name="RepoList" component={RepoListScreen} options={{ title: 'Repositories' }} />
40 </Stack.Navigator>
41 );
42}
43
44function RepoStack() {
45 return (
46 <Stack.Navigator screenOptions={stackScreenOptions}>
47 <Stack.Screen name="RepoList" component={RepoListScreen} options={{ title: 'Repositories' }} />
48 <Stack.Screen name="RepoDetail" component={RepoDetailScreen} options={({ route }) => ({ title: route.params.repo })} />
49 <Stack.Screen name="FileViewer" component={FileViewerScreen} options={({ route }) => ({ title: route.params.path.split('/').pop() ?? 'File' })} />
50 <Stack.Screen name="IssueList" component={IssueListScreen} options={{ title: 'Issues' }} />
51 <Stack.Screen name="IssueDetail" component={IssueDetailScreen} options={({ route }) => ({ title: `#${route.params.number}` })} />
52 <Stack.Screen name="PullList" component={PullListScreen} options={{ title: 'Pull Requests' }} />
53 <Stack.Screen name="PullDetail" component={PullDetailScreen} options={({ route }) => ({ title: `PR #${route.params.number}` })} />
54 {/* Dummy — needed to satisfy shared type */}
55 <Stack.Screen name="Dashboard" component={DashboardScreen} options={{ title: 'Dashboard' }} />
56 </Stack.Navigator>
57 );
58}
59
60// ─── Tab navigator ───────────────────────────────────────────────────────────
61
62export function MainTabNavigator() {
63 return (
64 <Tab.Navigator
65 screenOptions={{
66 headerShown: false,
67 tabBarStyle: {
68 backgroundColor: colors.bgSecondary,
69 borderTopColor: colors.border,
70 borderTopWidth: 1,
71 height: 60,
72 paddingBottom: 8,
73 },
74 tabBarActiveTintColor: colors.accent,
75 tabBarInactiveTintColor: colors.textMuted,
76 tabBarLabelStyle: {
77 fontSize: fontSizes.xs,
78 fontWeight: '500',
79 },
80 }}
81 >
82 <Tab.Screen
83 name="DashboardTab"
84 component={DashboardStack}
85 options={{
86 title: 'Dashboard',
87 tabBarIcon: ({ color }) => <TabIcon icon="⌂" color={color} />,
88 }}
89 />
90 <Tab.Screen
91 name="ReposTab"
92 component={RepoStack}
93 options={{
94 title: 'Repos',
95 tabBarIcon: ({ color }) => <TabIcon icon="⌥" color={color} />,
96 }}
97 />
98 <Tab.Screen
99 name="NotificationsTab"
100 component={NotificationsScreen}
101 options={{
102 title: 'Notifications',
103 tabBarIcon: ({ color }) => <TabIcon icon="◉" color={color} />,
104 }}
105 />
106 <Tab.Screen
107 name="SettingsTab"
108 component={SettingsScreen}
109 options={{
110 title: 'Settings',
111 tabBarIcon: ({ color }) => <TabIcon icon="⚙" color={color} />,
112 }}
113 />
114 </Tab.Navigator>
115 );
116}
117
118// ─── Helpers ─────────────────────────────────────────────────────────────────
119
120function TabIcon({ icon, color }: { icon: string; color: string }) {
121 return (
122 <Text style={{ fontSize: 18, color, lineHeight: 22 }}>{icon}</Text>
123 );
124}
125
126const stackScreenOptions = {
127 headerStyle: { backgroundColor: colors.bgSecondary },
128 headerTintColor: colors.text,
129 headerTitleStyle: { color: colors.text, fontWeight: '600' as const },
130 headerBackTitleVisible: false,
131 contentStyle: { backgroundColor: colors.bg },
132};
Addedmobile/src/navigation/RootNavigator.tsx+69−0View fileUnifiedSplit
1import React from 'react';
2import { NavigationContainer } from '@react-navigation/native';
3import { createNativeStackNavigator } from '@react-navigation/native-stack';
4import { View, ActivityIndicator } from 'react-native';
5import { colors } from '../theme/colors';
6import { AuthScreen } from '../screens/AuthScreen';
7import { MainTabNavigator } from './MainTabNavigator';
8import { useAuth } from '../hooks/useAuth';
9import { AuthContext } from './AuthContext';
10
11// Re-export AuthContext so callers can do: import { AuthContext } from '../navigation/RootNavigator'
12export { AuthContext };
13export type { AuthContextValue } from './AuthContext';
14
15// ─── Root param list ──────────────────────────────────────────────────────────
16
17type RootStackParamList = {
18 Auth: undefined;
19 Main: undefined;
20};
21
22const Stack = createNativeStackNavigator<RootStackParamList>();
23
24// ─── Navigator ────────────────────────────────────────────────────────────────
25
26export function RootNavigator() {
27 const auth = useAuth();
28
29 if (auth.loading) {
30 return (
31 <View style={{ flex: 1, backgroundColor: colors.bg, alignItems: 'center', justifyContent: 'center' }}>
32 <ActivityIndicator size="large" color={colors.accent} />
33 </View>
34 );
35 }
36
37 return (
38 <AuthContext.Provider
39 value={{
40 user: auth.user,
41 token: auth.token,
42 login: auth.login,
43 logout: auth.logout,
44 }}
45 >
46 <NavigationContainer
47 theme={{
48 dark: true,
49 colors: {
50 primary: colors.accent,
51 background: colors.bg,
52 card: colors.bgSecondary,
53 text: colors.text,
54 border: colors.border,
55 notification: colors.accent,
56 },
57 }}
58 >
59 <Stack.Navigator screenOptions={{ headerShown: false }}>
60 {auth.isAuthenticated ? (
61 <Stack.Screen name="Main" component={MainTabNavigator} />
62 ) : (
63 <Stack.Screen name="Auth" component={AuthScreen} />
64 )}
65 </Stack.Navigator>
66 </NavigationContainer>
67 </AuthContext.Provider>
68 );
69}
Addedmobile/src/navigation/types.ts+13−0View fileUnifiedSplit
1// Navigation param types — extracted to a separate file to break circular imports.
2// Screens import from here; navigators import from here.
3
4export type MainStackParamList = {
5 Dashboard: undefined;
6 RepoList: undefined;
7 RepoDetail: { owner: string; repo: string };
8 FileViewer: { owner: string; repo: string; path: string; ref: string };
9 IssueList: { owner: string; repo: string };
10 IssueDetail: { owner: string; repo: string; number: number };
11 PullList: { owner: string; repo: string };
12 PullDetail: { owner: string; repo: string; number: number };
13};
Addedmobile/src/screens/AuthScreen.tsx+244−0View fileUnifiedSplit
1import React, { useState, useContext } from 'react';
2import {
3 View,
4 Text,
5 TextInput,
6 TouchableOpacity,
7 StyleSheet,
8 ScrollView,
9 KeyboardAvoidingView,
10 Platform,
11 ActivityIndicator,
12 Alert,
13} from 'react-native';
14import { SafeAreaView } from 'react-native-safe-area-context';
15import { colors } from '../theme/colors';
16import { fontSizes, fontWeights } from '../theme/typography';
17import { AuthContext } from '../navigation/AuthContext';
18
19export function AuthScreen() {
20 const { login } = useContext(AuthContext);
21 const [token, setToken] = useState('');
22 const [host, setHost] = useState('https://gluecron.com');
23 const [showAdvanced, setShowAdvanced] = useState(false);
24 const [loading, setLoading] = useState(false);
25
26 async function handleLogin() {
27 const trimmed = token.trim();
28 if (!trimmed) {
29 Alert.alert('Error', 'Please enter your Personal Access Token');
30 return;
31 }
32 setLoading(true);
33 try {
34 await login(trimmed, undefined, host.trim() || 'https://gluecron.com');
35 } catch (err) {
36 Alert.alert('Login failed', err instanceof Error ? err.message : 'Invalid token');
37 } finally {
38 setLoading(false);
39 }
40 }
41
42 return (
43 <SafeAreaView style={styles.safe}>
44 <KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined} style={styles.kav}>
45 <ScrollView contentContainerStyle={styles.scroll} keyboardShouldPersistTaps="handled">
46 {/* Logo */}
47 <View style={styles.logoWrap}>
48 <Text style={styles.logoIcon}></Text>
49 <Text style={styles.logoText}>
50 glue<Text style={styles.logoAccent}>cron</Text>
51 </Text>
52 <Text style={styles.tagline}>AI-native git hosting</Text>
53 </View>
54
55 {/* Card */}
56 <View style={styles.card}>
57 <Text style={styles.heading}>Sign in</Text>
58 <Text style={styles.subheading}>
59 Use a Personal Access Token from your Gluecron account settings.
60 </Text>
61
62 <View style={styles.field}>
63 <Text style={styles.label}>Personal Access Token</Text>
64 <TextInput
65 style={styles.input}
66 value={token}
67 onChangeText={setToken}
68 placeholder="glc_..."
69 placeholderTextColor={colors.textMuted}
70 secureTextEntry
71 autoCapitalize="none"
72 autoCorrect={false}
73 autoComplete="password"
74 returnKeyType="done"
75 onSubmitEditing={handleLogin}
76 />
77 </View>
78
79 <TouchableOpacity
80 style={styles.advancedToggle}
81 onPress={() => setShowAdvanced((v) => !v)}
82 activeOpacity={0.7}
83 >
84 <Text style={styles.advancedToggleText}>
85 {showAdvanced ? '− ' : '+ '}Advanced (self-hosted)
86 </Text>
87 </TouchableOpacity>
88
89 {showAdvanced && (
90 <View style={styles.field}>
91 <Text style={styles.label}>Gluecron Host URL</Text>
92 <TextInput
93 style={styles.input}
94 value={host}
95 onChangeText={setHost}
96 placeholder="https://gluecron.com"
97 placeholderTextColor={colors.textMuted}
98 autoCapitalize="none"
99 autoCorrect={false}
100 keyboardType="url"
101 />
102 </View>
103 )}
104
105 <TouchableOpacity
106 style={[styles.loginBtn, loading && styles.loginBtnDisabled]}
107 onPress={handleLogin}
108 disabled={loading}
109 activeOpacity={0.8}
110 >
111 {loading ? (
112 <ActivityIndicator color={colors.white} size="small" />
113 ) : (
114 <Text style={styles.loginBtnText}>Sign in</Text>
115 )}
116 </TouchableOpacity>
117
118 <Text style={styles.hint}>
119 Generate a token at{' '}
120 <Text style={styles.hintLink}>Settings → Tokens</Text>
121 {' '}on your Gluecron instance.
122 </Text>
123 </View>
124 </ScrollView>
125 </KeyboardAvoidingView>
126 </SafeAreaView>
127 );
128}
129
130const styles = StyleSheet.create({
131 safe: {
132 flex: 1,
133 backgroundColor: colors.bg,
134 },
135 kav: {
136 flex: 1,
137 },
138 scroll: {
139 flexGrow: 1,
140 alignItems: 'center',
141 justifyContent: 'center',
142 padding: 24,
143 },
144 logoWrap: {
145 alignItems: 'center',
146 marginBottom: 36,
147 },
148 logoIcon: {
149 fontSize: 48,
150 color: colors.accent,
151 marginBottom: 8,
152 },
153 logoText: {
154 fontSize: fontSizes.xxl,
155 fontWeight: fontWeights.bold,
156 color: colors.text,
157 letterSpacing: -0.5,
158 },
159 logoAccent: {
160 color: colors.accent,
161 },
162 tagline: {
163 color: colors.textMuted,
164 fontSize: fontSizes.sm,
165 marginTop: 4,
166 },
167 card: {
168 width: '100%',
169 maxWidth: 400,
170 backgroundColor: colors.bgSurface,
171 borderRadius: 14,
172 padding: 24,
173 borderWidth: 1,
174 borderColor: colors.border,
175 },
176 heading: {
177 color: colors.text,
178 fontSize: fontSizes.xl,
179 fontWeight: fontWeights.bold,
180 marginBottom: 6,
181 },
182 subheading: {
183 color: colors.textMuted,
184 fontSize: fontSizes.sm,
185 lineHeight: 18,
186 marginBottom: 22,
187 },
188 field: {
189 marginBottom: 16,
190 },
191 label: {
192 color: colors.textMuted,
193 fontSize: fontSizes.xs,
194 fontWeight: fontWeights.medium,
195 marginBottom: 6,
196 textTransform: 'uppercase',
197 letterSpacing: 0.5,
198 },
199 input: {
200 backgroundColor: colors.bgSecondary,
201 borderWidth: 1,
202 borderColor: colors.border,
203 borderRadius: 8,
204 paddingHorizontal: 14,
205 paddingVertical: 12,
206 color: colors.text,
207 fontSize: fontSizes.base,
208 minHeight: 44,
209 },
210 advancedToggle: {
211 marginBottom: 12,
212 },
213 advancedToggleText: {
214 color: colors.accent,
215 fontSize: fontSizes.sm,
216 },
217 loginBtn: {
218 backgroundColor: colors.accent,
219 borderRadius: 8,
220 paddingVertical: 14,
221 alignItems: 'center',
222 marginTop: 8,
223 minHeight: 48,
224 justifyContent: 'center',
225 },
226 loginBtnDisabled: {
227 opacity: 0.6,
228 },
229 loginBtnText: {
230 color: colors.white,
231 fontSize: fontSizes.base,
232 fontWeight: fontWeights.semibold,
233 },
234 hint: {
235 color: colors.textMuted,
236 fontSize: fontSizes.xs,
237 textAlign: 'center',
238 marginTop: 16,
239 lineHeight: 18,
240 },
241 hintLink: {
242 color: colors.accent,
243 },
244});
Addedmobile/src/screens/DashboardScreen.tsx+207−0View fileUnifiedSplit
1import React, { useContext, useEffect, useState } from 'react';
2import {
3 View,
4 Text,
5 ScrollView,
6 StyleSheet,
7 TouchableOpacity,
8 RefreshControl,
9} from 'react-native';
10import { SafeAreaView } from 'react-native-safe-area-context';
11import { type NativeStackNavigationProp } from '@react-navigation/native-stack';
12import { colors } from '../theme/colors';
13import { fontSizes, fontWeights } from '../theme/typography';
14import { AuthContext } from '../navigation/AuthContext';
15import { useUserRepos } from '../hooks/useRepo';
16import { RepoCard } from '../components/RepoCard';
17import { LoadingSpinner } from '../components/LoadingSpinner';
18import { type MainStackParamList } from '../navigation/types';
19
20interface Props {
21 navigation: NativeStackNavigationProp<MainStackParamList>;
22}
23
24export function DashboardScreen({ navigation }: Props) {
25 const { user } = useContext(AuthContext);
26 const { repos, loading, error, refresh } = useUserRepos(user?.username ?? null);
27 const [refreshing, setRefreshing] = useState(false);
28
29 async function onRefresh() {
30 setRefreshing(true);
31 await refresh();
32 setRefreshing(false);
33 }
34
35 const recentRepos = repos.slice(0, 6);
36 const hour = new Date().getHours();
37 const greeting = hour < 12 ? 'Good morning' : hour < 18 ? 'Good afternoon' : 'Good evening';
38
39 return (
40 <SafeAreaView style={styles.safe} edges={['top']}>
41 <ScrollView
42 style={styles.scroll}
43 contentContainerStyle={styles.content}
44 refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={colors.accent} />}
45 >
46 {/* Header */}
47 <View style={styles.header}>
48 <View style={styles.logoRow}>
49 <Text style={styles.logoIcon}></Text>
50 <Text style={styles.logoName}>
51 glue<Text style={styles.logoAccent}>cron</Text>
52 </Text>
53 </View>
54 <Text style={styles.greeting}>
55 {greeting}, <Text style={styles.username}>{user?.displayName || user?.username}</Text>
56 </Text>
57 <Text style={styles.subGreeting}>Here's what's happening in your repos.</Text>
58 </View>
59
60 {/* Stats row */}
61 <View style={styles.statsRow}>
62 <View style={styles.statCard}>
63 <Text style={styles.statNum}>{repos.length}</Text>
64 <Text style={styles.statLabel}>Repos</Text>
65 </View>
66 <View style={styles.statCard}>
67 <Text style={styles.statNum}>{repos.reduce((acc, r) => acc + r.starCount, 0)}</Text>
68 <Text style={styles.statLabel}>Stars</Text>
69 </View>
70 <View style={styles.statCard}>
71 <Text style={styles.statNum}>{repos.reduce((acc, r) => acc + r.issueCount, 0)}</Text>
72 <Text style={styles.statLabel}>Issues</Text>
73 </View>
74 </View>
75
76 {/* Recent Repos */}
77 <View style={styles.section}>
78 <View style={styles.sectionHeader}>
79 <Text style={styles.sectionTitle}>Recent repositories</Text>
80 <TouchableOpacity onPress={() => navigation.navigate('RepoList')} activeOpacity={0.7}>
81 <Text style={styles.seeAll}>See all</Text>
82 </TouchableOpacity>
83 </View>
84
85 {loading && !refreshing ? (
86 <LoadingSpinner size="small" />
87 ) : recentRepos.length === 0 ? (
88 <View style={styles.emptyWrap}>
89 <Text style={styles.emptyText}>No repositories yet</Text>
90 </View>
91 ) : (
92 recentRepos.map((repo) => (
93 <RepoCard
94 key={repo.id}
95 repo={repo}
96 onPress={() =>
97 navigation.navigate('RepoDetail', {
98 owner: user?.username ?? '',
99 repo: repo.name,
100 })
101 }
102 />
103 ))
104 )}
105 </View>
106 </ScrollView>
107 </SafeAreaView>
108 );
109}
110
111const styles = StyleSheet.create({
112 safe: {
113 flex: 1,
114 backgroundColor: colors.bg,
115 },
116 scroll: {
117 flex: 1,
118 },
119 content: {
120 padding: 16,
121 paddingBottom: 32,
122 },
123 header: {
124 marginBottom: 20,
125 },
126 logoRow: {
127 flexDirection: 'row',
128 alignItems: 'center',
129 gap: 6,
130 marginBottom: 16,
131 },
132 logoIcon: {
133 fontSize: 22,
134 color: colors.accent,
135 },
136 logoName: {
137 fontSize: fontSizes.md,
138 fontWeight: fontWeights.bold,
139 color: colors.text,
140 },
141 logoAccent: {
142 color: colors.accent,
143 },
144 greeting: {
145 fontSize: fontSizes.xl,
146 fontWeight: fontWeights.bold,
147 color: colors.text,
148 marginBottom: 4,
149 },
150 username: {
151 color: colors.accent,
152 },
153 subGreeting: {
154 color: colors.textMuted,
155 fontSize: fontSizes.sm,
156 },
157 statsRow: {
158 flexDirection: 'row',
159 gap: 10,
160 marginBottom: 24,
161 },
162 statCard: {
163 flex: 1,
164 backgroundColor: colors.bgSurface,
165 borderRadius: 10,
166 padding: 14,
167 alignItems: 'center',
168 borderWidth: 1,
169 borderColor: colors.border,
170 },
171 statNum: {
172 color: colors.accent,
173 fontSize: fontSizes.xl,
174 fontWeight: fontWeights.bold,
175 },
176 statLabel: {
177 color: colors.textMuted,
178 fontSize: fontSizes.xs,
179 marginTop: 2,
180 },
181 section: {
182 marginBottom: 24,
183 },
184 sectionHeader: {
185 flexDirection: 'row',
186 justifyContent: 'space-between',
187 alignItems: 'center',
188 marginBottom: 12,
189 },
190 sectionTitle: {
191 color: colors.text,
192 fontSize: fontSizes.md,
193 fontWeight: fontWeights.semibold,
194 },
195 seeAll: {
196 color: colors.accent,
197 fontSize: fontSizes.sm,
198 },
199 emptyWrap: {
200 padding: 20,
201 alignItems: 'center',
202 },
203 emptyText: {
204 color: colors.textMuted,
205 fontSize: fontSizes.sm,
206 },
207});
Addedmobile/src/screens/FileViewerScreen.tsx+231−0View fileUnifiedSplit
1import React, { useMemo } from 'react';
2import {
3 View,
4 Text,
5 ScrollView,
6 StyleSheet,
7} from 'react-native';
8import { SafeAreaView } from 'react-native-safe-area-context';
9import { type RouteProp } from '@react-navigation/native';
10import { colors } from '../theme/colors';
11import { fontSizes, fonts } from '../theme/typography';
12import { useFileContent } from '../hooks/useRepo';
13import { LoadingSpinner } from '../components/LoadingSpinner';
14import { ErrorState } from '../components/ErrorState';
15import { type MainStackParamList } from '../navigation/types';
16
17type Props = {
18 route: RouteProp<MainStackParamList, 'FileViewer'>;
19};
20
21// Minimal keyword syntax highlighting via regex replacement.
22// Returns an array of {text, color} segments for a single line.
23type Segment = { text: string; color: string };
24
25const KEYWORDS: Record<string, RegExp> = {
26 keyword: /\b(const|let|var|function|class|if|else|for|while|return|import|export|default|from|async|await|try|catch|throw|new|typeof|instanceof|void|null|undefined|true|false|extends|implements|interface|type|enum|namespace|module|declare|abstract|public|private|protected|static|readonly|override|def|fn|pub|use|mod|struct|impl|match|where|self|super|trait|yield|in|of|do|switch|case|break|continue|pass|and|or|not|is|as|with|lambda|del|global|nonlocal|assert|finally|raise|except|elif)\b/g,
27 string: /(["'`])(?:\\.|(?!\1)[^\\])*\1/g,
28 comment: /(\/\/[^\n]*|#[^\n]*|\/\*[\s\S]*?\*\/)/g,
29 number: /\b(\d+\.?\d*)\b/g,
30};
31
32function tokenizeLine(line: string, lang: string): Segment[] {
33 // Skip highlighting for binary or very long lines
34 if (line.length > 500) return [{ text: line, color: colors.text }];
35
36 // Collect all token ranges
37 const ranges: Array<{ start: number; end: number; color: string }> = [];
38
39 function addRanges(regex: RegExp, color: string) {
40 regex.lastIndex = 0;
41 let m: RegExpExecArray | null;
42 while ((m = regex.exec(line)) !== null) {
43 ranges.push({ start: m.index, end: m.index + m[0].length, color });
44 }
45 }
46
47 addRanges(new RegExp(KEYWORDS.comment.source, 'g'), colors.textMuted);
48 addRanges(new RegExp(KEYWORDS.string.source, 'g'), colors.green);
49 addRanges(new RegExp(KEYWORDS.keyword.source, 'g'), colors.accent);
50 addRanges(new RegExp(KEYWORDS.number.source, 'g'), colors.yellow);
51
52 if (ranges.length === 0) return [{ text: line, color: colors.text }];
53
54 // Sort by start, resolve overlaps
55 ranges.sort((a, b) => a.start - b.start);
56
57 const segments: Segment[] = [];
58 let cursor = 0;
59 for (const r of ranges) {
60 if (r.start < cursor) continue; // overlapping — skip
61 if (r.start > cursor) {
62 segments.push({ text: line.slice(cursor, r.start), color: colors.text });
63 }
64 segments.push({ text: line.slice(r.start, r.end), color: r.color });
65 cursor = r.end;
66 }
67 if (cursor < line.length) {
68 segments.push({ text: line.slice(cursor), color: colors.text });
69 }
70
71 return segments;
72}
73
74function getExtension(path: string): string {
75 const parts = path.split('.');
76 return parts.length > 1 ? parts[parts.length - 1].toLowerCase() : '';
77}
78
79const CODE_EXTS = new Set([
80 'ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs',
81 'py', 'rb', 'go', 'rs', 'c', 'cpp', 'cc', 'h', 'hpp',
82 'java', 'kt', 'swift', 'cs', 'php', 'sh', 'bash', 'zsh',
83 'toml', 'yaml', 'yml', 'json', 'md', 'sql', 'graphql',
84 'css', 'scss', 'sass', 'html', 'xml', 'svelte', 'vue',
85]);
86
87export function FileViewerScreen({ route }: Props) {
88 const { owner, repo, path, ref } = route.params;
89 const { file, loading, error } = useFileContent(owner, repo, path, ref);
90
91 const ext = getExtension(path);
92 const isCode = CODE_EXTS.has(ext);
93
94 const content = useMemo(() => {
95 if (!file) return '';
96 if (file.encoding === 'base64') {
97 try {
98 return atob(file.content.replace(/\n/g, ''));
99 } catch {
100 return file.content;
101 }
102 }
103 return file.content;
104 }, [file]);
105
106 const lines = useMemo(() => content.split('\n'), [content]);
107
108 if (loading) return <LoadingSpinner fullScreen />;
109 if (error || !file) return <ErrorState message={error || 'File not found'} />;
110
111 const isBinary = !isCode && file.size > 0;
112
113 return (
114 <SafeAreaView style={styles.safe} edges={['top', 'bottom']}>
115 {/* File info bar */}
116 <View style={styles.infoBar}>
117 <Text style={styles.fileName} numberOfLines={1}>{path.split('/').pop()}</Text>
118 <Text style={styles.fileMeta}>{formatSize(file.size)} · {lines.length} lines</Text>
119 </View>
120
121 {isBinary ? (
122 <View style={styles.binaryWrap}>
123 <Text style={styles.binaryText}>Binary file — preview not available</Text>
124 <Text style={styles.binarySize}>{formatSize(file.size)}</Text>
125 </View>
126 ) : (
127 <ScrollView horizontal showsHorizontalScrollIndicator style={styles.hScroll}>
128 <ScrollView style={styles.vScroll}>
129 <View style={styles.codeWrap}>
130 {lines.map((line, idx) => {
131 const segments = isCode ? tokenizeLine(line, ext) : [{ text: line, color: colors.text }];
132 return (
133 <View key={idx} style={styles.codeLine}>
134 <Text style={styles.lineNo}>{idx + 1}</Text>
135 <Text style={styles.lineContent}>
136 {segments.map((seg, si) => (
137 <Text key={si} style={{ color: seg.color }}>
138 {seg.text}
139 </Text>
140 ))}
141 </Text>
142 </View>
143 );
144 })}
145 </View>
146 </ScrollView>
147 </ScrollView>
148 )}
149 </SafeAreaView>
150 );
151}
152
153function formatSize(bytes: number): string {
154 if (bytes < 1024) return `${bytes} B`;
155 if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
156 return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
157}
158
159const styles = StyleSheet.create({
160 safe: {
161 flex: 1,
162 backgroundColor: colors.bg,
163 },
164 infoBar: {
165 flexDirection: 'row',
166 justifyContent: 'space-between',
167 alignItems: 'center',
168 paddingHorizontal: 16,
169 paddingVertical: 10,
170 borderBottomWidth: 1,
171 borderBottomColor: colors.border,
172 backgroundColor: colors.bgSecondary,
173 },
174 fileName: {
175 color: colors.text,
176 fontSize: fontSizes.sm,
177 fontFamily: fonts.mono,
178 fontWeight: '600',
179 flex: 1,
180 marginRight: 12,
181 },
182 fileMeta: {
183 color: colors.textMuted,
184 fontSize: fontSizes.xs,
185 },
186 hScroll: {
187 flex: 1,
188 },
189 vScroll: {
190 flex: 1,
191 },
192 codeWrap: {
193 padding: 8,
194 paddingBottom: 40,
195 },
196 codeLine: {
197 flexDirection: 'row',
198 minHeight: 20,
199 },
200 lineNo: {
201 width: 36,
202 color: colors.textMuted,
203 fontSize: fontSizes.xs,
204 fontFamily: fonts.mono,
205 textAlign: 'right',
206 paddingRight: 12,
207 lineHeight: 20,
208 userSelect: 'none',
209 },
210 lineContent: {
211 fontSize: fontSizes.xs,
212 fontFamily: fonts.mono,
213 lineHeight: 20,
214 color: colors.text,
215 },
216 binaryWrap: {
217 flex: 1,
218 alignItems: 'center',
219 justifyContent: 'center',
220 padding: 32,
221 },
222 binaryText: {
223 color: colors.textMuted,
224 fontSize: fontSizes.base,
225 marginBottom: 8,
226 },
227 binarySize: {
228 color: colors.textMuted,
229 fontSize: fontSizes.sm,
230 },
231});
Addedmobile/src/screens/IssueDetailScreen.tsx+257−0View fileUnifiedSplit
1import React, { useState, useRef } from 'react';
2import {
3 View,
4 Text,
5 ScrollView,
6 StyleSheet,
7 TextInput,
8 TouchableOpacity,
9 ActivityIndicator,
10 KeyboardAvoidingView,
11 Platform,
12 Alert,
13} from 'react-native';
14import { SafeAreaView } from 'react-native-safe-area-context';
15import { type RouteProp } from '@react-navigation/native';
16import { colors } from '../theme/colors';
17import { fontSizes, fontWeights } from '../theme/typography';
18import { useIssue } from '../hooks/useIssues';
19import { LoadingSpinner } from '../components/LoadingSpinner';
20import { ErrorState } from '../components/ErrorState';
21import { type MainStackParamList } from '../navigation/types';
22
23type Props = {
24 route: RouteProp<MainStackParamList, 'IssueDetail'>;
25};
26
27function renderBody(text: string | null) {
28 if (!text) return null;
29 // Simple markdown-ish rendering: bold, italic, code spans
30 const lines = text.split('\n');
31 return lines.map((line, i) => {
32 // Headings
33 if (line.startsWith('### ')) {
34 return <Text key={i} style={styles.h3}>{line.slice(4)}</Text>;
35 }
36 if (line.startsWith('## ')) {
37 return <Text key={i} style={styles.h2}>{line.slice(3)}</Text>;
38 }
39 if (line.startsWith('# ')) {
40 return <Text key={i} style={styles.h1}>{line.slice(2)}</Text>;
41 }
42 // Code block lines
43 if (line.startsWith('```') || line.startsWith(' ')) {
44 return <Text key={i} style={styles.codeLine}>{line}</Text>;
45 }
46 if (!line.trim()) {
47 return <Text key={i} style={styles.emptyLine}>{'\n'}</Text>;
48 }
49 return <Text key={i} style={styles.bodyLine}>{line}</Text>;
50 });
51}
52
53function timeAgo(dateStr: string): string {
54 const diff = Date.now() - new Date(dateStr).getTime();
55 const minutes = Math.floor(diff / 60000);
56 if (minutes < 60) return `${minutes}m ago`;
57 const hours = Math.floor(minutes / 60);
58 if (hours < 24) return `${hours}h ago`;
59 const days = Math.floor(hours / 24);
60 return `${days}d ago`;
61}
62
63export function IssueDetailScreen({ route }: Props) {
64 const { owner, repo, number } = route.params;
65 const { issue, comments, loading, error, submitting, refresh, addComment } = useIssue(owner, repo, number);
66 const [commentText, setCommentText] = useState('');
67 const scrollRef = useRef<ScrollView>(null);
68
69 async function handleSubmit() {
70 const trimmed = commentText.trim();
71 if (!trimmed) return;
72 try {
73 await addComment(trimmed);
74 setCommentText('');
75 scrollRef.current?.scrollToEnd({ animated: true });
76 } catch (err) {
77 Alert.alert('Error', err instanceof Error ? err.message : 'Failed to post comment');
78 }
79 }
80
81 if (loading) return <LoadingSpinner fullScreen />;
82 if (error || !issue) return <ErrorState message={error || 'Issue not found'} onRetry={refresh} />;
83
84 const isOpen = issue.state === 'open';
85
86 return (
87 <SafeAreaView style={styles.safe} edges={['top', 'bottom']}>
88 <KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined} style={styles.kav}>
89 <ScrollView ref={scrollRef} style={styles.scroll} contentContainerStyle={styles.content}>
90 {/* Issue header */}
91 <View style={styles.issueHeader}>
92 <View style={[styles.stateBadge, { backgroundColor: isOpen ? colors.green + '22' : colors.textMuted + '22' }]}>
93 <View style={[styles.stateDot, { backgroundColor: isOpen ? colors.green : colors.textMuted }]} />
94 <Text style={[styles.stateText, { color: isOpen ? colors.green : colors.textMuted }]}>
95 {isOpen ? 'Open' : 'Closed'}
96 </Text>
97 </View>
98 <Text style={styles.number}>#{issue.number}</Text>
99 </View>
100
101 <Text style={styles.title}>{issue.title}</Text>
102
103 <Text style={styles.meta}>
104 opened {timeAgo(issue.createdAt)}
105 </Text>
106
107 {/* Body */}
108 {issue.body ? (
109 <View style={styles.bodyWrap}>
110 {renderBody(issue.body)}
111 </View>
112 ) : (
113 <Text style={styles.noBody}>No description provided.</Text>
114 )}
115
116 {/* Comments */}
117 {comments.length > 0 && (
118 <View style={styles.commentsSection}>
119 <Text style={styles.commentsTitle}>{comments.length} comment{comments.length !== 1 ? 's' : ''}</Text>
120 {comments.map((comment) => (
121 <View key={comment.id} style={styles.commentCard}>
122 <View style={styles.commentHeader}>
123 <Text style={styles.commentAuthor}>
124 {comment.author?.username ?? 'Unknown'}
125 </Text>
126 <Text style={styles.commentTime}>{timeAgo(comment.createdAt)}</Text>
127 </View>
128 <View style={styles.commentBody}>
129 {renderBody(comment.body)}
130 </View>
131 </View>
132 ))}
133 </View>
134 )}
135 </ScrollView>
136
137 {/* Comment composer */}
138 <View style={styles.composer}>
139 <TextInput
140 style={styles.composerInput}
141 value={commentText}
142 onChangeText={setCommentText}
143 placeholder="Leave a comment..."
144 placeholderTextColor={colors.textMuted}
145 multiline
146 maxLength={65536}
147 />
148 <TouchableOpacity
149 style={[styles.sendBtn, (!commentText.trim() || submitting) && styles.sendBtnDisabled]}
150 onPress={handleSubmit}
151 disabled={!commentText.trim() || submitting}
152 activeOpacity={0.8}
153 >
154 {submitting ? (
155 <ActivityIndicator color={colors.white} size="small" />
156 ) : (
157 <Text style={styles.sendBtnText}>Comment</Text>
158 )}
159 </TouchableOpacity>
160 </View>
161 </KeyboardAvoidingView>
162 </SafeAreaView>
163 );
164}
165
166const styles = StyleSheet.create({
167 safe: { flex: 1, backgroundColor: colors.bg },
168 kav: { flex: 1 },
169 scroll: { flex: 1 },
170 content: { padding: 16, paddingBottom: 8 },
171 issueHeader: {
172 flexDirection: 'row',
173 alignItems: 'center',
174 gap: 10,
175 marginBottom: 10,
176 },
177 stateBadge: {
178 flexDirection: 'row',
179 alignItems: 'center',
180 gap: 6,
181 borderRadius: 12,
182 paddingHorizontal: 10,
183 paddingVertical: 4,
184 },
185 stateDot: { width: 8, height: 8, borderRadius: 4 },
186 stateText: { fontSize: fontSizes.sm, fontWeight: fontWeights.medium },
187 number: { color: colors.textMuted, fontSize: fontSizes.sm },
188 title: {
189 fontSize: fontSizes.xl,
190 fontWeight: fontWeights.bold,
191 color: colors.text,
192 lineHeight: 28,
193 marginBottom: 6,
194 },
195 meta: { color: colors.textMuted, fontSize: fontSizes.sm, marginBottom: 16 },
196 bodyWrap: {
197 backgroundColor: colors.bgSurface,
198 borderRadius: 8,
199 padding: 14,
200 marginBottom: 24,
201 borderWidth: 1,
202 borderColor: colors.border,
203 },
204 noBody: { color: colors.textMuted, fontSize: fontSizes.sm, fontStyle: 'italic', marginBottom: 24 },
205 h1: { color: colors.text, fontSize: fontSizes.xl, fontWeight: fontWeights.bold, marginBottom: 6 },
206 h2: { color: colors.text, fontSize: fontSizes.lg, fontWeight: fontWeights.bold, marginBottom: 4 },
207 h3: { color: colors.text, fontSize: fontSizes.md, fontWeight: fontWeights.semibold, marginBottom: 3 },
208 bodyLine: { color: colors.text, fontSize: fontSizes.sm, lineHeight: 20 },
209 codeLine: { color: colors.green, fontSize: fontSizes.xs, fontFamily: 'monospace', backgroundColor: colors.bgSecondary, paddingHorizontal: 4 },
210 emptyLine: { height: 8 },
211 commentsSection: { borderTopWidth: 1, borderTopColor: colors.border, paddingTop: 20 },
212 commentsTitle: { color: colors.textMuted, fontSize: fontSizes.sm, fontWeight: fontWeights.medium, marginBottom: 14 },
213 commentCard: {
214 backgroundColor: colors.bgSurface,
215 borderRadius: 8,
216 padding: 12,
217 marginBottom: 12,
218 borderWidth: 1,
219 borderColor: colors.border,
220 },
221 commentHeader: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 8 },
222 commentAuthor: { color: colors.accent, fontSize: fontSizes.sm, fontWeight: fontWeights.medium },
223 commentTime: { color: colors.textMuted, fontSize: fontSizes.xs },
224 commentBody: {},
225 composer: {
226 flexDirection: 'row',
227 alignItems: 'flex-end',
228 padding: 12,
229 borderTopWidth: 1,
230 borderTopColor: colors.border,
231 gap: 10,
232 backgroundColor: colors.bgSecondary,
233 },
234 composerInput: {
235 flex: 1,
236 backgroundColor: colors.bgSurface,
237 borderWidth: 1,
238 borderColor: colors.border,
239 borderRadius: 8,
240 paddingHorizontal: 12,
241 paddingVertical: 10,
242 color: colors.text,
243 fontSize: fontSizes.sm,
244 maxHeight: 120,
245 minHeight: 40,
246 },
247 sendBtn: {
248 backgroundColor: colors.accent,
249 borderRadius: 8,
250 paddingHorizontal: 16,
251 paddingVertical: 10,
252 minHeight: 40,
253 justifyContent: 'center',
254 },
255 sendBtnDisabled: { opacity: 0.5 },
256 sendBtnText: { color: colors.white, fontSize: fontSizes.sm, fontWeight: fontWeights.semibold },
257});
Addedmobile/src/screens/IssueListScreen.tsx+149−0View fileUnifiedSplit
1import React, { useState } from 'react';
2import {
3 View,
4 Text,
5 FlatList,
6 StyleSheet,
7 RefreshControl,
8 TouchableOpacity,
9} from 'react-native';
10import { SafeAreaView } from 'react-native-safe-area-context';
11import { type NativeStackNavigationProp } from '@react-navigation/native-stack';
12import { type RouteProp } from '@react-navigation/native';
13import { colors } from '../theme/colors';
14import { fontSizes, fontWeights } from '../theme/typography';
15import { useIssues } from '../hooks/useIssues';
16import { IssueRow } from '../components/IssueRow';
17import { LoadingSpinner } from '../components/LoadingSpinner';
18import { ErrorState } from '../components/ErrorState';
19import { EmptyState } from '../components/EmptyState';
20import { type MainStackParamList } from '../navigation/types';
21
22type Props = {
23 navigation: NativeStackNavigationProp<MainStackParamList, 'IssueList'>;
24 route: RouteProp<MainStackParamList, 'IssueList'>;
25};
26
27export function IssueListScreen({ navigation, route }: Props) {
28 const { owner, repo } = route.params;
29 const [stateFilter, setStateFilter] = useState<'open' | 'closed'>('open');
30 const [refreshing, setRefreshing] = useState(false);
31 const { issues, loading, error, refresh } = useIssues(owner, repo, stateFilter);
32
33 async function onRefresh() {
34 setRefreshing(true);
35 await refresh();
36 setRefreshing(false);
37 }
38
39 if (loading && !refreshing && issues.length === 0) {
40 return <LoadingSpinner fullScreen />;
41 }
42
43 if (error && issues.length === 0) {
44 return <ErrorState message={error} onRetry={refresh} />;
45 }
46
47 return (
48 <SafeAreaView style={styles.safe} edges={['top']}>
49 {/* State toggle */}
50 <View style={styles.toggleBar}>
51 <TouchableOpacity
52 style={[styles.toggleBtn, stateFilter === 'open' && styles.toggleActive]}
53 onPress={() => setStateFilter('open')}
54 activeOpacity={0.75}
55 >
56 <View style={[styles.dot, { backgroundColor: colors.green }]} />
57 <Text style={[styles.toggleText, stateFilter === 'open' && styles.toggleTextActive]}>
58 Open
59 </Text>
60 </TouchableOpacity>
61 <TouchableOpacity
62 style={[styles.toggleBtn, stateFilter === 'closed' && styles.toggleActive]}
63 onPress={() => setStateFilter('closed')}
64 activeOpacity={0.75}
65 >
66 <View style={[styles.dot, { backgroundColor: colors.textMuted }]} />
67 <Text style={[styles.toggleText, stateFilter === 'closed' && styles.toggleTextActive]}>
68 Closed
69 </Text>
70 </TouchableOpacity>
71 <Text style={styles.repoBadge}>{owner}/{repo}</Text>
72 </View>
73
74 <FlatList
75 data={issues}
76 keyExtractor={(item) => item.id}
77 renderItem={({ item }) => (
78 <IssueRow
79 issue={item}
80 onPress={() =>
81 navigation.navigate('IssueDetail', {
82 owner,
83 repo,
84 number: item.number,
85 })
86 }
87 />
88 )}
89 refreshControl={
90 <RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={colors.accent} />
91 }
92 ListEmptyComponent={
93 <EmptyState
94 title={`No ${stateFilter} issues`}
95 subtitle="Issues will appear here when created"
96 icon="●"
97 />
98 }
99 />
100 </SafeAreaView>
101 );
102}
103
104const styles = StyleSheet.create({
105 safe: {
106 flex: 1,
107 backgroundColor: colors.bg,
108 },
109 toggleBar: {
110 flexDirection: 'row',
111 alignItems: 'center',
112 padding: 12,
113 gap: 8,
114 borderBottomWidth: 1,
115 borderBottomColor: colors.border,
116 },
117 toggleBtn: {
118 flexDirection: 'row',
119 alignItems: 'center',
120 gap: 6,
121 paddingHorizontal: 12,
122 paddingVertical: 6,
123 borderRadius: 20,
124 borderWidth: 1,
125 borderColor: 'transparent',
126 },
127 toggleActive: {
128 borderColor: colors.border,
129 backgroundColor: colors.bgSurface,
130 },
131 dot: {
132 width: 8,
133 height: 8,
134 borderRadius: 4,
135 },
136 toggleText: {
137 color: colors.textMuted,
138 fontSize: fontSizes.sm,
139 fontWeight: fontWeights.medium,
140 },
141 toggleTextActive: {
142 color: colors.text,
143 },
144 repoBadge: {
145 marginLeft: 'auto',
146 color: colors.textMuted,
147 fontSize: fontSizes.xs,
148 },
149});
Addedmobile/src/screens/NotificationsScreen.tsx+102−0View fileUnifiedSplit
1import React, { useContext, useEffect } from 'react';
2import {
3 View,
4 Text,
5 FlatList,
6 StyleSheet,
7 TouchableOpacity,
8 RefreshControl,
9} from 'react-native';
10import { SafeAreaView } from 'react-native-safe-area-context';
11import { colors } from '../theme/colors';
12import { fontSizes, fontWeights } from '../theme/typography';
13import { AuthContext } from '../navigation/AuthContext';
14import { useUserRepos } from '../hooks/useRepo';
15import { useNotifications } from '../hooks/useNotifications';
16import { NotifRow } from '../components/NotifRow';
17import { LoadingSpinner } from '../components/LoadingSpinner';
18import { EmptyState } from '../components/EmptyState';
19
20export function NotificationsScreen() {
21 const { user } = useContext(AuthContext);
22 const { repos } = useUserRepos(user?.username ?? null);
23
24 const repoRefs = repos.slice(0, 5).map((r) => ({
25 owner: user?.username ?? '',
26 name: r.name,
27 }));
28
29 const { notifications, loading, error, refresh, markAllRead, unreadCount } = useNotifications(repoRefs);
30
31 useEffect(() => {
32 if (repos.length > 0) {
33 refresh();
34 }
35 }, [repos.length]);
36
37 return (
38 <SafeAreaView style={styles.safe} edges={['top']}>
39 {/* Header */}
40 <View style={styles.header}>
41 <Text style={styles.title}>
42 Notifications{unreadCount > 0 ? ` (${unreadCount})` : ''}
43 </Text>
44 {unreadCount > 0 && (
45 <TouchableOpacity onPress={markAllRead} style={styles.markReadBtn} activeOpacity={0.75}>
46 <Text style={styles.markReadText}>Mark all read</Text>
47 </TouchableOpacity>
48 )}
49 </View>
50
51 {loading && notifications.length === 0 ? (
52 <LoadingSpinner size="large" />
53 ) : (
54 <FlatList
55 data={notifications}
56 keyExtractor={(item) => item.id}
57 renderItem={({ item }) => <NotifRow notification={item} />}
58 refreshControl={
59 <RefreshControl refreshing={loading} onRefresh={refresh} tintColor={colors.accent} />
60 }
61 ListEmptyComponent={
62 <EmptyState
63 title="No notifications"
64 subtitle="Activity from your repos will appear here"
65 icon="🔔"
66 />
67 }
68 />
69 )}
70 </SafeAreaView>
71 );
72}
73
74const styles = StyleSheet.create({
75 safe: { flex: 1, backgroundColor: colors.bg },
76 header: {
77 flexDirection: 'row',
78 justifyContent: 'space-between',
79 alignItems: 'center',
80 paddingHorizontal: 16,
81 paddingTop: 16,
82 paddingBottom: 12,
83 borderBottomWidth: 1,
84 borderBottomColor: colors.border,
85 },
86 title: {
87 color: colors.text,
88 fontSize: fontSizes.xl,
89 fontWeight: fontWeights.bold,
90 },
91 markReadBtn: {
92 paddingHorizontal: 12,
93 paddingVertical: 6,
94 backgroundColor: colors.accentDim,
95 borderRadius: 16,
96 },
97 markReadText: {
98 color: colors.accent,
99 fontSize: fontSizes.sm,
100 fontWeight: fontWeights.medium,
101 },
102});
Addedmobile/src/screens/PullDetailScreen.tsx+210−0View fileUnifiedSplit
1import React from 'react';
2import {
3 View,
4 Text,
5 ScrollView,
6 StyleSheet,
7} from 'react-native';
8import { SafeAreaView } from 'react-native-safe-area-context';
9import { type RouteProp } from '@react-navigation/native';
10import { colors } from '../theme/colors';
11import { fontSizes, fontWeights, fonts } from '../theme/typography';
12import { usePull } from '../hooks/usePulls';
13import { LoadingSpinner } from '../components/LoadingSpinner';
14import { ErrorState } from '../components/ErrorState';
15import { type MainStackParamList } from '../navigation/types';
16
17type Props = {
18 route: RouteProp<MainStackParamList, 'PullDetail'>;
19};
20
21function stateColor(state: string): string {
22 switch (state) {
23 case 'open': return colors.green;
24 case 'merged': return colors.accent;
25 case 'closed': return colors.red;
26 default: return colors.textMuted;
27 }
28}
29
30function stateLabel(state: string): string {
31 switch (state) {
32 case 'open': return 'Open';
33 case 'merged': return 'Merged';
34 case 'closed': return 'Closed';
35 default: return state;
36 }
37}
38
39function timeAgo(dateStr: string): string {
40 const diff = Date.now() - new Date(dateStr).getTime();
41 const minutes = Math.floor(diff / 60000);
42 if (minutes < 60) return `${minutes}m ago`;
43 const hours = Math.floor(minutes / 60);
44 if (hours < 24) return `${hours}h ago`;
45 const days = Math.floor(hours / 24);
46 return `${days}d ago`;
47}
48
49function renderBody(text: string | null) {
50 if (!text) return null;
51 return text.split('\n').map((line, i) => {
52 if (!line.trim()) return <Text key={i} style={{ height: 8 }} />;
53 return <Text key={i} style={styles.bodyLine}>{line}</Text>;
54 });
55}
56
57export function PullDetailScreen({ route }: Props) {
58 const { owner, repo, number } = route.params;
59 const { pull, comments, loading, error, refresh } = usePull(owner, repo, number);
60
61 if (loading) return <LoadingSpinner fullScreen />;
62 if (error || !pull) return <ErrorState message={error || 'PR not found'} onRetry={refresh} />;
63
64 const sc = stateColor(pull.state);
65
66 return (
67 <SafeAreaView style={styles.safe} edges={['top']}>
68 <ScrollView style={styles.scroll} contentContainerStyle={styles.content}>
69 {/* Header */}
70 <View style={styles.prHeader}>
71 <View style={[styles.stateBadge, { backgroundColor: sc + '22', borderColor: sc }]}>
72 <Text style={[styles.stateText, { color: sc }]}>{stateLabel(pull.state)}</Text>
73 </View>
74 <Text style={styles.prNum}>#{pull.number}</Text>
75 </View>
76
77 <Text style={styles.title}>{pull.title}</Text>
78
79 <View style={styles.branchRow}>
80 <Text style={styles.branch}>{pull.headBranch}</Text>
81 <Text style={styles.branchArrow}></Text>
82 <Text style={styles.branch}>{pull.baseBranch}</Text>
83 </View>
84
85 <Text style={styles.meta}>
86 opened {timeAgo(pull.createdAt)}
87 {pull.mergedAt ? ` · merged ${timeAgo(pull.mergedAt)}` : ''}
88 {pull.closedAt && !pull.mergedAt ? ` · closed ${timeAgo(pull.closedAt)}` : ''}
89 </Text>
90
91 {/* Description */}
92 {pull.body ? (
93 <View style={styles.bodyWrap}>
94 {renderBody(pull.body)}
95 </View>
96 ) : (
97 <Text style={styles.noBody}>No description provided.</Text>
98 )}
99
100 {/* Comments */}
101 {comments.length > 0 && (
102 <View style={styles.commentsSection}>
103 <Text style={styles.commentsTitle}>
104 {comments.length} comment{comments.length !== 1 ? 's' : ''}
105 </Text>
106 {comments.map((c) => (
107 <View key={c.id} style={[styles.commentCard, c.isAiReview && styles.aiCommentCard]}>
108 <View style={styles.commentHeader}>
109 <Text style={styles.commentAuthor}>
110 {c.isAiReview ? '⬡ AI Review' : (c.author?.username ?? 'Unknown')}
111 </Text>
112 <Text style={styles.commentTime}>{timeAgo(c.createdAt)}</Text>
113 </View>
114 {c.filePath && (
115 <Text style={styles.filePath}>
116 {c.filePath}{c.line != null ? `:${c.line}` : ''}
117 </Text>
118 )}
119 <Text style={styles.commentBody}>{c.body}</Text>
120 </View>
121 ))}
122 </View>
123 )}
124 </ScrollView>
125 </SafeAreaView>
126 );
127}
128
129const styles = StyleSheet.create({
130 safe: { flex: 1, backgroundColor: colors.bg },
131 scroll: { flex: 1 },
132 content: { padding: 16, paddingBottom: 32 },
133 prHeader: {
134 flexDirection: 'row',
135 alignItems: 'center',
136 gap: 10,
137 marginBottom: 10,
138 },
139 stateBadge: {
140 borderRadius: 12,
141 paddingHorizontal: 10,
142 paddingVertical: 4,
143 borderWidth: 1,
144 },
145 stateText: { fontSize: fontSizes.sm, fontWeight: fontWeights.medium },
146 prNum: { color: colors.textMuted, fontSize: fontSizes.sm },
147 title: {
148 fontSize: fontSizes.xl,
149 fontWeight: fontWeights.bold,
150 color: colors.text,
151 lineHeight: 28,
152 marginBottom: 8,
153 },
154 branchRow: {
155 flexDirection: 'row',
156 alignItems: 'center',
157 marginBottom: 6,
158 flexWrap: 'wrap',
159 },
160 branch: {
161 color: colors.accent,
162 fontSize: fontSizes.sm,
163 fontFamily: fonts.mono,
164 backgroundColor: colors.accentDim,
165 paddingHorizontal: 6,
166 paddingVertical: 2,
167 borderRadius: 4,
168 },
169 branchArrow: { color: colors.textMuted, fontSize: fontSizes.sm, marginHorizontal: 4 },
170 meta: { color: colors.textMuted, fontSize: fontSizes.sm, marginBottom: 16 },
171 bodyWrap: {
172 backgroundColor: colors.bgSurface,
173 borderRadius: 8,
174 padding: 14,
175 marginBottom: 24,
176 borderWidth: 1,
177 borderColor: colors.border,
178 },
179 bodyLine: { color: colors.text, fontSize: fontSizes.sm, lineHeight: 20 },
180 noBody: { color: colors.textMuted, fontSize: fontSizes.sm, fontStyle: 'italic', marginBottom: 24 },
181 commentsSection: { borderTopWidth: 1, borderTopColor: colors.border, paddingTop: 20 },
182 commentsTitle: { color: colors.textMuted, fontSize: fontSizes.sm, fontWeight: fontWeights.medium, marginBottom: 14 },
183 commentCard: {
184 backgroundColor: colors.bgSurface,
185 borderRadius: 8,
186 padding: 12,
187 marginBottom: 12,
188 borderWidth: 1,
189 borderColor: colors.border,
190 },
191 aiCommentCard: {
192 borderColor: colors.accent + '55',
193 backgroundColor: colors.accentDim,
194 },
195 commentHeader: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 6 },
196 commentAuthor: { color: colors.accent, fontSize: fontSizes.sm, fontWeight: fontWeights.medium },
197 commentTime: { color: colors.textMuted, fontSize: fontSizes.xs },
198 filePath: {
199 color: colors.textMuted,
200 fontSize: fontSizes.xs,
201 fontFamily: fonts.mono,
202 marginBottom: 6,
203 backgroundColor: colors.bgSecondary,
204 paddingHorizontal: 6,
205 paddingVertical: 2,
206 borderRadius: 4,
207 alignSelf: 'flex-start',
208 },
209 commentBody: { color: colors.text, fontSize: fontSizes.sm, lineHeight: 20 },
210});
Addedmobile/src/screens/PullListScreen.tsx+134−0View fileUnifiedSplit
1import React, { useState } from 'react';
2import {
3 View,
4 Text,
5 FlatList,
6 StyleSheet,
7 RefreshControl,
8 TouchableOpacity,
9} from 'react-native';
10import { SafeAreaView } from 'react-native-safe-area-context';
11import { type NativeStackNavigationProp } from '@react-navigation/native-stack';
12import { type RouteProp } from '@react-navigation/native';
13import { colors } from '../theme/colors';
14import { fontSizes, fontWeights } from '../theme/typography';
15import { usePulls } from '../hooks/usePulls';
16import { PullRow } from '../components/PullRow';
17import { LoadingSpinner } from '../components/LoadingSpinner';
18import { ErrorState } from '../components/ErrorState';
19import { EmptyState } from '../components/EmptyState';
20import { type MainStackParamList } from '../navigation/types';
21
22type PullState = 'open' | 'closed' | 'merged';
23
24type Props = {
25 navigation: NativeStackNavigationProp<MainStackParamList, 'PullList'>;
26 route: RouteProp<MainStackParamList, 'PullList'>;
27};
28
29const STATE_LABELS: PullState[] = ['open', 'merged', 'closed'];
30
31export function PullListScreen({ navigation, route }: Props) {
32 const { owner, repo } = route.params;
33 const [stateFilter, setStateFilter] = useState<PullState>('open');
34 const [refreshing, setRefreshing] = useState(false);
35 const { pulls, loading, error, refresh } = usePulls(owner, repo, stateFilter);
36
37 async function onRefresh() {
38 setRefreshing(true);
39 await refresh();
40 setRefreshing(false);
41 }
42
43 if (loading && !refreshing && pulls.length === 0) {
44 return <LoadingSpinner fullScreen />;
45 }
46
47 if (error && pulls.length === 0) {
48 return <ErrorState message={error} onRetry={refresh} />;
49 }
50
51 return (
52 <SafeAreaView style={styles.safe} edges={['top']}>
53 <View style={styles.filterBar}>
54 {STATE_LABELS.map((s) => (
55 <TouchableOpacity
56 key={s}
57 style={[styles.filterBtn, stateFilter === s && styles.filterBtnActive]}
58 onPress={() => setStateFilter(s)}
59 activeOpacity={0.75}
60 >
61 <Text style={[styles.filterText, stateFilter === s && styles.filterTextActive]}>
62 {s.charAt(0).toUpperCase() + s.slice(1)}
63 </Text>
64 </TouchableOpacity>
65 ))}
66 <Text style={styles.repoBadge}>{owner}/{repo}</Text>
67 </View>
68
69 <FlatList
70 data={pulls}
71 keyExtractor={(item) => item.id}
72 renderItem={({ item }) => (
73 <PullRow
74 pull={item}
75 onPress={() =>
76 navigation.navigate('PullDetail', {
77 owner,
78 repo,
79 number: item.number,
80 })
81 }
82 />
83 )}
84 refreshControl={
85 <RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={colors.accent} />
86 }
87 ListEmptyComponent={
88 <EmptyState
89 title={`No ${stateFilter} pull requests`}
90 subtitle="Pull requests will appear here when created"
91 icon="⑂"
92 />
93 }
94 />
95 </SafeAreaView>
96 );
97}
98
99const styles = StyleSheet.create({
100 safe: { flex: 1, backgroundColor: colors.bg },
101 filterBar: {
102 flexDirection: 'row',
103 alignItems: 'center',
104 padding: 12,
105 gap: 6,
106 borderBottomWidth: 1,
107 borderBottomColor: colors.border,
108 flexWrap: 'wrap',
109 },
110 filterBtn: {
111 paddingHorizontal: 12,
112 paddingVertical: 6,
113 borderRadius: 20,
114 borderWidth: 1,
115 borderColor: 'transparent',
116 },
117 filterBtnActive: {
118 borderColor: colors.border,
119 backgroundColor: colors.bgSurface,
120 },
121 filterText: {
122 color: colors.textMuted,
123 fontSize: fontSizes.sm,
124 fontWeight: fontWeights.medium,
125 },
126 filterTextActive: {
127 color: colors.text,
128 },
129 repoBadge: {
130 marginLeft: 'auto',
131 color: colors.textMuted,
132 fontSize: fontSizes.xs,
133 },
134});
Addedmobile/src/screens/RepoDetailScreen.tsx+323−0View fileUnifiedSplit
1import React, { useState } from 'react';
2import {
3 View,
4 Text,
5 ScrollView,
6 StyleSheet,
7 TouchableOpacity,
8 FlatList,
9} from 'react-native';
10import { SafeAreaView } from 'react-native-safe-area-context';
11import { type NativeStackNavigationProp } from '@react-navigation/native-stack';
12import { type RouteProp } from '@react-navigation/native';
13import { colors } from '../theme/colors';
14import { fontSizes, fontWeights, fonts } from '../theme/typography';
15import { useRepo, useFileTree, useCommits } from '../hooks/useRepo';
16import { useIssues } from '../hooks/useIssues';
17import { usePulls } from '../hooks/usePulls';
18import { LoadingSpinner } from '../components/LoadingSpinner';
19import { ErrorState } from '../components/ErrorState';
20import { CommitRow } from '../components/CommitRow';
21import { IssueRow } from '../components/IssueRow';
22import { PullRow } from '../components/PullRow';
23import { type MainStackParamList } from '../navigation/types';
24
25type Props = {
26 navigation: NativeStackNavigationProp<MainStackParamList, 'RepoDetail'>;
27 route: RouteProp<MainStackParamList, 'RepoDetail'>;
28};
29
30type Tab = 'Code' | 'Issues' | 'PRs' | 'Commits';
31
32const TABS: Tab[] = ['Code', 'Issues', 'PRs', 'Commits'];
33
34export function RepoDetailScreen({ navigation, route }: Props) {
35 const { owner, repo: repoName } = route.params;
36 const [activeTab, setActiveTab] = useState<Tab>('Code');
37
38 const { repo, loading: repoLoading, error: repoError } = useRepo(owner, repoName);
39 const { tree, loading: treeLoading } = useFileTree(owner, repoName, repo?.defaultBranch || 'HEAD');
40 const { commits, loading: commitsLoading } = useCommits(owner, repoName);
41 const { issues, loading: issuesLoading } = useIssues(owner, repoName, 'open');
42 const { pulls, loading: pullsLoading } = usePulls(owner, repoName, 'open');
43
44 if (repoLoading) return <LoadingSpinner fullScreen />;
45 if (repoError || !repo) return <ErrorState message={repoError || 'Repo not found'} />;
46
47 const sorted = [...tree].sort((a, b) => {
48 if (a.type === 'tree' && b.type !== 'tree') return -1;
49 if (a.type !== 'tree' && b.type === 'tree') return 1;
50 return a.name.localeCompare(b.name);
51 });
52
53 return (
54 <SafeAreaView style={styles.safe} edges={['top']}>
55 <ScrollView stickyHeaderIndices={[1]}>
56 {/* Repo header */}
57 <View style={styles.header}>
58 <Text style={styles.repoName}>
59 <Text style={styles.owner}>{owner}/</Text>
60 {repo.name}
61 </Text>
62 {repo.description ? (
63 <Text style={styles.desc}>{repo.description}</Text>
64 ) : null}
65
66 <View style={styles.statsRow}>
67 <View style={styles.stat}>
68 <Text style={styles.statIcon}></Text>
69 <Text style={styles.statVal}>{repo.starCount}</Text>
70 </View>
71 <View style={styles.stat}>
72 <Text style={styles.statIcon}></Text>
73 <Text style={styles.statVal}>{repo.forkCount}</Text>
74 </View>
75 <View style={styles.stat}>
76 <Text style={styles.statIcon}></Text>
77 <Text style={styles.statVal}>{repo.issueCount} issues</Text>
78 </View>
79 <View style={styles.branchBadge}>
80 <Text style={styles.branchText}>{repo.defaultBranch}</Text>
81 </View>
82 </View>
83 </View>
84
85 {/* Tab bar — sticky */}
86 <View style={styles.tabBar}>
87 {TABS.map((tab) => (
88 <TouchableOpacity
89 key={tab}
90 style={[styles.tab, activeTab === tab && styles.tabActive]}
91 onPress={() => setActiveTab(tab)}
92 activeOpacity={0.75}
93 >
94 <Text style={[styles.tabText, activeTab === tab && styles.tabTextActive]}>
95 {tab}
96 </Text>
97 </TouchableOpacity>
98 ))}
99 </View>
100
101 {/* Code tab */}
102 {activeTab === 'Code' && (
103 <View style={styles.fileList}>
104 {treeLoading ? (
105 <LoadingSpinner size="small" />
106 ) : sorted.length === 0 ? (
107 <Text style={styles.emptyText}>Empty repository</Text>
108 ) : (
109 sorted.map((entry) => (
110 <TouchableOpacity
111 key={entry.path}
112 style={styles.fileRow}
113 activeOpacity={0.75}
114 onPress={() => {
115 if (entry.type === 'blob') {
116 navigation.navigate('FileViewer', {
117 owner,
118 repo: repoName,
119 path: entry.path,
120 ref: repo.defaultBranch,
121 });
122 }
123 }}
124 >
125 <Text style={styles.fileIcon}>{entry.type === 'tree' ? '📁' : '📄'}</Text>
126 <Text style={styles.fileName}>{entry.name}</Text>
127 {entry.size !== undefined && entry.type === 'blob' && (
128 <Text style={styles.fileSize}>{formatSize(entry.size)}</Text>
129 )}
130 </TouchableOpacity>
131 ))
132 )}
133 </View>
134 )}
135
136 {/* Issues tab */}
137 {activeTab === 'Issues' && (
138 <View>
139 {issuesLoading ? (
140 <LoadingSpinner size="small" />
141 ) : issues.length === 0 ? (
142 <Text style={styles.emptyText}>No open issues</Text>
143 ) : (
144 issues.map((issue) => (
145 <IssueRow
146 key={issue.id}
147 issue={issue}
148 onPress={() =>
149 navigation.navigate('IssueDetail', {
150 owner,
151 repo: repoName,
152 number: issue.number,
153 })
154 }
155 />
156 ))
157 )}
158 </View>
159 )}
160
161 {/* PRs tab */}
162 {activeTab === 'PRs' && (
163 <View>
164 {pullsLoading ? (
165 <LoadingSpinner size="small" />
166 ) : pulls.length === 0 ? (
167 <Text style={styles.emptyText}>No open pull requests</Text>
168 ) : (
169 pulls.map((pull) => (
170 <PullRow
171 key={pull.id}
172 pull={pull}
173 onPress={() =>
174 navigation.navigate('PullDetail', {
175 owner,
176 repo: repoName,
177 number: pull.number,
178 })
179 }
180 />
181 ))
182 )}
183 </View>
184 )}
185
186 {/* Commits tab */}
187 {activeTab === 'Commits' && (
188 <View>
189 {commitsLoading ? (
190 <LoadingSpinner size="small" />
191 ) : commits.length === 0 ? (
192 <Text style={styles.emptyText}>No commits yet</Text>
193 ) : (
194 commits.map((commit) => <CommitRow key={commit.sha} commit={commit} />)
195 )}
196 </View>
197 )}
198 </ScrollView>
199 </SafeAreaView>
200 );
201}
202
203function formatSize(bytes: number): string {
204 if (bytes < 1024) return `${bytes} B`;
205 if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
206 return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
207}
208
209const styles = StyleSheet.create({
210 safe: {
211 flex: 1,
212 backgroundColor: colors.bg,
213 },
214 header: {
215 padding: 16,
216 borderBottomWidth: 1,
217 borderBottomColor: colors.border,
218 },
219 repoName: {
220 fontSize: fontSizes.xl,
221 fontWeight: fontWeights.bold,
222 color: colors.text,
223 marginBottom: 6,
224 },
225 owner: {
226 color: colors.textMuted,
227 fontWeight: fontWeights.regular,
228 },
229 desc: {
230 color: colors.textMuted,
231 fontSize: fontSizes.sm,
232 lineHeight: 18,
233 marginBottom: 12,
234 },
235 statsRow: {
236 flexDirection: 'row',
237 alignItems: 'center',
238 gap: 12,
239 flexWrap: 'wrap',
240 },
241 stat: {
242 flexDirection: 'row',
243 alignItems: 'center',
244 gap: 4,
245 },
246 statIcon: {
247 color: colors.textMuted,
248 fontSize: fontSizes.sm,
249 },
250 statVal: {
251 color: colors.textMuted,
252 fontSize: fontSizes.sm,
253 },
254 branchBadge: {
255 backgroundColor: colors.bgSecondary,
256 borderRadius: 12,
257 paddingHorizontal: 8,
258 paddingVertical: 3,
259 borderWidth: 1,
260 borderColor: colors.border,
261 },
262 branchText: {
263 color: colors.textMuted,
264 fontSize: fontSizes.xs,
265 fontFamily: fonts.mono,
266 },
267 tabBar: {
268 flexDirection: 'row',
269 backgroundColor: colors.bg,
270 borderBottomWidth: 1,
271 borderBottomColor: colors.border,
272 },
273 tab: {
274 flex: 1,
275 paddingVertical: 12,
276 alignItems: 'center',
277 },
278 tabActive: {
279 borderBottomWidth: 2,
280 borderBottomColor: colors.accent,
281 },
282 tabText: {
283 color: colors.textMuted,
284 fontSize: fontSizes.sm,
285 fontWeight: fontWeights.medium,
286 },
287 tabTextActive: {
288 color: colors.accent,
289 },
290 fileList: {
291 padding: 4,
292 },
293 fileRow: {
294 flexDirection: 'row',
295 alignItems: 'center',
296 paddingHorizontal: 16,
297 paddingVertical: 10,
298 borderBottomWidth: 1,
299 borderBottomColor: colors.border,
300 gap: 10,
301 },
302 fileIcon: {
303 fontSize: 16,
304 width: 20,
305 textAlign: 'center',
306 },
307 fileName: {
308 flex: 1,
309 color: colors.text,
310 fontSize: fontSizes.sm,
311 fontFamily: fonts.mono,
312 },
313 fileSize: {
314 color: colors.textMuted,
315 fontSize: fontSizes.xs,
316 },
317 emptyText: {
318 color: colors.textMuted,
319 fontSize: fontSizes.sm,
320 textAlign: 'center',
321 padding: 32,
322 },
323});
Addedmobile/src/screens/RepoListScreen.tsx+146−0View fileUnifiedSplit
1import React, { useContext, useState } from 'react';
2import {
3 View,
4 Text,
5 FlatList,
6 StyleSheet,
7 RefreshControl,
8 TextInput,
9} from 'react-native';
10import { SafeAreaView } from 'react-native-safe-area-context';
11import { type NativeStackNavigationProp } from '@react-navigation/native-stack';
12import { colors } from '../theme/colors';
13import { fontSizes, fontWeights } from '../theme/typography';
14import { AuthContext } from '../navigation/AuthContext';
15import { useUserRepos } from '../hooks/useRepo';
16import { RepoCard } from '../components/RepoCard';
17import { LoadingSpinner } from '../components/LoadingSpinner';
18import { ErrorState } from '../components/ErrorState';
19import { EmptyState } from '../components/EmptyState';
20import { type MainStackParamList } from '../navigation/types';
21
22interface Props {
23 navigation: NativeStackNavigationProp<MainStackParamList>;
24}
25
26export function RepoListScreen({ navigation }: Props) {
27 const { user } = useContext(AuthContext);
28 const { repos, loading, error, refresh } = useUserRepos(user?.username ?? null);
29 const [refreshing, setRefreshing] = useState(false);
30 const [query, setQuery] = useState('');
31
32 async function onRefresh() {
33 setRefreshing(true);
34 await refresh();
35 setRefreshing(false);
36 }
37
38 const filtered = query.trim()
39 ? repos.filter(
40 (r) =>
41 r.name.toLowerCase().includes(query.toLowerCase()) ||
42 (r.description && r.description.toLowerCase().includes(query.toLowerCase()))
43 )
44 : repos;
45
46 if (loading && !refreshing && repos.length === 0) {
47 return <LoadingSpinner fullScreen />;
48 }
49
50 if (error && repos.length === 0) {
51 return <ErrorState message={error} onRetry={refresh} />;
52 }
53
54 return (
55 <SafeAreaView style={styles.safe} edges={['top']}>
56 <View style={styles.header}>
57 <Text style={styles.title}>Repositories</Text>
58 <Text style={styles.count}>{repos.length} repos</Text>
59 </View>
60
61 <View style={styles.searchWrap}>
62 <TextInput
63 style={styles.search}
64 value={query}
65 onChangeText={setQuery}
66 placeholder="Search repositories..."
67 placeholderTextColor={colors.textMuted}
68 autoCapitalize="none"
69 autoCorrect={false}
70 clearButtonMode="while-editing"
71 />
72 </View>
73
74 <FlatList
75 data={filtered}
76 keyExtractor={(item) => item.id}
77 contentContainerStyle={styles.list}
78 renderItem={({ item }) => (
79 <RepoCard
80 repo={item}
81 onPress={() =>
82 navigation.navigate('RepoDetail', {
83 owner: user?.username ?? '',
84 repo: item.name,
85 })
86 }
87 />
88 )}
89 refreshControl={
90 <RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={colors.accent} />
91 }
92 ListEmptyComponent={
93 <EmptyState
94 title={query ? 'No matching repos' : 'No repositories'}
95 subtitle={query ? 'Try a different search term' : 'Create your first repo on gluecron.com'}
96 icon="⌥"
97 />
98 }
99 />
100 </SafeAreaView>
101 );
102}
103
104const styles = StyleSheet.create({
105 safe: {
106 flex: 1,
107 backgroundColor: colors.bg,
108 },
109 header: {
110 flexDirection: 'row',
111 justifyContent: 'space-between',
112 alignItems: 'center',
113 paddingHorizontal: 16,
114 paddingTop: 16,
115 paddingBottom: 8,
116 },
117 title: {
118 color: colors.text,
119 fontSize: fontSizes.xl,
120 fontWeight: fontWeights.bold,
121 },
122 count: {
123 color: colors.textMuted,
124 fontSize: fontSizes.sm,
125 },
126 searchWrap: {
127 paddingHorizontal: 16,
128 paddingBottom: 12,
129 },
130 search: {
131 backgroundColor: colors.bgSurface,
132 borderWidth: 1,
133 borderColor: colors.border,
134 borderRadius: 8,
135 paddingHorizontal: 12,
136 paddingVertical: 10,
137 color: colors.text,
138 fontSize: fontSizes.base,
139 minHeight: 40,
140 },
141 list: {
142 padding: 16,
143 paddingTop: 4,
144 paddingBottom: 32,
145 },
146});
Addedmobile/src/screens/SettingsScreen.tsx+248−0View fileUnifiedSplit
1import React, { useContext, useState } from 'react';
2import {
3 View,
4 Text,
5 StyleSheet,
6 TouchableOpacity,
7 ScrollView,
8 Alert,
9 TextInput,
10} from 'react-native';
11import { SafeAreaView } from 'react-native-safe-area-context';
12import { colors } from '../theme/colors';
13import { fontSizes, fontWeights } from '../theme/typography';
14import { AuthContext } from '../navigation/AuthContext';
15import { getBaseUrl, setBaseUrl } from '../api/client';
16import { saveHost } from '../store/auth';
17
18const APP_VERSION = '1.0.0';
19
20export function SettingsScreen() {
21 const { user, logout } = useContext(AuthContext);
22 const [host, setHostState] = useState(getBaseUrl());
23 const [editingHost, setEditingHost] = useState(false);
24
25 async function handleLogout() {
26 Alert.alert(
27 'Sign out',
28 'Are you sure you want to sign out?',
29 [
30 { text: 'Cancel', style: 'cancel' },
31 {
32 text: 'Sign out',
33 style: 'destructive',
34 onPress: async () => {
35 await logout();
36 },
37 },
38 ]
39 );
40 }
41
42 async function handleSaveHost() {
43 const trimmed = host.trim().replace(/\/$/, '');
44 if (!trimmed) return;
45 setBaseUrl(trimmed);
46 await saveHost(trimmed);
47 setEditingHost(false);
48 }
49
50 return (
51 <SafeAreaView style={styles.safe} edges={['top']}>
52 <ScrollView contentContainerStyle={styles.content}>
53 {/* Title */}
54 <Text style={styles.pageTitle}>Settings</Text>
55
56 {/* User profile section */}
57 <View style={styles.section}>
58 <Text style={styles.sectionLabel}>Account</Text>
59 <View style={styles.card}>
60 <View style={styles.avatarRow}>
61 <View style={styles.avatar}>
62 <Text style={styles.avatarText}>
63 {(user?.displayName || user?.username || '?')[0].toUpperCase()}
64 </Text>
65 </View>
66 <View style={styles.userInfo}>
67 <Text style={styles.displayName}>{user?.displayName || user?.username}</Text>
68 <Text style={styles.username}>@{user?.username}</Text>
69 {user?.email && <Text style={styles.email}>{user.email}</Text>}
70 </View>
71 </View>
72 {user?.bio ? (
73 <Text style={styles.bio}>{user.bio}</Text>
74 ) : null}
75 </View>
76 </View>
77
78 {/* Host section */}
79 <View style={styles.section}>
80 <Text style={styles.sectionLabel}>Gluecron Host</Text>
81 <View style={styles.card}>
82 <View style={styles.hostRow}>
83 <Text style={styles.hostLabel}>Server URL</Text>
84 {!editingHost ? (
85 <View style={styles.hostValueRow}>
86 <Text style={styles.hostValue} numberOfLines={1}>{host}</Text>
87 <TouchableOpacity
88 onPress={() => setEditingHost(true)}
89 style={styles.editBtn}
90 activeOpacity={0.75}
91 >
92 <Text style={styles.editBtnText}>Edit</Text>
93 </TouchableOpacity>
94 </View>
95 ) : (
96 <View style={styles.hostEditRow}>
97 <TextInput
98 style={styles.hostInput}
99 value={host}
100 onChangeText={setHostState}
101 autoCapitalize="none"
102 autoCorrect={false}
103 keyboardType="url"
104 placeholder="https://gluecron.com"
105 placeholderTextColor={colors.textMuted}
106 returnKeyType="done"
107 onSubmitEditing={handleSaveHost}
108 />
109 <TouchableOpacity onPress={handleSaveHost} style={styles.saveBtn} activeOpacity={0.75}>
110 <Text style={styles.saveBtnText}>Save</Text>
111 </TouchableOpacity>
112 <TouchableOpacity
113 onPress={() => { setHostState(getBaseUrl()); setEditingHost(false); }}
114 style={styles.cancelBtn}
115 activeOpacity={0.75}
116 >
117 <Text style={styles.cancelBtnText}>Cancel</Text>
118 </TouchableOpacity>
119 </View>
120 )}
121 </View>
122 <Text style={styles.hostHint}>
123 Change this if you run a self-hosted Gluecron instance.
124 </Text>
125 </View>
126 </View>
127
128 {/* App info */}
129 <View style={styles.section}>
130 <Text style={styles.sectionLabel}>About</Text>
131 <View style={styles.card}>
132 <View style={styles.infoRow}>
133 <Text style={styles.infoLabel}>App version</Text>
134 <Text style={styles.infoValue}>{APP_VERSION}</Text>
135 </View>
136 <View style={styles.divider} />
137 <View style={styles.infoRow}>
138 <Text style={styles.infoLabel}>Platform</Text>
139 <Text style={styles.infoValue}>Gluecron Mobile</Text>
140 </View>
141 </View>
142 </View>
143
144 {/* Logout */}
145 <View style={styles.section}>
146 <TouchableOpacity style={styles.logoutBtn} onPress={handleLogout} activeOpacity={0.8}>
147 <Text style={styles.logoutText}>Sign out</Text>
148 </TouchableOpacity>
149 </View>
150 </ScrollView>
151 </SafeAreaView>
152 );
153}
154
155const styles = StyleSheet.create({
156 safe: { flex: 1, backgroundColor: colors.bg },
157 content: { padding: 16, paddingBottom: 40 },
158 pageTitle: {
159 color: colors.text,
160 fontSize: fontSizes.xl,
161 fontWeight: fontWeights.bold,
162 marginBottom: 20,
163 },
164 section: { marginBottom: 24 },
165 sectionLabel: {
166 color: colors.textMuted,
167 fontSize: fontSizes.xs,
168 fontWeight: fontWeights.semibold,
169 textTransform: 'uppercase',
170 letterSpacing: 0.8,
171 marginBottom: 8,
172 },
173 card: {
174 backgroundColor: colors.bgSurface,
175 borderRadius: 12,
176 padding: 16,
177 borderWidth: 1,
178 borderColor: colors.border,
179 },
180 avatarRow: {
181 flexDirection: 'row',
182 alignItems: 'center',
183 gap: 14,
184 marginBottom: 8,
185 },
186 avatar: {
187 width: 52,
188 height: 52,
189 borderRadius: 26,
190 backgroundColor: colors.accentDim,
191 alignItems: 'center',
192 justifyContent: 'center',
193 borderWidth: 2,
194 borderColor: colors.accent,
195 },
196 avatarText: {
197 color: colors.accent,
198 fontSize: fontSizes.xl,
199 fontWeight: fontWeights.bold,
200 },
201 userInfo: { flex: 1 },
202 displayName: {
203 color: colors.text,
204 fontSize: fontSizes.md,
205 fontWeight: fontWeights.semibold,
206 marginBottom: 2,
207 },
208 username: { color: colors.textMuted, fontSize: fontSizes.sm, marginBottom: 2 },
209 email: { color: colors.textMuted, fontSize: fontSizes.xs },
210 bio: { color: colors.textMuted, fontSize: fontSizes.sm, lineHeight: 18, marginTop: 4 },
211 hostRow: { marginBottom: 8 },
212 hostLabel: { color: colors.textMuted, fontSize: fontSizes.xs, fontWeight: fontWeights.medium, marginBottom: 6, textTransform: 'uppercase' },
213 hostValueRow: { flexDirection: 'row', alignItems: 'center', gap: 10 },
214 hostValue: { color: colors.text, fontSize: fontSizes.sm, flex: 1 },
215 editBtn: { paddingHorizontal: 10, paddingVertical: 5, backgroundColor: colors.bgSecondary, borderRadius: 6 },
216 editBtnText: { color: colors.accent, fontSize: fontSizes.xs, fontWeight: fontWeights.medium },
217 hostEditRow: { flexDirection: 'row', alignItems: 'center', gap: 8, flexWrap: 'wrap' },
218 hostInput: {
219 flex: 1,
220 backgroundColor: colors.bgSecondary,
221 borderWidth: 1,
222 borderColor: colors.border,
223 borderRadius: 6,
224 paddingHorizontal: 10,
225 paddingVertical: 8,
226 color: colors.text,
227 fontSize: fontSizes.sm,
228 minHeight: 36,
229 },
230 saveBtn: { paddingHorizontal: 12, paddingVertical: 8, backgroundColor: colors.accent, borderRadius: 6 },
231 saveBtnText: { color: colors.white, fontSize: fontSizes.sm, fontWeight: fontWeights.medium },
232 cancelBtn: { paddingHorizontal: 10, paddingVertical: 8 },
233 cancelBtnText: { color: colors.textMuted, fontSize: fontSizes.sm },
234 hostHint: { color: colors.textMuted, fontSize: fontSizes.xs, lineHeight: 16 },
235 infoRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingVertical: 4 },
236 infoLabel: { color: colors.textMuted, fontSize: fontSizes.sm },
237 infoValue: { color: colors.text, fontSize: fontSizes.sm, fontWeight: fontWeights.medium },
238 divider: { height: 1, backgroundColor: colors.border, marginVertical: 8 },
239 logoutBtn: {
240 backgroundColor: colors.red + '22',
241 borderWidth: 1,
242 borderColor: colors.red + '55',
243 borderRadius: 10,
244 paddingVertical: 14,
245 alignItems: 'center',
246 },
247 logoutText: { color: colors.red, fontSize: fontSizes.base, fontWeight: fontWeights.semibold },
248});
Addedmobile/src/store/auth.ts+58−0View fileUnifiedSplit
1import * as SecureStore from 'expo-secure-store';
2import { api, type User } from '../api/client';
3
4const TOKEN_KEY = 'gluecron_token';
5const USER_KEY = 'gluecron_user';
6const HOST_KEY = 'gluecron_host';
7
8export async function saveToken(token: string): Promise<void> {
9 await SecureStore.setItemAsync(TOKEN_KEY, token);
10 api.setToken(token);
11}
12
13export async function loadToken(): Promise<string | null> {
14 const token = await SecureStore.getItemAsync(TOKEN_KEY);
15 if (token) {
16 api.setToken(token);
17 }
18 return token;
19}
20
21export async function clearToken(): Promise<void> {
22 await SecureStore.deleteItemAsync(TOKEN_KEY);
23 api.clearToken();
24}
25
26export async function saveUser(user: User): Promise<void> {
27 await SecureStore.setItemAsync(USER_KEY, JSON.stringify(user));
28}
29
30export async function loadUser(): Promise<User | null> {
31 const raw = await SecureStore.getItemAsync(USER_KEY);
32 if (!raw) return null;
33 try {
34 return JSON.parse(raw) as User;
35 } catch {
36 return null;
37 }
38}
39
40export async function clearUser(): Promise<void> {
41 await SecureStore.deleteItemAsync(USER_KEY);
42}
43
44export async function saveHost(host: string): Promise<void> {
45 await SecureStore.setItemAsync(HOST_KEY, host);
46}
47
48export async function loadHost(): Promise<string | null> {
49 return SecureStore.getItemAsync(HOST_KEY);
50}
51
52export async function clearAll(): Promise<void> {
53 await Promise.all([
54 SecureStore.deleteItemAsync(TOKEN_KEY),
55 SecureStore.deleteItemAsync(USER_KEY),
56 ]);
57 api.clearToken();
58}
Addedmobile/src/store/theme.ts+19−0View fileUnifiedSplit
1import { colors } from '../theme/colors';
2
3export type ThemeMode = 'dark' | 'light';
4
5// Currently only dark mode is supported — matches Gluecron's design.
6// Light mode scaffolding is here for future expansion.
7let _mode: ThemeMode = 'dark';
8
9export function getThemeMode(): ThemeMode {
10 return _mode;
11}
12
13export function setThemeMode(mode: ThemeMode): void {
14 _mode = mode;
15}
16
17export function getColors() {
18 return colors;
19}
Addedmobile/src/theme/colors.ts+16−0View fileUnifiedSplit
1export const colors = {
2 bg: '#08090f',
3 bgSecondary: '#0c0d14',
4 bgSurface: '#161826',
5 border: 'rgba(255,255,255,0.06)',
6 text: '#ededf2',
7 textMuted: '#8b8c9c',
8 accent: '#8c6dff',
9 accentDim: 'rgba(140,109,255,0.15)',
10 green: '#34d399',
11 red: '#f87171',
12 yellow: '#fbbf24',
13 blue: '#60a5fa',
14 white: '#ffffff',
15 black: '#000000',
16};
Addedmobile/src/theme/typography.ts+37−0View fileUnifiedSplit
1import { Platform } from 'react-native';
2
3export const fonts = {
4 mono: Platform.select({
5 ios: 'Menlo',
6 android: 'monospace',
7 default: 'monospace',
8 }),
9 sans: Platform.select({
10 ios: 'System',
11 android: 'Roboto',
12 default: 'System',
13 }),
14};
15
16export const fontSizes = {
17 xs: 11,
18 sm: 13,
19 base: 15,
20 md: 17,
21 lg: 20,
22 xl: 24,
23 xxl: 30,
24};
25
26export const fontWeights = {
27 regular: '400' as const,
28 medium: '500' as const,
29 semibold: '600' as const,
30 bold: '700' as const,
31};
32
33export const lineHeights = {
34 tight: 1.3,
35 normal: 1.5,
36 relaxed: 1.75,
37};
Addedmobile/tsconfig.json+10−0View fileUnifiedSplit
1{
2 "extends": "expo/tsconfig.base",
3 "compilerOptions": {
4 "strict": true,
5 "baseUrl": ".",
6 "paths": {
7 "@/*": ["src/*"]
8 }
9 }
10}
Modifiedsrc/app.tsx+30−0View fileUnifiedSplit
2929import agentsRoutes from "./routes/agents";
3030import agentPipelinesRoutes from "./routes/agent-pipelines";
3131import issueRoutes from "./routes/issues";
32import milestonesRoutes from "./routes/milestones";
33import shipAgentRoutes from "./routes/ship-agent";
3234import commentModerationRoutes from "./routes/comment-moderation";
3335import repoSettings from "./routes/repo-settings";
3436import collaboratorRoutes from "./routes/collaborators";
6365import publicStatsRoutes from "./routes/public-stats";
6466import demoRoutes from "./routes/demo";
6567import insightRoutes from "./routes/insights";
68import engineeringInsightsRoutes from "./routes/engineering-insights";
6669import doraRoutes from "./routes/dora";
6770import dashboardRoutes from "./routes/dashboard";
6871import legalRoutes from "./routes/legal";
7881import migrateRoutes from "./routes/migrate";
7982import specsRoutes from "./routes/specs";
8083import refactorRoutes from "./routes/refactors";
84import codebaseMigratorRoutes from "./routes/codebase-migrator";
8185import webRoutes from "./routes/web";
8286import hookRoutes from "./routes/hooks";
8387import eventsRoutes from "./routes/events";
161165import signingKeysRoutes from "./routes/signing-keys";
162166import sponsorsRoutes from "./routes/sponsors";
163167import ssoRoutes from "./routes/sso";
168import samlSsoRoutes from "./routes/saml-sso";
169import scimRoutes from "./routes/scim";
170import orgSsoSettingsRoutes from "./routes/org-sso-settings";
164171import githubOauthRoutes from "./routes/github-oauth";
165172import googleOauthRoutes from "./routes/google-oauth";
166173import symbolsRoutes from "./routes/symbols";
183190import pulseRoutes from "./routes/pulse";
184191import healthScoreRoutes from "./routes/health-score";
185192import hotFilesRoutes from "./routes/hot-files";
193import debtMapRoutes from "./routes/debt-map";
186194import developerProgramRoutes from "./routes/developer-program";
187195import shareRoutes from "./routes/share";
196import incidentHookRoutes from "./routes/incident-hooks";
188197import { authRateLimit, gitRateLimit, searchRateLimit } from "./middleware/rate-limit";
189198import { csrfToken, csrfProtect } from "./middleware/csrf";
190199import { noCache } from "./middleware/no-cache";
499508// Issue tracker
500509app.route("/", issueRoutes);
501510
511// Milestones — group issues + PRs toward a shared goal with due dates + progress tracking.
512// Mounted after issueRoutes so /:owner/:repo/issues/* paths win before the milestone patterns.
513app.route("/", milestonesRoutes);
514
515// Ship Agent — autonomous AI feature implementation
516app.route("/", shipAgentRoutes);
517
502518// Comment moderation queue — owner-only `/:owner/:repo/comments/pending`
503519// + per-row approve/reject/spam actions. Mounted before `pullRoutes` so
504520// the `/:owner/:repo/comments/*` paths resolve before the broader PR
590606// Insights (time-travel, dependencies, rollback)
591607app.route("/", insightRoutes);
592608
609// Engineering Intelligence Dashboard — /insights + /:owner/:repo/insights/engineering
610app.route("/", engineeringInsightsRoutes);
611
593612// DORA metrics page (/:owner/:repo/insights/dora)
594613app.route("/", doraRoutes);
595614
620639// Spec-to-PR (experimental AI-generated draft PRs)
621640app.route("/", specsRoutes);
622641app.route("/", refactorRoutes);
642app.route("/", codebaseMigratorRoutes);
623643
624644// Explore page
625645app.route("/", exploreRoutes);
714734app.route("/", healthScoreRoutes);
715735// Hot Files Heatmap — BLOCK M16 — /:owner/:repo/insights/hotfiles
716736app.route("/", hotFilesRoutes);
737// Incident Auto-Fix — /hooks/{pagerduty,datadog,opsgenie,incident} + /settings/incident-hooks
738app.route("/", incidentHookRoutes);
739// AI Technical Debt Map — /:owner/:repo/debt-map (visual debt graph + Claude analysis)
740app.route("/", debtMapRoutes);
717741// Hosted Claude tool-use loops — paste loop, get endpoint, billing meter.
718742// See src/routes/claude-deploy.tsx + src/lib/hosted-claude-loop.ts.
719743app.route("/", claudeDeployRoutes);
725749app.route("/", signingKeysRoutes);
726750app.route("/", sponsorsRoutes);
727751app.route("/", ssoRoutes);
752// Enterprise per-org SAML 2.0 + OIDC SSO routes
753app.route("/", samlSsoRoutes);
754// SCIM 2.0 user provisioning endpoints
755app.route("/", scimRoutes);
756// Per-org SSO + SCIM admin settings UI
757app.route("/", orgSsoSettingsRoutes);
728758app.route("/", githubOauthRoutes);
729759app.route("/", googleOauthRoutes);
730760app.route("/", symbolsRoutes);
Modifiedsrc/db/schema.ts+217−0View fileUnifiedSplit
236236 dataRegion: text("data_region").default("us").notNull(),
237237 previewBuildCommand: text("preview_build_command"),
238238 previewOutputDir: text("preview_output_dir").default("dist"),
239 // Migration 0077 — opt-in flag for the AI dependency auto-updater.
240 // When true, the autopilot dep-update-sweep task reads package.json,
241 // queries npm for patch/minor updates, applies them, runs GateTest,
242 // and auto-merges (pass) or opens a PR with an AI migration guide
243 // (fail). Default false — off by default because it touches branches.
244 depUpdaterEnabled: boolean("dep_updater_enabled").default(false).notNull(),
239245 },
240246 (table) => [
241247 // Partial: uniqueness only in the user namespace (org-owned rows exempt).
489495 (table) => [index("code_owners_repo").on(table.repositoryId)]
490496);
491497
498/**
499 * PR review requests — tracks reviewers auto-assigned via CODEOWNERS
500 * or manually requested. The UNIQUE constraint prevents duplicate requests.
501 * Migration 0077.
502 */
503export const prReviewRequests = pgTable(
504 "pr_review_requests",
505 {
506 id: uuid("id").primaryKey().defaultRandom(),
507 prId: uuid("pr_id")
508 .notNull()
509 .references(() => pullRequests.id, { onDelete: "cascade" }),
510 reviewerId: uuid("reviewer_id")
511 .notNull()
512 .references(() => users.id, { onDelete: "cascade" }),
513 requestedBy: uuid("requested_by").references(() => users.id),
514 createdAt: timestamp("created_at").defaultNow().notNull(),
515 },
516 (table) => [
517 index("pr_review_requests_pr").on(table.prId),
518 index("pr_review_requests_reviewer").on(table.reviewerId),
519 ]
520);
521
492522/**
493523 * Per-repo AI chat sessions — conversational repo assistant.
494524 */
618648 title: text("title").notNull(),
619649 body: text("body"),
620650 state: text("state").notNull().default("open"), // open, closed
651 // Migration 0077 — milestone grouping. Optional; ON DELETE SET NULL so
652 // deleting a milestone never removes the issue.
653 milestoneId: uuid("milestone_id").references(() => milestones.id, {
654 onDelete: "set null",
655 }),
621656 createdAt: timestamp("created_at").defaultNow().notNull(),
622657 updatedAt: timestamp("updated_at").defaultNow().notNull(),
623658 closedAt: timestamp("closed_at"),
625660 (table) => [
626661 index("issues_repo_state").on(table.repositoryId, table.state),
627662 index("issues_repo_number").on(table.repositoryId, table.number),
663 index("issues_milestone").on(table.milestoneId),
628664 ]
629665);
630666
9951031export type Reaction = typeof reactions.$inferSelect;
9961032export type PrReview = typeof prReviews.$inferSelect;
9971033export type CodeOwner = typeof codeOwners.$inferSelect;
1034export type PrReviewRequest = typeof prReviewRequests.$inferSelect;
9981035export type AiChat = typeof aiChats.$inferSelect;
9991036export type AuditLogEntry = typeof auditLog.$inferSelect;
10001037export type Deployment = typeof deployments.$inferSelect;
42544291
42554292export type PrPreview = typeof prPreviews.$inferSelect;
42564293export type NewPrPreview = typeof prPreviews.$inferInsert;
4294
4295// ----------------------------------------------------------------------------
4296// Migration 0092 — Enterprise SSO (SAML 2.0 + OIDC per-org) + SCIM
4297// ----------------------------------------------------------------------------
4298
4299/**
4300 * Per-org SSO configuration. One row per org. Supports both SAML 2.0 and OIDC.
4301 * Distinct from the site-wide `sso_config` table (Block I10 / OIDC-only).
4302 */
4303export const orgSsoConfigs = pgTable("org_sso_configs", {
4304 id: uuid("id").primaryKey().defaultRandom(),
4305 orgId: uuid("org_id")
4306 .notNull()
4307 .unique()
4308 .references(() => organizations.id, { onDelete: "cascade" }),
4309 provider: text("provider").notNull().default("saml"), // 'saml' | 'oidc'
4310 // SAML fields
4311 idpEntityId: text("idp_entity_id"),
4312 idpSsoUrl: text("idp_sso_url"),
4313 idpCertificate: text("idp_certificate"), // PEM cert from IdP
4314 spEntityId: text("sp_entity_id"), // our SP entity ID
4315 // OIDC fields
4316 oidcClientId: text("oidc_client_id"),
4317 oidcClientSecret: text("oidc_client_secret"),
4318 oidcDiscoveryUrl: text("oidc_discovery_url"),
4319 // Common
4320 domainHint: text("domain_hint"), // e.g. "acme.com" — auto-routes users from this domain
4321 attributeMapping: jsonb("attribute_mapping")
4322 .$type<Record<string, string>>()
4323 .default({ email: "email", name: "name", username: "preferred_username" }),
4324 enabled: boolean("enabled").notNull().default(false),
4325 createdAt: timestamp("created_at").defaultNow(),
4326 updatedAt: timestamp("updated_at").defaultNow(),
4327});
4328
4329/**
4330 * SCIM provisioning tokens. Hashed SHA-256 bearer tokens issued per-org.
4331 * Identity providers (Okta, Azure AD) use these to provision/deprovision users.
4332 */
4333export const scimTokens = pgTable("scim_tokens", {
4334 id: uuid("id").primaryKey().defaultRandom(),
4335 orgId: uuid("org_id")
4336 .notNull()
4337 .references(() => organizations.id, { onDelete: "cascade" }),
4338 tokenHash: text("token_hash").notNull().unique(), // SHA-256 of the token
4339 createdBy: uuid("created_by")
4340 .notNull()
4341 .references(() => users.id),
4342 createdAt: timestamp("created_at").defaultNow(),
4343 lastUsedAt: timestamp("last_used_at"),
4344});
4345
4346/**
4347 * Tracks active IdP-initiated SSO sessions per org. Allows per-org
4348 * SLO (Single Logout) and session audit.
4349 */
4350export const orgSsoSessions = pgTable(
4351 "org_sso_sessions",
4352
4353// Migration 0077 — Incident hook configs
4354// Maps a monitoring provider (PagerDuty / Datadog / Opsgenie / generic) to a
4355// specific repo. Inbound webhooks are validated against secret_hash (SHA-256
4356// of the user-chosen webhook secret passed in the ?secret= query param).
4357// ---------------------------------------------------------------------------
4358export const incidentHookConfigs = pgTable(
4359 "incident_hook_configs",
4360>>>>>>> 9953332 (feat: production incident auto-fix — PagerDuty/Datadog webhook → AI-generated fix PR)
4361
4362
4363>>>>>>> 3a845e4 (feat: enterprise SSO (SAML 2.0 + OIDC) and SCIM user provisioning)
4364 {
4365 id: uuid("id").primaryKey().defaultRandom(),
4366 userId: uuid("user_id")
4367 .notNull()
4368 .references(() => users.id, { onDelete: "cascade" }),
4369 orgId: uuid("org_id")
4370 .notNull()
4371 .references(() => organizations.id, { onDelete: "cascade" }),
4372 idpSessionId: text("idp_session_id"),
4373 createdAt: timestamp("created_at").defaultNow(),
4374 expiresAt: timestamp("expires_at").notNull(),
4375 },
4376 (table) => [
4377 index("org_sso_sessions_user").on(table.userId),
4378 index("org_sso_sessions_expires").on(table.expiresAt),
4379 ]
4380);
4381
4382export type OrgSsoConfig = typeof orgSsoConfigs.$inferSelect;
4383export type NewOrgSsoConfig = typeof orgSsoConfigs.$inferInsert;
4384export type ScimToken = typeof scimTokens.$inferSelect;
4385export type OrgSsoSession = typeof orgSsoSessions.$inferSelect;
4386
4387 repoId: uuid("repo_id")
4388 .notNull()
4389 .references(() => repositories.id, { onDelete: "cascade" }),
4390 /** 'pagerduty' | 'datadog' | 'opsgenie' | 'generic' */
4391 provider: text("provider").notNull(),
4392 /** SHA-256 hex of the user's plaintext webhook secret. */
4393 secretHash: text("secret_hash").notNull(),
4394 createdAt: timestamp("created_at").defaultNow(),
4395 },
4396 (table) => [
4397 uniqueIndex("incident_hook_configs_repo_provider").on(
4398 table.repoId,
4399 table.provider
4400 ),
4401 index("incident_hook_configs_user").on(table.userId),
4402 ]
4403);
4404
4405export type IncidentHookConfig = typeof incidentHookConfigs.$inferSelect;
4406export type NewIncidentHookConfig = typeof incidentHookConfigs.$inferInsert;
4407>>>>>>> 9953332 (feat: production incident auto-fix — PagerDuty/Datadog webhook → AI-generated fix PR)
4408
4409
4410>>>>>>> 3a845e4 (feat: enterprise SSO (SAML 2.0 + OIDC) and SCIM user provisioning)
4411
4412/**
4413 * Cloud deploy configurations — per-repo settings for push-triggered deploys
4414 * to Fly.io, Railway, Render, Vercel, Netlify, or a generic webhook URL.
4415 * Migration 0077.
4416 */
4417export const cloudDeployConfigs = pgTable(
4418 "cloud_deploy_configs",
4419 {
4420 id: uuid("id").primaryKey().defaultRandom(),
4421 repoId: uuid("repo_id")
4422 .notNull()
4423 .references(() => repositories.id, { onDelete: "cascade" }),
4424 // 'fly' | 'railway' | 'render' | 'vercel' | 'netlify' | 'webhook'
4425 provider: text("provider").notNull(),
4426 // Fly app name, Railway service ID, Render service ID, Vercel project ID, webhook URL
4427 providerAppId: text("provider_app_id").notNull(),
4428 // AES-256-GCM encrypted via SERVER_TARGETS_KEY
4429 apiTokenEncrypted: text("api_token_encrypted").notNull(),
4430 triggerBranch: text("trigger_branch").notNull().default("main"),
4431 enabled: boolean("enabled").notNull().default(true),
4432 createdAt: timestamp("created_at").defaultNow(),
4433 },
4434 (table) => [index("cloud_deploy_configs_repo").on(table.repoId)]
4435);
4436
4437/**
4438 * Cloud deployment runs — one row per triggered deployment attempt.
4439 * Status transitions: pending -> running -> success | failed | cancelled.
4440 * Migration 0077.
4441 */
4442export const cloudDeployments = pgTable(
4443 "cloud_deployments",
4444 {
4445 id: uuid("id").primaryKey().defaultRandom(),
4446 configId: uuid("config_id")
4447 .notNull()
4448 .references(() => cloudDeployConfigs.id, { onDelete: "cascade" }),
4449 repoId: uuid("repo_id")
4450 .notNull()
4451 .references(() => repositories.id, { onDelete: "cascade" }),
4452 commitSha: text("commit_sha").notNull(),
4453 // pending | running | success | failed | cancelled
4454 status: text("status").notNull().default("pending"),
4455 providerDeployId: text("provider_deploy_id"),
4456 logUrl: text("log_url"),
4457 deployUrl: text("deploy_url"),
4458 errorMessage: text("error_message"),
4459 startedAt: timestamp("started_at").defaultNow(),
4460 completedAt: timestamp("completed_at"),
4461 durationMs: integer("duration_ms"),
4462 },
4463 (table) => [
4464 index("cloud_deployments_repo").on(table.repoId, table.startedAt),
4465 index("cloud_deployments_config").on(table.configId, table.startedAt),
4466 ]
4467);
4468
4469export type CloudDeployConfig = typeof cloudDeployConfigs.$inferSelect;
4470export type NewCloudDeployConfig = typeof cloudDeployConfigs.$inferInsert;
4471export type CloudDeployment = typeof cloudDeployments.$inferSelect;
4472export type NewCloudDeployment = typeof cloudDeployments.$inferInsert;
4473>>>>>>> b11ffa9 (feat: multi-cloud deploy integration — push to main deploys to Fly/Railway/Render/Vercel)
Modifiedsrc/hooks/post-receive.ts+11−0View fileUnifiedSplit
3636 startDeployRow,
3737} from "../lib/server-target-store";
3838import { deployToTarget } from "../lib/server-targets";
39import { fireCloudDeploys } from "../lib/cloud-deploy";
3940
4041interface PushRef {
4142 oldSha: string;
213214 console.warn("[server-targets] dispatch error:", err)
214215 );
215216
217 // 5c. Cloud deploy integrations (migration 0077). Fire push-triggered
218 // deploys to Fly.io, Railway, Render, Vercel, Netlify, or a generic
219 // webhook for any cloud_deploy_configs rows matching the pushed branch.
220 // Fire-and-forget; all failures are swallowed so the push path is
221 // never blocked.
222 void fireCloudDeploys(owner, repo, refs).catch((err) =>
223 console.warn("[cloud-deploy] dispatch error:", err)
224 );
225
216226 // 6. BLOCK W — Self-host. When Gluecron.com itself receives a push to
217227 // main, fire the local deploy via scripts/self-deploy.sh. The script
218228 // forks into the background, so this call returns immediately (git
792802 fireDocDriftCheck,
793803 fireServerTargetDeploys,
794804 fireDependencyScan,
805 fireCloudDeploys,
795806};
Modifiedsrc/index.ts+6−0View fileUnifiedSplit
11import { mkdir } from "fs/promises";
22import app from "./app";
33import { config } from "./lib/config";
4import { presenceWebsocket } from "./routes/pulls";
45import { startWorker } from "./lib/workflow-runner";
56import { startWebhookDeliveryWorker } from "./lib/webhook-delivery";
67import { startAutopilot } from "./lib/autopilot";
121122export default {
122123 port: config.port,
123124 fetch: app.fetch,
125 // WebSocket handler for real-time PR presence (Figma-style diff cursors).
126 // Bun.serve forwards WS upgrade requests through the same fetch pipeline;
127 // the `upgradeWebSocket` middleware in pulls.tsx calls server.upgrade() and
128 // returns a null response. This `websocket` object handles the ws lifecycle.
129 websocket: presenceWebsocket,
124130};
Modifiedsrc/lib/ai-client.ts+2−2View fileUnifiedSplit
2222 return !!config.anthropicApiKey;
2323}
2424
25/** Default model for code understanding + review */
25/** Primary model for all AI features — code understanding, review, generation */
2626export const MODEL_SONNET = "claude-sonnet-4-6";
27/** Fast model for lightweight tasks (commit messages, titles) */
27/** Legacy constant — kept for backwards compatibility, do not use in new code */
2828export const MODEL_HAIKU = "claude-haiku-4-5-20251001";
2929
3030/**
Modifiedsrc/lib/ai-commit-message.ts+2−2View fileUnifiedSplit
273273 try {
274274 const client = getAnthropic();
275275 const message = await client.messages.create({
276 model: MODEL_HAIKU,
276 model: MODEL_SONNET,
277277 max_tokens: 512,
278278 messages: [
279279 {
286286 const { recordAiCost, extractUsage } = await import("./ai-cost-tracker");
287287 const usage = extractUsage(message);
288288 await recordAiCost({
289 model: MODEL_HAIKU,
289 model: MODEL_SONNET,
290290 inputTokens: usage.input,
291291 outputTokens: usage.output,
292292 category: "other",
Modifiedsrc/lib/ai-completion.ts+4−4View fileUnifiedSplit
77 * JetBrains) call on every keystroke.
88 *
99 * Design notes:
10 * - Uses Haiku because latency matters more than depth for inline suggestions.
10 * - Uses Sonnet 4.6 for quality inline suggestions.
1111 * - Input is clipped aggressively (8k chars before, 2k chars after) so a huge
1212 * file doesn't blow the token budget.
1313 * - Never throws. On any error (bad key, timeout, rate limit) we return an
120120 const key = cacheKey(prefix, suffix, language);
121121 const hit = cacheGet(key);
122122 if (hit !== undefined) {
123 return { completion: hit, model: MODEL_HAIKU, cached: true };
123 return { completion: hit, model: MODEL_SONNET, cached: true };
124124 }
125125
126126 try {
127127 const client = getAnthropic();
128128 const response = await client.messages.create({
129 model: MODEL_HAIKU,
129 model: MODEL_SONNET,
130130 max_tokens: maxTokens,
131131 system:
132132 "You are a code completion engine. Given a prefix and optional suffix, output ONLY the characters that should be inserted at the cursor. No explanations. No markdown fences. No commentary.",
145145 const raw = extractText(response);
146146 const completion = stripCodeFences(raw);
147147 cacheSet(key, completion);
148 return { completion, model: MODEL_HAIKU, cached: false };
148 return { completion, model: MODEL_SONNET, cached: false };
149149 } catch (err) {
150150 // Never throw — the editor should degrade silently. Log length only, not
151151 // the prefix itself, which can contain secrets.
Modifiedsrc/lib/ai-generators.ts+3−3View fileUnifiedSplit
1818 }
1919 const client = getAnthropic();
2020 const message = await client.messages.create({
21 model: MODEL_HAIKU,
21 model: MODEL_SONNET,
2222 max_tokens: 512,
2323 messages: [
2424 {
124124 if (!isAiAvailable()) return fallback;
125125 const client = getAnthropic();
126126 const message = await client.messages.create({
127 model: MODEL_HAIKU,
127 model: MODEL_SONNET,
128128 max_tokens: 512,
129129 messages: [
130130 {
200200 try {
201201 const client = getAnthropic();
202202 const message = await client.messages.create({
203 model: MODEL_HAIKU,
203 model: MODEL_SONNET,
204204 max_tokens: 512,
205205 messages: [
206206 {
Modifiedsrc/lib/autopilot.ts+33−0View fileUnifiedSplit
7171import { expireOldPreviews } from "./branch-previews";
7272import { getBotUserIdOrFallback } from "./bot-user";
7373import { runOnboardingDripTaskOnce } from "./onboarding-drip";
74import { runDepUpdateSweepOnce } from "./dep-updater-sweep";
7475
7576export interface AutopilotTaskResult {
7677 name: string;
158159 */
159160const DEV_ENV_IDLE_SWEEP_INTERVAL_MS = 5 * 60 * 1000;
160161let _lastDevEnvIdleSweepAt = 0;
162/**
163 * AI dependency auto-updater cadence (migration 0077). Once per day is
164 * plenty — the task itself caps at 10 repos and 2 candidates per repo,
165 * keeping npm registry traffic low. Skips when DEP_UPDATER_ENABLED env
166 * flag is not set to "1".
167 */
168const DEP_UPDATE_SWEEP_INTERVAL_MS = 24 * 60 * 60 * 1000;
169let _lastDepUpdateSweepAt = 0;
161170/**
162171 * Advancement scanner cadence. Designed to run weekly on Mondays at
163172 * 08:00 UTC. The task itself is the cheap gate (checks both day-of-week
662671 }
663672 },
664673 },
674 {
675 // AI dependency auto-updater (migration 0077). Once per day: scans
676 // up to 10 repos with depUpdaterEnabled=true, checks for patch/minor
677 // npm updates, applies them, runs GateTest, and either auto-merges
678 // (green) or opens a PR with an AI migration guide (red). Skips
679 // when DEP_UPDATER_ENABLED env flag is not set to "1".
680 name: "dep-update-sweep",
681 run: async () => {
682 if (process.env.DEP_UPDATER_ENABLED !== "1") return;
683 const now = Date.now();
684 if (now - _lastDepUpdateSweepAt < DEP_UPDATE_SWEEP_INTERVAL_MS) {
685 return;
686 }
687 _lastDepUpdateSweepAt = now;
688 try {
689 const summary = await runDepUpdateSweepOnce();
690 console.log(
691 `[autopilot] dep-update-sweep: repos=${summary.repos} runs=${summary.runs} merged=${summary.merged} prs=${summary.prs} skipped=${summary.skipped} errors=${summary.errors}`
692 );
693 } catch (err) {
694 console.error("[autopilot] dep-update-sweep: threw:", err);
695 }
696 },
697 },
665698 ];
666699}
667700
Addedsrc/lib/branch-rules.ts+98−0View fileUnifiedSplit
1/**
2 * Branch rules — higher-level wrapper around the existing `branch_protection`
3 * table + `pr_reviews` table. Provides `checkMergeEligible` for the merge
4 * route to call, and `getBranchRules` for the repo-settings UI.
5 *
6 * The underlying enforcement engine lives in `src/lib/branch-protection.ts`.
7 * This module exposes a simplified interface used by the route layer.
8 */
9
10import { eq } from "drizzle-orm";
11import { db } from "../db";
12import { branchProtection, prReviews } from "../db/schema";
13import {
14 matchProtection,
15 countHumanApprovals,
16} from "./branch-protection";
17
18export interface BranchRule {
19 id: string;
20 pattern: string; // branch name pattern (e.g. "main", "release/*")
21 requiredReviews: number; // 0 = no requirement
22 requireCodeownerReview: boolean;
23 dismissStaleReviews: boolean;
24}
25
26/**
27 * Return all branch-protection rules for a repository, mapped to the
28 * simplified BranchRule interface the UI and merge-check use.
29 */
30export async function getBranchRules(repoId: string): Promise<BranchRule[]> {
31 try {
32 const rows = await db
33 .select()
34 .from(branchProtection)
35 .where(eq(branchProtection.repositoryId, repoId));
36
37 return rows.map((r) => ({
38 id: r.id,
39 pattern: r.pattern,
40 requiredReviews: r.requiredApprovals,
41 requireCodeownerReview: r.requireHumanReview, // closest semantic match
42 dismissStaleReviews: r.dismissStaleReviews,
43 }));
44 } catch {
45 return [];
46 }
47}
48
49export interface MergeEligibleResult {
50 eligible: boolean;
51 reason?: string;
52 approvalCount: number;
53 requiredCount: number;
54}
55
56/**
57 * Check whether a PR is eligible to be merged, based on the branch-protection
58 * rules for `targetBranch`. This supplements (does not replace) the full gate
59 * check in the merge route — it specifically handles the human-review count
60 * requirement coming from `branch_protection.required_approvals`.
61 *
62 * Returns `eligible: true` when:
63 * - No rule matches the target branch, OR
64 * - The rule's `requiredApprovals` threshold is satisfied.
65 */
66export async function checkMergeEligible(
67 prId: string,
68 repoId: string,
69 targetBranch: string
70): Promise<MergeEligibleResult> {
71 try {
72 const rule = await matchProtection(repoId, targetBranch);
73
74 if (!rule || rule.requiredApprovals === 0) {
75 // Count anyway for informational display
76 const approvalCount = await countHumanApprovals(prId);
77 return { eligible: true, approvalCount, requiredCount: 0 };
78 }
79
80 const approvalCount = await countHumanApprovals(prId);
81 const requiredCount = rule.requiredApprovals;
82
83 if (approvalCount < requiredCount) {
84 return {
85 eligible: false,
86 reason: `This PR requires ${requiredCount} approval${requiredCount !== 1 ? "s" : ""} before merging. Currently: ${approvalCount} approval${approvalCount !== 1 ? "s" : ""}.`,
87 approvalCount,
88 requiredCount,
89 };
90 }
91
92 return { eligible: true, approvalCount, requiredCount };
93 } catch {
94 // On error, allow the merge to proceed — the full gate check is the
95 // primary enforcement mechanism.
96 return { eligible: true, approvalCount: 0, requiredCount: 0 };
97 }
98}
Addedsrc/lib/changelog-generator.ts+65−0View fileUnifiedSplit
1/**
2 * changelog-generator.ts — AI-powered changelog generator for releases.
3 *
4 * Public entrypoint used by the release form and external automation.
5 * Thin wrapper over `ai-release-notes.ts` that exposes the canonical
6 * `generateChangelog(owner, repo, fromTag, toRef, repoId)` signature.
7 *
8 * Pipeline:
9 * 1. Walk `git log fromTag..toRef --no-merges` for raw commits.
10 * 2. Cross-reference commits with the `pull_requests` table to enrich
11 * each entry with PR metadata (labels, author, body excerpt).
12 * 3. Bucket commits/PRs by conventional-commit prefix or label into
13 * categories: features, fixes, perf, docs, security, ai_changes, other.
14 * 4. If `ANTHROPIC_API_KEY` is set, ask Claude (claude-sonnet-4-6) to write
15 * a polished, human-readable Markdown changelog from the grouped input.
16 * 5. Fall back to a deterministic grouped list when the key is absent.
17 *
18 * Output format (AI path):
19 *
20 * ## v1.3.0 (since v1.2.0)
21 * **Short release tagline**
22 *
23 * One-to-three sentence summary of what changed and why it matters.
24 *
25 * ### Features
26 * - Add PR preview environments (#42) — @alice
27 *
28 * ### Bug fixes
29 * - Fix race condition in SSE reconnection (#37) — @bob
30 *
31 * _Full changelog_: `v1.2.0...v1.3.0`
32 */
33
34import { generateReleaseNotes } from "./ai-release-notes";
35
36/**
37 * Generate a human-readable Markdown changelog for the commit range
38 * `fromTag..toRef` on `owner/repo`.
39 *
40 * @param owner — repository owner username
41 * @param repo — repository name
42 * @param fromTag — previous tag/ref (e.g. "v1.2.0"); pass empty string for
43 * "all commits reachable from toRef"
44 * @param toRef — target tag/ref (e.g. "HEAD" or "v1.3.0")
45 * @param repoId — database UUID of the repository (for PR cross-reference)
46 * @returns — Markdown string ready to store in `releases.body`
47 */
48export async function generateChangelog(
49 owner: string,
50 repo: string,
51 fromTag: string,
52 toRef: string,
53 repoId: string
54): Promise<string> {
55 const result = await generateReleaseNotes({
56 repositoryId: repoId,
57 fromTag: fromTag.trim() || null,
58 toTag: toRef.trim() || "HEAD",
59 });
60 return result.markdown;
61}
62
63// Re-export helpers that callers may want for testing / introspection.
64export type { ReleaseNotesResult, ReleaseSections, ReleaseSectionKey } from "./ai-release-notes";
65export { classifyPr, bucketPrs, renderSectionsToMarkdown, isSemverTag } from "./ai-release-notes";
Addedsrc/lib/claude-semantic-search.ts+538−0View fileUnifiedSplit
1/**
2 * Claude-API-based semantic code search.
3 *
4 * Unlike the embedding-based search in semantic-search.ts (which requires
5 * Voyage AI + a pre-built index), this approach uses Claude to understand
6 * the query and rank files in natural language — no vector DB, no prior
7 * indexing. Works immediately on any repo.
8 *
9 * Algorithm:
10 * 1. List all files via `git ls-tree -r --name-only HEAD` (up to 1000 files).
11 * 2. For large repos (>200 files): pre-filter with `git grep -l keyword`.
12 * 3. Build a compact index: for each candidate file, read its first 50 lines.
13 * 4. Send index + query to Claude (haiku — fast + cheap) and ask for a
14 * JSON-ranked result: [{file, reason, confidence}].
15 * 5. For the top 5 results, read the actual file and extract the most
16 * relevant 20-line snippet (heuristic: find the block that contains the
17 * most query-adjacent tokens).
18 * 6. Cache results in-memory for 5 minutes (Map<`${repoId}:${query}`, ...>).
19 *
20 * Rate limit: 20 semantic searches per user (by userId or IP) per hour.
21 * After the limit is hit, falls back to `git grep` keyword search.
22 *
23 * Fallback: if ANTHROPIC_API_KEY is not set, falls back to `git grep`.
24 */
25
26import { getAnthropic, MODEL_HAIKU, parseJsonResponse } from "./ai-client";
27import { config } from "./config";
28import { getRepoPath } from "../git/repository";
29
30// ---------------------------------------------------------------------------
31// Types
32// ---------------------------------------------------------------------------
33
34export interface SemanticSearchResult {
35 file: string;
36 reason: string; // AI's explanation of why this file is relevant
37 confidence: number; // 0–1
38 snippet: string; // up to 20 lines of the most relevant content
39 lineNumber?: number; // best-guess start line for the snippet
40}
41
42// ---------------------------------------------------------------------------
43// In-memory cache (5-minute TTL)
44// ---------------------------------------------------------------------------
45
46interface CacheEntry {
47 results: SemanticSearchResult[];
48 expiresAt: number;
49}
50
51const resultCache = new Map<string, CacheEntry>();
52const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
53
54function cacheGet(key: string): SemanticSearchResult[] | null {
55 const entry = resultCache.get(key);
56 if (!entry) return null;
57 if (Date.now() > entry.expiresAt) {
58 resultCache.delete(key);
59 return null;
60 }
61 return entry.results;
62}
63
64function cacheSet(key: string, results: SemanticSearchResult[]): void {
65 // Evict old entries to keep memory bounded.
66 if (resultCache.size > 500) {
67 const now = Date.now();
68 for (const [k, v] of resultCache) {
69 if (v.expiresAt < now) resultCache.delete(k);
70 }
71 }
72 resultCache.set(key, { results, expiresAt: Date.now() + CACHE_TTL_MS });
73}
74
75// ---------------------------------------------------------------------------
76// Per-user/IP rate limiting (20 semantic searches per hour)
77// ---------------------------------------------------------------------------
78
79interface BucketEntry {
80 count: number;
81 resetAt: number;
82}
83
84const semanticSearchBuckets = new Map<string, BucketEntry>();
85const SEMANTIC_RATE_WINDOW_MS = 60 * 60 * 1000; // 1 hour
86const SEMANTIC_RATE_MAX = 20;
87
88/** Returns true if the caller is within quota, false if exceeded. */
89export function checkSemanticRateLimit(key: string): boolean {
90 const now = Date.now();
91 let bucket = semanticSearchBuckets.get(key);
92 if (!bucket || bucket.resetAt < now) {
93 bucket = { count: 0, resetAt: now + SEMANTIC_RATE_WINDOW_MS };
94 semanticSearchBuckets.set(key, bucket);
95 }
96 bucket.count++;
97 return bucket.count <= SEMANTIC_RATE_MAX;
98}
99
100/** Returns remaining semantic searches in the current window (never negative). */
101export function semanticRateLimitRemaining(key: string): number {
102 const now = Date.now();
103 const bucket = semanticSearchBuckets.get(key);
104 if (!bucket || bucket.resetAt < now) return SEMANTIC_RATE_MAX;
105 return Math.max(0, SEMANTIC_RATE_MAX - bucket.count);
106}
107
108// ---------------------------------------------------------------------------
109// Skip-list: directories we never look inside
110// ---------------------------------------------------------------------------
111
112const SKIP_DIRS = new Set([
113 "node_modules",
114 ".git",
115 "dist",
116 "build",
117 "vendor",
118 ".next",
119 ".turbo",
120 "target",
121 "__pycache__",
122]);
123
124const SKIP_FILES = new Set([
125 "package-lock.json",
126 "yarn.lock",
127 "pnpm-lock.yaml",
128 "bun.lockb",
129 "bun.lock",
130 "poetry.lock",
131 "cargo.lock",
132 "composer.lock",
133 "gemfile.lock",
134]);
135
136function shouldSkipPath(path: string): boolean {
137 const parts = path.split("/");
138 // Skip if any directory segment is in the skip list.
139 for (const part of parts.slice(0, -1)) {
140 if (SKIP_DIRS.has(part.toLowerCase())) return true;
141 }
142 const basename = parts[parts.length - 1].toLowerCase();
143 if (SKIP_FILES.has(basename)) return true;
144 return false;
145}
146
147// ---------------------------------------------------------------------------
148// Git helpers
149// ---------------------------------------------------------------------------
150
151async function gitExec(
152 cmd: string[],
153 cwd: string
154): Promise<{ stdout: string; exitCode: number }> {
155 const proc = Bun.spawn(cmd, {
156 cwd,
157 env: process.env as Record<string, string>,
158 stdout: "pipe",
159 stderr: "pipe",
160 });
161 const stdout = await new Response(proc.stdout).text();
162 const exitCode = await proc.exited;
163 return { stdout, exitCode };
164}
165
166async function lsFiles(owner: string, repo: string, branch: string): Promise<string[]> {
167 const repoDir = getRepoPath(owner, repo);
168 const { stdout, exitCode } = await gitExec(
169 ["git", "ls-tree", "-r", "--name-only", branch],
170 repoDir
171 );
172 if (exitCode !== 0) return [];
173 return stdout
174 .trim()
175 .split("\n")
176 .filter(Boolean)
177 .filter((p) => !shouldSkipPath(p));
178}
179
180async function grepFiles(
181 owner: string,
182 repo: string,
183 branch: string,
184 keyword: string
185): Promise<string[]> {
186 const repoDir = getRepoPath(owner, repo);
187 const { stdout } = await gitExec(
188 ["git", "grep", "-l", "-i", keyword, branch, "--"],
189 repoDir
190 );
191 return stdout
192 .trim()
193 .split("\n")
194 .filter(Boolean)
195 .map((line) => {
196 // git grep -l output: "<ref>:<path>"
197 const idx = line.indexOf(":");
198 return idx >= 0 ? line.slice(idx + 1) : line;
199 })
200 .filter((p) => !shouldSkipPath(p));
201}
202
203async function readFileHead(
204 owner: string,
205 repo: string,
206 branch: string,
207 filePath: string,
208 maxLines = 50
209): Promise<string> {
210 const repoDir = getRepoPath(owner, repo);
211 const { stdout, exitCode } = await gitExec(
212 ["git", "show", `${branch}:${filePath}`],
213 repoDir
214 );
215 if (exitCode !== 0) return "";
216 return stdout.split("\n").slice(0, maxLines).join("\n");
217}
218
219async function readFileFull(
220 owner: string,
221 repo: string,
222 branch: string,
223 filePath: string
224): Promise<string> {
225 const repoDir = getRepoPath(owner, repo);
226 const { stdout, exitCode } = await gitExec(
227 ["git", "show", `${branch}:${filePath}`],
228 repoDir
229 );
230 if (exitCode !== 0) return "";
231 return stdout;
232}
233
234// ---------------------------------------------------------------------------
235// Snippet extraction
236// ---------------------------------------------------------------------------
237
238/**
239 * Extract the most relevant 20-line window from a file for a given query.
240 * Strategy: split query into lowercase tokens, score each line by how many
241 * tokens it contains, then find the 20-line window with the highest total
242 * score. Falls back to the first 20 lines when no tokens match anything.
243 */
244function extractSnippet(
245 content: string,
246 query: string,
247 windowSize = 20
248): { snippet: string; lineNumber: number } {
249 const lines = content.split("\n");
250 if (lines.length <= windowSize) {
251 return { snippet: content, lineNumber: 1 };
252 }
253
254 const tokens = query
255 .toLowerCase()
256 .split(/[^a-z0-9]+/)
257 .filter((t) => t.length >= 3);
258
259 if (tokens.length === 0) {
260 return { snippet: lines.slice(0, windowSize).join("\n"), lineNumber: 1 };
261 }
262
263 const lineScores = lines.map((line) => {
264 const lower = line.toLowerCase();
265 return tokens.reduce((acc, tok) => acc + (lower.includes(tok) ? 1 : 0), 0);
266 });
267
268 // Sliding window: find the windowSize-line slice with the highest score sum.
269 let windowScore = lineScores.slice(0, windowSize).reduce((a, b) => a + b, 0);
270 let bestStart = 0;
271 let bestScore = windowScore;
272
273 for (let i = 1; i + windowSize <= lines.length; i++) {
274 windowScore = windowScore - lineScores[i - 1] + lineScores[i + windowSize - 1];
275 if (windowScore > bestScore) {
276 bestScore = windowScore;
277 bestStart = i;
278 }
279 }
280
281 const snippet = lines.slice(bestStart, bestStart + windowSize).join("\n");
282 return { snippet, lineNumber: bestStart + 1 };
283}
284
285// ---------------------------------------------------------------------------
286// Claude API call
287// ---------------------------------------------------------------------------
288
289interface ClaudeRankedFile {
290 file: string;
291 reason: string;
292 confidence: number;
293}
294
295const MAX_INDEX_CHARS = 80_000; // cap total index size sent to Claude
296
297async function rankFilesWithClaude(
298 query: string,
299 fileIndex: Array<{ path: string; head: string }>
300): Promise<ClaudeRankedFile[]> {
301 const client = getAnthropic();
302
303 // Build a compact text index, respecting the char cap.
304 let indexText = "";
305 for (const { path, head } of fileIndex) {
306 const entry = `\n--- ${path} ---\n${head.slice(0, 600)}\n`;
307 if (indexText.length + entry.length > MAX_INDEX_CHARS) break;
308 indexText += entry;
309 }
310
311 const prompt = `You are a code navigation assistant. Given a codebase file index and a search query, identify the most relevant files.
312
313QUERY: "${query}"
314
315CODEBASE INDEX (filename + first 50 lines per file):
316${indexText}
317
318Return ONLY a JSON array (no prose, no markdown) of up to 8 objects, ranked by relevance, with this shape:
319[{"file": "<path>", "reason": "<1-sentence explanation>", "confidence": <0.0–1.0>}]
320
321Only include files with confidence > 0.2. Return [] if nothing is relevant.`;
322
323 try {
324 const message = await client.messages.create({
325 model: MODEL_SONNET,
326 max_tokens: 1000,
327 messages: [{ role: "user", content: prompt }],
328 });
329
330 const text =
331 message.content.find((b) => b.type === "text")?.text ?? "";
332 const parsed = parseJsonResponse<ClaudeRankedFile[]>(text);
333 if (!Array.isArray(parsed)) return [];
334 return parsed.filter(
335 (r): r is ClaudeRankedFile =>
336 typeof r.file === "string" &&
337 typeof r.reason === "string" &&
338 typeof r.confidence === "number"
339 );
340 } catch (err) {
341 console.error("[claude-semantic-search] Claude API error:", err);
342 return [];
343 }
344}
345
346// ---------------------------------------------------------------------------
347// Keyword fallback (git grep)
348// ---------------------------------------------------------------------------
349
350async function keywordFallback(
351 owner: string,
352 repo: string,
353 branch: string,
354 query: string
355): Promise<SemanticSearchResult[]> {
356 const repoDir = getRepoPath(owner, repo);
357 const keyword = query.split(/\s+/)[0] || query;
358 const { stdout, exitCode } = await gitExec(
359 ["git", "grep", "-n", "-i", "-m", "5", keyword, branch, "--"],
360 repoDir
361 );
362 if (exitCode !== 0) return [];
363
364 // Group by file, take top 5 files.
365 const byFile = new Map<string, { lineNum: number; line: string }[]>();
366 for (const raw of stdout.trim().split("\n").filter(Boolean)) {
367 // Format: <ref>:<file>:<lineNum>:<content>
368 const refPrefix = branch + ":";
369 const stripped = raw.startsWith(refPrefix) ? raw.slice(refPrefix.length) : raw;
370 const firstColon = stripped.indexOf(":");
371 if (firstColon < 0) continue;
372 const file = stripped.slice(0, firstColon);
373 const rest = stripped.slice(firstColon + 1);
374 const secondColon = rest.indexOf(":");
375 if (secondColon < 0) continue;
376 const lineNum = parseInt(rest.slice(0, secondColon), 10);
377 const line = rest.slice(secondColon + 1);
378 if (!byFile.has(file)) byFile.set(file, []);
379 byFile.get(file)!.push({ lineNum, line });
380 }
381
382 const results: SemanticSearchResult[] = [];
383 let count = 0;
384 for (const [file, matches] of byFile) {
385 if (count++ >= 5) break;
386 const snippet = matches
387 .slice(0, 5)
388 .map((m) => `${m.lineNum}: ${m.line}`)
389 .join("\n");
390 results.push({
391 file,
392 reason: `Keyword match for "${keyword}"`,
393 confidence: 0.5,
394 snippet,
395 lineNumber: matches[0]?.lineNum,
396 });
397 }
398 return results;
399}
400
401// ---------------------------------------------------------------------------
402// Main export
403// ---------------------------------------------------------------------------
404
405export interface SemanticSearchOptions {
406 maxFiles?: number; // max candidate files to index (default 200)
407 branch?: string; // branch/ref to search (default "HEAD")
408 rateLimitKey?: string; // userId or IP for rate limiting
409}
410
411/**
412 * Semantic code search powered by Claude.
413 *
414 * @param owner - repo owner username
415 * @param repo - repo name
416 * @param repoId - DB repository id (used as cache key)
417 * @param query - natural-language search query
418 * @param opts - optional config
419 * @returns ranked list of matching files with AI reasoning
420 */
421export async function claudeSemanticSearch(
422 owner: string,
423 repo: string,
424 repoId: string,
425 query: string,
426 opts: SemanticSearchOptions = {}
427): Promise<{ results: SemanticSearchResult[]; mode: "semantic" | "keyword"; quotaExceeded: boolean }> {
428 const q = query.trim();
429 if (!q) return { results: [], mode: "keyword", quotaExceeded: false };
430
431 const branch = opts.branch || "HEAD";
432 const maxFiles = opts.maxFiles ?? 200;
433 const cacheKey = `${repoId}:${branch}:${q}`;
434
435 // Cache hit
436 const cached = cacheGet(cacheKey);
437 if (cached) {
438 return { results: cached, mode: "semantic", quotaExceeded: false };
439 }
440
441 // Rate limit check (per-user or per-IP)
442 let quotaExceeded = false;
443 if (opts.rateLimitKey) {
444 const within = checkSemanticRateLimit(opts.rateLimitKey);
445 if (!within) {
446 quotaExceeded = true;
447 }
448 }
449
450 // No Claude API key or quota exceeded → keyword fallback
451 if (!config.anthropicApiKey || quotaExceeded) {
452 const results = await keywordFallback(owner, repo, branch, q);
453 return { results, mode: "keyword", quotaExceeded };
454 }
455
456 // Step 1: list all files
457 let allFiles: string[];
458 try {
459 allFiles = await lsFiles(owner, repo, branch);
460 } catch {
461 return { results: await keywordFallback(owner, repo, branch, q), mode: "keyword", quotaExceeded: false };
462 }
463
464 // Step 2: pre-filter if repo is large
465 let candidates: string[];
466 if (allFiles.length > maxFiles) {
467 const keyword = q.split(/\s+/)[0] || q;
468 try {
469 const grepHits = new Set(await grepFiles(owner, repo, branch, keyword));
470 candidates = allFiles.filter((f) => grepHits.has(f));
471 // If grep found nothing, fall back to first maxFiles files.
472 if (candidates.length === 0) {
473 candidates = allFiles.slice(0, maxFiles);
474 } else if (candidates.length > maxFiles) {
475 candidates = candidates.slice(0, maxFiles);
476 }
477 } catch {
478 candidates = allFiles.slice(0, maxFiles);
479 }
480 } else {
481 candidates = allFiles;
482 }
483
484 // Step 3: build file index (filename + first 50 lines)
485 const fileIndex: Array<{ path: string; head: string }> = [];
486 const HEAD_CONCURRENCY = 20;
487 for (let i = 0; i < candidates.length; i += HEAD_CONCURRENCY) {
488 const batch = candidates.slice(i, i + HEAD_CONCURRENCY);
489 const heads = await Promise.all(
490 batch.map((p) => readFileHead(owner, repo, branch, p, 50))
491 );
492 for (let j = 0; j < batch.length; j++) {
493 if (heads[j]) fileIndex.push({ path: batch[j], head: heads[j] });
494 }
495 }
496
497 if (fileIndex.length === 0) {
498 return { results: await keywordFallback(owner, repo, branch, q), mode: "keyword", quotaExceeded: false };
499 }
500
501 // Step 4: ask Claude to rank files
502 const ranked = await rankFilesWithClaude(q, fileIndex);
503
504 if (ranked.length === 0) {
505 // Claude found nothing — try keyword fallback
506 const kwResults = await keywordFallback(owner, repo, branch, q);
507 return { results: kwResults, mode: "keyword", quotaExceeded: false };
508 }
509
510 // Step 5: for each top result, read full file and extract relevant snippet
511 const TOP_N = 5;
512 const top = ranked.slice(0, TOP_N);
513 const results: SemanticSearchResult[] = [];
514
515 await Promise.all(
516 top.map(async (r) => {
517 const content = await readFileFull(owner, repo, branch, r.file).catch(() => "");
518 if (!content) {
519 results.push({ ...r, snippet: "", lineNumber: 1 });
520 return;
521 }
522 const { snippet, lineNumber } = extractSnippet(content, q, 20);
523 results.push({
524 file: r.file,
525 reason: r.reason,
526 confidence: Math.min(1, Math.max(0, r.confidence)),
527 snippet,
528 lineNumber,
529 });
530 })
531 );
532
533 // Sort by confidence desc (Promise.all doesn't preserve order after push).
534 results.sort((a, b) => b.confidence - a.confidence);
535
536 cacheSet(cacheKey, results);
537 return { results, mode: "semantic", quotaExceeded: false };
538}
Addedsrc/lib/cloud-deploy.ts+809−0View fileUnifiedSplit
1/**
2 * Multi-cloud deploy integration (migration 0077).
3 *
4 * Supports push-triggered deploys to:
5 * - Fly.io — Machines API deploy trigger
6 * - Railway — GraphQL deploymentTrigger mutation
7 * - Render — REST API POST /v1/services/:id/deploys
8 * - Vercel — REST API POST /v13/deployments (git-source)
9 * - Netlify — REST API POST /v1/sites/:id/builds
10 * - webhook — Generic POST (covers Coolify, CapRover, Dokku, etc.)
11 *
12 * Each provider function returns a {deployId, logUrl?, deployUrl?} on
13 * success or throws on hard error. Background polling updates the DB row
14 * status every ~10s until a terminal state is reached.
15 *
16 * Token storage: API tokens are AES-256-GCM encrypted in the DB via
17 * `server-targets-crypto.ts` (same key: SERVER_TARGETS_KEY).
18 */
19
20import { eq, and } from "drizzle-orm";
21import { db } from "../db";
22import { cloudDeployConfigs, cloudDeployments, repositories, users } from "../db/schema";
23import { decryptValue } from "./server-targets-crypto";
24
25// ─── Provider types ───────────────────────────────────────────────────────────
26
27export type CloudProvider =
28 | "fly"
29 | "railway"
30 | "render"
31 | "vercel"
32 | "netlify"
33 | "webhook";
34
35interface DeployResult {
36 providerDeployId?: string;
37 logUrl?: string;
38 deployUrl?: string;
39}
40
41// ─── Fly.io ──────────────────────────────────────────────────────────────────
42
43/**
44 * Trigger a Fly.io deployment via the Fly Machines REST API.
45 *
46 * Uses the "create a new machine that immediately exits" approach:
47 * creates a temp machine from the fly-builder image which triggers Fly's
48 * built-in build + release pipeline. For most users the simpler approach is
49 * to use flyctl, but we invoke the Machines API so we don't need the binary.
50 *
51 * Fly deploy token: generate with `flyctl tokens create deploy -a <app>`
52 * and store encrypted in cloud_deploy_configs.api_token_encrypted.
53 *
54 * The appName is the Fly app name (e.g. "my-app").
55 */
56export async function deployToFly(
57 appName: string,
58 token: string,
59 commitSha: string,
60 fetchImpl: typeof fetch = fetch
61): Promise<DeployResult> {
62 // POST to the Fly Machines API to create a one-shot deploy machine.
63 // The machine runs `fly deploy` logic internally when provisioned with
64 // a deploy token and app name — effectively a remote flyctl.
65 const url = `https://api.machines.dev/v1/apps/${encodeURIComponent(appName)}/machines`;
66
67 // For Fly's Deploy-via-API pattern: hit the `releases` endpoint instead.
68 // This is equivalent to what the Fly dashboard does when you click "Redeploy".
69 // We signal "deploy HEAD" by triggering a new release from the current image.
70 const releaseUrl = `https://api.fly.io/v1/apps/${encodeURIComponent(appName)}/releases`;
71 const body = JSON.stringify({
72 image: null, // null = re-deploy the latest image
73 strategy: "rolling",
74 commit_message: `gluecron deploy @ ${commitSha.slice(0, 7)}`,
75 });
76
77 const res = await fetchImpl(releaseUrl, {
78 method: "POST",
79 headers: {
80 Authorization: `Bearer ${token}`,
81 "Content-Type": "application/json",
82 },
83 body,
84 });
85
86 if (!res.ok) {
87 const text = await res.text().catch(() => "");
88 throw new Error(`Fly deploy failed HTTP ${res.status}: ${text.slice(0, 200)}`);
89 }
90
91 let data: Record<string, unknown> = {};
92 try {
93 data = (await res.json()) as Record<string, unknown>;
94 } catch {
95 /* ignore non-JSON bodies */
96 }
97
98 const releaseId = String(data.id || data.release_id || "");
99 const releaseVersion = data.version !== undefined ? String(data.version) : "";
100 const logUrl = releaseId
101 ? `https://fly.io/apps/${encodeURIComponent(appName)}/monitoring?release=${releaseId}`
102 : `https://fly.io/apps/${encodeURIComponent(appName)}/monitoring`;
103
104 return {
105 providerDeployId: releaseId || releaseVersion || undefined,
106 logUrl,
107 deployUrl: `https://${appName}.fly.dev`,
108 };
109}
110
111/**
112 * Poll Fly.io release status.
113 * Returns "success" | "failed" | "running" | "pending".
114 */
115export async function pollFlyStatus(
116 appName: string,
117 releaseId: string,
118 token: string,
119 fetchImpl: typeof fetch = fetch
120): Promise<string> {
121 try {
122 const url = `https://api.fly.io/v1/apps/${encodeURIComponent(appName)}/releases/${encodeURIComponent(releaseId)}`;
123 const res = await fetchImpl(url, {
124 headers: { Authorization: `Bearer ${token}` },
125 });
126 if (!res.ok) return "running"; // assume still running on transient error
127 const data = (await res.json()) as Record<string, unknown>;
128 const status = String(data.status || "").toLowerCase();
129 if (status === "complete" || status === "succeeded") return "success";
130 if (status === "failed" || status === "error" || status === "cancelled") return "failed";
131 return "running";
132 } catch {
133 return "running";
134 }
135}
136
137// ─── Railway ─────────────────────────────────────────────────────────────────
138
139/**
140 * Trigger a Railway service redeployment via their GraphQL API.
141 *
142 * Railway API token: https://railway.app/account/tokens
143 * serviceId: from the Railway dashboard URL (Settings > General).
144 */
145export async function deployToRailway(
146 serviceId: string,
147 token: string,
148 commitSha: string,
149 fetchImpl: typeof fetch = fetch
150): Promise<DeployResult> {
151 const query = `
152 mutation ServiceInstanceRedeploy($serviceId: String!) {
153 serviceInstanceRedeploy(input: { serviceId: $serviceId })
154 }
155 `;
156
157 const res = await fetchImpl("https://backboard.railway.app/graphql/v2", {
158 method: "POST",
159 headers: {
160 Authorization: `Bearer ${token}`,
161 "Content-Type": "application/json",
162 },
163 body: JSON.stringify({ query, variables: { serviceId } }),
164 });
165
166 if (!res.ok) {
167 const text = await res.text().catch(() => "");
168 throw new Error(`Railway deploy failed HTTP ${res.status}: ${text.slice(0, 200)}`);
169 }
170
171 const data = (await res.json()) as {
172 data?: { serviceInstanceRedeploy?: string };
173 errors?: Array<{ message: string }>;
174 };
175
176 if (data.errors?.length) {
177 throw new Error(`Railway GraphQL error: ${data.errors[0].message}`);
178 }
179
180 const deployId = data.data?.serviceInstanceRedeploy || "";
181
182 return {
183 providerDeployId: deployId || undefined,
184 logUrl: deployId
185 ? `https://railway.app/project/-/service/${serviceId}/logs`
186 : undefined,
187 deployUrl: undefined, // Railway generates a dynamic URL per project
188 };
189}
190
191/**
192 * Poll Railway deployment status.
193 */
194export async function pollRailwayStatus(
195 deployId: string,
196 token: string,
197 fetchImpl: typeof fetch = fetch
198): Promise<string> {
199 try {
200 const query = `
201 query Deployment($id: String!) {
202 deployment(id: $id) { status }
203 }
204 `;
205 const res = await fetchImpl("https://backboard.railway.app/graphql/v2", {
206 method: "POST",
207 headers: {
208 Authorization: `Bearer ${token}`,
209 "Content-Type": "application/json",
210 },
211 body: JSON.stringify({ query, variables: { id: deployId } }),
212 });
213 if (!res.ok) return "running";
214 const data = (await res.json()) as {
215 data?: { deployment?: { status?: string } };
216 };
217 const status = (data.data?.deployment?.status || "").toUpperCase();
218 if (status === "SUCCESS" || status === "COMPLETE") return "success";
219 if (status === "FAILED" || status === "CANCELLED" || status === "CRASHED") return "failed";
220 return "running";
221 } catch {
222 return "running";
223 }
224}
225
226// ─── Render ──────────────────────────────────────────────────────────────────
227
228/**
229 * Trigger a Render service deployment via their REST API.
230 *
231 * Render API key: https://dashboard.render.com/u/settings
232 * serviceId: the service's ID from the Render dashboard URL.
233 */
234export async function deployToRender(
235 serviceId: string,
236 token: string,
237 fetchImpl: typeof fetch = fetch
238): Promise<DeployResult> {
239 const res = await fetchImpl(
240 `https://api.render.com/v1/services/${encodeURIComponent(serviceId)}/deploys`,
241 {
242 method: "POST",
243 headers: {
244 Authorization: `Bearer ${token}`,
245 "Content-Type": "application/json",
246 Accept: "application/json",
247 },
248 body: JSON.stringify({ clearCache: "do_not_clear" }),
249 }
250 );
251
252 if (!res.ok) {
253 const text = await res.text().catch(() => "");
254 throw new Error(`Render deploy failed HTTP ${res.status}: ${text.slice(0, 200)}`);
255 }
256
257 const data = (await res.json()) as {
258 id?: string;
259 deploy?: { id?: string; status?: string; url?: string };
260 };
261 const deployId = data.deploy?.id || data.id || "";
262
263 return {
264 providerDeployId: deployId || undefined,
265 logUrl: deployId
266 ? `https://dashboard.render.com/web/${serviceId}/deploys/${deployId}`
267 : undefined,
268 deployUrl: undefined, // returned in service details, not deploy
269 };
270}
271
272/**
273 * Poll Render deployment status.
274 */
275export async function pollRenderStatus(
276 serviceId: string,
277 deployId: string,
278 token: string,
279 fetchImpl: typeof fetch = fetch
280): Promise<string> {
281 try {
282 const res = await fetchImpl(
283 `https://api.render.com/v1/services/${encodeURIComponent(serviceId)}/deploys/${encodeURIComponent(deployId)}`,
284 {
285 headers: { Authorization: `Bearer ${token}`, Accept: "application/json" },
286 }
287 );
288 if (!res.ok) return "running";
289 const data = (await res.json()) as { deploy?: { status?: string } };
290 const status = (data.deploy?.status || "").toLowerCase();
291 if (status === "live") return "success";
292 if (status === "failed" || status === "canceled" || status === "deactivated") return "failed";
293 return "running";
294 } catch {
295 return "running";
296 }
297}
298
299// ─── Vercel ──────────────────────────────────────────────────────────────────
300
301/**
302 * Trigger a Vercel deployment via their REST API.
303 *
304 * Vercel token: https://vercel.com/account/tokens
305 * projectId: from Vercel project settings.
306 *
307 * Note: this creates a "forced" redeploy of the latest successful deployment,
308 * since we're not pushing to a Vercel-connected Git repo. For full Git
309 * integration, users should connect their Gluecron repo to Vercel via webhook.
310 */
311export async function deployToVercel(
312 projectId: string,
313 token: string,
314 commitSha: string,
315 fetchImpl: typeof fetch = fetch
316): Promise<DeployResult> {
317 // Redeploy the latest deployment of the project
318 const res = await fetchImpl(
319 `https://api.vercel.com/v13/deployments`,
320 {
321 method: "POST",
322 headers: {
323 Authorization: `Bearer ${token}`,
324 "Content-Type": "application/json",
325 },
326 body: JSON.stringify({
327 name: projectId,
328 target: "production",
329 meta: {
330 githubCommitSha: commitSha,
331 source: "gluecron",
332 },
333 // Trigger a redeploy — Vercel will use the latest build config
334 forceNew: 0,
335 }),
336 }
337 );
338
339 if (!res.ok) {
340 const text = await res.text().catch(() => "");
341 // 400 with "no deployments" means we need to check for existing deployment
342 if (res.status === 400) {
343 // Try the redeploy endpoint instead
344 const searchRes = await fetchImpl(
345 `https://api.vercel.com/v6/deployments?projectId=${encodeURIComponent(projectId)}&limit=1&target=production`,
346 { headers: { Authorization: `Bearer ${token}` } }
347 );
348 if (searchRes.ok) {
349 const searchData = (await searchRes.json()) as {
350 deployments?: Array<{ uid?: string; url?: string }>;
351 };
352 const latest = searchData.deployments?.[0];
353 if (latest?.uid) {
354 const redeployRes = await fetchImpl(
355 `https://api.vercel.com/v13/deployments?forceNew=1`,
356 {
357 method: "POST",
358 headers: {
359 Authorization: `Bearer ${token}`,
360 "Content-Type": "application/json",
361 },
362 body: JSON.stringify({ deploymentId: latest.uid }),
363 }
364 );
365 if (redeployRes.ok) {
366 const data = (await redeployRes.json()) as { id?: string; url?: string };
367 return {
368 providerDeployId: data.id || undefined,
369 logUrl: data.id ? `https://vercel.com/deployments/${data.id}` : undefined,
370 deployUrl: data.url ? `https://${data.url}` : undefined,
371 };
372 }
373 }
374 }
375 }
376 throw new Error(`Vercel deploy failed HTTP ${res.status}: ${text.slice(0, 200)}`);
377 }
378
379 const data = (await res.json()) as { id?: string; url?: string; readyState?: string };
380 return {
381 providerDeployId: data.id || undefined,
382 logUrl: data.id ? `https://vercel.com/deployments/${data.id}` : undefined,
383 deployUrl: data.url ? `https://${data.url}` : undefined,
384 };
385}
386
387/**
388 * Poll Vercel deployment status.
389 */
390export async function pollVercelStatus(
391 deployId: string,
392 token: string,
393 fetchImpl: typeof fetch = fetch
394): Promise<string> {
395 try {
396 const res = await fetchImpl(
397 `https://api.vercel.com/v13/deployments/${encodeURIComponent(deployId)}`,
398 { headers: { Authorization: `Bearer ${token}` } }
399 );
400 if (!res.ok) return "running";
401 const data = (await res.json()) as { readyState?: string; state?: string };
402 const state = (data.readyState || data.state || "").toUpperCase();
403 if (state === "READY") return "success";
404 if (state === "ERROR" || state === "CANCELED" || state === "FAILED") return "failed";
405 return "running";
406 } catch {
407 return "running";
408 }
409}
410
411// ─── Netlify ─────────────────────────────────────────────────────────────────
412
413/**
414 * Trigger a Netlify site build via their REST API.
415 *
416 * Netlify token: https://app.netlify.com/user/applications
417 * providerAppId: the Netlify site ID.
418 */
419export async function deployToNetlify(
420 siteId: string,
421 token: string,
422 fetchImpl: typeof fetch = fetch
423): Promise<DeployResult> {
424 const res = await fetchImpl(
425 `https://api.netlify.com/api/v1/sites/${encodeURIComponent(siteId)}/builds`,
426 {
427 method: "POST",
428 headers: {
429 Authorization: `Bearer ${token}`,
430 "Content-Type": "application/json",
431 },
432 body: JSON.stringify({}),
433 }
434 );
435
436 if (!res.ok) {
437 const text = await res.text().catch(() => "");
438 throw new Error(`Netlify deploy failed HTTP ${res.status}: ${text.slice(0, 200)}`);
439 }
440
441 const data = (await res.json()) as { id?: string; deploy?: { id?: string; deploy_url?: string } };
442 const deployId = data.id || data.deploy?.id || "";
443 const deployUrl = data.deploy?.deploy_url || "";
444
445 return {
446 providerDeployId: deployId || undefined,
447 logUrl: deployId
448 ? `https://app.netlify.com/sites/${siteId}/deploys/${deployId}`
449 : undefined,
450 deployUrl: deployUrl || undefined,
451 };
452}
453
454/**
455 * Poll Netlify build status.
456 */
457export async function pollNetlifyStatus(
458 siteId: string,
459 buildId: string,
460 token: string,
461 fetchImpl: typeof fetch = fetch
462): Promise<string> {
463 try {
464 const res = await fetchImpl(
465 `https://api.netlify.com/api/v1/sites/${encodeURIComponent(siteId)}/builds/${encodeURIComponent(buildId)}`,
466 { headers: { Authorization: `Bearer ${token}` } }
467 );
468 if (!res.ok) return "running";
469 const data = (await res.json()) as { state?: string };
470 const state = (data.state || "").toLowerCase();
471 if (state === "ready") return "success";
472 if (state === "error" || state === "cancelled") return "failed";
473 return "running";
474 } catch {
475 return "running";
476 }
477}
478
479// ─── Generic webhook ─────────────────────────────────────────────────────────
480
481/**
482 * Fire a generic deploy webhook — covers Coolify, CapRover, Dokku, etc.
483 *
484 * providerAppId = the full webhook URL.
485 * token = optional HMAC secret or Bearer token (sent as Authorization header if set).
486 *
487 * POSTs JSON: { event: "push", commit_sha: "...", source: "gluecron" }
488 */
489export async function deployViaWebhook(
490 webhookUrl: string,
491 token: string,
492 commitSha: string,
493 fetchImpl: typeof fetch = fetch
494): Promise<DeployResult> {
495 const headers: Record<string, string> = {
496 "Content-Type": "application/json",
497 "User-Agent": "gluecron-deploy/1",
498 };
499 if (token) headers["Authorization"] = `Bearer ${token}`;
500
501 const body = JSON.stringify({
502 event: "push",
503 commit_sha: commitSha,
504 source: "gluecron",
505 });
506
507 const res = await fetchImpl(webhookUrl, { method: "POST", headers, body });
508
509 if (!res.ok) {
510 const text = await res.text().catch(() => "");
511 throw new Error(`Webhook deploy failed HTTP ${res.status}: ${text.slice(0, 200)}`);
512 }
513
514 return {}; // webhooks are fire-and-forget — no deploy ID to poll
515}
516
517// ─── Dispatch + polling orchestration ────────────────────────────────────────
518
519/**
520 * Dispatch a single cloud deploy config — creates the DB row, fires the
521 * provider API, then polls in a background async loop until terminal state.
522 *
523 * Never throws — all errors are caught and recorded in the DB row.
524 */
525export async function dispatchCloudDeploy(
526 config: {
527 id: string;
528 repoId: string;
529 provider: string;
530 providerAppId: string;
531 apiTokenEncrypted: string;
532 },
533 commitSha: string,
534 opts: { fetchImpl?: typeof fetch; pollIntervalMs?: number } = {}
535): Promise<void> {
536 const fetchImpl = opts.fetchImpl ?? fetch;
537 const pollIntervalMs = opts.pollIntervalMs ?? 10_000;
538
539 // Decrypt the API token
540 const tokenResult = decryptValue(config.apiTokenEncrypted);
541 if (!tokenResult.ok) {
542 console.warn(`[cloud-deploy] cannot decrypt token for config ${config.id}: ${tokenResult.error}`);
543 return;
544 }
545 const apiToken = tokenResult.plaintext;
546
547 // Create the deployment row
548 let deployRowId = "";
549 try {
550 const [row] = await db
551 .insert(cloudDeployments)
552 .values({
553 configId: config.id,
554 repoId: config.repoId,
555 commitSha,
556 status: "pending",
557 })
558 .returning({ id: cloudDeployments.id });
559 deployRowId = row?.id || "";
560 } catch (err) {
561 console.warn("[cloud-deploy] failed to create deployment row:", err);
562 return;
563 }
564
565 const updateRow = async (patch: Partial<{
566 status: string;
567 providerDeployId: string | null;
568 logUrl: string | null;
569 deployUrl: string | null;
570 errorMessage: string | null;
571 completedAt: Date | null;
572 durationMs: number | null;
573 }>) => {
574 try {
575 await db
576 .update(cloudDeployments)
577 // eslint-disable-next-line @typescript-eslint/no-explicit-any
578 .set(patch as any)
579 .where(eq(cloudDeployments.id, deployRowId));
580 } catch {
581 /* ignore */
582 }
583 };
584
585 const startedAt = Date.now();
586
587 // Mark as running
588 await updateRow({ status: "running" });
589
590 let result: DeployResult = {};
591 let providerError = "";
592
593 try {
594 switch (config.provider) {
595 case "fly":
596 result = await deployToFly(config.providerAppId, apiToken, commitSha, fetchImpl);
597 break;
598 case "railway":
599 result = await deployToRailway(config.providerAppId, apiToken, commitSha, fetchImpl);
600 break;
601 case "render":
602 result = await deployToRender(config.providerAppId, apiToken, fetchImpl);
603 break;
604 case "vercel":
605 result = await deployToVercel(config.providerAppId, apiToken, commitSha, fetchImpl);
606 break;
607 case "netlify":
608 result = await deployToNetlify(config.providerAppId, apiToken, fetchImpl);
609 break;
610 case "webhook":
611 result = await deployViaWebhook(config.providerAppId, apiToken, commitSha, fetchImpl);
612 break;
613 default:
614 throw new Error(`Unknown provider: ${config.provider}`);
615 }
616 } catch (err) {
617 providerError = err instanceof Error ? err.message : String(err);
618 console.warn(`[cloud-deploy] ${config.provider} trigger error:`, providerError);
619 await updateRow({
620 status: "failed",
621 errorMessage: providerError,
622 completedAt: new Date(),
623 durationMs: Date.now() - startedAt,
624 });
625 return;
626 }
627
628 // Update with initial deploy info
629 await updateRow({
630 providerDeployId: result.providerDeployId ?? null,
631 logUrl: result.logUrl ?? null,
632 deployUrl: result.deployUrl ?? null,
633 });
634
635 // For webhooks or when we have no deploy ID, mark success immediately
636 if (!result.providerDeployId || config.provider === "webhook") {
637 await updateRow({
638 status: "success",
639 completedAt: new Date(),
640 durationMs: Date.now() - startedAt,
641 });
642 console.log(`[cloud-deploy] ${config.provider} ${config.providerAppId}: delivered (no polling)`);
643 return;
644 }
645
646 // Poll until terminal state (max 10 minutes)
647 const maxPolls = Math.floor(600_000 / pollIntervalMs);
648 let polls = 0;
649 let finalStatus = "running";
650
651 while (polls < maxPolls) {
652 await new Promise((r) => setTimeout(r, pollIntervalMs));
653 polls++;
654
655 try {
656 switch (config.provider) {
657 case "fly":
658 finalStatus = await pollFlyStatus(
659 config.providerAppId,
660 result.providerDeployId,
661 apiToken,
662 fetchImpl
663 );
664 break;
665 case "railway":
666 finalStatus = await pollRailwayStatus(
667 result.providerDeployId,
668 apiToken,
669 fetchImpl
670 );
671 break;
672 case "render":
673 finalStatus = await pollRenderStatus(
674 config.providerAppId,
675 result.providerDeployId,
676 apiToken,
677 fetchImpl
678 );
679 break;
680 case "vercel":
681 finalStatus = await pollVercelStatus(
682 result.providerDeployId,
683 apiToken,
684 fetchImpl
685 );
686 break;
687 case "netlify":
688 finalStatus = await pollNetlifyStatus(
689 config.providerAppId,
690 result.providerDeployId,
691 apiToken,
692 fetchImpl
693 );
694 break;
695 default:
696 finalStatus = "success";
697 }
698 } catch {
699 /* poll error — keep retrying */
700 }
701
702 if (finalStatus === "success" || finalStatus === "failed") {
703 break;
704 }
705 }
706
707 // If we exhausted polls without terminal state, mark failed
708 if (finalStatus !== "success" && finalStatus !== "failed") {
709 finalStatus = "failed";
710 providerError = "Timed out waiting for deployment to complete";
711 }
712
713 await updateRow({
714 status: finalStatus,
715 errorMessage: finalStatus === "failed" && !providerError ? "Deploy failed" : providerError || null,
716 completedAt: new Date(),
717 durationMs: Date.now() - startedAt,
718 });
719
720 console.log(
721 `[cloud-deploy] ${config.provider} ${config.providerAppId}@${commitSha.slice(0, 7)}: ${finalStatus} (${Math.round((Date.now() - startedAt) / 1000)}s)`
722 );
723}
724
725// ─── Post-receive integration ─────────────────────────────────────────────────
726
727interface PushRef {
728 oldSha: string;
729 newSha: string;
730 refName: string;
731}
732
733/**
734 * Called from post-receive. Looks up cloud_deploy_configs for this repo
735 * and fires a deploy for every config whose trigger_branch matches a pushed ref.
736 * Runs all matching deploys in parallel. Never throws.
737 */
738export async function fireCloudDeploys(
739 owner: string,
740 repoName: string,
741 refs: PushRef[]
742): Promise<void> {
743 const liveRefs = refs.filter(
744 (r) => r.refName.startsWith("refs/heads/") && !r.newSha.startsWith("0000")
745 );
746 if (liveRefs.length === 0) return;
747
748 // Resolve repo ID
749 let repoId = "";
750 try {
751 const [row] = await db
752 .select({ id: repositories.id })
753 .from(repositories)
754 .innerJoin(users, eq(repositories.ownerId, users.id))
755 .where(and(eq(users.username, owner), eq(repositories.name, repoName)))
756 .limit(1);
757 repoId = row?.id || "";
758 } catch {
759 return;
760 }
761 if (!repoId) return;
762
763 // Load all enabled configs for this repo
764 let configs: Array<{
765 id: string;
766 repoId: string;
767 provider: string;
768 providerAppId: string;
769 apiTokenEncrypted: string;
770 triggerBranch: string;
771 }> = [];
772 try {
773 configs = await db
774 .select({
775 id: cloudDeployConfigs.id,
776 repoId: cloudDeployConfigs.repoId,
777 provider: cloudDeployConfigs.provider,
778 providerAppId: cloudDeployConfigs.providerAppId,
779 apiTokenEncrypted: cloudDeployConfigs.apiTokenEncrypted,
780 triggerBranch: cloudDeployConfigs.triggerBranch,
781 })
782 .from(cloudDeployConfigs)
783 .where(eq(cloudDeployConfigs.repoId, repoId));
784 configs = configs.filter((c) => (c as any).enabled !== false);
785 } catch {
786 return;
787 }
788
789 if (!configs.length) return;
790
791 // Match pushed branches to configs
792 const dispatches: Array<Promise<void>> = [];
793 for (const ref of liveRefs) {
794 const branch = ref.refName.replace("refs/heads/", "");
795 for (const cfg of configs) {
796 if (cfg.triggerBranch === branch) {
797 dispatches.push(
798 dispatchCloudDeploy(cfg, ref.newSha).catch((err) =>
799 console.warn(`[cloud-deploy] dispatch error for config ${cfg.id}:`, err)
800 )
801 );
802 }
803 }
804 }
805
806 if (dispatches.length > 0) {
807 await Promise.all(dispatches);
808 }
809}
Addedsrc/lib/codebase-migrator.ts+694−0View fileUnifiedSplit
1/**
2 * Codebase Migration Service — AI-powered one-click language/framework translation.
3 *
4 * Accepts a MigrationTarget (language swap, framework swap, or custom instruction),
5 * analyses the repository, generates a Claude-driven translation plan, translates
6 * up to 40 files, commits everything to a new branch, and opens a pull request.
7 *
8 * Job lifecycle: queued → analyzing → translating → committing → opening-pr → done
9 * ↘ failed
10 *
11 * All jobs live in an in-memory Map, auto-purged 4 hours after completion.
12 */
13
14import { join } from "path";
15import { mkdir, rm, writeFile, readFile } from "fs/promises";
16import { existsSync } from "fs";
17import { config } from "./config";
18import { getAnthropic, MODEL_SONNET, extractText, parseJsonResponse } from "./ai-client";
19import { db } from "../db";
20import { users, repositories, pullRequests } from "../db/schema";
21import { eq, and } from "drizzle-orm";
22
23// ---------------------------------------------------------------------------
24// Types
25// ---------------------------------------------------------------------------
26
27export type MigrationTarget =
28 | { type: "language"; from: string; to: string }
29 | { type: "framework"; from: string; to: string }
30 | { type: "custom"; description: string };
31
32export interface MigrationJob {
33 id: string;
34 repoId: string;
35 owner: string;
36 repo: string;
37 userId: string;
38 target: MigrationTarget;
39 status:
40 | "queued"
41 | "analyzing"
42 | "translating"
43 | "committing"
44 | "opening-pr"
45 | "done"
46 | "failed";
47 progress: number; // 0-100
48 currentFile?: string;
49 branchName: string;
50 prNumber?: number;
51 error?: string;
52 filesTotal: number;
53 filesTranslated: number;
54 startedAt: string;
55 completedAt?: string;
56}
57
58interface MigrationPlan {
59 filesToTranslate: Array<{ from: string; to: string; notes: string }>;
60 filesToSkip: string[];
61 newFiles: Array<{ path: string; content: string }>;
62}
63
64interface ResolvedRepo {
65 ownerId: string;
66 repoId: string;
67 defaultBranch: string;
68 diskPath: string;
69}
70
71// ---------------------------------------------------------------------------
72// In-memory job store
73// ---------------------------------------------------------------------------
74
75const migrationJobs = new Map<string, MigrationJob>();
76
77// Sweep jobs older than 4 hours after completion.
78setInterval(() => {
79 const cutoff = Date.now() - 4 * 60 * 60 * 1000;
80 for (const [id, job] of migrationJobs) {
81 if (
82 (job.status === "done" || job.status === "failed") &&
83 job.completedAt &&
84 new Date(job.completedAt).getTime() < cutoff
85 ) {
86 migrationJobs.delete(id);
87 }
88 }
89}, 5 * 60 * 1000);
90
91// ---------------------------------------------------------------------------
92// Rate limiting — 1 active migration per repo, 3 per user per day
93// ---------------------------------------------------------------------------
94
95/** jobId → repoId for active jobs */
96const activeByRepo = new Map<string, string>(); // repoId → jobId
97
98/** userId → [timestamp, ...] rolling daily window */
99const dailyCounts = new Map<string, number[]>();
100
101function recordDailyUse(userId: string): boolean {
102 const now = Date.now();
103 const dayMs = 24 * 60 * 60 * 1000;
104 const existing = (dailyCounts.get(userId) ?? []).filter(
105 (ts) => now - ts < dayMs
106 );
107 if (existing.length >= 3) return false;
108 existing.push(now);
109 dailyCounts.set(userId, existing);
110 return true;
111}
112
113export function isRepoMigrating(repoId: string): boolean {
114 const jobId = activeByRepo.get(repoId);
115 if (!jobId) return false;
116 const job = migrationJobs.get(jobId);
117 if (!job) {
118 activeByRepo.delete(repoId);
119 return false;
120 }
121 if (job.status === "done" || job.status === "failed") {
122 activeByRepo.delete(repoId);
123 return false;
124 }
125 return true;
126}
127
128export function getJob(jobId: string): MigrationJob | undefined {
129 return migrationJobs.get(jobId);
130}
131
132// ---------------------------------------------------------------------------
133// Git helpers (subprocess via Bun.spawn)
134// ---------------------------------------------------------------------------
135
136async function git(
137 args: string[],
138 opts?: { cwd?: string }
139): Promise<{ stdout: string; stderr: string; exitCode: number }> {
140 const proc = Bun.spawn(["git", ...args], {
141 cwd: opts?.cwd,
142 stdout: "pipe",
143 stderr: "pipe",
144 env: {
145 ...process.env,
146 GIT_AUTHOR_NAME: "Gluecron Migration Bot",
147 GIT_AUTHOR_EMAIL: "migration-bot@gluecron.com",
148 GIT_COMMITTER_NAME: "Gluecron Migration Bot",
149 GIT_COMMITTER_EMAIL: "migration-bot@gluecron.com",
150 },
151 });
152 const [stdout, stderr] = await Promise.all([
153 new Response(proc.stdout).text(),
154 new Response(proc.stderr).text(),
155 ]);
156 const exitCode = await proc.exited;
157 return { stdout, stderr, exitCode };
158}
159
160/** Return true if the buffer looks like a binary file (null byte in first 512 B) */
161function isBinaryContent(content: string): boolean {
162 const sample = content.slice(0, 512);
163 return sample.includes("\0");
164}
165
166/** True if the path should always be skipped */
167function shouldSkipPath(path: string): boolean {
168 const lower = path.toLowerCase();
169 const skip = [
170 "node_modules/",
171 "dist/",
172 ".git/",
173 ".next/",
174 "build/",
175 "target/",
176 "__pycache__/",
177 ".venv/",
178 "vendor/",
179 "package-lock.json",
180 "yarn.lock",
181 "pnpm-lock.yaml",
182 "bun.lockb",
183 "poetry.lock",
184 "cargo.lock",
185 "go.sum",
186 "composer.lock",
187 "gemfile.lock",
188 ];
189 return skip.some((s) => lower.includes(s));
190}
191
192// ---------------------------------------------------------------------------
193// Claude helpers
194// ---------------------------------------------------------------------------
195
196function targetLabel(target: MigrationTarget): string {
197 if (target.type === "language") return `${target.from}${target.to}`;
198 if (target.type === "framework") return `${target.from}${target.to}`;
199 return target.description;
200}
201
202async function planMigration(
203 fileList: string[],
204 target: MigrationTarget
205): Promise<MigrationPlan | null> {
206 const anthropic = getAnthropic();
207
208 let goalDescription: string;
209 if (target.type === "language") {
210 goalDescription = `Convert all source code from ${target.from} to ${target.to}`;
211 } else if (target.type === "framework") {
212 goalDescription = `Migrate the codebase from ${target.from} framework to ${target.to} framework`;
213 } else {
214 goalDescription = target.description;
215 }
216
217 const fileListStr = fileList.slice(0, 500).join("\n");
218
219 const prompt = `You are a migration expert. Given this repository's file list, create a migration plan.
220
221Goal: ${goalDescription}
222
223Files in repository:
224${fileListStr}
225
226Return a JSON object (no markdown, no code fences, raw JSON only) with this exact shape:
227{
228 "filesToTranslate": [
229 { "from": "src/index.ts", "to": "src/index.py", "notes": "convert Express handlers to Flask routes" }
230 ],
231 "filesToSkip": ["package-lock.json", "node_modules/..."],
232 "newFiles": [
233 { "path": "requirements.txt", "content": "flask==3.0.0\n..." }
234 ]
235}
236
237Rules:
238- filesToTranslate: only source code files (no binaries, no lock files, no minified JS in dist/)
239- Cap filesToTranslate at 40 entries maximum
240- filesToSkip: lock files, binary assets, generated files that don't need translation
241- newFiles: new config/manifest files needed for the target (e.g. requirements.txt for Python, go.mod for Go)
242- Keep new file content concise and correct for the target stack
243- The "to" field should use the correct extension for the target language`;
244
245 try {
246 const msg = await anthropic.messages.create({
247 model: MODEL_SONNET,
248 max_tokens: 4096,
249 messages: [{ role: "user", content: prompt }],
250 });
251 const text = extractText(msg);
252 const plan = parseJsonResponse<MigrationPlan>(text);
253 if (!plan) return null;
254 // Clamp to 40 files
255 if (plan.filesToTranslate && plan.filesToTranslate.length > 40) {
256 plan.filesToTranslate = plan.filesToTranslate.slice(0, 40);
257 }
258 return plan;
259 } catch {
260 return null;
261 }
262}
263
264async function translateFile(
265 content: string,
266 fromPath: string,
267 target: MigrationTarget,
268 notes: string
269): Promise<string | null> {
270 const anthropic = getAnthropic();
271
272 let instruction: string;
273 if (target.type === "language") {
274 instruction = `Translate this ${target.from} file to ${target.to}.${notes ? ` Notes: ${notes}` : ""}`;
275 } else if (target.type === "framework") {
276 instruction = `Migrate this file from ${target.from} to ${target.to}.${notes ? ` Notes: ${notes}` : ""}`;
277 } else {
278 instruction = `Apply this transformation: ${target.description}${notes ? `. Notes: ${notes}` : ""}`;
279 }
280
281 const prompt = `${instruction}
282
283Return ONLY the translated file content, no explanation, no code fences, no markdown. Start the output with the actual file content.
284
285Original file (${fromPath}):
286${content}`;
287
288 try {
289 const msg = await anthropic.messages.create({
290 model: MODEL_SONNET,
291 max_tokens: 8192,
292 messages: [{ role: "user", content: prompt }],
293 });
294 return extractText(msg);
295 } catch {
296 return null;
297 }
298}
299
300// ---------------------------------------------------------------------------
301// DB helpers
302// ---------------------------------------------------------------------------
303
304async function resolveRepo(
305 ownerName: string,
306 repoName: string
307): Promise<ResolvedRepo | null> {
308 try {
309 const [ownerRow] = await db
310 .select()
311 .from(users)
312 .where(eq(users.username, ownerName))
313 .limit(1);
314 if (!ownerRow) return null;
315 const [repoRow] = await db
316 .select()
317 .from(repositories)
318 .where(
319 and(
320 eq(repositories.ownerId, ownerRow.id),
321 eq(repositories.name, repoName)
322 )
323 )
324 .limit(1);
325 if (!repoRow) return null;
326 return {
327 ownerId: ownerRow.id,
328 repoId: repoRow.id,
329 defaultBranch: repoRow.defaultBranch || "main",
330 diskPath: repoRow.diskPath,
331 };
332 } catch {
333 return null;
334 }
335}
336
337async function insertPullRequest(params: {
338 repositoryId: string;
339 authorId: string;
340 title: string;
341 body: string;
342 baseBranch: string;
343 headBranch: string;
344}): Promise<number> {
345 const [row] = await db
346 .insert(pullRequests)
347 .values({
348 repositoryId: params.repositoryId,
349 authorId: params.authorId,
350 title: params.title,
351 body: params.body,
352 state: "open",
353 baseBranch: params.baseBranch,
354 headBranch: params.headBranch,
355 isDraft: true,
356 })
357 .returning({ number: pullRequests.number });
358 return row.number;
359}
360
361// ---------------------------------------------------------------------------
362// Main migration pipeline
363// ---------------------------------------------------------------------------
364
365async function runMigration(job: MigrationJob): Promise<void> {
366 const worktreeBase = join(config.gitReposPath, ".migration-worktrees");
367 const worktreePath = join(worktreeBase, job.id);
368
369 try {
370 // ── 1. Resolve the repo ──────────────────────────────────────────────────
371 job.status = "analyzing";
372 job.progress = 5;
373
374 const resolved = await resolveRepo(job.owner, job.repo);
375 if (!resolved) throw new Error("Repository not found");
376
377 const bareRepoPath = resolved.diskPath;
378
379 // ── 2. Get file list ─────────────────────────────────────────────────────
380 const lsResult = await git(["ls-tree", "-r", "--name-only", "HEAD"], {
381 cwd: bareRepoPath,
382 });
383 if (lsResult.exitCode !== 0) {
384 // Empty repo
385 throw new Error("Repository has no commits yet — nothing to migrate");
386 }
387
388 const allFiles = lsResult.stdout
389 .split("\n")
390 .map((f) => f.trim())
391 .filter(Boolean)
392 .filter((f) => !shouldSkipPath(f));
393
394 if (allFiles.length === 0) {
395 throw new Error("No translatable files found in the repository");
396 }
397
398 job.progress = 10;
399
400 // ── 3. Plan the migration ────────────────────────────────────────────────
401 const plan = await planMigration(allFiles, job.target);
402 if (!plan) throw new Error("Failed to generate migration plan from Claude");
403
404 job.filesTotal = plan.filesToTranslate.length + plan.newFiles.length;
405 job.progress = 20;
406
407 // ── 4. Create worktree ──────────────────────────────────────────────────
408 await mkdir(worktreeBase, { recursive: true });
409
410 // git worktree add creates a linked working tree from the bare repo
411 const wtResult = await git(
412 ["worktree", "add", "--no-checkout", worktreePath, "HEAD"],
413 { cwd: bareRepoPath }
414 );
415 if (wtResult.exitCode !== 0) {
416 throw new Error(`Failed to create worktree: ${wtResult.stderr}`);
417 }
418
419 // Checkout the default branch content into the worktree
420 const checkoutResult = await git(["checkout", "-f", "HEAD", "--", "."], {
421 cwd: worktreePath,
422 });
423 // Silently continue even if partial checkout — some files may not exist
424
425 // Create and switch to the migration branch
426 const branchResult = await git(
427 ["checkout", "-b", job.branchName],
428 { cwd: worktreePath }
429 );
430 if (branchResult.exitCode !== 0) {
431 throw new Error(`Failed to create branch: ${branchResult.stderr}`);
432 }
433
434 // ── 5. Translate files ───────────────────────────────────────────────────
435 job.status = "translating";
436 job.progress = 25;
437
438 const progressPerFile = plan.filesToTranslate.length > 0
439 ? 50 / plan.filesToTranslate.length
440 : 50;
441
442 for (let i = 0; i < plan.filesToTranslate.length; i++) {
443 const entry = plan.filesToTranslate[i];
444 job.currentFile = entry.from;
445 job.filesTranslated = i;
446
447 // Read original file content
448 let originalContent: string;
449 try {
450 const showResult = await git(
451 ["show", `HEAD:${entry.from}`],
452 { cwd: bareRepoPath }
453 );
454 if (showResult.exitCode !== 0) {
455 // File doesn't exist or can't be read — skip
456 job.progress = Math.round(25 + (i + 1) * progressPerFile);
457 continue;
458 }
459 originalContent = showResult.stdout;
460 } catch {
461 job.progress = Math.round(25 + (i + 1) * progressPerFile);
462 continue;
463 }
464
465 // Skip binary files
466 if (isBinaryContent(originalContent)) {
467 job.progress = Math.round(25 + (i + 1) * progressPerFile);
468 continue;
469 }
470
471 // Skip very large files (50KB)
472 if (originalContent.length > 50 * 1024) {
473 job.progress = Math.round(25 + (i + 1) * progressPerFile);
474 continue;
475 }
476
477 // Translate via Claude
478 const translated = await translateFile(
479 originalContent,
480 entry.from,
481 job.target,
482 entry.notes || ""
483 );
484 if (!translated) {
485 // Translation failed for this file — skip gracefully
486 job.progress = Math.round(25 + (i + 1) * progressPerFile);
487 continue;
488 }
489
490 // Write translated file to the destination path
491 const destPath = join(worktreePath, entry.to);
492 const destDir = destPath.substring(0, destPath.lastIndexOf("/"));
493 if (destDir && destDir !== worktreePath) {
494 await mkdir(destDir, { recursive: true });
495 }
496 await writeFile(destPath, translated, "utf-8");
497
498 // If the source and destination paths differ, remove the old file
499 if (entry.from !== entry.to) {
500 const srcPath = join(worktreePath, entry.from);
501 if (existsSync(srcPath)) {
502 try {
503 await rm(srcPath);
504 } catch {
505 // Non-fatal
506 }
507 }
508 }
509
510 job.filesTranslated = i + 1;
511 job.progress = Math.round(25 + (i + 1) * progressPerFile);
512 }
513
514 // Write new files (package.json, requirements.txt, etc.)
515 for (const newFile of plan.newFiles) {
516 const destPath = join(worktreePath, newFile.path);
517 const destDir = destPath.substring(0, destPath.lastIndexOf("/"));
518 if (destDir && destDir !== worktreePath) {
519 await mkdir(destDir, { recursive: true });
520 }
521 await writeFile(destPath, newFile.content, "utf-8");
522 job.filesTranslated = Math.min(
523 job.filesTranslated + 1,
524 job.filesTotal
525 );
526 }
527
528 job.currentFile = undefined;
529 job.progress = 75;
530
531 // ── 6. Commit ────────────────────────────────────────────────────────────
532 job.status = "committing";
533
534 const label = targetLabel(job.target);
535 const addResult = await git(["add", "-A"], { cwd: worktreePath });
536 if (addResult.exitCode !== 0) {
537 throw new Error(`git add failed: ${addResult.stderr}`);
538 }
539
540 // Check if there's anything to commit
541 const statusResult = await git(
542 ["status", "--porcelain"],
543 { cwd: worktreePath }
544 );
545 if (!statusResult.stdout.trim()) {
546 throw new Error("No changes were produced by the migration — all files may have been skipped");
547 }
548
549 const commitMsg = `migrate: AI translation — ${label}\n\nAutomatically generated by Gluecron AI Codebase Migrator.\nFiles translated: ${job.filesTranslated}/${job.filesTotal}`;
550 const commitResult = await git(
551 ["commit", "-m", commitMsg],
552 { cwd: worktreePath }
553 );
554 if (commitResult.exitCode !== 0) {
555 throw new Error(`git commit failed: ${commitResult.stderr}`);
556 }
557
558 job.progress = 85;
559
560 // ── 7. Push ──────────────────────────────────────────────────────────────
561 // Push the new branch from the worktree into the bare repo
562 const pushResult = await git(
563 ["push", bareRepoPath, `HEAD:refs/heads/${job.branchName}`],
564 { cwd: worktreePath }
565 );
566 if (pushResult.exitCode !== 0) {
567 throw new Error(`git push failed: ${pushResult.stderr}`);
568 }
569
570 job.progress = 92;
571
572 // ── 8. Open PR ───────────────────────────────────────────────────────────
573 job.status = "opening-pr";
574
575 const prBody = [
576 `## AI Codebase Migration — ${label}`,
577 "",
578 "This pull request was **automatically generated** by the Gluecron AI Codebase Migrator.",
579 "",
580 `**Migration type:** ${job.target.type}`,
581 `**Target:** ${label}`,
582 `**Files translated:** ${job.filesTranslated}`,
583 `**Total files processed:** ${job.filesTotal}`,
584 "",
585 "> **Review carefully before merging.** AI translation is thorough but not perfect.",
586 "> Test the migrated code in a staging environment before landing to main.",
587 ].join("\n");
588
589 const prTitle = `migrate: AI codebase migration — ${label}`;
590
591 const prNumber = await insertPullRequest({
592 repositoryId: resolved.repoId,
593 authorId: job.userId,
594 title: prTitle,
595 body: prBody,
596 baseBranch: resolved.defaultBranch,
597 headBranch: job.branchName,
598 });
599
600 job.prNumber = prNumber;
601 job.progress = 100;
602 job.status = "done";
603 job.completedAt = new Date().toISOString();
604 } catch (err) {
605 job.status = "failed";
606 job.error = err instanceof Error ? err.message : "Unknown error";
607 job.completedAt = new Date().toISOString();
608 } finally {
609 // Clean up the worktree regardless of success or failure
610 try {
611 if (existsSync(worktreePath)) {
612 // Prune the worktree registration from the bare repo
613 const resolved2 = await resolveRepo(job.owner, job.repo);
614 if (resolved2) {
615 await git(["worktree", "prune"], { cwd: resolved2.diskPath });
616 }
617 await rm(worktreePath, { recursive: true, force: true });
618 }
619 } catch {
620 // Best-effort cleanup
621 }
622 // Release the repo lock
623 activeByRepo.delete(job.repoId);
624 }
625}
626
627// ---------------------------------------------------------------------------
628// Public API
629// ---------------------------------------------------------------------------
630
631export interface StartMigrationParams {
632 owner: string;
633 repo: string;
634 repoId: string;
635 userId: string;
636 target: MigrationTarget;
637}
638
639export async function startMigration(
640 params: StartMigrationParams
641): Promise<{ ok: true; job: MigrationJob } | { ok: false; error: string }> {
642 // Rate limit: 1 active migration per repo
643 if (isRepoMigrating(params.repoId)) {
644 return {
645 ok: false,
646 error: "A migration is already in progress for this repository. Wait for it to finish.",
647 };
648 }
649
650 // Rate limit: 3 per user per day
651 if (!recordDailyUse(params.userId)) {
652 return {
653 ok: false,
654 error: "You have reached the daily limit of 3 migrations. Try again tomorrow.",
655 };
656 }
657
658 const jobId = crypto.randomUUID().replace(/-/g, "").slice(0, 16);
659 const timestamp = Math.floor(Date.now() / 1000);
660 let branchSuffix: string;
661 if (params.target.type === "language") {
662 branchSuffix = `${params.target.from.toLowerCase()}-to-${params.target.to.toLowerCase()}`;
663 } else if (params.target.type === "framework") {
664 branchSuffix = `${params.target.from.toLowerCase()}-to-${params.target.to.toLowerCase()}`;
665 } else {
666 branchSuffix = "custom";
667 }
668 // Sanitize branch name
669 branchSuffix = branchSuffix.replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").slice(0, 40);
670 const branchName = `migrate/${branchSuffix}-${timestamp}`;
671
672 const job: MigrationJob = {
673 id: jobId,
674 repoId: params.repoId,
675 owner: params.owner,
676 repo: params.repo,
677 userId: params.userId,
678 target: params.target,
679 status: "queued",
680 progress: 0,
681 branchName,
682 filesTotal: 0,
683 filesTranslated: 0,
684 startedAt: new Date().toISOString(),
685 };
686
687 migrationJobs.set(jobId, job);
688 activeByRepo.set(params.repoId, jobId);
689
690 // Fire and forget
691 void runMigration(job);
692
693 return { ok: true, job };
694}
Modifiedsrc/lib/codeowners.ts+46−0View fileUnifiedSplit
2424 teamMembers,
2525 users,
2626} from "../db/schema";
27import { getBlob } from "../git/repository";
2728
2829export interface OwnerRule {
2930 pattern: string;
3536 owners: string[];
3637}
3738
39/** Public alias used by new callers (matches task spec interface). */
40export type CodeOwnerRule = OwnerRule;
41
3842export function isTeamToken(token: string): boolean {
3943 return token.includes("/");
4044}
191195 return [];
192196 }
193197}
198
199/**
200 * matchOwners — public alias for `reviewersForChangedFiles` using in-memory
201 * rules instead of DB. Accepts the pre-parsed rules array directly.
202 * Deduplicated; team tokens are NOT expanded here (use expandOwnerTokens).
203 */
204export function matchOwners(filePaths: string[], rules: OwnerRule[]): string[] {
205 const out = new Set<string>();
206 for (const p of filePaths) {
207 for (const u of ownersForPath(p, rules)) out.add(u);
208 }
209 return Array.from(out);
210}
211
212/**
213 * Fetch and parse the CODEOWNERS file for a repo from git.
214 * Checks three canonical locations in order:
215 * 1. `CODEOWNERS`
216 * 2. `.github/CODEOWNERS`
217 * 3. `docs/CODEOWNERS`
218 * Returns [] if none found.
219 */
220export async function getCodeownersForRepo(
221 owner: string,
222 repo: string,
223 branch: string
224): Promise<OwnerRule[]> {
225 const candidates = ["CODEOWNERS", ".github/CODEOWNERS", "docs/CODEOWNERS"];
226
227 for (const path of candidates) {
228 try {
229 const blob = await getBlob(owner, repo, branch, path);
230 if (blob && !blob.isBinary && blob.content) {
231 return parseCodeowners(blob.content);
232 }
233 } catch {
234 // file not found — try next candidate
235 }
236 }
237
238 return [];
239}
Addedsrc/lib/debt-analyzer.ts+302−0View fileUnifiedSplit
1/**
2 * AI Technical Debt Analyzer.
3 *
4 * Scans a repository's files, extracts static metrics, then uses Claude
5 * Sonnet to score the top 20 files by technical debt. Returns a DebtReport
6 * with per-file scores, issue lists, and estimated cleanup hours.
7 */
8
9import { join } from "path";
10import { config } from "./config";
11import { getAnthropic, MODEL_SONNET, extractText, parseJsonResponse } from "./ai-client";
12
13// ─── Types ────────────────────────────────────────────────────────────────────
14
15export interface DebtNode {
16 path: string;
17 lines: number;
18 debtScore: number; // 0-100, higher = more debt
19 issues: string[]; // e.g. ["Long functions (avg 87 lines)", "Deep nesting"]
20 estimatedHours: number; // Claude's estimate to clean up
21 imports: string[]; // files this file imports (resolved relative paths)
22}
23
24export interface DebtReport {
25 repoId: string;
26 commitSha: string;
27 nodes: DebtNode[];
28 totalDebtHours: number;
29 analyzedAt: string;
30}
31
32// ─── Constants ────────────────────────────────────────────────────────────────
33
34const CODE_EXTENSIONS = new Set([
35 ".ts", ".tsx", ".js", ".jsx",
36 ".py", ".go", ".rs", ".java", ".rb", ".php",
37]);
38
39const SKIP_PATTERNS = ["node_modules/", "dist/", ".min.", "vendor/"];
40
41const MAX_FILES = 150;
42const MAX_AI_FILES = 20;
43
44// ─── Helpers ─────────────────────────────────────────────────────────────────
45
46/** Run a git command inside the bare repo directory for `owner/repo`. */
47async function gitExec(
48 repoPath: string,
49 args: string[]
50): Promise<string> {
51 const proc = Bun.spawn(["git", ...args], {
52 cwd: repoPath,
53 stdout: "pipe",
54 stderr: "pipe",
55 });
56 const text = await new Response(proc.stdout).text();
57 await proc.exited;
58 return text;
59}
60
61/** Return true if a file path should be skipped. */
62function shouldSkip(path: string): boolean {
63 for (const pat of SKIP_PATTERNS) {
64 if (path.includes(pat)) return true;
65 }
66 return false;
67}
68
69/** Return true if the file has a code extension we want to analyze. */
70function isCodeFile(path: string): boolean {
71 const dot = path.lastIndexOf(".");
72 if (dot === -1) return false;
73 return CODE_EXTENSIONS.has(path.slice(dot));
74}
75
76/** Count lines in a string. */
77function countLines(content: string): number {
78 if (!content) return 0;
79 return content.split("\n").length;
80}
81
82/** Count TODO/FIXME/HACK/XXX occurrences. */
83function countTodos(content: string): number {
84 return (content.match(/\bTODO\b|\bFIXME\b|\bHACK\b|\bXXX\b/g) || []).length;
85}
86
87/** Rough complexity hint: count function/def/fn keywords vs line count. */
88function complexityHint(content: string, lines: number): string {
89 const fnCount = (
90 content.match(/\b(function|def |fn |func |async function|const \w+ = \(|=> \{)/g) || []
91 ).length;
92 if (lines === 0) return "empty";
93 if (lines > 800) return "very large file";
94 if (lines > 400) return "large file";
95 if (fnCount > 0) {
96 const avgFnLen = Math.round(lines / fnCount);
97 if (avgFnLen > 60) return `avg function ${avgFnLen} lines`;
98 }
99 return "normal";
100}
101
102/**
103 * Extract import paths from a file's content.
104 * Handles: import/from (ES modules), require() calls.
105 * Returns only local relative imports (starting with . or /).
106 */
107function extractImports(content: string, filePath: string): string[] {
108 const imports: string[] = [];
109 const dir = filePath.includes("/")
110 ? filePath.slice(0, filePath.lastIndexOf("/"))
111 : "";
112
113 // ES import: import ... from '...' or import '...'
114 const esImports = content.matchAll(/(?:import|from)\s+['"]([^'"]+)['"]/g);
115 for (const m of esImports) {
116 imports.push(m[1]);
117 }
118 // require(): require('...')
119 const requireImports = content.matchAll(/require\s*\(\s*['"]([^'"]+)['"]\s*\)/g);
120 for (const m of requireImports) {
121 imports.push(m[1]);
122 }
123
124 // Filter to relative/local only, resolve to plausible path
125 const resolved: string[] = [];
126 for (const imp of imports) {
127 if (!imp.startsWith(".") && !imp.startsWith("/")) continue;
128 // Simple resolution
129 let resolved_path = imp.startsWith("/")
130 ? imp.slice(1)
131 : dir
132 ? `${dir}/${imp}`
133 : imp;
134 // Normalize .. segments (very roughly)
135 const parts = resolved_path.split("/").filter(Boolean);
136 const stack: string[] = [];
137 for (const p of parts) {
138 if (p === "..") stack.pop();
139 else if (p !== ".") stack.push(p);
140 }
141 resolved_path = stack.join("/");
142 if (resolved_path) resolved.push(resolved_path);
143 }
144 return [...new Set(resolved)];
145}
146
147/** Heuristic debt score for files not sent to Claude. */
148function heuristicScore(todos: number, lines: number): number {
149 return Math.min(100, todos * 5 + Math.min(50, Math.round(lines / 20)));
150}
151
152// ─── Main analyzer ────────────────────────────────────────────────────────────
153
154export async function analyzeRepo(
155 repoId: string,
156 owner: string,
157 repo: string
158): Promise<DebtReport> {
159 const repoPath = join(config.gitReposPath, owner, `${repo}.git`);
160
161 // 1. List all files at HEAD
162 const lsOutput = await gitExec(repoPath, ["ls-tree", "-r", "--name-only", "HEAD"]);
163 const allFiles = lsOutput
164 .split("\n")
165 .map((f) => f.trim())
166 .filter((f) => f.length > 0 && isCodeFile(f) && !shouldSkip(f))
167 .slice(0, MAX_FILES);
168
169 // 2. Get HEAD commit SHA
170 const sha = (await gitExec(repoPath, ["rev-parse", "HEAD"])).trim();
171
172 // 3. Read each file, gather static metrics
173 interface FileInfo {
174 path: string;
175 content: string;
176 lines: number;
177 todos: number;
178 hint: string;
179 imports: string[];
180 }
181
182 const fileInfos: FileInfo[] = [];
183 for (const path of allFiles) {
184 const content = await gitExec(repoPath, ["show", `HEAD:${path}`]).catch(() => "");
185 const lines = countLines(content);
186 const todos = countTodos(content);
187 const hint = complexityHint(content, lines);
188 const imports = extractImports(content, path);
189 fileInfos.push({ path, content, lines, todos, hint, imports });
190 }
191
192 // 4. Sort by line count desc; pick top 20 for Claude
193 fileInfos.sort((a, b) => b.lines - a.lines);
194 const topFiles = fileInfos.slice(0, MAX_AI_FILES);
195 const restFiles = fileInfos.slice(MAX_AI_FILES);
196
197 // 5. Call Claude for the top 20
198 let aiResults: Map<string, { debtScore: number; issues: string[]; estimatedHours: number }> =
199 new Map();
200
201 if (topFiles.length > 0) {
202 const prompt = `You are a senior engineer assessing technical debt. Rate each file's debt 0-100 and estimate cleanup hours.
203Return a JSON array only, no prose: [{"path":"...","debtScore":N,"issues":["..."],"estimatedHours":N}, ...]
204
205Files:
206${JSON.stringify(
207 topFiles.map((f) => ({
208 path: f.path,
209 lines: f.lines,
210 todos: f.todos,
211 complexityHint: f.hint,
212 }))
213)}`;
214
215 try {
216 const client = getAnthropic();
217 const msg = await client.messages.create({
218 model: MODEL_SONNET,
219 max_tokens: 4096,
220 messages: [{ role: "user", content: prompt }],
221 });
222 const text = extractText(msg);
223 type AiRow = { path: string; debtScore: number; issues: string[]; estimatedHours: number };
224 const parsed = parseJsonResponse<AiRow[]>(text);
225 if (Array.isArray(parsed)) {
226 for (const row of parsed) {
227 if (row && typeof row.path === "string") {
228 aiResults.set(row.path, {
229 debtScore: Math.min(100, Math.max(0, Number(row.debtScore) || 0)),
230 issues: Array.isArray(row.issues)
231 ? row.issues.map(String)
232 : [],
233 estimatedHours: Math.max(0, Number(row.estimatedHours) || 0),
234 });
235 }
236 }
237 }
238 } catch {
239 // Claude unavailable — fall through to heuristics for all
240 }
241 }
242
243 // 6. Build nodes
244 const nodes: DebtNode[] = [];
245
246 for (const f of topFiles) {
247 const ai = aiResults.get(f.path);
248 if (ai) {
249 nodes.push({
250 path: f.path,
251 lines: f.lines,
252 debtScore: ai.debtScore,
253 issues: ai.issues,
254 estimatedHours: ai.estimatedHours,
255 imports: f.imports,
256 });
257 } else {
258 const score = heuristicScore(f.todos, f.lines);
259 nodes.push({
260 path: f.path,
261 lines: f.lines,
262 debtScore: score,
263 issues: buildHeuristicIssues(f.todos, f.lines, f.hint),
264 estimatedHours: Math.round(score / 10),
265 imports: f.imports,
266 });
267 }
268 }
269
270 for (const f of restFiles) {
271 const score = heuristicScore(f.todos, f.lines);
272 nodes.push({
273 path: f.path,
274 lines: f.lines,
275 debtScore: score,
276 issues: buildHeuristicIssues(f.todos, f.lines, f.hint),
277 estimatedHours: Math.round(score / 10),
278 imports: f.imports,
279 });
280 }
281
282 const totalDebtHours = nodes.reduce((sum, n) => sum + n.estimatedHours, 0);
283
284 return {
285 repoId,
286 commitSha: sha,
287 nodes,
288 totalDebtHours,
289 analyzedAt: new Date().toISOString(),
290 };
291}
292
293function buildHeuristicIssues(todos: number, lines: number, hint: string): string[] {
294 const issues: string[] = [];
295 if (todos > 0) issues.push(`${todos} TODO/FIXME comment${todos > 1 ? "s" : ""}`);
296 if (lines > 800) issues.push("Very large file (>800 lines)");
297 else if (lines > 400) issues.push("Large file (>400 lines)");
298 if (hint !== "normal" && hint !== "empty" && hint !== "large file" && hint !== "very large file") {
299 issues.push(hint.charAt(0).toUpperCase() + hint.slice(1));
300 }
301 return issues;
302}
Addedsrc/lib/debt-cache.ts+56−0View fileUnifiedSplit
1/**
2 * In-memory cache for DebtReport objects.
3 * Reports are invalidated after 1 hour.
4 */
5
6import type { DebtReport } from "./debt-analyzer";
7
8interface CacheEntry {
9 report: DebtReport;
10 cachedAt: number;
11}
12
13const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
14
15const cache = new Map<string, CacheEntry>();
16
17/** Returns the cached DebtReport if it's less than 1 hour old, else null. */
18export function getDebtReport(repoId: string): DebtReport | null {
19 const entry = cache.get(repoId);
20 if (!entry) return null;
21 if (Date.now() - entry.cachedAt > CACHE_TTL_MS) {
22 cache.delete(repoId);
23 return null;
24 }
25 return entry.report;
26}
27
28/** Store a DebtReport in the cache. */
29export function setDebtReport(repoId: string, report: DebtReport): void {
30 cache.set(repoId, { report, cachedAt: Date.now() });
31}
32
33/** Evict the cached report for a repository. */
34export function invalidateDebtReport(repoId: string): void {
35 cache.delete(repoId);
36}
37
38// ─── In-memory job status ─────────────────────────────────────────────────────
39
40export type JobStatus = "pending" | "running" | "done" | "error";
41
42interface JobEntry {
43 status: JobStatus;
44 error?: string;
45 startedAt: number;
46}
47
48const jobs = new Map<string, JobEntry>();
49
50export function getJobStatus(repoId: string): JobEntry | null {
51 return jobs.get(repoId) ?? null;
52}
53
54export function setJobStatus(repoId: string, status: JobStatus, error?: string): void {
55 jobs.set(repoId, { status, error, startedAt: Date.now() });
56}
Addedsrc/lib/dep-updater-sweep.ts+395−0View fileUnifiedSplit
1/**
2 * Block D2 — AI dependency auto-updater autopilot sweep.
3 *
4 * Called once per day by the autopilot `dep-update-sweep` task. For each
5 * repository with `dep_updater_enabled = true`, it:
6 *
7 * 1. Reads `package.json` from the default branch.
8 * 2. Queries the npm registry for patch/minor updates (major skipped —
9 * those go to the migration-watcher which writes a full migration guide).
10 * 3. For each candidate (up to 2 per repo):
11 * a. Applies the bump and creates a branch via `runDepUpdateRun`.
12 * b. Calls GateTest (if GATETEST_URL is configured) or tries the test
13 * script from package.json scripts.
14 * c. If gate passes: auto-merges by updating PR state to "merged".
15 * d. If gate fails: leaves the PR open with an AI-written comment
16 * explaining what broke and how to fix it.
17 *
18 * SAFETY:
19 * - Every error is caught per-repo — a single failure cannot stall others.
20 * - No-op when DEP_UPDATER_ENABLED env var is not "1" (checked by autopilot).
21 * - Requires ANTHROPIC_API_KEY only for the failure-path AI guide; the
22 * happy path (gate passes → auto-merge) works without it.
23 * - Uses the existing `runDepUpdateRun` helper which already handles the
24 * git plumbing + PR row insertion so we don't duplicate that logic.
25 */
26
27import { eq, and } from "drizzle-orm";
28import { db } from "../db";
29import {
30 depUpdateRuns,
31 pullRequests,
32 prComments,
33 repositories,
34 users,
35} from "../db/schema";
36import {
37 parseManifest,
38 planUpdates,
39 runDepUpdateRun,
40 queryNpmLatest,
41 type Bump,
42} from "./dep-updater";
43import { getBlob, getDefaultBranch } from "../git/repository";
44import { config } from "./config";
45import { getAnthropic, MODEL_SONNET, extractText, isAiAvailable } from "./ai-client";
46
47// ---------------------------------------------------------------------------
48// Public types
49// ---------------------------------------------------------------------------
50
51export interface DepUpdateSweepSummary {
52 repos: number;
53 runs: number;
54 merged: number;
55 prs: number;
56 skipped: number;
57 errors: number;
58}
59
60// ---------------------------------------------------------------------------
61// Helpers
62// ---------------------------------------------------------------------------
63
64/**
65 * Attempt to call GateTest to validate the update. Returns 'passed' |
66 * 'failed' | 'skipped' (when GATETEST_URL is not configured).
67 */
68async function runGateCheck(
69 owner: string,
70 repo: string,
71 branchName: string
72): Promise<"passed" | "failed" | "skipped"> {
73 const url = config.gatetestUrl;
74 if (!url) return "skipped";
75 try {
76 const res = await fetch(url, {
77 method: "POST",
78 headers: {
79 "Content-Type": "application/json",
80 ...(config.gatetestApiKey
81 ? { Authorization: `Bearer ${config.gatetestApiKey}` }
82 : {}),
83 },
84 body: JSON.stringify({
85 owner,
86 repo,
87 ref: `refs/heads/${branchName}`,
88 event: "dep_update",
89 }),
90 });
91 if (!res.ok) return "failed";
92 const data = (await res.json()) as { status?: string; result?: string };
93 const status = data.status ?? data.result ?? "";
94 return status === "passed" || status === "success" ? "passed" : "failed";
95 } catch {
96 // Network failure → treat as skipped so we don't block the PR.
97 return "skipped";
98 }
99}
100
101/**
102 * Ask Claude to write a short migration guide when gate checks fail.
103 * Returns a markdown string (or a plain fallback when AI is unavailable).
104 */
105async function generateMigrationGuide(
106 bumps: Bump[],
107 gateResult: string
108): Promise<string> {
109 const bumpSummary = bumps
110 .map((b) => `- \`${b.name}\`: ${b.from}${b.to}`)
111 .join("\n");
112
113 if (!isAiAvailable()) {
114 return [
115 "## Dependency update failed gate check",
116 "",
117 "The following packages were bumped but the gate check did not pass:",
118 "",
119 bumpSummary,
120 "",
121 "Gate result: " + gateResult,
122 "",
123 "Please review the changes manually and fix any compatibility issues before merging.",
124 ].join("\n");
125 }
126
127 try {
128 const client = getAnthropic();
129 const message = await client.messages.create({
130 model: MODEL_SONNET,
131 max_tokens: 512,
132 messages: [
133 {
134 role: "user",
135 content: [
136 {
137 type: "text",
138 text: [
139 "You are a dependency migration expert. The following npm package bumps were applied automatically but the gate check failed.",
140 "",
141 "Bumps applied:",
142 bumpSummary,
143 "",
144 "Gate check result: " + gateResult,
145 "",
146 "Write a concise markdown guide (under 300 words) explaining:",
147 "1. What likely changed in each package that could cause failures.",
148 "2. Practical steps to fix the issues and make the gate pass.",
149 "Keep it actionable and developer-friendly.",
150 ].join("\n"),
151 },
152 ],
153 },
154 ],
155 });
156 const text = extractText(message);
157 return text || "Gate check failed. Please review the bumped packages for breaking changes.";
158 } catch {
159 return [
160 "## Gate check failed after dependency update",
161 "",
162 "The following packages were bumped:",
163 "",
164 bumpSummary,
165 "",
166 "Gate result: " + gateResult,
167 "",
168 "Please review each package's changelog for breaking changes and update call-sites accordingly.",
169 ].join("\n");
170 }
171}
172
173/**
174 * Auto-merge a PR by marking it merged in the DB. This is a lightweight
175 * merge that skips branch protection — acceptable for bot-authored
176 * dependency-only PRs that have already passed a gate check.
177 */
178async function autoMergePr(
179 prId: string,
180 repoId: string,
181 authorId: string
182): Promise<boolean> {
183 try {
184 await db
185 .update(pullRequests)
186 .set({
187 state: "merged",
188 mergedAt: new Date(),
189 mergedBy: authorId,
190 updatedAt: new Date(),
191 })
192 .where(
193 and(
194 eq(pullRequests.id, prId),
195 eq(pullRequests.repositoryId, repoId)
196 )
197 );
198 return true;
199 } catch {
200 return false;
201 }
202}
203
204/**
205 * Post a comment on a PR with the AI-written migration guide.
206 */
207async function postMigrationGuideComment(
208 prId: string,
209 authorId: string,
210 guide: string
211): Promise<void> {
212 try {
213 await db.insert(prComments).values({
214 pullRequestId: prId,
215 authorId,
216 isAiReview: true,
217 body: `<!-- gluecron:dep-updater:gate-failed -->\n${guide}`,
218 });
219 } catch {
220 // Best-effort — comment failure should not block the sweep.
221 }
222}
223
224// ---------------------------------------------------------------------------
225// Main sweep
226// ---------------------------------------------------------------------------
227
228/**
229 * One pass of the dep-update sweep. Processes up to 10 opted-in repos,
230 * max 2 candidate bumps per repo per day. Never throws.
231 */
232export async function runDepUpdateSweepOnce(): Promise<DepUpdateSweepSummary> {
233 const summary: DepUpdateSweepSummary = {
234 repos: 0,
235 runs: 0,
236 merged: 0,
237 prs: 0,
238 skipped: 0,
239 errors: 0,
240 };
241
242 // Find repos with dep updater enabled.
243 let repoRows: Array<{
244 id: string;
245 name: string;
246 ownerId: string;
247 ownerUsername: string | null;
248 }> = [];
249 try {
250 const rows = await db
251 .select({
252 id: repositories.id,
253 name: repositories.name,
254 ownerId: repositories.ownerId,
255 ownerUsername: users.username,
256 })
257 .from(repositories)
258 .leftJoin(users, eq(users.id, repositories.ownerId))
259 .where(
260 and(
261 eq(repositories.depUpdaterEnabled, true),
262 eq(repositories.isArchived, false)
263 )
264 )
265 .limit(10);
266 repoRows = rows;
267 } catch (err) {
268 console.error("[dep-update-sweep] candidate query failed:", err);
269 return summary;
270 }
271
272 summary.repos = repoRows.length;
273
274 for (const row of repoRows) {
275 const owner = row.ownerUsername;
276 if (!owner) {
277 summary.skipped += 1;
278 continue;
279 }
280
281 try {
282 // Read package.json from default branch.
283 const branch = (await getDefaultBranch(owner, row.name)) || "main";
284 const blob = await getBlob(owner, row.name, branch, "package.json");
285 if (!blob || blob.isBinary) {
286 summary.skipped += 1;
287 continue;
288 }
289
290 const manifest = parseManifest(blob.content);
291 const allBumps = await planUpdates(manifest, { fetchLatest: queryNpmLatest });
292
293 // Filter to patch/minor only — majors go to migration-watcher.
294 const candidates = allBumps
295 .filter((b) => !b.major)
296 .slice(0, 2); // max 2 per repo per day
297
298 if (candidates.length === 0) {
299 summary.skipped += 1;
300 continue;
301 }
302
303 // Process each candidate individually so a single failure doesn't
304 // block the others.
305 for (const bump of candidates) {
306 try {
307 // Run the dep update (creates branch + PR row).
308 const result = await runDepUpdateRun({
309 repositoryId: row.id,
310 owner,
311 repo: row.name,
312 userId: row.ownerId,
313 manifestPath: "package.json",
314 });
315
316 summary.runs += 1;
317
318 if (result.status !== "success" && result.status !== "no_updates") {
319 summary.errors += 1;
320 continue;
321 }
322 if (result.status === "no_updates") {
323 summary.skipped += 1;
324 continue;
325 }
326
327 // Fetch the PR that was just created.
328 let prRow: { id: string; headBranch: string } | null = null;
329 if (result.runId) {
330 try {
331 const [run] = await db
332 .select({ branchName: depUpdateRuns.branchName })
333 .from(depUpdateRuns)
334 .where(eq(depUpdateRuns.id, result.runId))
335 .limit(1);
336 if (run?.branchName) {
337 const [pr] = await db
338 .select({ id: pullRequests.id, headBranch: pullRequests.headBranch })
339 .from(pullRequests)
340 .where(
341 and(
342 eq(pullRequests.repositoryId, row.id),
343 eq(pullRequests.headBranch, run.branchName),
344 eq(pullRequests.state, "open")
345 )
346 )
347 .limit(1);
348 prRow = pr ?? null;
349 }
350 } catch {
351 // Can't locate PR — fall through to just count it.
352 }
353 }
354
355 if (!prRow) {
356 summary.prs += 1;
357 continue;
358 }
359
360 // Run gate check.
361 const gateResult = await runGateCheck(owner, row.name, prRow.headBranch);
362
363 if (gateResult === "passed") {
364 // Auto-merge.
365 const merged = await autoMergePr(prRow.id, row.id, row.ownerId);
366 if (merged) {
367 summary.merged += 1;
368 } else {
369 summary.prs += 1;
370 }
371 } else {
372 // Gate failed or skipped — post an AI migration guide comment.
373 summary.prs += 1;
374 const guide = await generateMigrationGuide([bump], gateResult);
375 await postMigrationGuideComment(prRow.id, row.ownerId, guide);
376 }
377 } catch (err) {
378 summary.errors += 1;
379 console.error(
380 `[dep-update-sweep] per-bump error for repo=${row.name} pkg=${bump.name}:`,
381 err
382 );
383 }
384 }
385 } catch (err) {
386 summary.errors += 1;
387 console.error(
388 `[dep-update-sweep] per-repo error for repo=${row.name}:`,
389 err
390 );
391 }
392 }
393
394 return summary;
395}
Modifiedsrc/lib/dev-env.ts+2−2View fileUnifiedSplit
166166}
167167
168168/**
169 * Ask Claude (Haiku) to draft a `.gluecron/dev.yml` for a repo. Used when
169 * Ask Claude (Sonnet) to draft a `.gluecron/dev.yml` for a repo. Used when
170170 * the repo hasn't committed one. Returns the YAML body, or
171171 * `DEFAULT_DEV_YML` if AI is unavailable / errors. Never throws.
172172 */
175175 try {
176176 const client = getAnthropic();
177177 const message = await client.messages.create({
178 model: MODEL_HAIKU,
178 model: MODEL_SONNET,
179179 max_tokens: 800,
180180 messages: [
181181 {
Modifiedsrc/lib/hosted-claude-loop.ts+2−2View fileUnifiedSplit
6262const COST_CATEGORY: AiCostCategory = "other";
6363
6464/** Default model fallback when the snippet didn't tell us what it ran. */
65const DEFAULT_MODEL = "claude-haiku-4-5";
65const DEFAULT_MODEL = "claude-sonnet-4-6";
6666
6767// ---------------------------------------------------------------------------
6868// Pure helpers
170170const repo = input.repo || "ccantynz-alt/Gluecron.com";
171171
172172const result = await client.messages.create({
173 model: "claude-haiku-4-5",
173 model: "claude-sonnet-4-6",
174174 max_tokens: 1024,
175175 messages: [
176176 { role: "user", content: \`Summarise repo \${repo} in 3 bullets\` },
Addedsrc/lib/incident-analyzer.ts+244−0View fileUnifiedSplit
1/**
2 * AI incident analysis — given an alert title/description and a repo, identify
3 * the likely guilty commit(s) and produce a fix suggestion.
4 *
5 * Used by src/routes/incident-hooks.tsx when PagerDuty / Datadog / Opsgenie /
6 * generic webhook alerts land. Degrades gracefully: without ANTHROPIC_API_KEY
7 * the analysis fields are populated with safe fallback text and the caller
8 * still opens the issue, it just skips the draft PR.
9 */
10
11import { getRepoPath } from "../git/repository";
12import {
13 MODEL_SONNET,
14 extractText,
15 getAnthropic,
16 isAiAvailable,
17 parseJsonResponse,
18} from "./ai-client";
19
20export interface IncidentAnalysisResult {
21 likelyFiles: Array<{ path: string; reason: string }>;
22 suggestedFix: string; // markdown with code blocks
23 issueTitle: string;
24 issueBody: string;
25 branchName: string;
26}
27
28export interface AnalyzeIncidentParams {
29 title: string;
30 description: string;
31 owner: string;
32 repo: string;
33 repoId: string;
34}
35
36// ─────────────────────────────────────────────────────────────────────────────
37// Git helpers (all fire-and-forget safe — return empty on error)
38// ─────────────────────────────────────────────────────────────────────────────
39
40async function runGit(
41 args: string[],
42 cwd: string
43): Promise<string> {
44 try {
45 const proc = Bun.spawn(["git", ...args], {
46 cwd,
47 stdout: "pipe",
48 stderr: "pipe",
49 });
50 const text = await new Response(proc.stdout).text();
51 await proc.exited;
52 return text.trim();
53 } catch {
54 return "";
55 }
56}
57
58/** Get commits from the last 24 hours: "<sha> <subject>" lines. */
59async function recentCommits(owner: string, repo: string): Promise<string> {
60 const cwd = getRepoPath(owner, repo);
61 return runGit(
62 ["log", "--since=24 hours ago", "--format=%H %s", "HEAD", "--"],
63 cwd
64 );
65}
66
67/** Files changed in the last 10 commits (unique). */
68async function recentChangedFiles(
69 owner: string,
70 repo: string
71): Promise<string[]> {
72 const cwd = getRepoPath(owner, repo);
73 const out = await runGit(
74 ["diff", "--name-only", "HEAD~10..HEAD", "--"],
75 cwd
76 );
77 if (!out) return [];
78 return [
79 ...new Set(
80 out
81 .split("\n")
82 .map((l) => l.trim())
83 .filter((l) => l.length > 0)
84 ),
85 ];
86}
87
88/** Read the first `lines` lines of a file from the HEAD commit. */
89async function readFileHead(
90 owner: string,
91 repo: string,
92 path: string,
93 lines = 100
94): Promise<string> {
95 const cwd = getRepoPath(owner, repo);
96 const full = await runGit(["show", `HEAD:${path}`], cwd);
97 if (!full) return "";
98 return full.split("\n").slice(0, lines).join("\n");
99}
100
101// ─────────────────────────────────────────────────────────────────────────────
102// Claude prompt
103// ─────────────────────────────────────────────────────────────────────────────
104
105interface AiResponse {
106 likelyFiles: Array<{ path: string; reason: string }>;
107 suggestedFix: string;
108 issueTitle: string;
109 issueBody: string;
110 branchName: string;
111}
112
113async function callClaude(
114 title: string,
115 description: string,
116 commits: string,
117 fileContents: string
118): Promise<AiResponse | null> {
119 try {
120 const client = getAnthropic();
121 const message = await client.messages.create({
122 model: MODEL_SONNET,
123 max_tokens: 2048,
124 system:
125 "You are a senior on-call engineer. Given a production incident and recent code changes, identify the likely cause and suggest a fix. Always respond with valid JSON only.",
126 messages: [
127 {
128 role: "user",
129 content: `Incident: ${title}
130
131${description || "(no additional details)"}
132
133Recent commits (last 24h):
134${commits || "(none)"}
135
136Changed files (last 10 commits — first 100 lines each):
137${fileContents || "(none)"}
138
139Return JSON exactly matching this shape:
140{
141 "likelyFiles": [{"path": "string", "reason": "string"}],
142 "suggestedFix": "markdown code block with the fix",
143 "issueTitle": "string",
144 "issueBody": "string (markdown, include context + fix steps)",
145 "branchName": "string (slugified, e.g. fix/incident-db-spike)"
146}`,
147 },
148 ],
149 });
150
151 const raw = extractText(message);
152 const parsed = parseJsonResponse<AiResponse>(raw);
153 if (!parsed) return null;
154
155 // Validate + normalise
156 return {
157 likelyFiles: Array.isArray(parsed.likelyFiles) ? parsed.likelyFiles : [],
158 suggestedFix:
159 typeof parsed.suggestedFix === "string" ? parsed.suggestedFix : "",
160 issueTitle:
161 typeof parsed.issueTitle === "string" && parsed.issueTitle.trim()
162 ? parsed.issueTitle.trim().slice(0, 200)
163 : `Incident: ${title}`,
164 issueBody:
165 typeof parsed.issueBody === "string" ? parsed.issueBody : "",
166 branchName:
167 typeof parsed.branchName === "string" && parsed.branchName.trim()
168 ? parsed.branchName
169 .trim()
170 .toLowerCase()
171 .replace(/[^a-z0-9/-]/g, "-")
172 .replace(/-{2,}/g, "-")
173 .slice(0, 80)
174 : `fix/incident-${Date.now()}`,
175 };
176 } catch (err) {
177 console.error("[incident-analyzer] claude call failed:", err);
178 return null;
179 }
180}
181
182// ─────────────────────────────────────────────────────────────────────────────
183// Public API
184// ─────────────────────────────────────────────────────────────────────────────
185
186export async function analyzeIncident(
187 params: AnalyzeIncidentParams
188): Promise<IncidentAnalysisResult> {
189 const { title, description, owner, repo } = params;
190 const ts = Date.now();
191
192 // Fallback (no AI or git fails): still produces a usable issue.
193 const fallback: IncidentAnalysisResult = {
194 likelyFiles: [],
195 suggestedFix: "",
196 issueTitle: `Incident: ${title}`,
197 issueBody: [
198 `## Alert`,
199 `**${title}**`,
200 "",
201 description || "(no additional details)",
202 "",
203 "---",
204 "_AI analysis unavailable — ANTHROPIC_API_KEY not set._",
205 ].join("\n"),
206 branchName: `fix/incident-${ts}`,
207 };
208
209 if (!isAiAvailable()) {
210 return fallback;
211 }
212
213 // 1. Gather git context (best-effort — failures return empty strings/arrays)
214 let commits = "";
215 let changedFiles: string[] = [];
216 try {
217 [commits, changedFiles] = await Promise.all([
218 recentCommits(owner, repo),
219 recentChangedFiles(owner, repo),
220 ]);
221 } catch {
222 /* swallow */
223 }
224
225 // 2. Read file content for recently changed files (up to 10 files, 100 lines each)
226 const fileSnippets: string[] = [];
227 for (const f of changedFiles.slice(0, 10)) {
228 try {
229 const content = await readFileHead(owner, repo, f, 100);
230 if (content) {
231 fileSnippets.push(`### ${f}\n\`\`\`\n${content}\n\`\`\``);
232 }
233 } catch {
234 /* skip */
235 }
236 }
237 const fileContents = fileSnippets.join("\n\n");
238
239 // 3. Call Claude
240 const ai = await callClaude(title, description, commits, fileContents);
241 if (!ai) return fallback;
242
243 return ai;
244}
Modifiedsrc/lib/notify.ts+86−4View fileUnifiedSplit
88 * preferences. Email failures are logged and swallowed.
99 */
1010
11import { inArray, eq } from "drizzle-orm";
11import { inArray, eq, and, desc, sql } from "drizzle-orm";
1212import { db } from "../db";
13import { notifications, auditLog, users } from "../db/schema";
13import { notifications as notificationsMain, auditLog, users } from "../db/schema";
14import { notifications as notificationsExt } from "../db/schema-extensions";
1415import { sendEmail, absoluteUrl } from "./email";
1516
17// ─── Public helpers (schema-extensions notifications table) ─────────────────
18// These operate on the schema-extensions `notifications` table (which has
19// `type`, `isRead`, `actorId` columns) — the same table the inbox page uses.
20
21/** Create a single in-app notification. Fire-and-forget safe: swallows errors. */
22export async function createNotification(params: {
23 userId: string;
24 type: string;
25 title: string;
26 body?: string;
27 url?: string;
28 repoId?: string;
29}): Promise<void> {
30 try {
31 await db.insert(notificationsExt).values({
32 userId: params.userId,
33 type: params.type,
34 title: params.title,
35 body: params.body,
36 url: params.url,
37 repositoryId: params.repoId,
38 });
39 } catch (err) {
40 console.error("[createNotification] failed:", err);
41 }
42}
43
44/** Return the count of unread notifications for a user. Returns 0 on error. */
45export async function getUnreadCount(userId: string): Promise<number> {
46 try {
47 const [row] = await db
48 .select({ count: sql<number>`count(*)` })
49 .from(notificationsExt)
50 .where(and(eq(notificationsExt.userId, userId), eq(notificationsExt.isRead, false)));
51 return Number(row?.count ?? 0);
52 } catch {
53 return 0;
54 }
55}
56
57/** Return notifications for a user (most recent first). Returns [] on error. */
58export async function getNotifications(
59 userId: string,
60 limit = 50
61): Promise<typeof notificationsExt.$inferSelect[]> {
62 try {
63 return await db
64 .select()
65 .from(notificationsExt)
66 .where(eq(notificationsExt.userId, userId))
67 .orderBy(desc(notificationsExt.createdAt))
68 .limit(limit);
69 } catch {
70 return [];
71 }
72}
73
74/** Mark a single notification as read (only if it belongs to userId). */
75export async function markRead(notificationId: string, userId: string): Promise<void> {
76 try {
77 await db
78 .update(notificationsExt)
79 .set({ isRead: true })
80 .where(and(eq(notificationsExt.id, notificationId), eq(notificationsExt.userId, userId)));
81 } catch (err) {
82 console.error("[markRead] failed:", err);
83 }
84}
85
86/** Mark all of a user's notifications as read. */
87export async function markAllRead(userId: string): Promise<void> {
88 try {
89 await db
90 .update(notificationsExt)
91 .set({ isRead: true })
92 .where(and(eq(notificationsExt.userId, userId), eq(notificationsExt.isRead, false)));
93 } catch (err) {
94 console.error("[markAllRead] failed:", err);
95 }
96}
97
1698export type NotificationKind =
1799 | "mention"
18100 | "review_requested"
148230 }
149231): Promise<void> {
150232 try {
151 await db.insert(notifications).values({
233 await db.insert(notificationsMain).values({
152234 userId,
153235 kind: opts.kind,
154236 title: opts.title,
175257 const unique = Array.from(new Set(userIds));
176258 if (unique.length === 0) return;
177259 try {
178 await db.insert(notifications).values(
260 await db.insert(notificationsMain).values(
179261 unique.map((userId) => ({
180262 userId,
181263 kind: opts.kind,
Addedsrc/lib/pr-presence.ts+214−0View fileUnifiedSplit
1/**
2 * Real-time collaborative PR review presence.
3 *
4 * Maintains an in-memory room map keyed by PR id. Each room tracks every
5 * connected reviewer's current line hover and typing state. The module is
6 * self-contained: no DB, no Redis — purely ephemeral per-process state.
7 *
8 * Rooms are automatically created on first join and swept clean when the
9 * last user leaves. A background sweep evicts sessions whose lastSeen
10 * timestamp is older than STALE_THRESHOLD_MS (30 seconds).
11 *
12 * Public API:
13 * joinRoom(prId, sessionId, user)
14 * leaveRoom(prId, sessionId)
15 * updatePresence(prId, sessionId, line, typing)
16 * getRoomUsers(prId)
17 * broadcastToRoom(prId, message, excludeSession?)
18 * registerSocket(prId, sessionId, ws)
19 * unregisterSocket(prId, sessionId)
20 */
21
22export type PresenceUser = {
23 userId: string;
24 username: string;
25 colour: string;
26 line: number | null;
27 typing: boolean;
28 lastSeen: number; // Date.now()
29};
30
31type WsLike = {
32 send: (data: string) => void;
33 readyState?: number;
34};
35
36// Room: sessionId → PresenceUser
37const rooms = new Map<string, Map<string, PresenceUser>>();
38
39// Socket registry: prId → sessionId → WebSocket
40const sockets = new Map<string, Map<string, WsLike>>();
41
42const COLOURS = [
43 "#e74c3c",
44 "#3498db",
45 "#2ecc71",
46 "#f39c12",
47 "#9b59b6",
48 "#1abc9c",
49 "#e67e22",
50 "#34495e",
51];
52
53const STALE_THRESHOLD_MS = 30_000;
54const SWEEP_INTERVAL_MS = 15_000;
55
56/** Stable colour assignment: hash userId → one of 8 preset colours. */
57function assignColour(userId: string): string {
58 let hash = 0;
59 for (let i = 0; i < userId.length; i++) {
60 hash = (hash * 31 + userId.charCodeAt(i)) >>> 0;
61 }
62 return COLOURS[hash % COLOURS.length];
63}
64
65function getRoom(prId: string): Map<string, PresenceUser> {
66 let room = rooms.get(prId);
67 if (!room) {
68 room = new Map();
69 rooms.set(prId, room);
70 }
71 return room;
72}
73
74function getSockets(prId: string): Map<string, WsLike> {
75 let map = sockets.get(prId);
76 if (!map) {
77 map = new Map();
78 sockets.set(prId, map);
79 }
80 return map;
81}
82
83/** Add or refresh a user's presence in a room. */
84export function joinRoom(
85 prId: string,
86 sessionId: string,
87 user: { userId: string; username: string }
88): PresenceUser {
89 const room = getRoom(prId);
90 const existing = room.get(sessionId);
91 const entry: PresenceUser = {
92 userId: user.userId,
93 username: user.username,
94 colour: existing?.colour ?? assignColour(user.userId),
95 line: existing?.line ?? null,
96 typing: existing?.typing ?? false,
97 lastSeen: Date.now(),
98 };
99 room.set(sessionId, entry);
100 return entry;
101}
102
103/** Remove a session from a room; prune empty rooms. */
104export function leaveRoom(prId: string, sessionId: string): void {
105 const room = rooms.get(prId);
106 if (!room) return;
107 room.delete(sessionId);
108 if (room.size === 0) rooms.delete(prId);
109
110 const ss = sockets.get(prId);
111 if (ss) {
112 ss.delete(sessionId);
113 if (ss.size === 0) sockets.delete(prId);
114 }
115}
116
117/** Update cursor line / typing state and refresh lastSeen. */
118export function updatePresence(
119 prId: string,
120 sessionId: string,
121 line: number | null,
122 typing: boolean
123): PresenceUser | null {
124 const room = rooms.get(prId);
125 if (!room) return null;
126 const entry = room.get(sessionId);
127 if (!entry) return null;
128 entry.line = line;
129 entry.typing = typing;
130 entry.lastSeen = Date.now();
131 return entry;
132}
133
134/** Ping-only: refresh lastSeen without changing line/typing. */
135export function pingSession(prId: string, sessionId: string): void {
136 const room = rooms.get(prId);
137 if (!room) return;
138 const entry = room.get(sessionId);
139 if (entry) entry.lastSeen = Date.now();
140}
141
142/** Snapshot of all current users in a room (excluding stale sessions). */
143export function getRoomUsers(prId: string): Array<PresenceUser & { sessionId: string }> {
144 const room = rooms.get(prId);
145 if (!room) return [];
146 const now = Date.now();
147 return Array.from(room.entries())
148 .filter(([, u]) => now - u.lastSeen < STALE_THRESHOLD_MS)
149 .map(([sessionId, u]) => ({ ...u, sessionId }));
150}
151
152/** Register a WebSocket connection for a session. */
153export function registerSocket(prId: string, sessionId: string, ws: WsLike): void {
154 getSockets(prId).set(sessionId, ws);
155}
156
157/** Remove a WebSocket registration for a session. */
158export function unregisterSocket(prId: string, sessionId: string): void {
159 const ss = sockets.get(prId);
160 if (!ss) return;
161 ss.delete(sessionId);
162 if (ss.size === 0) sockets.delete(prId);
163}
164
165/**
166 * Broadcast a JSON message to every connected WebSocket in a room.
167 * If excludeSession is set, that session's socket is skipped (suppress echo).
168 */
169export function broadcastToRoom(
170 prId: string,
171 message: unknown,
172 excludeSession?: string
173): void {
174 const ss = sockets.get(prId);
175 if (!ss) return;
176 const payload = JSON.stringify(message);
177 for (const [sessionId, ws] of ss) {
178 if (excludeSession && sessionId === excludeSession) continue;
179 // 1 = OPEN
180 if (ws.readyState !== undefined && ws.readyState !== 1) continue;
181 try {
182 ws.send(payload);
183 } catch {
184 // Stale socket — will be cleaned up by sweep or disconnect handler.
185 }
186 }
187}
188
189// ---------------------------------------------------------------------------
190// Background sweep — evict stale sessions every 15 s
191// ---------------------------------------------------------------------------
192
193function sweep(): void {
194 const now = Date.now();
195 for (const [prId, room] of rooms) {
196 for (const [sessionId, user] of room) {
197 if (now - user.lastSeen >= STALE_THRESHOLD_MS) {
198 // Notify room peers before evicting.
199 broadcastToRoom(prId, { type: "leave", sessionId }, sessionId);
200 room.delete(sessionId);
201 const ss = sockets.get(prId);
202 if (ss) ss.delete(sessionId);
203 }
204 }
205 if (room.size === 0) rooms.delete(prId);
206 }
207}
208
209// Start sweep loop — fire-and-forget, never throws.
210const _sweepInterval = setInterval(sweep, SWEEP_INTERVAL_MS);
211// Allow Bun/Node to exit cleanly even if this interval is live.
212if (typeof _sweepInterval === "object" && _sweepInterval !== null) {
213 (_sweepInterval as NodeJS.Timeout).unref?.();
214}
Modifiedsrc/lib/pr-risk.ts+3−3View fileUnifiedSplit
55 * the reviewer can see at a glance before clicking Merge. The score is a
66 * pure function over a small set of signals (file count, line count,
77 * teams affected, schema migrations touched, dependency churn, test
8 * ratio). The LLM (Haiku — fast + cheap) only writes the one-paragraph
8 * ratio). The LLM (Sonnet 4.6) only writes the one-paragraph
99 * prose summary; it never influences the numeric score.
1010 *
1111 * Architecture:
1212 *
1313 * 1. `computePrRiskScore(signals)` — pure helper. Same input always
1414 * yields the same score. Documented inline; auditable.
15 * 2. `generatePrRiskSummary(args)` — calls Haiku for prose. Never
15 * 2. `generatePrRiskSummary(args)` — calls Sonnet for prose. Never
1616 * throws — falls back to a deterministic sentence when no API key
1717 * is set or the call fails.
1818 * 3. `computePrRiskForPullRequest(prId)`DB-backed orchestrator.
174174 try {
175175 const client = getAnthropic();
176176 const message = await client.messages.create({
177 model: MODEL_HAIKU,
177 model: MODEL_SONNET,
178178 max_tokens: 200,
179179 messages: [
180180 {
Modifiedsrc/lib/pr-sandbox.ts+2−2View fileUnifiedSplit
150150}
151151
152152/**
153 * Ask Claude (Haiku) to draft a playground.yml for a repo. Used when the
153 * Ask Claude (Sonnet) to draft a playground.yml for a repo. Used when the
154154 * repo hasn't committed one. Returns the YAML body, or
155155 * `DEFAULT_PLAYGROUND_YML` if AI is unavailable / errors. Never throws.
156156 *
164164 try {
165165 const client = getAnthropic();
166166 const message = await client.messages.create({
167 model: MODEL_HAIKU,
167 model: MODEL_SONNET,
168168 max_tokens: 800,
169169 messages: [
170170 {
Addedsrc/lib/ship-agent.ts+557−0View fileUnifiedSplit
1/**
2 * Ship Agent — autonomous AI feature implementation pipeline.
3 *
4 * Given a GitHub issue, the agent:
5 * 1. Plans the implementation by reading the file tree and key files.
6 * 2. Reads all files the plan references.
7 * 3. Creates a branch and rewrites each file via Claude.
8 * 4. Commits the changes.
9 * 5. Opens a PR and posts a comment on the original issue.
10 *
11 * Jobs run fire-and-forget (async, no await at call-site).
12 * Progress is stored in-memory in `shipJobs` and polled by the UI.
13 */
14
15import { randomUUID } from "crypto";
16import { join } from "path";
17import { writeFile, mkdir } from "fs/promises";
18import { config } from "./config";
19import { getAnthropic, extractText, parseJsonResponse, MODEL_SONNET } from "./ai-client";
20import { getRepoPath, getDefaultBranch, resolveRef } from "../git/repository";
21import { db } from "../db";
22import { pullRequests, issues, issueComments, users } from "../db/schema";
23import { and, eq, desc } from "drizzle-orm";
24
25// ─── Types ─────────────────────────────────────────────────────────────────
26
27export type ShipStatus =
28 | "planning"
29 | "reading"
30 | "coding"
31 | "committing"
32 | "opening-pr"
33 | "done"
34 | "failed";
35
36export interface ShipJob {
37 id: string;
38 issueId: string;
39 repoId: string;
40 owner: string;
41 repo: string;
42 issueNumber: number;
43 issueTitle: string;
44 issueBody: string;
45 requestedByUserId: string;
46 status: ShipStatus;
47 plan?: string;
48 branchName?: string;
49 prNumber?: number;
50 prUrl?: string;
51 log: string[];
52 error?: string;
53 createdAt: Date;
54 completedAt?: Date;
55}
56
57interface PlanResponse {
58 plan: string;
59 files_to_modify: Array<{ path: string; change_description: string }>;
60 new_files: Array<{ path: string; purpose: string }>;
61 branch_name: string;
62}
63
64// ─── In-memory store ────────────────────────────────────────────────────────
65
66export const shipJobs = new Map<string, ShipJob>();
67
68// ─── Rate-limiting state ────────────────────────────────────────────────────
69
70// Track jobs started per user per day (UTC).
71const userDayJobCount = new Map<string, { date: string; count: number }>();
72// Track active jobs per repo.
73const repoActiveJobs = new Map<string, string>(); // repoId -> jobId
74
75function todayUtc(): string {
76 return new Date().toISOString().slice(0, 10);
77}
78
79function checkRateLimits(userId: string, repoId: string): string | null {
80 const today = todayUtc();
81 const userKey = `${userId}:${today}`;
82 const entry = userDayJobCount.get(userId);
83 if (entry && entry.date === today && entry.count >= 3) {
84 return "Rate limit: max 3 ship jobs per user per day.";
85 }
86 const activeJobId = repoActiveJobs.get(repoId);
87 if (activeJobId && shipJobs.get(activeJobId)?.status !== "done" && shipJobs.get(activeJobId)?.status !== "failed") {
88 return "Rate limit: only 1 concurrent ship job per repo.";
89 }
90 return null;
91}
92
93function incrementUserCount(userId: string) {
94 const today = todayUtc();
95 const entry = userDayJobCount.get(userId);
96 if (!entry || entry.date !== today) {
97 userDayJobCount.set(userId, { date: today, count: 1 });
98 } else {
99 entry.count++;
100 }
101}
102
103// ─── Helpers ────────────────────────────────────────────────────────────────
104
105function addLog(job: ShipJob, msg: string) {
106 job.log.push(`[${new Date().toISOString()}] ${msg}`);
107}
108
109async function execGit(
110 args: string[],
111 cwd: string,
112 env?: Record<string, string>
113): Promise<{ stdout: string; stderr: string; exitCode: number }> {
114 const proc = Bun.spawn(args, {
115 cwd,
116 env: { ...process.env, ...env },
117 stdout: "pipe",
118 stderr: "pipe",
119 });
120 const [stdout, stderr] = await Promise.all([
121 new Response(proc.stdout).text(),
122 new Response(proc.stderr).text(),
123 ]);
124 const exitCode = await proc.exited;
125 return { stdout, stderr, exitCode };
126}
127
128/**
129 * Get a flat list of all files (blob paths) in the repo, up to maxCount.
130 */
131async function listAllFiles(
132 repoPath: string,
133 ref: string,
134 maxCount = 200
135): Promise<string[]> {
136 const { stdout, exitCode } = await execGit(
137 ["git", "ls-tree", "-r", "--name-only", ref],
138 repoPath
139 );
140 if (exitCode !== 0) return [];
141 return stdout
142 .trim()
143 .split("\n")
144 .filter(Boolean)
145 .slice(0, maxCount);
146}
147
148/**
149 * Read a file's content from the bare git repo.
150 */
151async function readFileFromRepo(repoPath: string, ref: string, filePath: string): Promise<string> {
152 const { stdout, exitCode } = await execGit(
153 ["git", "show", `${ref}:${filePath}`],
154 repoPath
155 );
156 if (exitCode !== 0) return "";
157 return stdout;
158}
159
160/**
161 * Get a bot user for authoring the PR.
162 * Falls back to the requesting user if no bot user exists.
163 */
164async function getBotUserId(fallbackUserId: string): Promise<string> {
165 try {
166 const [bot] = await db
167 .select({ id: users.id })
168 .from(users)
169 .where(eq(users.username, "gluecron-bot"))
170 .limit(1);
171 if (bot) return bot.id;
172 } catch {
173 // fall through
174 }
175 return fallbackUserId;
176}
177
178/**
179 * Post a comment on the issue from the requesting user.
180 */
181async function postIssueComment(
182 issueId: string,
183 authorId: string,
184 body: string
185): Promise<void> {
186 try {
187 await db.insert(issueComments).values({
188 issueId,
189 authorId,
190 body,
191 moderationStatus: "approved",
192 });
193 } catch (err) {
194 console.warn("[ship-agent] failed to post issue comment:", err);
195 }
196}
197
198// ─── Public API ─────────────────────────────────────────────────────────────
199
200export async function startShipJob(params: {
201 issueId: string;
202 repoId: string;
203 owner: string;
204 repo: string;
205 issueNumber: number;
206 issueTitle: string;
207 issueBody: string;
208 requestedByUserId: string;
209}): Promise<string> {
210 const rateLimitErr = checkRateLimits(params.requestedByUserId, params.repoId);
211 if (rateLimitErr) throw new Error(rateLimitErr);
212
213 const jobId = randomUUID();
214 const job: ShipJob = {
215 id: jobId,
216 ...params,
217 status: "planning",
218 log: [],
219 createdAt: new Date(),
220 };
221
222 shipJobs.set(jobId, job);
223 incrementUserCount(params.requestedByUserId);
224 repoActiveJobs.set(params.repoId, jobId);
225
226 // Fire-and-forget
227 runShipJob(job).catch((err) => {
228 console.error("[ship-agent] unhandled error in runShipJob:", err);
229 });
230
231 return jobId;
232}
233
234export function getShipJob(jobId: string): ShipJob | undefined {
235 return shipJobs.get(jobId);
236}
237
238// ─── Main pipeline ───────────────────────────────────────────────────────────
239
240async function runShipJob(job: ShipJob): Promise<void> {
241 try {
242 await phasePlan(job);
243 await phaseRead(job);
244 await phaseCode(job);
245 await phaseCommit(job);
246 await phaseOpenPr(job);
247 job.status = "done";
248 job.completedAt = new Date();
249 addLog(job, "Ship agent completed successfully.");
250 await postIssueComment(
251 job.issueId,
252 job.requestedByUserId,
253 `Ship Agent completed! Changes are ready for review in PR #${job.prNumber}. If the GateTest passes, this is ready to merge.`
254 );
255 } catch (err) {
256 const msg = err instanceof Error ? err.message : String(err);
257 job.status = "failed";
258 job.error = msg;
259 job.completedAt = new Date();
260 addLog(job, `FAILED: ${msg}`);
261 await postIssueComment(
262 job.issueId,
263 job.requestedByUserId,
264 `Ship Agent failed during the **${job.status}** phase.\n\nError: \`${msg}\`\n\nPlease review the issue and try again or implement manually.`
265 ).catch(() => {});
266 }
267}
268
269// ─── Phase 1: Planning ───────────────────────────────────────────────────────
270
271async function phasePlan(job: ShipJob): Promise<void> {
272 job.status = "planning";
273 addLog(job, "Starting planning phase...");
274
275 const repoDiskPath = getRepoPath(job.owner, job.repo);
276 const defaultBranch = (await getDefaultBranch(job.owner, job.repo)) ?? "main";
277 const ref = await resolveRef(job.owner, job.repo, defaultBranch);
278 if (!ref) throw new Error(`Cannot resolve ref for branch '${defaultBranch}'. Does the repo have commits?`);
279
280 // File tree (up to 200 files)
281 const allFiles = await listAllFiles(repoDiskPath, ref, 200);
282 const treeStr = allFiles.join("\n");
283 addLog(job, `Read file tree: ${allFiles.length} files.`);
284
285 // Read a few key files for context
286 const keyFiles = ["README.md", "package.json", "bun.lockb", "src/app.tsx", "CLAUDE.md"];
287 const keyFileContents: string[] = [];
288 for (const f of keyFiles) {
289 if (allFiles.includes(f)) {
290 const content = await readFileFromRepo(repoDiskPath, ref, f);
291 if (content && !content.includes("\0")) {
292 keyFileContents.push(`--- ${f} ---\n${content.slice(0, 2000)}`);
293 }
294 }
295 }
296
297 const client = getAnthropic();
298
299 const userPrompt = `Issue: ${job.issueTitle}\n\n${job.issueBody}\n\nFile tree:\n${treeStr}\n\nKey files:\n${keyFileContents.join("\n\n").slice(0, 6000)}\n\nReturn JSON with this exact shape:\n{"plan": "string describing what will be done", "files_to_modify": [{"path": "src/...", "change_description": "..."}], "new_files": [{"path": "src/...", "purpose": "..."}], "branch_name": "feat/short-slug"}`;
300
301 let planRaw: PlanResponse | null = null;
302 for (let attempt = 0; attempt < 2; attempt++) {
303 const msg = await client.messages.create({
304 model: MODEL_SONNET,
305 max_tokens: 4000,
306 system:
307 "You are an expert developer. Given a GitHub issue and codebase, create a precise implementation plan. Return ONLY valid JSON, no explanations.",
308 messages: [{ role: "user", content: attempt === 0 ? userPrompt : `Return ONLY valid JSON, no explanation:\n${userPrompt}` }],
309 });
310 const text = extractText(msg);
311 planRaw = parseJsonResponse<PlanResponse>(text);
312 if (planRaw) break;
313 }
314
315 if (!planRaw) {
316 throw new Error("AI returned invalid JSON for the implementation plan. Aborting.");
317 }
318
319 job.plan = planRaw.plan;
320 job.branchName = sanitizeBranchName(planRaw.branch_name || `ship-agent/issue-${job.issueNumber}`);
321 // Store the plan details for later phases
322 (job as any)._planDetails = planRaw;
323 (job as any)._defaultBranch = defaultBranch;
324 (job as any)._ref = ref;
325
326 addLog(job, `Plan: ${job.plan}`);
327 addLog(job, `Branch: ${job.branchName}`);
328 addLog(
329 job,
330 `Files to modify: ${planRaw.files_to_modify.length}, new files: ${planRaw.new_files.length}`
331 );
332}
333
334// ─── Phase 2: Reading ────────────────────────────────────────────────────────
335
336async function phaseRead(job: ShipJob): Promise<void> {
337 job.status = "reading";
338 addLog(job, "Reading relevant files...");
339
340 const plan: PlanResponse = (job as any)._planDetails;
341 const ref: string = (job as any)._ref;
342 const repoDiskPath = getRepoPath(job.owner, job.repo);
343
344 const fileContents = new Map<string, string>();
345
346 for (const fm of plan.files_to_modify) {
347 const content = await readFileFromRepo(repoDiskPath, ref, fm.path);
348 fileContents.set(fm.path, content);
349 }
350
351 (job as any)._fileContents = fileContents;
352
353 addLog(job, `Read ${fileContents.size} files.`);
354}
355
356// ─── Phase 3: Coding ─────────────────────────────────────────────────────────
357
358async function phaseCode(job: ShipJob): Promise<void> {
359 job.status = "coding";
360 addLog(job, "Creating branch and writing code...");
361
362 const plan: PlanResponse = (job as any)._planDetails;
363 const defaultBranch: string = (job as any)._defaultBranch;
364 const fileContents: Map<string, string> = (job as any)._fileContents;
365 const repoDiskPath = getRepoPath(job.owner, job.repo);
366
367 // We work in a temporary clone of the bare repo so we can read/write files
368 // without polluting the bare repo. Use a worktree instead.
369 const worktreeBase = join(config.gitReposPath, ".ship-agent-worktrees");
370 const worktreePath = join(worktreeBase, job.id);
371
372 await mkdir(worktreeBase, { recursive: true });
373
374 // Create a worktree at the default branch
375 const addResult = await execGit(
376 ["git", "worktree", "add", "--no-checkout", worktreePath, defaultBranch],
377 repoDiskPath
378 );
379 if (addResult.exitCode !== 0) {
380 throw new Error(`Failed to create worktree: ${addResult.stderr}`);
381 }
382
383 // Configure identity for commits inside worktree
384 await execGit(["git", "config", "user.email", "ship-agent@gluecron.com"], worktreePath);
385 await execGit(["git", "config", "user.name", "Gluecron Ship Agent"], worktreePath);
386
387 // Checkout the default branch
388 await execGit(["git", "checkout", defaultBranch], worktreePath);
389
390 // Create the feature branch
391 const branchResult = await execGit(
392 ["git", "checkout", "-b", job.branchName!],
393 worktreePath
394 );
395 if (branchResult.exitCode !== 0) {
396 throw new Error(`Failed to create branch '${job.branchName}': ${branchResult.stderr}`);
397 }
398 addLog(job, `Created branch: ${job.branchName}`);
399
400 const client = getAnthropic();
401
402 // Modify existing files
403 for (const fm of plan.files_to_modify) {
404 const currentContent = fileContents.get(fm.path) ?? "";
405
406 const msg = await client.messages.create({
407 model: MODEL_SONNET,
408 max_tokens: 8000,
409 system:
410 "You are implementing a feature. Return the COMPLETE updated file content. No explanations, no markdown code blocks — just the raw file content.",
411 messages: [
412 {
413 role: "user",
414 content: `File: ${fm.path}\nCurrent content:\n${currentContent}\n\nChange needed: ${fm.change_description}\nFull issue context: ${job.issueTitle}\n${job.issueBody}\nImplementation plan: ${job.plan}`,
415 },
416 ],
417 });
418
419 let newContent = extractText(msg);
420 // Strip potential markdown code fences if Claude added them
421 newContent = stripCodeFences(newContent);
422
423 const targetPath = join(worktreePath, fm.path);
424 await mkdir(join(targetPath, ".."), { recursive: true }).catch(() => {});
425 await writeFile(targetPath, newContent, "utf8");
426 await execGit(["git", "add", fm.path], worktreePath);
427 addLog(job, `Modified: ${fm.path}`);
428 }
429
430 // Create new files
431 for (const nf of plan.new_files) {
432 const msg = await client.messages.create({
433 model: MODEL_SONNET,
434 max_tokens: 8000,
435 system:
436 "You are implementing a feature. Return the COMPLETE file content for the new file. No explanations, no markdown code blocks — just the raw file content.",
437 messages: [
438 {
439 role: "user",
440 content: `New file: ${nf.path}\nPurpose: ${nf.purpose}\nFull issue context: ${job.issueTitle}\n${job.issueBody}\nImplementation plan: ${job.plan}`,
441 },
442 ],
443 });
444
445 let newContent = extractText(msg);
446 newContent = stripCodeFences(newContent);
447
448 const targetPath = join(worktreePath, nf.path);
449 await mkdir(join(targetPath, ".."), { recursive: true }).catch(() => {});
450 await writeFile(targetPath, newContent, "utf8");
451 await execGit(["git", "add", nf.path], worktreePath);
452 addLog(job, `Created: ${nf.path}`);
453 }
454
455 (job as any)._worktreePath = worktreePath;
456}
457
458// ─── Phase 4: Committing ─────────────────────────────────────────────────────
459
460async function phaseCommit(job: ShipJob): Promise<void> {
461 job.status = "committing";
462 addLog(job, "Committing changes...");
463
464 const worktreePath: string = (job as any)._worktreePath;
465 const repoDiskPath = getRepoPath(job.owner, job.repo);
466
467 const commitMsg = `feat: ${job.issueTitle}\n\nCloses #${job.issueNumber}\n\nAI-implemented via Gluecron Ship Agent`;
468
469 const commitResult = await execGit(
470 ["git", "commit", "-m", commitMsg],
471 worktreePath
472 );
473 if (commitResult.exitCode !== 0) {
474 throw new Error(`git commit failed: ${commitResult.stderr}`);
475 }
476 addLog(job, "Committed changes.");
477
478 // Push the branch to origin (the bare repo itself)
479 const pushResult = await execGit(
480 ["git", "push", repoDiskPath, job.branchName!],
481 worktreePath
482 );
483 if (pushResult.exitCode !== 0) {
484 throw new Error(`git push failed: ${pushResult.stderr}`);
485 }
486 addLog(job, `Pushed branch '${job.branchName}' to origin.`);
487
488 // Clean up worktree
489 await execGit(
490 ["git", "worktree", "remove", "--force", worktreePath],
491 repoDiskPath
492 ).catch(() => {});
493}
494
495// ─── Phase 5: Opening PR ─────────────────────────────────────────────────────
496
497async function phaseOpenPr(job: ShipJob): Promise<void> {
498 job.status = "opening-pr";
499 addLog(job, "Opening pull request...");
500
501 const plan: PlanResponse = (job as any)._planDetails;
502 const defaultBranch: string = (job as any)._defaultBranch;
503 const authorId = await getBotUserId(job.requestedByUserId);
504
505 const fileList = [
506 ...plan.files_to_modify.map((f) => `- Modified: \`${f.path}\``),
507 ...plan.new_files.map((f) => `- Created: \`${f.path}\``),
508 ].join("\n");
509
510 const prBody = `Closes #${job.issueNumber}\n\n## What was done\n\n${job.plan}\n\n## Changes\n\n${fileList}\n\n*AI-implemented via Gluecron Ship Agent*`;
511
512 const [pr] = await db
513 .insert(pullRequests)
514 .values({
515 repositoryId: job.repoId,
516 authorId,
517 title: `feat: ${job.issueTitle}`,
518 body: prBody,
519 baseBranch: defaultBranch,
520 headBranch: job.branchName!,
521 state: "open",
522 })
523 .returning();
524
525 job.prNumber = pr.number;
526 job.prUrl = `/${job.owner}/${job.repo}/pulls/${pr.number}`;
527
528 addLog(job, `PR #${pr.number} opened.`);
529
530 // Post comment on the issue linking to the PR
531 await postIssueComment(
532 job.issueId,
533 job.requestedByUserId,
534 `**Ship Agent started work!** PR opened: #${pr.number} — [View PR](/${job.owner}/${job.repo}/pulls/${pr.number})`
535 );
536}
537
538// ─── Utilities ───────────────────────────────────────────────────────────────
539
540function sanitizeBranchName(name: string): string {
541 return name
542 .toLowerCase()
543 .replace(/[^a-z0-9._\-/]/g, "-")
544 .replace(/^[-./]+|[-./]+$/g, "")
545 .replace(/\/+/g, "/")
546 .slice(0, 80) || `ship-agent/issue`;
547}
548
549function stripCodeFences(text: string): string {
550 // Remove ```lang ... ``` wrapper if present
551 const match = text.match(/^```(?:\w+)?\n([\s\S]*)\n```$/);
552 if (match) return match[1];
553 // Also handle without trailing newline
554 const match2 = text.match(/^```(?:\w+)?\n([\s\S]*)```$/);
555 if (match2) return match2[1];
556 return text;
557}
Modifiedsrc/routes/ai-editor.ts+1−1View fileUnifiedSplit
9090 try {
9191 const anthropic = getAnthropic();
9292 const message = await anthropic.messages.create({
93 model: MODEL_HAIKU,
93 model: MODEL_SONNET,
9494 max_tokens: 256,
9595 system:
9696 "You are a code completion AI. Complete the code snippet at the cursor. " +
Modifiedsrc/routes/auth.tsx+74−1View fileUnifiedSplit
1010 users,
1111 sessions,
1212 organizations,
13 orgSsoConfigs,
1314 userTotp,
1415 userRecoveryCodes,
1516 loginAttempts,
397398 <Button type="submit" variant="primary">
398399 Sign in
399400 </Button>
401 {/* SSO domain hint — shown dynamically when the typed email domain
402 matches a known org SSO config. JS fetches /api/sso/domain-hint. */}
403 <div id="sso-domain-hint" style="display:none;margin-top:10px;padding:10px 12px;background:rgba(140,109,255,0.10);border:1px solid rgba(140,109,255,0.30);border-radius:8px;font-size:13px;color:var(--text)" aria-live="polite">
404 Your organization uses SSO. You'll be redirected to sign in.
405 </div>
400406 </Form>
407 <script dangerouslySetInnerHTML={{__html: /* js */`
408 (function(){
409 var inp = document.querySelector('input[name="username"]');
410 var hint = document.getElementById('sso-domain-hint');
411 if(!inp || !hint) return;
412 var last = '';
413 inp.addEventListener('input', function(){
414 var val = inp.value.trim();
415 var at = val.indexOf('@');
416 if(at < 0) { hint.style.display='none'; return; }
417 var domain = val.slice(at+1).toLowerCase();
418 if(!domain || domain === last) return;
419 last = domain;
420 fetch('/api/sso/domain-hint?domain='+encodeURIComponent(domain))
421 .then(function(r){ return r.ok ? r.json() : null; })
422 .then(function(d){ hint.style.display = (d && d.sso) ? '' : 'none'; })
423 .catch(function(){ hint.style.display='none'; });
424 });
425 })();
426 `}} />
401427 {/* Provider buttons (Google + GitHub) — only rendered when the
402428 admin has configured + enabled them via /admin/google-oauth or
403429 /admin/github-oauth. The "or" divider above the first available
603629 return c.redirect("/login?error=All+fields+are+required");
604630 }
605631
606 // Resolve the canonical email for lockout checks regardless of whether
632 // Enterprise SSO domain-hint routing: if the identifier is an email and the
633 // domain matches an org's `domain_hint`, redirect to that org's SSO flow
634 // instead of checking the password.
635 // Also: resolve the canonical email for lockout checks regardless of whether
607636 // the user typed username or email.
608637 const isEmail = identifier.includes("@");
638 if (isEmail) {
639 const emailDomain = identifier.split("@")[1]?.toLowerCase();
640 if (emailDomain) {
641 const [ssoHint] = await db
642 .select({
643 provider: orgSsoConfigs.provider,
644 orgId: orgSsoConfigs.orgId,
645 })
646 .from(orgSsoConfigs)
647 .where(eq(orgSsoConfigs.domainHint, emailDomain))
648 .limit(1);
649
650 if (ssoHint) {
651 // Resolve org slug from org ID
652 const [orgRow] = await db
653 .select({ slug: organizations.slug })
654 .from(organizations)
655 .where(eq(organizations.id, ssoHint.orgId))
656 .limit(1);
657
658 if (orgRow) {
659 const protocol = ssoHint.provider === "oidc" ? "oidc" : "saml";
660 return c.redirect(`/sso/${protocol}/${orgRow.slug}/login`);
661 }
662 }
663 }
664 }
665
666 // Find user by username or email
609667 const [user] = await db
610668 .select()
611669 .from(users)
9671025 });
9681026});
9691027
1028// --- SSO domain-hint API (used by the login form JS) ---
1029
1030auth.get("/api/sso/domain-hint", async (c) => {
1031 const domain = String(c.req.query("domain") || "").toLowerCase().trim();
1032 if (!domain || domain.length > 253) {
1033 return c.json({ sso: false });
1034 }
1035 const [row] = await db
1036 .select({ orgId: orgSsoConfigs.orgId, provider: orgSsoConfigs.provider })
1037 .from(orgSsoConfigs)
1038 .where(eq(orgSsoConfigs.domainHint, domain))
1039 .limit(1);
1040 return c.json({ sso: !!row, provider: row?.provider ?? null });
1041});
1042
9701043/**
9711044 * Pick a friendly provider name for the "Sign in with X" button when the
9721045 * admin hasn't set one explicitly. Looks at the configured IdP URLs.
Addedsrc/routes/codebase-migrator.tsx+1128−0View fileUnifiedSplit
Large file (1,128 lines). Load full file
Addedsrc/routes/debt-map.tsx+1241−0View fileUnifiedSplit
Large file (1,241 lines). Load full file
Modifiedsrc/routes/deployments.tsx+595−1View fileUnifiedSplit
2424import { Hono } from "hono";
2525import { desc, eq, and } from "drizzle-orm";
2626import { db } from "../db";
27import { deployments, repositories, users } from "../db/schema";
27import { deployments, repositories, users, cloudDeployConfigs, cloudDeployments } from "../db/schema";
2828import type { AuthEnv } from "../middleware/auth";
2929import { softAuth, requireAuth } from "../middleware/auth";
3030import { Layout } from "../views/layout";
3131import { RepoHeader } from "../views/components";
3232import { onDeployFailure } from "../lib/ai-incident";
33import { encryptValue } from "../lib/server-targets-crypto";
3334
3435const dep = new Hono<AuthEnv>();
3536
798799 }
799800);
800801
802/* ─────────────────────────────────────────────────────────────────────────────
803 * Cloud deploy integration routes (migration 0077)
804 * ───────────────────────────────────────────────────────────────────────── */
805
806const PROVIDER_LABELS: Record<string, string> = {
807 fly: "Fly.io",
808 railway: "Railway",
809 render: "Render",
810 vercel: "Vercel",
811 netlify: "Netlify",
812 webhook: "Generic Webhook",
813};
814
815const cloudDeploySettingsStyles = `
816 .cds-wrap { max-width: 900px; margin: 0 auto; padding: var(--space-6) var(--space-4); }
817 .cds-head { margin-bottom: var(--space-5); }
818 .cds-title {
819 font-size: clamp(20px,2.8vw,28px); font-family: var(--font-display); font-weight: 800;
820 letter-spacing: -0.022em; margin: 0 0 var(--space-2); color: var(--text-strong);
821 }
822 .cds-sub { font-size: 14px; color: var(--text-muted); margin: 0; line-height: 1.55; }
823 .cds-card {
824 background: var(--bg-elevated); border: 1px solid var(--border);
825 border-radius: 14px; overflow: hidden; margin-bottom: var(--space-4);
826 }
827 .cds-card-head {
828 padding: var(--space-3) var(--space-4); border-bottom: 1px solid var(--border);
829 display: flex; align-items: center; justify-content: space-between;
830 background: rgba(255,255,255,0.012);
831 }
832 .cds-card-title { margin: 0; font-size: 14px; font-weight: 700; color: var(--text-strong); }
833 .cds-config-row {
834 display: grid; grid-template-columns: 120px 1fr 120px 80px auto;
835 align-items: center; gap: 12px; padding: 10px var(--space-4);
836 border-top: 1px solid rgba(255,255,255,0.04); font-size: 13px;
837 }
838 .cds-config-row:first-child { border-top: none; }
839 .cds-badge {
840 display: inline-flex; align-items: center; padding: 2px 8px;
841 border-radius: 6px; font-size: 11px; font-weight: 700;
842 background: rgba(140,109,255,0.14); color: #b69dff;
843 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.3);
844 }
845 .cds-mono { font-family: var(--font-mono); font-size: 12px; }
846 .cds-form label { display: block; font-size: 12.5px; font-weight: 600; color: var(--text-muted); margin-bottom: 4px; }
847 .cds-form input, .cds-form select {
848 width: 100%; padding: 8px 10px; background: var(--bg-tertiary);
849 border: 1px solid var(--border); border-radius: 8px; color: var(--text);
850 font-family: inherit; font-size: 13px;
851 }
852 .cds-form input:focus, .cds-form select:focus { outline: 2px solid var(--accent); border-color: var(--accent); }
853 .cds-grid-3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: var(--space-3); }
854 .cds-btn {
855 display: inline-flex; align-items: center; gap: 6px;
856 padding: 8px 16px; font-size: 13px; font-weight: 600;
857 border-radius: 8px; cursor: pointer; font-family: inherit;
858 border: 1px solid transparent;
859 transition: background 120ms ease, border-color 120ms ease;
860 }
861 .cds-btn-primary { background: var(--accent); color: #fff; border-color: var(--accent); }
862 .cds-btn-primary:hover { filter: brightness(1.1); }
863 .cds-btn-ghost { background: rgba(255,255,255,0.03); border-color: var(--border); color: var(--text); }
864 .cds-btn-ghost:hover { background: rgba(255,255,255,0.06); border-color: var(--border-strong); }
865 .cds-btn-danger { background: rgba(239,68,68,0.12); border-color: rgba(239,68,68,0.35); color: #fca5a5; }
866 .cds-btn-danger:hover { background: rgba(239,68,68,0.2); }
867 .cds-footer { margin-top: var(--space-4); display: flex; justify-content: flex-end; }
868 .cds-empty { padding: var(--space-5); text-align: center; color: var(--text-muted); font-size: 13px; }
869 .cds-status { display: inline-flex; align-items: center; gap: 5px; font-size: 12px; font-weight: 700;
870 padding: 2px 8px; border-radius: 9999px; }
871 .cds-status.is-success { background: rgba(52,211,153,0.14); color: #6ee7b7; box-shadow: inset 0 0 0 1px rgba(52,211,153,0.32); }
872 .cds-status.is-failed { background: rgba(248,113,113,0.14); color: #fca5a5; box-shadow: inset 0 0 0 1px rgba(248,113,113,0.32); }
873 .cds-status.is-running, .cds-status.is-pending { background: rgba(96,165,250,0.14); color: #bfdbfe; box-shadow: inset 0 0 0 1px rgba(96,165,250,0.32); }
874 .cds-status.is-cancelled { background: rgba(107,114,128,0.16); color: #d1d5db; box-shadow: inset 0 0 0 1px rgba(107,114,128,0.32); }
875 .cds-runs { display: flex; flex-direction: column; gap: 0; }
876 .cds-run-row {
877 display: grid; grid-template-columns: 80px 90px 1fr auto auto;
878 align-items: center; gap: 12px; padding: 10px var(--space-4);
879 border-top: 1px solid rgba(255,255,255,0.04); font-size: 13px;
880 }
881 .cds-run-row:first-child { border-top: none; }
882 .cds-run-row:hover { background: rgba(255,255,255,0.02); }
883 .cds-sha { font-family: var(--font-mono); font-size: 12px; background: rgba(255,255,255,0.04);
884 border: 1px solid var(--border); padding: 2px 7px; border-radius: 6px; }
885 .cds-link { color: var(--accent); text-decoration: none; font-size: 12px; }
886 .cds-link:hover { text-decoration: underline; }
887 @keyframes cds-spin { to { transform: rotate(360deg); } }
888 .cds-spinner { display: inline-block; width: 10px; height: 10px; border: 2px solid rgba(96,165,250,0.3);
889 border-top-color: #93c5fd; border-radius: 50%; animation: cds-spin 0.8s linear infinite; }
890`;
891
892function cdStatusClass(status: string): string {
893 switch (status) {
894 case "success":
895 return "cds-status is-success";
896 case "failed":
897 return "cds-status is-failed";
898 case "running":
899 return "cds-status is-running";
900 case "pending":
901 return "cds-status is-pending";
902 case "cancelled":
903 return "cds-status is-cancelled";
904 default:
905 return "cds-status is-cancelled";
906 }
907}
908
909/** GET /:owner/:repo/settings/deployments — cloud deploy config page */
910dep.get("/:owner/:repo/settings/deployments", requireAuth, async (c) => {
911 const { owner, repo } = c.req.param();
912 const user = c.get("user")!;
913 const repoRow = await resolveRepo(owner, repo);
914 if (!repoRow) return c.notFound();
915 if (repoRow.ownerId !== user.id) return c.redirect(`/${owner}/${repo}`);
916
917 let configs: Array<typeof cloudDeployConfigs.$inferSelect> = [];
918 try {
919 configs = await db
920 .select()
921 .from(cloudDeployConfigs)
922 .where(eq(cloudDeployConfigs.repoId, repoRow.id))
923 .orderBy(desc(cloudDeployConfigs.createdAt));
924 } catch {
925 /* ignore */
926 }
927
928 const flash = c.req.query("flash");
929
930 return c.html(
931 <Layout title={`${owner}/${repo} — cloud deploy settings`} user={user}>
932 <RepoHeader owner={owner} repo={repo} />
933 <div class="cds-wrap">
934 <header class="cds-head">
935 <h2 class="cds-title">Cloud Deploy Integrations</h2>
936 <p class="cds-sub">
937 Push to a branch and Gluecron automatically deploys to your cloud
938 provider. Supports Fly.io, Railway, Render, Vercel, Netlify, and any
939 webhook-based platform.
940 </p>
941 </header>
942
943 {flash === "added" && (
944 <div style="background:rgba(52,211,153,0.1);border:1px solid rgba(52,211,153,0.3);border-radius:8px;padding:10px 14px;font-size:13px;color:#6ee7b7;margin-bottom:var(--space-4);">
945 Integration added successfully.
946 </div>
947 )}
948 {flash === "deleted" && (
949 <div style="background:rgba(248,113,113,0.1);border:1px solid rgba(248,113,113,0.3);border-radius:8px;padding:10px 14px;font-size:13px;color:#fca5a5;margin-bottom:var(--space-4);">
950 Integration removed.
951 </div>
952 )}
953 {flash === "error" && (
954 <div style="background:rgba(248,113,113,0.1);border:1px solid rgba(248,113,113,0.3);border-radius:8px;padding:10px 14px;font-size:13px;color:#fca5a5;margin-bottom:var(--space-4);">
955 Error: SERVER_TARGETS_KEY env var is not set. Cloud deploy tokens cannot
956 be encrypted.
957 </div>
958 )}
959
960 <div class="cds-card">
961 <div class="cds-card-head">
962 <h3 class="cds-card-title">Active integrations</h3>
963 </div>
964 {configs.length === 0 ? (
965 <div class="cds-empty">
966 No integrations configured yet. Add one below.
967 </div>
968 ) : (
969 <div style="padding:0;">
970 {configs.map((cfg) => (
971 <div class="cds-config-row">
972 <span class="cds-badge">
973 {PROVIDER_LABELS[cfg.provider] ?? cfg.provider}
974 </span>
975 <span class="cds-mono">{cfg.providerAppId}</span>
976 <span style="font-size:12px;color:var(--text-muted);">
977 branch:{" "}
978 <code class="cds-mono">{cfg.triggerBranch}</code>
979 </span>
980 <span style="font-size:12px;color:var(--text-muted);">
981 {cfg.enabled ? "enabled" : "disabled"}
982 </span>
983 <form
984 method="post"
985 action={`/${owner}/${repo}/settings/deployments/${cfg.id}/delete`}
986 >
987 <button
988 type="submit"
989 class="cds-btn cds-btn-danger"
990 style="padding:4px 10px;font-size:12px;"
991 >
992 Remove
993 </button>
994 </form>
995 </div>
996 ))}
997 </div>
998 )}
999 </div>
1000
1001 <div class="cds-card">
1002 <div class="cds-card-head">
1003 <h3 class="cds-card-title">Add integration</h3>
1004 </div>
1005 <div style="padding:var(--space-4);">
1006 <form
1007 method="post"
1008 action={`/${owner}/${repo}/settings/deployments`}
1009 class="cds-form"
1010 >
1011 <div
1012 class="cds-grid-3"
1013 style="margin-bottom:var(--space-3);"
1014 >
1015 <div>
1016 <label for="provider">Provider</label>
1017 <select id="provider" name="provider" required>
1018 <option value="fly">Fly.io</option>
1019 <option value="railway">Railway</option>
1020 <option value="render">Render</option>
1021 <option value="vercel">Vercel</option>
1022 <option value="netlify">Netlify</option>
1023 <option value="webhook">Generic Webhook</option>
1024 </select>
1025 </div>
1026 <div>
1027 <label for="provider_app_id">
1028 App / Service ID (or webhook URL)
1029 </label>
1030 <input
1031 type="text"
1032 id="provider_app_id"
1033 name="provider_app_id"
1034 placeholder="e.g. my-app, srv-xxx, webhook URL"
1035 required
1036 />
1037 </div>
1038 <div>
1039 <label for="trigger_branch">Trigger branch</label>
1040 <input
1041 type="text"
1042 id="trigger_branch"
1043 name="trigger_branch"
1044 placeholder="main"
1045 value="main"
1046 required
1047 />
1048 </div>
1049 </div>
1050 <div style="margin-bottom:var(--space-4);">
1051 <label for="api_token">API token (stored encrypted)</label>
1052 <input
1053 type="password"
1054 id="api_token"
1055 name="api_token"
1056 placeholder="Paste your provider API token"
1057 required
1058 />
1059 <p style="font-size:11.5px;color:var(--text-muted);margin:4px 0 0;">
1060 Token is encrypted with AES-256-GCM before storing. Never
1061 logged.
1062 </p>
1063 </div>
1064 <div class="cds-footer">
1065 <button type="submit" class="cds-btn cds-btn-primary">
1066 Add integration
1067 </button>
1068 </div>
1069 </form>
1070 </div>
1071 </div>
1072
1073 <div style="text-align:center;margin-top:var(--space-3);">
1074 <a
1075 href={`/${owner}/${repo}/cloud-deployments`}
1076 class="cds-link"
1077 >
1078 View deployment history &rarr;
1079 </a>
1080 </div>
1081 </div>
1082 <style dangerouslySetInnerHTML={{ __html: cloudDeploySettingsStyles }} />
1083 </Layout>
1084 );
1085});
1086
1087/** POST /:owner/:repo/settings/deployments — add new cloud deploy config */
1088dep.post(
1089 "/:owner/:repo/settings/deployments",
1090 requireAuth,
1091 async (c) => {
1092 const { owner, repo } = c.req.param();
1093 const user = c.get("user")!;
1094 const repoRow = await resolveRepo(owner, repo);
1095 const back = `/${owner}/${repo}/settings/deployments`;
1096 if (!repoRow) return c.notFound();
1097 if (repoRow.ownerId !== user.id) return c.redirect(back);
1098
1099 const form = await c.req.formData();
1100 const provider = String(form.get("provider") || "").trim();
1101 const providerAppId = String(form.get("provider_app_id") || "").trim();
1102 const triggerBranch = String(
1103 form.get("trigger_branch") || "main"
1104 ).trim();
1105 const apiToken = String(form.get("api_token") || "").trim();
1106
1107 const validProviders = [
1108 "fly",
1109 "railway",
1110 "render",
1111 "vercel",
1112 "netlify",
1113 "webhook",
1114 ];
1115 if (
1116 !validProviders.includes(provider) ||
1117 !providerAppId ||
1118 !triggerBranch ||
1119 !apiToken
1120 ) {
1121 return c.redirect(`${back}?flash=error`);
1122 }
1123
1124 const encResult = encryptValue(apiToken);
1125 if (!encResult.ok) {
1126 return c.redirect(`${back}?flash=error`);
1127 }
1128
1129 try {
1130 await db.insert(cloudDeployConfigs).values({
1131 repoId: repoRow.id,
1132 provider,
1133 providerAppId,
1134 apiTokenEncrypted: encResult.ciphertext,
1135 triggerBranch,
1136 enabled: true,
1137 });
1138 } catch (err) {
1139 console.error("[cloud-deploy] insert config:", err);
1140 return c.redirect(`${back}?flash=error`);
1141 }
1142
1143 return c.redirect(`${back}?flash=added`);
1144 }
1145);
1146
1147/** POST /:owner/:repo/settings/deployments/:configId/delete */
1148dep.post(
1149 "/:owner/:repo/settings/deployments/:configId/delete",
1150 requireAuth,
1151 async (c) => {
1152 const { owner, repo, configId } = c.req.param();
1153 const user = c.get("user")!;
1154 const repoRow = await resolveRepo(owner, repo);
1155 const back = `/${owner}/${repo}/settings/deployments`;
1156 if (!repoRow) return c.notFound();
1157 if (repoRow.ownerId !== user.id) return c.redirect(back);
1158
1159 try {
1160 await db
1161 .delete(cloudDeployConfigs)
1162 .where(
1163 and(
1164 eq(cloudDeployConfigs.id, configId),
1165 eq(cloudDeployConfigs.repoId, repoRow.id)
1166 )
1167 );
1168 } catch (err) {
1169 console.error("[cloud-deploy] delete config:", err);
1170 }
1171
1172 return c.redirect(`${back}?flash=deleted`);
1173 }
1174);
1175
1176/** GET /:owner/:repo/cloud-deployments — list recent cloud deploy runs */
1177dep.get("/:owner/:repo/cloud-deployments", softAuth, async (c) => {
1178 const { owner, repo } = c.req.param();
1179 const user = c.get("user");
1180 const repoRow = await resolveRepo(owner, repo);
1181 if (!repoRow) return c.notFound();
1182
1183 let runs: Array<{
1184 id: string;
1185 configId: string;
1186 commitSha: string;
1187 status: string;
1188 providerDeployId: string | null;
1189 logUrl: string | null;
1190 deployUrl: string | null;
1191 errorMessage: string | null;
1192 startedAt: Date | null;
1193 completedAt: Date | null;
1194 durationMs: number | null;
1195 provider: string;
1196 providerAppId: string;
1197 triggerBranch: string;
1198 }> = [];
1199 try {
1200 runs = await db
1201 .select({
1202 id: cloudDeployments.id,
1203 configId: cloudDeployments.configId,
1204 commitSha: cloudDeployments.commitSha,
1205 status: cloudDeployments.status,
1206 providerDeployId: cloudDeployments.providerDeployId,
1207 logUrl: cloudDeployments.logUrl,
1208 deployUrl: cloudDeployments.deployUrl,
1209 errorMessage: cloudDeployments.errorMessage,
1210 startedAt: cloudDeployments.startedAt,
1211 completedAt: cloudDeployments.completedAt,
1212 durationMs: cloudDeployments.durationMs,
1213 provider: cloudDeployConfigs.provider,
1214 providerAppId: cloudDeployConfigs.providerAppId,
1215 triggerBranch: cloudDeployConfigs.triggerBranch,
1216 })
1217 .from(cloudDeployments)
1218 .innerJoin(
1219 cloudDeployConfigs,
1220 eq(cloudDeployments.configId, cloudDeployConfigs.id)
1221 )
1222 .where(eq(cloudDeployments.repoId, repoRow.id))
1223 .orderBy(desc(cloudDeployments.startedAt))
1224 .limit(100);
1225 } catch (err) {
1226 console.error("[cloud-deployments] list:", err);
1227 }
1228
1229 return c.html(
1230 <Layout title={`${owner}/${repo} — cloud deployments`} user={user}>
1231 <RepoHeader owner={owner} repo={repo} />
1232 <div class="cds-wrap">
1233 <header
1234 class="cds-head"
1235 style="display:flex;align-items:center;justify-content:space-between;gap:16px;flex-wrap:wrap;"
1236 >
1237 <div>
1238 <h2 class="cds-title">Cloud Deployments</h2>
1239 <p class="cds-sub">
1240 Recent push-triggered deploys across all configured integrations.
1241 </p>
1242 </div>
1243 {user?.id === repoRow.ownerId && (
1244 <a
1245 href={`/${owner}/${repo}/settings/deployments`}
1246 class="cds-btn cds-btn-ghost"
1247 style="text-decoration:none;"
1248 >
1249 Configure integrations
1250 </a>
1251 )}
1252 </header>
1253
1254 <div class="cds-card">
1255 {runs.length === 0 ? (
1256 <div class="cds-empty">
1257 No cloud deployments yet.{" "}
1258 {user?.id === repoRow.ownerId ? (
1259 <a
1260 href={`/${owner}/${repo}/settings/deployments`}
1261 class="cds-link"
1262 >
1263 Add an integration
1264 </a>
1265 ) : (
1266 "Ask the repo owner to configure a cloud deploy integration."
1267 )}
1268 </div>
1269 ) : (
1270 <div class="cds-runs">
1271 {runs.map((run) => {
1272 const durMs = run.durationMs;
1273 const dur =
1274 durMs != null
1275 ? durMs >= 60_000
1276 ? `${Math.round(durMs / 60_000)}m ${Math.round((durMs % 60_000) / 1000)}s`
1277 : `${Math.round(durMs / 1000)}s`
1278 : null;
1279 const isRunning =
1280 run.status === "running" || run.status === "pending";
1281 return (
1282 <div class="cds-run-row">
1283 <span class={cdStatusClass(run.status)}>
1284 {isRunning ? (
1285 <span
1286 class="cds-spinner"
1287 aria-label="deploying"
1288 />
1289 ) : null}
1290 {run.status}
1291 </span>
1292 <code class="cds-sha">
1293 {run.commitSha.slice(0, 7)}
1294 </code>
1295 <div
1296 style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;min-width:0;"
1297 >
1298 <span class="cds-badge">
1299 {PROVIDER_LABELS[run.provider] ?? run.provider}
1300 </span>
1301 <span
1302 class="cds-mono"
1303 style="font-size:12px;color:var(--text-muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"
1304 >
1305 {run.providerAppId}
1306 </span>
1307 {run.deployUrl && run.status === "success" && (
1308 <a
1309 href={run.deployUrl}
1310 target="_blank"
1311 rel="noopener noreferrer"
1312 class="cds-link"
1313 >
1314 {run.deployUrl
1315 .replace(/^https?:\/\//, "")
1316 .slice(0, 40)}
1317 </a>
1318 )}
1319 {run.status === "failed" && run.errorMessage && (
1320 <span
1321 style="font-size:11.5px;color:#fca5a5;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"
1322 title={run.errorMessage}
1323 >
1324 {run.errorMessage.slice(0, 60)}
1325 </span>
1326 )}
1327 </div>
1328 <span
1329 style="font-size:12px;color:var(--text-muted);white-space:nowrap;font-variant-numeric:tabular-nums;"
1330 >
1331 {dur
1332 ? run.status === "success"
1333 ? `Deployed in ${dur}`
1334 : run.status === "failed"
1335 ? `Failed after ${dur}`
1336 : dur
1337 : dkRelativeTime(run.startedAt)}
1338 </span>
1339 <div style="display:flex;gap:6px;align-items:center;">
1340 {run.logUrl && (
1341 <a
1342 href={run.logUrl}
1343 target="_blank"
1344 rel="noopener noreferrer"
1345 class="cds-link"
1346 >
1347 Logs
1348 </a>
1349 )}
1350 </div>
1351 </div>
1352 );
1353 })}
1354 </div>
1355 )}
1356 </div>
1357 </div>
1358 <style
1359 dangerouslySetInnerHTML={{ __html: cloudDeploySettingsStyles }}
1360 />
1361 </Layout>
1362 );
1363});
1364
1365/** POST /:owner/:repo/deployments/:deployId/cancel — cancel a running cloud deployment */
1366dep.post(
1367 "/:owner/:repo/deployments/:deployId/cancel",
1368 requireAuth,
1369 async (c) => {
1370 const { owner, repo, deployId } = c.req.param();
1371 const user = c.get("user")!;
1372 const repoRow = await resolveRepo(owner, repo);
1373 const back = `/${owner}/${repo}/cloud-deployments`;
1374 if (!repoRow) return c.notFound();
1375 if (repoRow.ownerId !== user.id) return c.redirect(back);
1376
1377 try {
1378 await db
1379 .update(cloudDeployments)
1380 .set({ status: "cancelled", completedAt: new Date() })
1381 .where(
1382 and(
1383 eq(cloudDeployments.id, deployId),
1384 eq(cloudDeployments.repoId, repoRow.id)
1385 )
1386 );
1387 } catch (err) {
1388 console.error("[cloud-deploy] cancel:", err);
1389 }
1390
1391 return c.redirect(back);
1392 }
1393);
1394
8011395export default dep;
Addedsrc/routes/engineering-insights.tsx+1826−0View fileUnifiedSplit
Large file (1,826 lines). Load full file
Addedsrc/routes/incident-hooks.tsx+1300−0View fileUnifiedSplit
Large file (1,300 lines). Load full file
Modifiedsrc/routes/issues.tsx+109−1View fileUnifiedSplit
1313 labels,
1414 issueLabels,
1515 pullRequests,
16 milestones,
1617} from "../db/schema";
1718import { Layout } from "../views/layout";
1819import { RepoHeader, RepoNav } from "../views/components";
5758} from "../views/ui";
5859import { getDefaultBranch, resolveRef, updateRef } from "../git/repository";
5960import { BOT_USERNAME } from "../lib/bot-user";
61import { isAiAvailable } from "../lib/ai-client";
6062
6163const issueRoutes = new Hono<AuthEnv>();
6264
817819 const state = c.req.query("state") || "open";
818820 const searchQ = (c.req.query("q") || "").trim();
819821 const labelFilter = (c.req.query("label") || "").trim();
822 const milestoneFilter = (c.req.query("milestone") || "").trim();
820823 const sort = (c.req.query("sort") || "newest").trim();
821824 // Bounded pagination — unbounded selects ran a full table scan + O(n)
822825 // sort on every page load; with 10k+ issues the request would hang.
892895 }
893896 }
894897
898 // If milestone filter is set, resolve the milestone ID
899 let milestoneFilterId: string | null = null;
900 if (milestoneFilter) {
901 const [matchedMs] = await db
902 .select({ id: milestones.id })
903 .from(milestones)
904 .where(and(eq(milestones.repositoryId, repo.id), eq(milestones.id, milestoneFilter)))
905 .limit(1);
906 milestoneFilterId = matchedMs?.id ?? null;
907 }
908
895909 const baseWhere = and(
896910 eq(issues.repositoryId, repo.id),
897911 state === "open" || state === "closed" ? eq(issues.state, state) : undefined,
900914 ? inArray(issues.id, labelFilteredIds)
901915 : labelFilteredIds !== null && labelFilteredIds.length === 0
902916 ? sql`false`
903 : undefined
917 : undefined,
918 milestoneFilterId ? eq(issues.milestoneId, milestoneFilterId) : undefined
904919 );
905920
906921 const issueList = await db
928943 .from(issues)
929944 .where(eq(issues.repositoryId, repo.id));
930945
946 // Count open milestones for the "N Milestones" link
947 const [milestoneCounts] = await db
948 .select({
949 open: sql<number>`count(*) filter (where ${milestones.state} = 'open')::int`,
950 })
951 .from(milestones)
952 .where(eq(milestones.repositoryId, repo.id));
953
931954 const viewerIsOwnerOnList = !!(user && user.id === resolved.owner.id);
932955 const pendingCountList = viewerIsOwnerOnList
933956 ? await countPendingForRepo(repo.id)
973996 + New issue
974997 </a>
975998 )}
999 <a
1000 href={`/${ownerName}/${repoName}/milestones`}
1001 class="btn"
1002 title="View milestones for this repository"
1003 >
1004 Milestones
1005 {Number(milestoneCounts?.open ?? 0) > 0 && (
1006 <span style="margin-left:5px;font-size:11.5px;background:rgba(140,109,255,0.18);color:#a78bfa;padding:1px 7px;border-radius:9999px">
1007 {Number(milestoneCounts?.open ?? 0)}
1008 </span>
1009 )}
1010 </a>
9761011 <a href={`/${ownerName}/${repoName}`} class="btn">
9771012 Back to code
9781013 </a>
11491184 const error = c.req.query("error");
11501185 const template = await loadIssueTemplate(ownerName, repoName);
11511186
1187 // Load open milestones for the dropdown
1188 const resolved = await resolveRepo(ownerName, repoName);
1189 const openMilestones = resolved
1190 ? await db
1191 .select({ id: milestones.id, title: milestones.title })
1192 .from(milestones)
1193 .where(and(eq(milestones.repositoryId, resolved.repo.id), eq(milestones.state, "open")))
1194 .orderBy(milestones.title)
1195 : [];
1196
11521197 return c.html(
11531198 <Layout title={`New issue — ${ownerName}/${repoName}`} user={user}>
11541199 <IssuesStyle />
12001245 mono
12011246 />
12021247 </FormGroup>
1248 {openMilestones.length > 0 && (
1249 <FormGroup>
1250 <label
1251 for="milestone_id"
1252 style="display:block;font-size:13px;font-weight:600;color:var(--text);margin-bottom:6px"
1253 >
1254 Milestone <span style="font-weight:400;color:var(--text-muted)">(optional)</span>
1255 </label>
1256 <select
1257 id="milestone_id"
1258 name="milestone_id"
1259 style="background:var(--bg-surface);border:1px solid var(--border);border-radius:8px;color:var(--text);font-size:14px;padding:8px 12px;outline:none;width:100%;max-width:340px"
1260 >
1261 <option value="">No milestone</option>
1262 {openMilestones.map((ms) => (
1263 <option value={ms.id}>{ms.title}</option>
1264 ))}
1265 </select>
1266 </FormGroup>
1267 )}
12031268 <Button type="submit" variant="primary">
12041269 Submit new issue
12051270 </Button>
12221287 const body = await c.req.parseBody();
12231288 const title = String(body.title || "").trim();
12241289 const issueBody = String(body.body || "").trim();
1290 const milestoneIdRaw = String(body.milestone_id || "").trim() || null;
12251291
12261292 if (!title) {
12271293 return c.redirect(
12321298 const resolved = await resolveRepo(ownerName, repoName);
12331299 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
12341300
1301 // Validate milestone belongs to this repo if provided
1302 let validatedMilestoneId: string | null = null;
1303 if (milestoneIdRaw) {
1304 const [msRow] = await db
1305 .select({ id: milestones.id })
1306 .from(milestones)
1307 .where(and(eq(milestones.id, milestoneIdRaw), eq(milestones.repositoryId, resolved.repo.id)))
1308 .limit(1);
1309 validatedMilestoneId = msRow?.id ?? null;
1310 }
1311
12351312 const [issue] = await db
12361313 .insert(issues)
12371314 .values({
12391316 authorId: user.id,
12401317 title,
12411318 body: issueBody || null,
1319 milestoneId: validatedMilestoneId,
12421320 })
12431321 .returning();
12441322
14551533 Build with AI
14561534 </a>
14571535 )}
1536 {issue.state === "open" && user && isAiAvailable() && (
1537 <form
1538 method="post"
1539 action={`/${ownerName}/${repoName}/issues/${issue.number}/ship`}
1540 style="display:inline"
1541 title="Let AI implement this feature automatically"
1542 >
1543 <button
1544 type="submit"
1545 class="btn btn-primary"
1546 style="font-size:13px;padding:6px 12px;background:linear-gradient(135deg,#8c6dff,#36c5d6);border:none;cursor:pointer"
1547 onclick="return confirm('Ship Agent will read the codebase, write code, and open a PR automatically. Review the PR before merging. Continue?')"
1548 >
1549 Ship It
1550 </button>
1551 </form>
1552 )}
14581553 {issue.state === "open" && user && (
14591554 <details class="issue-branch-dropdown" style="position:relative;display:inline-block">
14601555 <summary
16991794 } catch {
17001795 /* SSE is best-effort */
17011796 }
1797 // Notify the issue author — fire-and-forget, never blocks the response.
1798 if (issue.authorId && issue.authorId !== user.id) {
1799 void import("../lib/notify").then(({ createNotification }) =>
1800 createNotification({
1801 userId: issue.authorId,
1802 type: "issue_comment",
1803 title: `New comment on "${issue.title}"`,
1804 body: commentBody.length > 200 ? commentBody.slice(0, 200) + "…" : commentBody,
1805 url: `/${ownerName}/${repoName}/issues/${issueNum}`,
1806 repoId: resolved.repo.id,
1807 })
1808 ).catch(() => { /* never block the response */ });
1809 }
17021810 }
17031811
17041812 if (decision.status === "pending") {
Addedsrc/routes/milestones.tsx+1274−0View fileUnifiedSplit
Large file (1,274 lines). Load full file
Modifiedsrc/routes/notifications.tsx+6−3View fileUnifiedSplit
786786});
787787
788788// API: Get unread count (for bell icon polling)
789// Returns both `unread` (canonical) and `count` (legacy alias) so the nav
790// bell polling script and any older callers both work without changes.
789791notificationRoutes.get("/api/notifications/count", softAuth, async (c) => {
790792 const user = c.get("user");
791 if (!user) return c.json({ count: 0 });
793 if (!user) return c.json({ unread: 0, count: 0 });
792794
793795 try {
794796 const [result] = await db
795797 .select({ count: sql<number>`count(*)` })
796798 .from(notifications)
797799 .where(and(eq(notifications.userId, user.id), eq(notifications.isRead, false)));
798 return c.json({ count: result?.count ?? 0 });
800 const unread = Number(result?.count ?? 0);
801 return c.json({ unread, count: unread });
799802 } catch {
800 return c.json({ count: 0 });
803 return c.json({ unread: 0, count: 0 });
801804 }
802805});
803806
Addedsrc/routes/org-sso-settings.tsx+605−0View fileUnifiedSplit
1/**
2 * Per-org enterprise SSO + SCIM settings.
3 *
4 * GET /orgs/:orgSlug/settings/sso — SSO config page
5 * POST /orgs/:orgSlug/settings/sso — save SSO config
6 * POST /orgs/:orgSlug/settings/sso/generate-scim-token — generate a new SCIM token
7 */
8
9import { Hono } from "hono";
10import { eq, and } from "drizzle-orm";
11import * as crypto from "crypto";
12import { db } from "../db";
13import {
14 orgSsoConfigs,
15 scimTokens,
16 organizations,
17 orgMembers,
18} from "../db/schema";
19import { Layout } from "../views/layout";
20import { requireAuth, softAuth } from "../middleware/auth";
21import type { AuthEnv } from "../middleware/auth";
22
23const orgSsoSettings = new Hono<AuthEnv>();
24orgSsoSettings.use("*", softAuth);
25
26// ---------------------------------------------------------------------------
27// Scoped CSS
28// ---------------------------------------------------------------------------
29
30const orgSsoCss = `
31 .org-sso-wrap { max-width: 980px; margin: 0 auto; padding: var(--space-6) var(--space-4); }
32
33 .org-sso-hero {
34 position: relative;
35 margin-bottom: var(--space-5);
36 padding: var(--space-5) var(--space-6);
37 background: var(--bg-elevated);
38 border: 1px solid var(--border);
39 border-radius: 16px;
40 overflow: hidden;
41 }
42 .org-sso-hero::before {
43 content: '';
44 position: absolute;
45 top: 0; left: 0; right: 0; height: 2px;
46 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
47 opacity: 0.7;
48 pointer-events: none;
49 }
50 .org-sso-title {
51 font-family: var(--font-display);
52 font-size: 24px;
53 font-weight: 800;
54 letter-spacing: -0.025em;
55 color: var(--text-strong);
56 margin: 0 0 6px;
57 }
58 .org-sso-sub { font-size: 13.5px; color: var(--text-muted); margin: 0; }
59
60 .org-sso-section {
61 margin-bottom: var(--space-5);
62 background: var(--bg-elevated);
63 border: 1px solid var(--border);
64 border-radius: 14px;
65 overflow: hidden;
66 }
67 .org-sso-section-head {
68 padding: var(--space-4) var(--space-5);
69 border-bottom: 1px solid var(--border);
70 }
71 .org-sso-section-title {
72 margin: 0 0 4px;
73 font-size: 15px;
74 font-weight: 700;
75 color: var(--text-strong);
76 }
77 .org-sso-section-sub { margin: 0; font-size: 12.5px; color: var(--text-muted); }
78 .org-sso-section-body { padding: var(--space-4) var(--space-5); }
79
80 .org-sso-field { margin-bottom: var(--space-4); }
81 .org-sso-field:last-child { margin-bottom: 0; }
82 .org-sso-field label {
83 display: block;
84 font-size: 12.5px;
85 font-weight: 600;
86 color: var(--text-strong);
87 margin-bottom: 5px;
88 font-family: var(--font-mono);
89 }
90 .org-sso-input {
91 width: 100%;
92 padding: 9px 12px;
93 font-size: 13.5px;
94 color: var(--text);
95 background: var(--bg);
96 border: 1px solid var(--border-strong);
97 border-radius: 8px;
98 box-sizing: border-box;
99 font-family: var(--font-mono);
100 outline: none;
101 }
102 .org-sso-input:focus {
103 border-color: var(--border-focus);
104 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
105 }
106 .org-sso-textarea {
107 min-height: 120px;
108 resize: vertical;
109 }
110 .org-sso-hint { font-size: 11.5px; color: var(--text-muted); margin-top: 5px; }
111
112 .org-sso-toggle {
113 display: flex;
114 gap: 10px;
115 align-items: flex-start;
116 padding: 10px 12px;
117 background: rgba(255,255,255,0.025);
118 border: 1px solid var(--border);
119 border-radius: 10px;
120 margin-bottom: var(--space-4);
121 }
122 .org-sso-toggle input { margin-top: 2px; flex-shrink: 0; }
123 .org-sso-toggle span { font-size: 13px; color: var(--text); line-height: 1.45; }
124
125 .org-sso-foot {
126 padding: var(--space-3) var(--space-5);
127 border-top: 1px solid var(--border);
128 display: flex;
129 justify-content: space-between;
130 align-items: center;
131 gap: var(--space-2);
132 }
133 .org-sso-foot-hint { font-size: 12.5px; color: var(--text-muted); }
134
135 .org-sso-btn {
136 display: inline-flex;
137 align-items: center;
138 gap: 8px;
139 padding: 10px 18px;
140 border-radius: 10px;
141 font-size: 13.5px;
142 font-weight: 600;
143 border: 1px solid transparent;
144 cursor: pointer;
145 font: inherit;
146 text-decoration: none;
147 }
148 .org-sso-btn-primary {
149 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
150 color: #fff;
151 }
152 .org-sso-btn-primary:hover { opacity: 0.9; text-decoration: none; color: #fff; }
153 .org-sso-btn-ghost {
154 background: transparent;
155 border-color: var(--border-strong);
156 color: var(--text);
157 }
158 .org-sso-btn-ghost:hover { background: rgba(140,109,255,0.07); border-color: rgba(140,109,255,0.45); color: var(--text-strong); text-decoration: none; }
159
160 .org-sso-banner {
161 margin-bottom: var(--space-4);
162 padding: 10px 14px;
163 border-radius: 10px;
164 font-size: 13.5px;
165 border: 1px solid var(--border);
166 }
167 .org-sso-banner.is-ok { border-color: rgba(52,211,153,0.4); background: rgba(52,211,153,0.07); color: #bbf7d0; }
168 .org-sso-banner.is-error { border-color: rgba(248,113,113,0.4); background: rgba(248,113,113,0.07); color: #fecaca; }
169
170 .org-sso-callout {
171 display: flex;
172 align-items: center;
173 gap: var(--space-3);
174 padding: 12px 14px;
175 background: rgba(140,109,255,0.05);
176 border: 1px dashed rgba(140,109,255,0.30);
177 border-radius: 12px;
178 flex-wrap: wrap;
179 margin-bottom: var(--space-4);
180 }
181 .org-sso-callout-label { font-size: 11px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: var(--text-muted); flex-shrink: 0; }
182 .org-sso-callout code {
183 flex: 1;
184 font-family: var(--font-mono);
185 font-size: 12.5px;
186 color: var(--text);
187 background: rgba(255,255,255,0.04);
188 border: 1px solid var(--border);
189 padding: 6px 10px;
190 border-radius: 8px;
191 word-break: break-all;
192 }
193
194 .org-sso-token-box {
195 background: var(--bg);
196 border: 1px solid var(--border-strong);
197 border-radius: 10px;
198 padding: 12px 14px;
199 font-family: var(--font-mono);
200 font-size: 13px;
201 color: var(--text-strong);
202 word-break: break-all;
203 margin-top: var(--space-3);
204 }
205 .org-sso-token-warning {
206 font-size: 12px;
207 color: #fde68a;
208 margin-top: 8px;
209 }
210
211 .org-sso-provider-tabs {
212 display: flex;
213 gap: 8px;
214 margin-bottom: var(--space-4);
215 }
216 .org-sso-tab {
217 padding: 8px 16px;
218 border-radius: 8px;
219 font-size: 13px;
220 font-weight: 600;
221 border: 1px solid var(--border-strong);
222 background: transparent;
223 color: var(--text);
224 cursor: pointer;
225 font: inherit;
226 }
227 .org-sso-tab.is-active {
228 background: rgba(140,109,255,0.14);
229 border-color: rgba(140,109,255,0.45);
230 color: var(--text-strong);
231 }
232
233 .org-sso-status {
234 display: inline-flex;
235 align-items: center;
236 gap: 6px;
237 padding: 3px 10px;
238 border-radius: 9999px;
239 font-size: 11px;
240 font-weight: 700;
241 letter-spacing: 0.05em;
242 text-transform: uppercase;
243 margin-left: var(--space-3);
244 }
245 .org-sso-status.is-on { background: rgba(52,211,153,0.12); color: #6ee7b7; box-shadow: inset 0 0 0 1px rgba(52,211,153,0.32); }
246 .org-sso-status.is-off { background: rgba(148,163,184,0.10); color: #cbd5e1; box-shadow: inset 0 0 0 1px rgba(148,163,184,0.28); }
247 .org-sso-status .dot { width: 5px; height: 5px; border-radius: 9999px; background: currentColor; }
248`;
249
250// ---------------------------------------------------------------------------
251// Auth + org-admin gate
252// ---------------------------------------------------------------------------
253
254async function requireOrgAdmin(c: any, orgSlug: string) {
255 const user = c.get("user");
256 if (!user) return null;
257
258 const [org] = await db
259 .select()
260 .from(organizations)
261 .where(eq(organizations.slug, orgSlug))
262 .limit(1);
263 if (!org) return null;
264
265 const [membership] = await db
266 .select({ role: orgMembers.role })
267 .from(orgMembers)
268 .where(and(eq(orgMembers.orgId, org.id), eq(orgMembers.userId, user.id)))
269 .limit(1);
270
271 // Only owners and admins can configure SSO
272 if (!membership || (membership.role !== "owner" && membership.role !== "admin")) {
273 return null;
274 }
275
276 return { user, org };
277}
278
279// ---------------------------------------------------------------------------
280// GET /orgs/:orgSlug/settings/sso
281// ---------------------------------------------------------------------------
282
283orgSsoSettings.get("/orgs/:orgSlug/settings/sso", requireAuth, async (c) => {
284 const { orgSlug } = c.req.param();
285 const ctx = await requireOrgAdmin(c, orgSlug);
286 if (!ctx) {
287 return c.html(
288 <Layout title="Forbidden" user={c.get("user")}>
289 <div style="max-width:540px;margin:80px auto;text-align:center;padding:40px;background:var(--bg-elevated);border:1px solid var(--border);border-radius:16px">
290 <h2 style="font-size:20px;margin:0 0 8px;color:var(--text-strong)">403 — Access denied</h2>
291 <p style="color:var(--text-muted);margin:0">Only org owners and admins can configure SSO.</p>
292 </div>
293 </Layout>,
294 403
295 );
296 }
297
298 const { user, org } = ctx;
299 const success = c.req.query("success");
300 const error = c.req.query("error");
301 const newToken = c.req.query("token"); // plaintext token shown once after generation
302
303 const [cfg] = await db
304 .select()
305 .from(orgSsoConfigs)
306 .where(eq(orgSsoConfigs.orgId, org.id))
307 .limit(1);
308
309 const base = process.env.APP_URL || process.env.BASE_URL || "https://gluecron.com";
310 const spMetadataUrl = `${base}/sso/saml/${orgSlug}/metadata`;
311 const spAcsUrl = `${base}/sso/saml/${orgSlug}/callback`;
312 const oidcCallbackUrl = `${base}/sso/oidc/${orgSlug}/callback`;
313 const scimBaseUrl = `${base}/scim/v2/${org.id}`;
314
315 const activeProvider = cfg?.provider || "saml";
316 const isEnabled = !!cfg?.enabled;
317
318 return c.html(
319 <Layout title={`SSO Settings — ${org.name}`} user={user}>
320 <style dangerouslySetInnerHTML={{ __html: orgSsoCss }} />
321 <div class="org-sso-wrap">
322 <div class="org-sso-hero">
323 <h1 class="org-sso-title">
324 Enterprise SSO
325 <span class={`org-sso-status ${isEnabled ? "is-on" : "is-off"}`}>
326 <span class="dot" />
327 {isEnabled ? "Enabled" : "Disabled"}
328 </span>
329 </h1>
330 <p class="org-sso-sub">
331 Configure SAML 2.0 or OIDC single sign-on and SCIM provisioning for <strong>{org.name}</strong>.
332 Members from <code style="font-size:12px;background:var(--bg-tertiary);padding:1px 4px;border-radius:4px">{cfg?.domainHint || "your domain"}</code> will be automatically routed to your identity provider.
333 </p>
334 </div>
335
336 {success && <div class="org-sso-banner is-ok">{decodeURIComponent(success)}</div>}
337 {error && <div class="org-sso-banner is-error">{decodeURIComponent(error)}</div>}
338 {newToken && (
339 <div class="org-sso-banner is-ok">
340 SCIM token generated. Copy it now — it won't be shown again.
341 <div class="org-sso-token-box">{newToken}</div>
342 <div class="org-sso-token-warning">Store this token securely. It grants full SCIM access to your org.</div>
343 </div>
344 )}
345
346 <form method="post" action={`/orgs/${orgSlug}/settings/sso`}>
347 <input type="hidden" name="_csrf" value={(c.get("csrfToken") as string | undefined) ?? ""} />
348
349 {/* Provider tabs */}
350 <div class="org-sso-section">
351 <header class="org-sso-section-head">
352 <h2 class="org-sso-section-title">SSO Provider</h2>
353 <p class="org-sso-section-sub">Choose your identity protocol. SAML 2.0 is most common for enterprise IdPs (Okta, Azure AD, PingFederate). OIDC works with Google Workspace, Auth0, and modern Okta.</p>
354 </header>
355 <div class="org-sso-section-body">
356 <div class="org-sso-toggle">
357 <input
358 type="checkbox"
359 name="enabled"
360 value="1"
361 checked={isEnabled}
362 id="enabled"
363 aria-label="Enable SSO for this organization"
364 />
365 <span>
366 <strong style="color:var(--text-strong)">Enable SSO.</strong>
367 {" "}When enabled, users with a matching domain hint are automatically redirected to your IdP instead of the password form.
368 </span>
369 </div>
370
371 <div class="org-sso-field">
372 <label for="provider">Provider protocol</label>
373 <select id="provider" name="provider" class="org-sso-input" style="cursor:pointer">
374 <option value="saml" selected={activeProvider === "saml"}>SAML 2.0</option>
375 <option value="oidc" selected={activeProvider === "oidc"}>OIDC / OAuth 2.0</option>
376 </select>
377 </div>
378
379 <div class="org-sso-field">
380 <label for="domain_hint">Domain hint</label>
381 <input
382 id="domain_hint"
383 name="domain_hint"
384 type="text"
385 class="org-sso-input"
386 placeholder="acme.com"
387 value={cfg?.domainHint || ""}
388 />
389 <p class="org-sso-hint">When a user logs in with an email from this domain, they'll be redirected to SSO automatically. Leave blank to require manual SSO initiation.</p>
390 </div>
391 </div>
392 </div>
393
394 {/* SAML config */}
395 <div class="org-sso-section">
396 <header class="org-sso-section-head">
397 <h2 class="org-sso-section-title">SAML 2.0 Configuration</h2>
398 <p class="org-sso-section-sub">Provide these SP details to your IdP, then paste the IdP metadata fields below.</p>
399 </header>
400 <div class="org-sso-section-body">
401 <div class="org-sso-callout">
402 <span class="org-sso-callout-label">SP Metadata</span>
403 <code>{spMetadataUrl}</code>
404 <a href={spMetadataUrl} target="_blank" class="org-sso-btn org-sso-btn-ghost" style="padding:6px 12px;font-size:12px">Download</a>
405 </div>
406 <div class="org-sso-callout">
407 <span class="org-sso-callout-label">ACS URL</span>
408 <code>{spAcsUrl}</code>
409 </div>
410
411 <div class="org-sso-field">
412 <label for="idp_entity_id">IdP Entity ID</label>
413 <input id="idp_entity_id" name="idp_entity_id" type="text" class="org-sso-input" placeholder="https://idp.example.com/saml/metadata" value={cfg?.idpEntityId || ""} />
414 </div>
415 <div class="org-sso-field">
416 <label for="idp_sso_url">IdP SSO URL</label>
417 <input id="idp_sso_url" name="idp_sso_url" type="text" class="org-sso-input" placeholder="https://idp.example.com/saml/sso" value={cfg?.idpSsoUrl || ""} />
418 <p class="org-sso-hint">The HTTP-Redirect or HTTP-POST binding URL from your IdP's metadata.</p>
419 </div>
420 <div class="org-sso-field">
421 <label for="idp_certificate">IdP X.509 Certificate (PEM)</label>
422 <textarea id="idp_certificate" name="idp_certificate" class="org-sso-input org-sso-textarea" placeholder="-----BEGIN CERTIFICATE-----&#10;MIIC...&#10;-----END CERTIFICATE-----">{cfg?.idpCertificate || ""}</textarea>
423 <p class="org-sso-hint">Paste the full PEM-encoded certificate from your IdP. Used to verify SAML assertion signatures.</p>
424 </div>
425 </div>
426 </div>
427
428 {/* OIDC config */}
429 <div class="org-sso-section">
430 <header class="org-sso-section-head">
431 <h2 class="org-sso-section-title">OIDC / OAuth 2.0 Configuration</h2>
432 <p class="org-sso-section-sub">Fill these if your provider uses OIDC (OpenID Connect).</p>
433 </header>
434 <div class="org-sso-section-body">
435 <div class="org-sso-callout">
436 <span class="org-sso-callout-label">Redirect URI</span>
437 <code>{oidcCallbackUrl}</code>
438 </div>
439
440 <div class="org-sso-field">
441 <label for="oidc_discovery_url">OIDC Discovery / Issuer URL</label>
442 <input id="oidc_discovery_url" name="oidc_discovery_url" type="text" class="org-sso-input" placeholder="https://accounts.google.com or https://YOUR-TENANT.okta.com" value={cfg?.oidcDiscoveryUrl || ""} />
443 <p class="org-sso-hint">The base issuer URL — we'll append <code>/.well-known/openid-configuration</code> automatically.</p>
444 </div>
445 <div class="org-sso-field">
446 <label for="oidc_client_id">Client ID</label>
447 <input id="oidc_client_id" name="oidc_client_id" type="text" class="org-sso-input" autocomplete="off" value={cfg?.oidcClientId || ""} />
448 </div>
449 <div class="org-sso-field">
450 <label for="oidc_client_secret">Client Secret</label>
451 <input id="oidc_client_secret" name="oidc_client_secret" type="password" class="org-sso-input" autocomplete="off" placeholder={cfg?.oidcClientSecret ? "(storedleave blank to keep)" : ""} />
452 </div>
453 </div>
454 </div>
455
456 <div class="org-sso-section">
457 <div class="org-sso-foot">
458 <span class="org-sso-foot-hint">
459 <a href={`/sso/${activeProvider === "saml" ? "saml" : "oidc"}/${orgSlug}/login`} target="_blank" style="color:var(--accent);text-decoration:none">Test SSO</a> after saving.
460 </span>
461 <button type="submit" class="org-sso-btn org-sso-btn-primary">Save SSO settings</button>
462 </div>
463 </div>
464 </form>
465
466 {/* SCIM section */}
467 <div class="org-sso-section">
468 <header class="org-sso-section-head">
469 <h2 class="org-sso-section-title">SCIM User Provisioning</h2>
470 <p class="org-sso-section-sub">
471 Connect your IdP (Okta, Azure AD, Google Workspace) to automatically provision and deprovision members of <strong>{org.name}</strong>.
472 </p>
473 </header>
474 <div class="org-sso-section-body">
475 <div class="org-sso-callout">
476 <span class="org-sso-callout-label">SCIM Base URL</span>
477 <code>{scimBaseUrl}</code>
478 </div>
479 <p class="org-sso-hint" style="margin-bottom:var(--space-4)">
480 Configure your IdP with the SCIM base URL above and a bearer token generated below. Set the SCIM version to 2.0 in your IdP.
481 </p>
482 </div>
483 <div class="org-sso-foot">
484 <span class="org-sso-foot-hint">Tokens are shown once at creation. Store them securely.</span>
485 <form method="post" action={`/orgs/${orgSlug}/settings/sso/generate-scim-token`}>
486 <input type="hidden" name="_csrf" value={(c.get("csrfToken") as string | undefined) ?? ""} />
487 <button type="submit" class="org-sso-btn org-sso-btn-ghost">Generate SCIM token</button>
488 </form>
489 </div>
490 </div>
491 </div>
492 </Layout>
493 );
494});
495
496// ---------------------------------------------------------------------------
497// POST /orgs/:orgSlug/settings/sso — save config
498// ---------------------------------------------------------------------------
499
500orgSsoSettings.post("/orgs/:orgSlug/settings/sso", requireAuth, async (c) => {
501 const { orgSlug } = c.req.param();
502 const ctx = await requireOrgAdmin(c, orgSlug);
503 if (!ctx) return c.redirect(`/orgs/${orgSlug}`);
504
505 const { org } = ctx;
506 const body = await c.req.parseBody();
507
508 const enabled = String(body.enabled || "") === "1";
509 const provider = String(body.provider || "saml");
510 const domainHint = String(body.domain_hint || "").trim() || null;
511
512 // SAML fields
513 const idpEntityId = String(body.idp_entity_id || "").trim() || null;
514 const idpSsoUrl = String(body.idp_sso_url || "").trim() || null;
515 const idpCertificate = String(body.idp_certificate || "").trim() || null;
516
517 // OIDC fields
518 const oidcDiscoveryUrl = String(body.oidc_discovery_url || "").trim() || null;
519 const oidcClientId = String(body.oidc_client_id || "").trim() || null;
520
521 // Existing config to preserve client_secret if not changed
522 const [existing] = await db
523 .select()
524 .from(orgSsoConfigs)
525 .where(eq(orgSsoConfigs.orgId, org.id))
526 .limit(1);
527
528 const oidcClientSecretNew = String(body.oidc_client_secret || "").trim();
529 const oidcClientSecret =
530 oidcClientSecretNew.length > 0 ? oidcClientSecretNew : existing?.oidcClientSecret || null;
531
532 const base = process.env.APP_URL || process.env.BASE_URL || "https://gluecron.com";
533 const spEntityId = `${base}/sso/saml/${orgSlug}`;
534
535 if (existing) {
536 await db
537 .update(orgSsoConfigs)
538 .set({
539 enabled,
540 provider,
541 domainHint,
542 idpEntityId,
543 idpSsoUrl,
544 idpCertificate,
545 spEntityId,
546 oidcDiscoveryUrl,
547 oidcClientId,
548 oidcClientSecret,
549 updatedAt: new Date(),
550 })
551 .where(eq(orgSsoConfigs.orgId, org.id));
552 } else {
553 await db.insert(orgSsoConfigs).values({
554 orgId: org.id,
555 enabled,
556 provider,
557 domainHint,
558 idpEntityId,
559 idpSsoUrl,
560 idpCertificate,
561 spEntityId,
562 oidcDiscoveryUrl,
563 oidcClientId,
564 oidcClientSecret,
565 });
566 }
567
568 return c.redirect(
569 `/orgs/${orgSlug}/settings/sso?success=${encodeURIComponent("SSO settings saved.")}`
570 );
571});
572
573// ---------------------------------------------------------------------------
574// POST /orgs/:orgSlug/settings/sso/generate-scim-token
575// ---------------------------------------------------------------------------
576
577orgSsoSettings.post(
578 "/orgs/:orgSlug/settings/sso/generate-scim-token",
579 requireAuth,
580 async (c) => {
581 const { orgSlug } = c.req.param();
582 const ctx = await requireOrgAdmin(c, orgSlug);
583 if (!ctx) return c.redirect(`/orgs/${orgSlug}`);
584
585 const { user, org } = ctx;
586
587 // Generate a random bearer token for SCIM provisioning.
588 // Prefix "gscim1_" identifies the token type in logs without embedding a secret.
589 const tokenPrefix = "gscim1_";
590 const rawToken = tokenPrefix + crypto.randomBytes(30).toString("hex");
591 const tokenHash = crypto.createHash("sha256").update(rawToken).digest("hex");
592
593 await db.insert(scimTokens).values({
594 orgId: org.id,
595 tokenHash,
596 createdBy: user.id,
597 });
598
599 return c.redirect(
600 `/orgs/${orgSlug}/settings/sso?token=${encodeURIComponent(rawToken)}&success=${encodeURIComponent("SCIM token generated. Copy it now — it won't be shown again.")}`
601 );
602 }
603);
604
605export default orgSsoSettings;
Modifiedsrc/routes/pulls.tsx+637−0View fileUnifiedSplit
2020 pullRequests,
2121 prComments,
2222 prReviews,
23 prReviewRequests,
2324 repositories,
2425 users,
2526 issues,
123124import { suggestReviewers, type ReviewerCandidate } from "../lib/reviewer-suggest";
124125import { computePrSize, type PrSizeInfo } from "../lib/pr-size";
125126import { BOT_USERNAME } from "../lib/bot-user";
127import {
128 getCodeownersForRepo,
129 reviewersForChangedFiles,
130} from "../lib/codeowners";
131import { checkMergeEligible } from "../lib/branch-rules";
132import {
133 joinRoom,
134 leaveRoom,
135 updatePresence,
136 pingSession,
137 getRoomUsers,
138 broadcastToRoom,
139 registerSocket,
140 unregisterSocket,
141} from "../lib/pr-presence";
142import { upgradeWebSocket, websocket as presenceWebsocket } from "hono/bun";
143
144export { presenceWebsocket };
126145
127146const pulls = new Hono<AuthEnv>();
128147
14711490 }
14721491`;
14731492
1493/* ──────────────────────────────────────────────────────────────────────
1494 * Figma-style collaborative PR presence — styles for the presence bar
1495 * above the diff and the per-line reviewer cursor pills. All scoped
1496 * with `.presence-*` prefix so they never bleed into other views.
1497 * ──────────────────────────────────────────────────────────────────── */
1498const PRESENCE_STYLES = `
1499 .presence-bar {
1500 display: flex;
1501 align-items: center;
1502 gap: 10px;
1503 padding: 8px 14px;
1504 margin: 0 0 10px;
1505 background: var(--bg-elevated);
1506 border: 1px solid var(--border);
1507 border-radius: 10px;
1508 font-size: 12.5px;
1509 color: var(--text-muted);
1510 min-height: 38px;
1511 }
1512 .presence-bar-label {
1513 font-weight: 600;
1514 color: var(--text-muted);
1515 flex-shrink: 0;
1516 }
1517 .presence-avatars {
1518 display: flex;
1519 align-items: center;
1520 gap: 6px;
1521 flex: 1;
1522 flex-wrap: wrap;
1523 }
1524 .presence-avatar {
1525 display: inline-flex;
1526 align-items: center;
1527 gap: 5px;
1528 padding: 3px 8px 3px 4px;
1529 border-radius: 9999px;
1530 font-size: 12px;
1531 font-weight: 600;
1532 color: #fff;
1533 opacity: 0.92;
1534 transition: opacity 200ms;
1535 }
1536 .presence-avatar-dot {
1537 width: 20px; height: 20px;
1538 border-radius: 9999px;
1539 background: rgba(255,255,255,0.22);
1540 display: inline-flex;
1541 align-items: center;
1542 justify-content: center;
1543 font-size: 10px;
1544 font-weight: 700;
1545 flex-shrink: 0;
1546 }
1547 .presence-count {
1548 font-size: 12px;
1549 color: var(--text-faint);
1550 flex-shrink: 0;
1551 }
1552 /* Per-line reviewer cursor pill — injected by JS into .diff-row */
1553 .presence-line-pill {
1554 position: absolute;
1555 right: 6px;
1556 top: 50%;
1557 transform: translateY(-50%);
1558 display: inline-flex;
1559 align-items: center;
1560 gap: 4px;
1561 padding: 1px 7px;
1562 border-radius: 9999px;
1563 font-size: 10.5px;
1564 font-weight: 600;
1565 color: #fff;
1566 pointer-events: none;
1567 white-space: nowrap;
1568 z-index: 10;
1569 opacity: 0.88;
1570 animation: presence-in 160ms ease;
1571 }
1572 @keyframes presence-in {
1573 from { opacity: 0; transform: translateY(-50%) scale(0.85); }
1574 to { opacity: 0.88; transform: translateY(-50%) scale(1); }
1575 }
1576 .presence-line-pill.is-typing::after {
1577 content: '…';
1578 opacity: 0.7;
1579 }
1580 /* diff rows with a cursor pill need relative positioning */
1581 .diff-row { position: relative; }
1582 /* Toast for join/leave events */
1583 .presence-toast-wrap {
1584 position: fixed;
1585 bottom: 24px;
1586 right: 24px;
1587 display: flex;
1588 flex-direction: column;
1589 gap: 8px;
1590 z-index: 9999;
1591 pointer-events: none;
1592 }
1593 .presence-toast {
1594 padding: 8px 14px;
1595 border-radius: 8px;
1596 background: var(--bg-elevated);
1597 border: 1px solid var(--border);
1598 box-shadow: 0 6px 20px -8px rgba(0,0,0,0.55);
1599 font-size: 13px;
1600 color: var(--text);
1601 opacity: 1;
1602 transition: opacity 400ms;
1603 }
1604 .presence-toast.fading { opacity: 0; }
1605`;
1606
1607
14741608/**
14751609 * Tiny inline JS that drives the "Suggest description with AI" button.
14761610 * On click, gathers form values, POSTs JSON to the given endpoint, and
15281662 * All endpoint URLs are JSON-escaped via safe replacements so they
15291663 * can't break out of the <script> tag.
15301664 */
1665
1666/**
1667 * Figma-style collaborative PR presence client (WebSocket).
1668 *
1669 * Connects to `GET /:owner/:repo/pulls/:number/presence` (WebSocket upgrade).
1670 * On connect the server sends `{type:"init", users:[...]}` so the bar renders
1671 * immediately. Subsequent messages from the server drive the presence bar and
1672 * per-line cursor pills in the diff.
1673 *
1674 * Outbound messages:
1675 * {type:"cursor", line: N} — user hovered a diff line
1676 * {type:"typing", line: N, typing: bool} — textarea focus/blur in diff
1677 * {type:"ping"} — keep-alive every 10s
1678 *
1679 * Inbound messages:
1680 * {type:"init", users:[{sessionId,username,colour,line,typing}]}
1681 * {type:"join", user:{sessionId,username,colour,line,typing}}
1682 * {type:"leave", sessionId}
1683 * {type:"cursor", sessionId, username, colour, line}
1684 * {type:"typing", sessionId, username, colour, line, typing}
1685 */
1686function PR_PRESENCE_SCRIPT(owner: string, repo: string, prNum: number): string {
1687 const wsPath = JSON.stringify(`/${owner}/${repo}/pulls/${prNum}/presence`)
1688 .split("<").join("\\u003C")
1689 .split(">").join("\\u003E")
1690 .split("&").join("\\u0026");
1691 return `(function(){
1692try{
1693var wsPath=${wsPath};
1694var proto=location.protocol==='https:'?'wss:':'ws:';
1695var url=proto+'//'+location.host+wsPath;
1696var mySessionId=null;
1697// sessionId -> {username, colour, line, typing}
1698var peers={};
1699var ws=null;
1700var pingTimer=null;
1701var reconnectDelay=1500;
1702var reconnectTimer=null;
1703
1704function esc(s){return String(s==null?'':s).replace(/[&<>"']/g,function(c){return{'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c];});}
1705
1706// ── Toast ──────────────────────────────────────────────────────────────
1707var toastWrap=document.getElementById('presence-toasts');
1708function toast(msg){
1709 if(!toastWrap)return;
1710 var t=document.createElement('div');
1711 t.className='presence-toast';
1712 t.textContent=msg;
1713 toastWrap.appendChild(t);
1714 setTimeout(function(){t.classList.add('fading');setTimeout(function(){if(t.parentNode)t.parentNode.removeChild(t);},420);},2500);
1715}
1716
1717// ── Presence bar ───────────────────────────────────────────────────────
1718var avEl=document.getElementById('presence-avatars');
1719var countEl=document.getElementById('presence-count');
1720function renderBar(){
1721 if(!avEl)return;
1722 var ids=Object.keys(peers);
1723 var html='';
1724 for(var i=0;i<ids.length&&i<8;i++){
1725 var p=peers[ids[i]];
1726 var initials=(p.username||'?').slice(0,2).toUpperCase();
1727 html+='<span class="presence-avatar" style="background:'+esc(p.colour)+'" title="'+esc(p.username)+'">';
1728 html+='<span class="presence-avatar-dot">'+esc(initials)+'</span>';
1729 html+=esc(p.username);
1730 html+='</span>';
1731 }
1732 avEl.innerHTML=html;
1733 if(countEl){
1734 var n=ids.length;
1735 countEl.textContent=n===0?'No other reviewers':n===1?'1 reviewer online':n+' reviewers online';
1736 }
1737}
1738
1739// ── Diff cursor pills ──────────────────────────────────────────────────
1740// data-line value is like "12:x:5" or "12:5:x" — pull numeric line only
1741function lineNumFromKey(key){var m=String(key).match(/(\d+)/);return m?parseInt(m[1],10):null;}
1742function findDiffRow(line){return document.querySelector('[data-line]') &&
1743 (function(){var rows=document.querySelectorAll('[data-line]');
1744 for(var i=0;i<rows.length;i++){var n=lineNumFromKey(rows[i].getAttribute('data-line')||'');if(n===line)return rows[i];}
1745 return null;
1746 })();}
1747function removePill(sessionId){var old=document.querySelector('[data-presence-sid="'+sessionId+'"]');if(old&&old.parentNode)old.parentNode.removeChild(old);}
1748function placePill(sessionId,username,colour,line,typing){
1749 removePill(sessionId);
1750 if(line==null)return;
1751 var row=findDiffRow(line);if(!row)return;
1752 var pill=document.createElement('span');
1753 pill.className='presence-line-pill'+(typing?' is-typing':'');
1754 pill.setAttribute('data-presence-sid',sessionId);
1755 pill.style.background=colour||'#8c6dff';
1756 pill.textContent=(username||'?').slice(0,12)+(typing?' typing':'');
1757 row.appendChild(pill);
1758}
1759function clearPeer(sessionId){removePill(sessionId);delete peers[sessionId];}
1760
1761// ── Inbound message handler ────────────────────────────────────────────
1762function onMsg(raw){
1763 var d;try{d=JSON.parse(raw);}catch(e){return;}
1764 if(!d||!d.type)return;
1765 if(d.type==='init'){
1766 mySessionId=d.sessionId||null;
1767 peers={};
1768 (d.users||[]).forEach(function(u){
1769 if(u.sessionId===mySessionId)return;
1770 peers[u.sessionId]={username:u.username,colour:u.colour,line:u.line,typing:u.typing};
1771 placePill(u.sessionId,u.username,u.colour,u.line,u.typing);
1772 });
1773 renderBar();
1774 } else if(d.type==='join'){
1775 if(d.user&&d.user.sessionId!==mySessionId){
1776 peers[d.user.sessionId]={username:d.user.username,colour:d.user.colour,line:d.user.line,typing:d.user.typing};
1777 renderBar();
1778 toast(esc(d.user.username)+' joined the review');
1779 }
1780 } else if(d.type==='leave'){
1781 if(d.sessionId&&d.sessionId!==mySessionId){
1782 var name=peers[d.sessionId]&&peers[d.sessionId].username;
1783 clearPeer(d.sessionId);
1784 renderBar();
1785 if(name)toast(esc(name)+' left the review');
1786 }
1787 } else if(d.type==='cursor'){
1788 if(d.sessionId&&d.sessionId!==mySessionId){
1789 if(peers[d.sessionId]){peers[d.sessionId].line=d.line;peers[d.sessionId].typing=false;}
1790 placePill(d.sessionId,d.username,d.colour,d.line,false);
1791 }
1792 } else if(d.type==='typing'){
1793 if(d.sessionId&&d.sessionId!==mySessionId){
1794 if(peers[d.sessionId]){peers[d.sessionId].line=d.line;peers[d.sessionId].typing=d.typing;}
1795 placePill(d.sessionId,d.username,d.colour,d.line,d.typing);
1796 }
1797 }
1798}
1799
1800// ── Outbound helpers ───────────────────────────────────────────────────
1801function send(obj){try{if(ws&&ws.readyState===1)ws.send(JSON.stringify(obj));}catch(e){}}
1802
1803// ── Mouse hover on diff rows ───────────────────────────────────────────
1804var hoverTimer=null;
1805document.addEventListener('mouseover',function(ev){
1806 var row=ev.target&&ev.target.closest&&ev.target.closest('[data-line]');
1807 if(!row)return;
1808 if(hoverTimer)clearTimeout(hoverTimer);
1809 hoverTimer=setTimeout(function(){
1810 var key=row.getAttribute('data-line')||'';
1811 var line=lineNumFromKey(key);
1812 if(line!=null)send({type:'cursor',line:line});
1813 },80);
1814});
1815
1816// ── Typing detection in diff comment textareas ─────────────────────────
1817document.addEventListener('focusin',function(ev){
1818 var ta=ev.target;
1819 if(!ta||ta.tagName!=='TEXTAREA')return;
1820 var row=ta.closest&&ta.closest('[data-line]');if(!row)return;
1821 var line=lineNumFromKey(row.getAttribute('data-line')||'');
1822 if(line!=null)send({type:'typing',line:line,typing:true});
1823});
1824document.addEventListener('focusout',function(ev){
1825 var ta=ev.target;
1826 if(!ta||ta.tagName!=='TEXTAREA')return;
1827 var row=ta.closest&&ta.closest('[data-line]');if(!row)return;
1828 var line=lineNumFromKey(row.getAttribute('data-line')||'');
1829 if(line!=null)send({type:'typing',line:line,typing:false});
1830});
1831
1832// ── WebSocket lifecycle ────────────────────────────────────────────────
1833function connect(){
1834 if(reconnectTimer){clearTimeout(reconnectTimer);reconnectTimer=null;}
1835 try{ws=new WebSocket(url);}catch(e){scheduleReconnect();return;}
1836 ws.onopen=function(){
1837 reconnectDelay=1500;
1838 pingTimer=setInterval(function(){send({type:'ping'});},10000);
1839 };
1840 ws.onmessage=function(ev){onMsg(ev.data);};
1841 ws.onclose=function(){
1842 if(pingTimer){clearInterval(pingTimer);pingTimer=null;}
1843 scheduleReconnect();
1844 };
1845 ws.onerror=function(){try{ws.close();}catch(e){}};
1846}
1847function scheduleReconnect(){
1848 reconnectTimer=setTimeout(function(){connect();},reconnectDelay);
1849 reconnectDelay=Math.min(reconnectDelay*2,30000);
1850}
1851
1852connect();
1853}catch(e){}})();`;
1854}
1855
15311856function LIVE_COEDIT_SCRIPT(prId: string): string {
15321857 const idJson = JSON.stringify(prId)
15331858 .split("<").join("\\u003C")
31833508 })
31843509 .returning();
31853510
3511 // CODEOWNERS — auto-request reviewers based on changed files.
3512 // Fire-and-forget; errors never block PR creation.
3513 (async () => {
3514 try {
3515 const repoDir = getRepoPath(ownerName, repoName);
3516 // Get list of changed files between base and head
3517 const diffProc = Bun.spawn(
3518 ["git", "diff", "--name-only", `${baseBranch}...${headBranch}`],
3519 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
3520 );
3521 const rawDiff = await new Response(diffProc.stdout).text();
3522 await diffProc.exited;
3523 const changedFiles = rawDiff.trim().split("\n").filter(Boolean);
3524
3525 if (changedFiles.length > 0) {
3526 // Get CODEOWNERS from the default branch of the repo
3527 const rules = await getCodeownersForRepo(
3528 ownerName,
3529 repoName,
3530 resolved.repo.defaultBranch
3531 );
3532 if (rules.length > 0) {
3533 const ownerUsernames = await reviewersForChangedFiles(
3534 resolved.repo.id,
3535 changedFiles
3536 );
3537 // Filter out the PR author
3538 const filteredOwners = ownerUsernames.filter(
3539 (u) => u !== resolved.owner.username
3540 );
3541
3542 if (filteredOwners.length > 0) {
3543 // Look up user IDs for the owner usernames
3544 const reviewerUsers = await db
3545 .select({ id: users.id, username: users.username })
3546 .from(users)
3547 .where(
3548 inArray(
3549 users.username,
3550 filteredOwners
3551 )
3552 );
3553
3554 // Create review request rows (UNIQUE constraint prevents dupes)
3555 if (reviewerUsers.length > 0) {
3556 await db
3557 .insert(prReviewRequests)
3558 .values(
3559 reviewerUsers.map((u) => ({
3560 prId: pr.id,
3561 reviewerId: u.id,
3562 requestedBy: null as string | null,
3563 }))
3564 )
3565 .onConflictDoNothing();
3566
3567 // Add a PR comment announcing the auto-assigned reviewers
3568 const mentionList = reviewerUsers
3569 .map((u) => `@${u.username}`)
3570 .join(", ");
3571 await db.insert(prComments).values({
3572 pullRequestId: pr.id,
3573 authorId: user.id,
3574 body: `AI: Requested review from ${mentionList} based on CODEOWNERS`,
3575 isAiReview: true,
3576 });
3577 }
3578 }
3579 }
3580 }
3581 } catch (err) {
3582 console.warn("[codeowners] auto-assign failed:", err instanceof Error ? err.message : err);
3583 }
3584 })();
3585
31863586 // Skip AI review on drafts — it runs again when the PR is marked ready.
31873587 if (!isDraft && isAiReviewEnabled()) {
31883588 triggerAiReview(ownerName, repoName, pr.id, title, prBody, baseBranch, headBranch).catch(
33403740 const changesRequested = [...latestReviewByReviewer.values()].filter(r => r.state === "changes_requested");
33413741 const viewerHasReviewed = user ? latestReviewByReviewer.has(user.id) : false;
33423742
3743 // Requested reviewers from CODEOWNERS auto-assign (migration 0077).
3744 const requestedReviewerRows = await db
3745 .select({
3746 reviewerUsername: users.username,
3747 reviewerId: prReviewRequests.reviewerId,
3748 createdAt: prReviewRequests.createdAt,
3749 })
3750 .from(prReviewRequests)
3751 .innerJoin(users, eq(prReviewRequests.reviewerId, users.id))
3752 .where(eq(prReviewRequests.prId, pr.id))
3753 .orderBy(asc(prReviewRequests.createdAt))
3754 .catch(() => [] as { reviewerUsername: string; reviewerId: string; createdAt: Date }[]);
3755
33433756 // Suggested reviewers — best-effort, never throws
33443757 let reviewerSuggestions: ReviewerCandidate[] = [];
33453758 try {
37984211 <script dangerouslySetInnerHTML={{ __html: markdownPreviewScript() }} />
37994212 <script dangerouslySetInnerHTML={{ __html: ctrlEnterSubmitScript() + codeBlockCopyScript() }} />
38004213
4214 {/* Presence styles + bar (shown only on the files tab so cursor pills work) */}
4215 <style dangerouslySetInnerHTML={{ __html: PRESENCE_STYLES }} />
4216 {/* Toast container — always present for join/leave toasts */}
4217 <div id="presence-toasts" class="presence-toast-wrap" aria-live="polite" />
4218 {user && (
4219 <>
4220 <div class="presence-bar" id="presence-bar">
4221 <span class="presence-bar-label">Live reviewers</span>
4222 <div class="presence-avatars" id="presence-avatars" />
4223 <span class="presence-count" id="presence-count">Loading…</span>
4224 </div>
4225 <script
4226 dangerouslySetInnerHTML={{
4227 __html: PR_PRESENCE_SCRIPT(ownerName, repoName, pr.number),
4228 }}
4229 />
4230 </>
4231 )}
4232
38014233 <nav class="prs-detail-tabs" aria-label="Pull request sections">
38024234 <a
38034235 class={`prs-detail-tab${tab === "conversation" ? " is-active" : ""}`}
40034435 <PrRiskCard risk={prRisk} calculating={prRiskCalculating} />
40044436 )}
40054437
4438 {/* ─── Requested reviewers (CODEOWNERS auto-assign, migration 0077) ─── */}
4439 {requestedReviewerRows.length > 0 && (
4440 <div class="prs-review-summary" style="margin-top:14px">
4441 <div style="font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:var(--text-muted);font-weight:700;margin-bottom:4px">
4442 Review requested
4443 </div>
4444 {requestedReviewerRows.map((rr) => {
4445 const hasReviewed = latestReviewByReviewer.has(rr.reviewerId);
4446 const review = latestReviewByReviewer.get(rr.reviewerId);
4447 const statusIcon = !hasReviewed ? "⏳" : review?.state === "approved" ? "✓" : "✗";
4448 const statusColor = !hasReviewed
4449 ? "var(--text-muted)"
4450 : review?.state === "approved"
4451 ? "#34d399"
4452 : "#f87171";
4453 return (
4454 <div class="prs-review-row" style={`gap:8px`}>
4455 <span class="prs-reviewer-avatar">
4456 {rr.reviewerUsername.slice(0, 1).toUpperCase()}
4457 </span>
4458 <a href={`/${rr.reviewerUsername}`}
4459 style="flex:1;font-size:13px;color:var(--text);font-weight:600;text-decoration:none">
4460 {rr.reviewerUsername}
4461 </a>
4462 <span style={`font-size:12px;font-weight:600;color:${statusColor}`}>
4463 {statusIcon} {!hasReviewed ? "Pending" : review?.state === "approved" ? "Approved" : "Changes requested"}
4464 </span>
4465 </div>
4466 );
4467 })}
4468 </div>
4469 )}
4470
40064471 {/* ─── Review summary ─────────────────────────────────── */}
40074472 {(approvals.length > 0 || changesRequested.length > 0) && (
40084473 <div class="prs-review-summary">
45455010 } catch {
45465011 /* SSE is best-effort */
45475012 }
5013 // Notify the PR author — fire-and-forget, never blocks the response.
5014 if (pr.authorId && pr.authorId !== user.id) {
5015 void import("../lib/notify").then(({ createNotification }) =>
5016 createNotification({
5017 userId: pr.authorId,
5018 type: "pr_comment",
5019 title: `New comment on "${pr.title}"`,
5020 body: commentBody.length > 200 ? commentBody.slice(0, 200) + "…" : commentBody,
5021 url: `/${ownerName}/${repoName}/pulls/${prNum}`,
5022 repoId: resolved.repo.id,
5023 })
5024 ).catch(() => { /* never block the response */ });
5025 }
45485026 }
45495027
45505028 if (decision.status === "pending") {
48355313 );
48365314 }
48375315
5316 // Required reviews check — branch-protection `required_approvals` gate.
5317 // Evaluated before running expensive gate checks so the feedback is fast.
5318 {
5319 const eligibility = await checkMergeEligible(pr.id, resolved.repo.id, pr.baseBranch);
5320 if (!eligibility.eligible && eligibility.reason) {
5321 return c.redirect(
5322 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(eligibility.reason)}`
5323 );
5324 }
5325 }
5326
48385327 // Resolve head SHA
48395328 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
48405329 if (!headSha) {
54155904 }
54165905);
54175906
5907// ─── WebSocket presence endpoint ─────────────────────────────────────────────
5908//
5909// GET /:owner/:repo/pulls/:number/presence (WebSocket upgrade)
5910//
5911// Unauthenticated connections are rejected with 401. On connect:
5912// → server sends {type:"init", sessionId, users:[...]}
5913// → server broadcasts {type:"join", user} to all other sessions in the room
5914//
5915// Accepted client messages:
5916// {type:"cursor", line: number} — user hovering a diff line
5917// {type:"typing", line: number, typing: bool} — textarea focus/blur
5918// {type:"ping"} — keep-alive (updates lastSeen)
5919//
5920// The WS `data` payload we store on each socket carries everything needed in
5921// the event handlers so no closure tricks are required.
5922
5923pulls.get(
5924 "/:owner/:repo/pulls/:number/presence",
5925 softAuth,
5926 upgradeWebSocket(async (c) => {
5927 const { owner: ownerName, repo: repoName, number: prNumStr } = c.req.param();
5928 const prNum = parseInt(prNumStr ?? "0", 10);
5929 const user = c.get("user");
5930
5931 // Auth check — no anonymous presence
5932 if (!user) {
5933 // upgradeWebSocket doesn't support returning a non-101 directly;
5934 // we return a dummy handler that immediately closes with 4001.
5935 return {
5936 onOpen(_evt: Event, ws: import("hono/ws").WSContext) {
5937 ws.close(4001, "Unauthorized");
5938 },
5939 onMessage() {},
5940 onClose() {},
5941 };
5942 }
5943
5944 // Resolve repo to get its numeric id for the room key
5945 const resolved = await resolveRepo(ownerName, repoName);
5946 if (!resolved || isNaN(prNum)) {
5947 return {
5948 onOpen(_evt: Event, ws: import("hono/ws").WSContext) {
5949 ws.close(4004, "Not found");
5950 },
5951 onMessage() {},
5952 onClose() {},
5953 };
5954 }
5955
5956 const prId = `${resolved.repo.id}:${prNum}`;
5957 const sessionId = `${user.id}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
5958
5959 return {
5960 onOpen(_evt: Event, ws: import("hono/ws").WSContext) {
5961 // Register and join room
5962 registerSocket(prId, sessionId, {
5963 send: (data: string) => ws.send(data),
5964 readyState: ws.readyState,
5965 });
5966 const presenceUser = joinRoom(prId, sessionId, {
5967 userId: user.id,
5968 username: user.username,
5969 });
5970
5971 // Send init snapshot to the new joiner
5972 const currentUsers = getRoomUsers(prId);
5973 ws.send(
5974 JSON.stringify({
5975 type: "init",
5976 sessionId,
5977 users: currentUsers,
5978 })
5979 );
5980
5981 // Broadcast join to all OTHER sessions
5982 broadcastToRoom(
5983 prId,
5984 {
5985 type: "join",
5986 user: { ...presenceUser, sessionId },
5987 },
5988 sessionId
5989 );
5990 },
5991
5992 onMessage(evt: MessageEvent, _ws: import("hono/ws").WSContext) {
5993 let msg: { type: string; line?: number; typing?: boolean };
5994 try {
5995 msg = JSON.parse(typeof evt.data === "string" ? evt.data : String(evt.data));
5996 } catch {
5997 return;
5998 }
5999
6000 if (msg.type === "ping") {
6001 pingSession(prId, sessionId);
6002 return;
6003 }
6004
6005 if (msg.type === "cursor") {
6006 const line = typeof msg.line === "number" ? msg.line : null;
6007 const updated = updatePresence(prId, sessionId, line, false);
6008 if (updated) {
6009 broadcastToRoom(
6010 prId,
6011 {
6012 type: "cursor",
6013 sessionId,
6014 username: updated.username,
6015 colour: updated.colour,
6016 line,
6017 },
6018 sessionId
6019 );
6020 }
6021 return;
6022 }
6023
6024 if (msg.type === "typing") {
6025 const line = typeof msg.line === "number" ? msg.line : null;
6026 const typing = !!msg.typing;
6027 const updated = updatePresence(prId, sessionId, line, typing);
6028 if (updated) {
6029 broadcastToRoom(
6030 prId,
6031 {
6032 type: "typing",
6033 sessionId,
6034 username: updated.username,
6035 colour: updated.colour,
6036 line,
6037 typing,
6038 },
6039 sessionId
6040 );
6041 }
6042 return;
6043 }
6044 },
6045
6046 onClose() {
6047 leaveRoom(prId, sessionId);
6048 unregisterSocket(prId, sessionId);
6049 broadcastToRoom(prId, { type: "leave", sessionId });
6050 },
6051 };
6052 })
6053);
6054
54186055export default pulls;
Modifiedsrc/routes/repo-settings.tsx+374−1View fileUnifiedSplit
55import { Hono } from "hono";
66import { eq, and } from "drizzle-orm";
77import { db } from "../db";
8import { repositories, users, repoTransfers } from "../db/schema";
8import { repositories, users, repoTransfers, branchProtection } from "../db/schema";
99import { Layout } from "../views/layout";
1010import { RepoHeader } from "../views/components";
1111import { softAuth, requireAuth } from "../middleware/auth";
583583
584584 const branches = await listBranches(ownerName, repoName);
585585
586 // Branch protection rules for the "Branch protection" settings section.
587 const existingBranchRules = await db
588 .select()
589 .from(branchProtection)
590 .where(eq(branchProtection.repositoryId, repo.id))
591 .catch(() => [] as typeof branchProtection.$inferSelect[]);
592
586593 return c.html(
587594 <Layout title={`Settings — ${ownerName}/${repoName}`} user={user}>
588595 <RepoHeader owner={ownerName} repo={repoName} />
905912 </form>
906913 </section>
907914
915 {/* ─── Dependency auto-updater ─── */}
916 <section
917 id="dep-updater"
918 class="repo-settings-section"
919 >
920 <div class="repo-settings-section-head">
921 <div class="repo-settings-section-eyebrow">AI dependency updates</div>
922 <h2 class="repo-settings-section-title">Automatic dependency updates</h2>
923 <p class="repo-settings-section-desc">
924 Once per day, Gluecron reads your <code>package.json</code>,
925 checks npm for patch and minor updates, and applies them
926 automatically. If the gate check passes, the PR is auto-merged.
927 If it fails, Gluecron opens a PR with an AI-written guide
928 explaining what broke and how to fix it. Major updates always
929 require human review and are handled separately by the migration
930 watcher. Default off.
931 </p>
932 </div>
933 <form
934 method="post"
935 action={`/${ownerName}/${repoName}/settings/dep-updater`}
936 >
937 <div class="repo-settings-section-body">
938 <label
939 class="repo-settings-toggle-row"
940 aria-label="Enable automatic dependency updates for this repo"
941 >
942 <input
943 type="checkbox"
944 name="dep_updater_enabled"
945 value="1"
946 checked={
947 (repo as { depUpdaterEnabled?: boolean }).depUpdaterEnabled ??
948 false
949 }
950 />
951 <span class="repo-settings-toggle-text">
952 <span class="repo-settings-toggle-text-title">
953 Enable automatic dependency updates
954 </span>
955 <span class="repo-settings-toggle-text-hint">
956 Patch and minor updates only. Runs once per day; max 2
957 packages per sweep. Requires <code>DEP_UPDATER_ENABLED=1</code>{" "}
958 on the server. Auto-merges when gate passes; opens a PR
959 with an AI migration guide when it fails.
960 </span>
961 </span>
962 </label>
963 </div>
964 <div class="repo-settings-section-foot">
965 <button type="submit" class="repo-settings-cta">
966 Save dependency update settings <span class="arrow"></span>
967 </button>
968 </div>
969 </form>
970 </section>
971
908972 {/* ─── Cloud dev environments ─── */}
909973 <section
910974 id="dev-envs"
10911155 </form>
10921156 </section>
10931157
1158 {/* ─── Branch protection ─── */}
1159 <section class="repo-settings-section" id="branch-protection">
1160 <div class="repo-settings-section-head">
1161 <div class="repo-settings-section-eyebrow">Branch protection</div>
1162 <h2 class="repo-settings-section-title">Required reviews before merge</h2>
1163 <p class="repo-settings-section-desc">
1164 Protect branches by requiring a minimum number of human approvals before
1165 a pull request can be merged. Patterns support wildcards (e.g.{" "}
1166 <code>release/*</code>). CODEOWNERS auto-assignment applies independently.
1167 </p>
1168 </div>
1169
1170 {/* Existing rules list */}
1171 {existingBranchRules.length > 0 && (
1172 <div style="padding: var(--space-3) var(--space-5) 0">
1173 {existingBranchRules.map((rule) => (
1174 <div style="display:flex;align-items:center;gap:12px;padding:10px 0;border-bottom:1px solid var(--border)">
1175 <span style="flex:1;font-family:var(--font-mono);font-size:13px;color:var(--text-strong)">
1176 {rule.pattern}
1177 </span>
1178 <span style="font-size:12.5px;color:var(--text-muted)">
1179 {rule.requiredApprovals} required approval{rule.requiredApprovals !== 1 ? "s" : ""}
1180 </span>
1181 {rule.requireHumanReview && (
1182 <span style="font-size:11px;font-weight:600;padding:2px 8px;border-radius:9999px;background:rgba(140,109,255,0.12);color:#b69dff">
1183 codeowner review required
1184 </span>
1185 )}
1186 {rule.dismissStaleReviews && (
1187 <span style="font-size:11px;color:var(--text-muted)">
1188 dismiss stale
1189 </span>
1190 )}
1191 <form method="post" action={`/${ownerName}/${repoName}/settings/branch-protection/${rule.id}/delete`}>
1192 <button type="submit"
1193 class="repo-settings-cta-secondary"
1194 style="padding:4px 10px;font-size:12px;color:var(--red,#f87171)"
1195 onclick="return confirm('Delete this branch protection rule?')"
1196 >
1197 Delete
1198 </button>
1199 </form>
1200 </div>
1201 ))}
1202 </div>
1203 )}
1204
1205 {/* Add new rule form */}
1206 <form
1207 method="post"
1208 action={`/${ownerName}/${repoName}/settings/branch-protection`}
1209 >
1210 <div class="repo-settings-section-body">
1211 <div style="display:grid;grid-template-columns:1fr 120px;gap:var(--space-3);align-items:end">
1212 <div class="repo-settings-field" style="margin-bottom:0">
1213 <label class="repo-settings-field-label" for="bp_pattern">
1214 Branch name pattern
1215 </label>
1216 <input
1217 class="repo-settings-input"
1218 name="pattern"
1219 id="bp_pattern"
1220 placeholder="main"
1221 required
1222 aria-label="Branch name pattern"
1223 />
1224 <div class="repo-settings-field-hint">
1225 Exact name or glob like <code>release/*</code>
1226 </div>
1227 </div>
1228 <div class="repo-settings-field" style="margin-bottom:0">
1229 <label class="repo-settings-field-label" for="bp_required">
1230 Required approvals
1231 </label>
1232 <input
1233 class="repo-settings-input"
1234 name="required_approvals"
1235 id="bp_required"
1236 type="number"
1237 min="0"
1238 max="10"
1239 value="1"
1240 aria-label="Number of required approvals"
1241 />
1242 </div>
1243 </div>
1244 <div style="margin-top:var(--space-3);display:flex;flex-direction:column;gap:8px">
1245 <label class="repo-settings-toggle-row" aria-label="Require codeowner review">
1246 <input
1247 type="checkbox"
1248 name="require_codeowner_review"
1249 value="1"
1250 />
1251 <span class="repo-settings-toggle-text">
1252 <span class="repo-settings-toggle-text-title">
1253 Require codeowner review
1254 </span>
1255 <span class="repo-settings-toggle-text-hint">
1256 At least one CODEOWNERS-matched reviewer must approve before merging.
1257 </span>
1258 </span>
1259 </label>
1260 <label class="repo-settings-toggle-row" aria-label="Dismiss stale reviews">
1261 <input
1262 type="checkbox"
1263 name="dismiss_stale_reviews"
1264 value="1"
1265 />
1266 <span class="repo-settings-toggle-text">
1267 <span class="repo-settings-toggle-text-title">
1268 Dismiss stale reviews on new push
1269 </span>
1270 <span class="repo-settings-toggle-text-hint">
1271 Prior approvals are dismissed when the head branch receives a new commit.
1272 </span>
1273 </span>
1274 </label>
1275 </div>
1276 </div>
1277 <div class="repo-settings-section-foot">
1278 <button type="submit" class="repo-settings-cta">
1279 Add rule <span class="arrow"></span>
1280 </button>
1281 </div>
1282 </form>
1283 </section>
1284
10941285 {/* ─── Danger zone ─── */}
10951286 <section class="repo-settings-danger">
10961287 <div class="repo-settings-danger-head">
14931684 }
14941685);
14951686
1687// Migration 0077 — toggle AI dependency auto-updater. Owner-only; audits
1688// the toggle delta so the repo's audit log shows the change.
1689repoSettings.post(
1690 "/:owner/:repo/settings/dep-updater",
1691 requireAuth,
1692 requireRepoAccess("admin"),
1693 async (c) => {
1694 const { owner: ownerName, repo: repoName } = c.req.param();
1695 const user = c.get("user")!;
1696 const body = await c.req.parseBody();
1697 const [owner] = await db
1698 .select()
1699 .from(users)
1700 .where(eq(users.username, ownerName))
1701 .limit(1);
1702 if (!owner || owner.id !== user.id) {
1703 return c.redirect(`/${ownerName}/${repoName}`);
1704 }
1705 const [repo] = await db
1706 .select()
1707 .from(repositories)
1708 .where(
1709 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
1710 )
1711 .limit(1);
1712 if (!repo) return c.notFound();
1713
1714 const next = body.dep_updater_enabled === "1";
1715 const prev =
1716 (repo as { depUpdaterEnabled?: boolean }).depUpdaterEnabled ?? false;
1717
1718 await db
1719 .update(repositories)
1720 .set({
1721 depUpdaterEnabled: next,
1722 updatedAt: new Date(),
1723 })
1724 .where(eq(repositories.id, repo.id));
1725
1726 if (next !== prev) {
1727 await audit({
1728 userId: user.id,
1729 repositoryId: repo.id,
1730 action: "repo.dep_updater_enabled.toggled",
1731 targetType: "repository",
1732 targetId: repo.id,
1733 metadata: { from: prev, to: next },
1734 });
1735 }
1736
1737 return c.redirect(
1738 `/${ownerName}/${repoName}/settings?success=Dependency+update+settings+saved#dep-updater`
1739 );
1740 }
1741);
1742
14961743// Delete repository
14971744repoSettings.post(
14981745 "/:owner/:repo/settings/delete",
15851832 }
15861833);
15871834
1835// ─── Branch protection: add rule ───────────────────────────────────────────
1836repoSettings.post(
1837 "/:owner/:repo/settings/branch-protection",
1838 requireAuth,
1839 requireRepoAccess("admin"),
1840 async (c) => {
1841 const { owner: ownerName, repo: repoName } = c.req.param();
1842 const user = c.get("user")!;
1843 const body = await c.req.parseBody();
1844
1845 const [owner] = await db
1846 .select()
1847 .from(users)
1848 .where(eq(users.username, ownerName))
1849 .limit(1);
1850 if (!owner || owner.id !== user.id) {
1851 return c.redirect(`/${ownerName}/${repoName}`);
1852 }
1853
1854 const [repo] = await db
1855 .select()
1856 .from(repositories)
1857 .where(and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName)))
1858 .limit(1);
1859 if (!repo) return c.notFound();
1860
1861 const pattern = String(body.pattern || "").trim();
1862 if (!pattern) {
1863 return c.redirect(
1864 `/${ownerName}/${repoName}/settings?error=Pattern+is+required#branch-protection`
1865 );
1866 }
1867
1868 const requiredApprovals = Math.max(0, parseInt(String(body.required_approvals || "1"), 10) || 0);
1869 const requireHumanReview = body.require_codeowner_review === "1";
1870 const dismissStaleReviews = body.dismiss_stale_reviews === "1";
1871
1872 try {
1873 await db
1874 .insert(branchProtection)
1875 .values({
1876 repositoryId: repo.id,
1877 pattern,
1878 requiredApprovals,
1879 requireHumanReview,
1880 dismissStaleReviews,
1881 })
1882 .onConflictDoUpdate({
1883 target: [branchProtection.repositoryId, branchProtection.pattern],
1884 set: {
1885 requiredApprovals,
1886 requireHumanReview,
1887 dismissStaleReviews,
1888 updatedAt: new Date(),
1889 },
1890 });
1891
1892 await audit({
1893 userId: user.id,
1894 repositoryId: repo.id,
1895 action: "branch_protection.updated",
1896 targetType: "repository",
1897 targetId: repo.id,
1898 metadata: { pattern, requiredApprovals, requireHumanReview, dismissStaleReviews },
1899 });
1900 } catch (err) {
1901 return c.redirect(
1902 `/${ownerName}/${repoName}/settings?error=${encodeURIComponent("Failed to save rule: " + String(err instanceof Error ? err.message : err))}#branch-protection`
1903 );
1904 }
1905
1906 return c.redirect(
1907 `/${ownerName}/${repoName}/settings?success=Branch+protection+rule+saved#branch-protection`
1908 );
1909 }
1910);
1911
1912// ─── Branch protection: delete rule ────────────────────────────────────────
1913repoSettings.post(
1914 "/:owner/:repo/settings/branch-protection/:ruleId/delete",
1915 requireAuth,
1916 requireRepoAccess("admin"),
1917 async (c) => {
1918 const { owner: ownerName, repo: repoName, ruleId } = c.req.param();
1919 const user = c.get("user")!;
1920
1921 const [owner] = await db
1922 .select()
1923 .from(users)
1924 .where(eq(users.username, ownerName))
1925 .limit(1);
1926 if (!owner || owner.id !== user.id) {
1927 return c.redirect(`/${ownerName}/${repoName}`);
1928 }
1929
1930 const [repo] = await db
1931 .select()
1932 .from(repositories)
1933 .where(and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName)))
1934 .limit(1);
1935 if (!repo) return c.notFound();
1936
1937 await db
1938 .delete(branchProtection)
1939 .where(
1940 and(
1941 eq(branchProtection.id, ruleId),
1942 eq(branchProtection.repositoryId, repo.id)
1943 )
1944 );
1945
1946 await audit({
1947 userId: user.id,
1948 repositoryId: repo.id,
1949 action: "branch_protection.deleted",
1950 targetType: "repository",
1951 targetId: repo.id,
1952 metadata: { ruleId },
1953 });
1954
1955 return c.redirect(
1956 `/${ownerName}/${repoName}/settings?success=Branch+protection+rule+deleted#branch-protection`
1957 );
1958 }
1959);
1960
15881961export default repoSettings;
Addedsrc/routes/saml-sso.tsx+649−0View fileUnifiedSplit
1/**
2 * Enterprise per-org SSO routes — SAML 2.0 and OIDC.
3 *
4 * GET /sso/saml/:orgSlug/metadata — SP metadata XML (configure your IdP with this)
5 * GET /sso/saml/:orgSlug/login — initiate SAML AuthnRequest → redirect to IdP
6 * POST /sso/saml/:orgSlug/callback — receive SAMLResponse from IdP, create session
7 * GET /sso/oidc/:orgSlug/login — initiate OIDC authorization code flow
8 * GET /sso/oidc/:orgSlug/callback — exchange code → tokens → create session
9 *
10 * Admin UI at /orgs/:orgSlug/settings/sso (see org-sso-settings.tsx).
11 */
12
13import { Hono } from "hono";
14import { eq } from "drizzle-orm";
15import { getCookie, setCookie, deleteCookie } from "hono/cookie";
16import * as crypto from "crypto";
17import { db } from "../db";
18import { orgSsoConfigs, orgSsoSessions, organizations, users, sessions } from "../db/schema";
19import { generateSessionToken, sessionCookieOptions, sessionExpiry } from "../lib/auth";
20import { softAuth } from "../middleware/auth";
21import type { AuthEnv } from "../middleware/auth";
22
23const samlSso = new Hono<AuthEnv>();
24samlSso.use("*", softAuth);
25
26// ---------------------------------------------------------------------------
27// Helpers
28// ---------------------------------------------------------------------------
29
30function getBaseUrl(): string {
31 return process.env.APP_URL || process.env.BASE_URL || "https://gluecron.com";
32}
33
34function generateId(len = 16): string {
35 return crypto.randomBytes(len).toString("hex");
36}
37
38/** Resolve org by slug; returns the org row or null. */
39async function getOrgBySlug(slug: string) {
40 const [org] = await db
41 .select()
42 .from(organizations)
43 .where(eq(organizations.slug, slug))
44 .limit(1);
45 return org ?? null;
46}
47
48/** Resolve org SSO config (must be enabled). */
49async function getOrgSsoConfig(orgId: string) {
50 const [cfg] = await db
51 .select()
52 .from(orgSsoConfigs)
53 .where(eq(orgSsoConfigs.orgId, orgId))
54 .limit(1);
55 return cfg ?? null;
56}
57
58/** Find or create a local user from SSO identity claims. */
59async function findOrCreateSsoUser(
60 email: string,
61 name: string | undefined,
62 preferredUsername: string | undefined,
63 orgId: string
64): Promise<{ ok: true; userId: string } | { ok: false; error: string }> {
65 if (!email) return { ok: false, error: "No email returned from IdP" };
66
67 // Look up by email
68 const [existing] = await db
69 .select({ id: users.id })
70 .from(users)
71 .where(eq(users.email, email))
72 .limit(1);
73
74 if (existing) return { ok: true, userId: existing.id };
75
76 // Auto-create: derive a username from email / preferred_username
77 let username = (preferredUsername || email.split("@")[0])
78 .toLowerCase()
79 .replace(/[^a-z0-9_-]/g, "-")
80 .slice(0, 39);
81 if (!username) username = "user-" + generateId(4);
82
83 // Deduplicate username
84 const [taken] = await db
85 .select({ id: users.id })
86 .from(users)
87 .where(eq(users.username, username))
88 .limit(1);
89 if (taken) username = username + "-" + generateId(4);
90
91 const [created] = await db
92 .insert(users)
93 .values({
94 username,
95 email,
96 displayName: name || username,
97 // No password — SSO-only accounts use a random hash placeholder
98 passwordHash: await Bun.password.hash(generateId(32), {
99 algorithm: "bcrypt",
100 cost: 10,
101 }),
102 emailVerifiedAt: new Date(),
103 })
104 .returning({ id: users.id });
105
106 if (!created) return { ok: false, error: "Failed to create user account" };
107 return { ok: true, userId: created.id };
108}
109
110/** Create a standard session cookie for a user. */
111async function createUserSession(userId: string): Promise<string> {
112 const token = generateSessionToken();
113 await db.insert(sessions).values({
114 userId,
115 token,
116 expiresAt: sessionExpiry(),
117 });
118 return token;
119}
120
121// ---------------------------------------------------------------------------
122// SAML 2.0 — SP Metadata
123// ---------------------------------------------------------------------------
124
125samlSso.get("/sso/saml/:orgSlug/metadata", async (c) => {
126 const { orgSlug } = c.req.param();
127 const org = await getOrgBySlug(orgSlug);
128 if (!org) return c.text("Organization not found", 404);
129
130 const cfg = await getOrgSsoConfig(org.id);
131 if (!cfg || cfg.provider !== "saml") {
132 return c.text("SAML not configured for this organization", 404);
133 }
134
135 const base = getBaseUrl();
136 const spEntityId =
137 cfg.spEntityId || `${base}/sso/saml/${orgSlug}`;
138 const callbackUrl = `${base}/sso/saml/${orgSlug}/callback`;
139
140 const xml = `<?xml version="1.0" encoding="UTF-8"?>
141<EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata"
142 entityID="${escapeXml(spEntityId)}">
143 <SPSSODescriptor AuthnRequestsSigned="false" WantAssertionsSigned="true"
144 protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
145 <NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress</NameIDFormat>
146 <AssertionConsumerService
147 Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
148 Location="${escapeXml(callbackUrl)}"
149 index="1"/>
150 </SPSSODescriptor>
151</EntityDescriptor>`;
152
153 return c.body(xml, 200, {
154 "Content-Type": "application/xml; charset=utf-8",
155 "Cache-Control": "public, max-age=3600",
156 });
157});
158
159// ---------------------------------------------------------------------------
160// SAML 2.0 — SP-initiated login
161// ---------------------------------------------------------------------------
162
163samlSso.get("/sso/saml/:orgSlug/login", async (c) => {
164 const { orgSlug } = c.req.param();
165 const org = await getOrgBySlug(orgSlug);
166 if (!org) return c.redirect(`/login?error=${encodeURIComponent("Organization not found")}`);
167
168 const cfg = await getOrgSsoConfig(org.id);
169 if (!cfg || !cfg.enabled || cfg.provider !== "saml") {
170 return c.redirect(`/login?error=${encodeURIComponent("SAML SSO not enabled for this organization")}`);
171 }
172 if (!cfg.idpSsoUrl) {
173 return c.redirect(`/login?error=${encodeURIComponent("SAML IdP SSO URL not configured")}`);
174 }
175
176 const base = getBaseUrl();
177 const spEntityId = cfg.spEntityId || `${base}/sso/saml/${orgSlug}`;
178 const acsUrl = `${base}/sso/saml/${orgSlug}/callback`;
179 const requestId = "_" + generateId(20);
180 const issueInstant = new Date().toISOString();
181
182 const relayState = generateId(16);
183 const authnRequest = `<samlp:AuthnRequest
184 xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
185 xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
186 ID="${requestId}"
187 Version="2.0"
188 IssueInstant="${issueInstant}"
189 Destination="${escapeXml(cfg.idpSsoUrl)}"
190 AssertionConsumerServiceURL="${escapeXml(acsUrl)}"
191 ProtocolBinding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST">
192 <saml:Issuer>${escapeXml(spEntityId)}</saml:Issuer>
193</samlp:AuthnRequest>`;
194
195 const encoded = Buffer.from(authnRequest).toString("base64");
196 const params = new URLSearchParams({
197 SAMLRequest: encoded,
198 RelayState: relayState,
199 });
200
201 // Store relay state for CSRF check
202 setCookie(c, "saml_relay_state", relayState, {
203 httpOnly: true,
204 secure: process.env.NODE_ENV === "production",
205 sameSite: "Lax",
206 path: "/",
207 maxAge: 600,
208 });
209 setCookie(c, "saml_org", orgSlug, {
210 httpOnly: true,
211 secure: process.env.NODE_ENV === "production",
212 sameSite: "Lax",
213 path: "/",
214 maxAge: 600,
215 });
216
217 return c.redirect(`${cfg.idpSsoUrl}?${params.toString()}`);
218});
219
220// ---------------------------------------------------------------------------
221// SAML 2.0 — ACS callback (HTTP-POST binding)
222// ---------------------------------------------------------------------------
223
224samlSso.post("/sso/saml/:orgSlug/callback", async (c) => {
225 const { orgSlug } = c.req.param();
226 const org = await getOrgBySlug(orgSlug);
227 if (!org) return c.redirect(`/login?error=${encodeURIComponent("Organization not found")}`);
228
229 const cfg = await getOrgSsoConfig(org.id);
230 if (!cfg || !cfg.enabled || cfg.provider !== "saml") {
231 return c.redirect(`/login?error=${encodeURIComponent("SAML SSO not enabled for this organization")}`);
232 }
233
234 // Validate relay state
235 const expectedRelay = getCookie(c, "saml_relay_state");
236 const expectedOrg = getCookie(c, "saml_org");
237 deleteCookie(c, "saml_relay_state", { path: "/" });
238 deleteCookie(c, "saml_org", { path: "/" });
239
240 if (!expectedRelay || expectedOrg !== orgSlug) {
241 return c.redirect(`/login?error=${encodeURIComponent("SAML relay state mismatch. Please try again.")}`);
242 }
243
244 const body = await c.req.parseBody();
245 const samlResponse = String(body.SAMLResponse || "");
246 if (!samlResponse) {
247 return c.redirect(`/login?error=${encodeURIComponent("No SAMLResponse received")}`);
248 }
249
250 try {
251 const claims = parseSamlResponse(samlResponse, cfg.idpCertificate || "", cfg);
252 if (!claims.ok) {
253 return c.redirect(`/login?error=${encodeURIComponent(claims.error)}`);
254 }
255
256 const result = await findOrCreateSsoUser(
257 claims.email,
258 claims.name,
259 claims.username,
260 org.id
261 );
262 if (!result.ok) {
263 return c.redirect(`/login?error=${encodeURIComponent(result.error)}`);
264 }
265
266 // Record the SSO session
267 await db.insert(orgSsoSessions).values({
268 userId: result.userId,
269 orgId: org.id,
270 idpSessionId: claims.sessionIndex ?? null,
271 expiresAt: new Date(Date.now() + 8 * 60 * 60 * 1000), // 8h
272 });
273
274 const token = await createUserSession(result.userId);
275 setCookie(c, "session", token, sessionCookieOptions());
276 return c.redirect("/");
277 } catch (err) {
278 console.error("[saml-sso] callback error:", err);
279 return c.redirect(`/login?error=${encodeURIComponent("SAML authentication failed. Check IdP configuration.")}`);
280 }
281});
282
283// ---------------------------------------------------------------------------
284// OIDC — per-org login
285// ---------------------------------------------------------------------------
286
287samlSso.get("/sso/oidc/:orgSlug/login", async (c) => {
288 const { orgSlug } = c.req.param();
289 const org = await getOrgBySlug(orgSlug);
290 if (!org) return c.redirect(`/login?error=${encodeURIComponent("Organization not found")}`);
291
292 const cfg = await getOrgSsoConfig(org.id);
293 if (!cfg || !cfg.enabled || cfg.provider !== "oidc") {
294 return c.redirect(`/login?error=${encodeURIComponent("OIDC SSO not enabled for this organization")}`);
295 }
296 if (!cfg.oidcDiscoveryUrl || !cfg.oidcClientId) {
297 return c.redirect(`/login?error=${encodeURIComponent("OIDC not fully configured")}`);
298 }
299
300 // Discover the authorization endpoint from the OIDC discovery document
301 let authorizationEndpoint: string;
302 try {
303 const discoveryUrl = cfg.oidcDiscoveryUrl.replace(/\/$/, "") + "/.well-known/openid-configuration";
304 const res = await fetch(discoveryUrl);
305 if (!res.ok) throw new Error(`Discovery returned ${res.status}`);
306 const doc = await res.json() as { authorization_endpoint: string };
307 authorizationEndpoint = doc.authorization_endpoint;
308 if (!authorizationEndpoint) throw new Error("No authorization_endpoint in discovery");
309 } catch (err) {
310 console.error("[oidc-sso] discovery error:", err);
311 return c.redirect(`/login?error=${encodeURIComponent("OIDC discovery failed")}`);
312 }
313
314 const base = getBaseUrl();
315 const redirectUri = `${base}/sso/oidc/${orgSlug}/callback`;
316 const state = generateId(16);
317 const nonce = generateId(16);
318
319 setCookie(c, "oidc_state", state, {
320 httpOnly: true,
321 secure: process.env.NODE_ENV === "production",
322 sameSite: "Lax",
323 path: "/",
324 maxAge: 600,
325 });
326 setCookie(c, "oidc_nonce", nonce, {
327 httpOnly: true,
328 secure: process.env.NODE_ENV === "production",
329 sameSite: "Lax",
330 path: "/",
331 maxAge: 600,
332 });
333 setCookie(c, "oidc_org", orgSlug, {
334 httpOnly: true,
335 secure: process.env.NODE_ENV === "production",
336 sameSite: "Lax",
337 path: "/",
338 maxAge: 600,
339 });
340
341 const params = new URLSearchParams({
342 client_id: cfg.oidcClientId,
343 redirect_uri: redirectUri,
344 response_type: "code",
345 scope: "openid email profile",
346 state,
347 nonce,
348 });
349
350 return c.redirect(`${authorizationEndpoint}?${params.toString()}`);
351});
352
353// ---------------------------------------------------------------------------
354// OIDC — per-org callback
355// ---------------------------------------------------------------------------
356
357samlSso.get("/sso/oidc/:orgSlug/callback", async (c) => {
358 const { orgSlug } = c.req.param();
359 const org = await getOrgBySlug(orgSlug);
360 if (!org) return c.redirect(`/login?error=${encodeURIComponent("Organization not found")}`);
361
362 const cfg = await getOrgSsoConfig(org.id);
363 if (!cfg || !cfg.enabled || cfg.provider !== "oidc") {
364 return c.redirect(`/login?error=${encodeURIComponent("OIDC SSO not enabled")}`);
365 }
366
367 const code = c.req.query("code");
368 const state = c.req.query("state");
369 const errCode = c.req.query("error");
370
371 if (errCode) {
372 return c.redirect(`/login?error=${encodeURIComponent(`IdP error: ${errCode}`)}`);
373 }
374 if (!code || !state) {
375 return c.redirect(`/login?error=${encodeURIComponent("Missing code or state")}`);
376 }
377
378 const expectedState = getCookie(c, "oidc_state");
379 const expectedOrg = getCookie(c, "oidc_org");
380 deleteCookie(c, "oidc_state", { path: "/" });
381 deleteCookie(c, "oidc_nonce", { path: "/" });
382 deleteCookie(c, "oidc_org", { path: "/" });
383
384 if (!expectedState || expectedState !== state || expectedOrg !== orgSlug) {
385 return c.redirect(`/login?error=${encodeURIComponent("OIDC state mismatch. Please try again.")}`);
386 }
387
388 try {
389 // Discover token endpoint
390 const discoveryUrl = cfg.oidcDiscoveryUrl!.replace(/\/$/, "") + "/.well-known/openid-configuration";
391 const discoveryRes = await fetch(discoveryUrl);
392 const discovery = await discoveryRes.json() as {
393 token_endpoint: string;
394 userinfo_endpoint: string;
395 };
396
397 const base = getBaseUrl();
398 const redirectUri = `${base}/sso/oidc/${orgSlug}/callback`;
399
400 // Exchange code for tokens
401 const tokenRes = await fetch(discovery.token_endpoint, {
402 method: "POST",
403 headers: { "Content-Type": "application/x-www-form-urlencoded" },
404 body: new URLSearchParams({
405 grant_type: "authorization_code",
406 code,
407 redirect_uri: redirectUri,
408 client_id: cfg.oidcClientId!,
409 client_secret: cfg.oidcClientSecret || "",
410 }).toString(),
411 });
412
413 if (!tokenRes.ok) {
414 throw new Error(`token_endpoint returned ${tokenRes.status}`);
415 }
416
417 const tokens = await tokenRes.json() as { access_token: string };
418
419 // Fetch userinfo
420 const userinfoRes = await fetch(discovery.userinfo_endpoint, {
421 headers: { Authorization: `Bearer ${tokens.access_token}` },
422 });
423 if (!userinfoRes.ok) {
424 throw new Error(`userinfo_endpoint returned ${userinfoRes.status}`);
425 }
426
427 const userinfo = await userinfoRes.json() as {
428 email?: string;
429 name?: string;
430 preferred_username?: string;
431 sub?: string;
432 };
433
434 const result = await findOrCreateSsoUser(
435 userinfo.email || "",
436 userinfo.name,
437 userinfo.preferred_username,
438 org.id
439 );
440 if (!result.ok) {
441 return c.redirect(`/login?error=${encodeURIComponent(result.error)}`);
442 }
443
444 await db.insert(orgSsoSessions).values({
445 userId: result.userId,
446 orgId: org.id,
447 idpSessionId: userinfo.sub ?? null,
448 expiresAt: new Date(Date.now() + 8 * 60 * 60 * 1000),
449 });
450
451 const token = await createUserSession(result.userId);
452 setCookie(c, "session", token, sessionCookieOptions());
453 return c.redirect("/");
454 } catch (err) {
455 console.error("[oidc-sso] callback error:", err);
456 return c.redirect(`/login?error=${encodeURIComponent("OIDC authentication failed.")}`);
457 }
458});
459
460// ---------------------------------------------------------------------------
461// SAML response parser (lightweight, no heavy deps)
462// ---------------------------------------------------------------------------
463
464function escapeXml(s: string): string {
465 return s
466 .replace(/&/g, "&amp;")
467 .replace(/</g, "&lt;")
468 .replace(/>/g, "&gt;")
469 .replace(/"/g, "&quot;")
470 .replace(/'/g, "&apos;");
471}
472
473interface SamlClaims {
474 ok: true;
475 email: string;
476 name?: string;
477 username?: string;
478 sessionIndex?: string;
479}
480
481interface SamlError {
482 ok: false;
483 error: string;
484}
485
486/**
487 * Lightweight SAML response parser.
488 *
489 * Steps:
490 * 1. Base64-decode the SAMLResponse.
491 * 2. Verify the XML Signature using the IdP certificate (if provided).
492 * 3. Extract email/name/username attributes via regex against the XML.
493 *
494 * This avoids pulling in full SAML libraries (samlify, passport-saml, etc.)
495 * which ship ~200 packages including xml-crypto. For production hardening,
496 * operators should upgrade to a dedicated SAML library.
497 */
498function parseSamlResponse(
499 base64Response: string,
500 idpCertPem: string,
501 cfg: { attributeMapping?: Record<string, string> | null }
502): SamlClaims | SamlError {
503 let xml: string;
504 try {
505 xml = Buffer.from(base64Response, "base64").toString("utf-8");
506 } catch {
507 return { ok: false, error: "Failed to decode SAMLResponse" };
508 }
509
510 // Basic structure check
511 if (!xml.includes("saml") && !xml.includes("SAML")) {
512 return { ok: false, error: "Invalid SAML response format" };
513 }
514
515 // Check for SAML status success
516 if (
517 xml.includes("urn:oasis:names:tc:SAML:2.0:status:Responder") ||
518 xml.includes("urn:oasis:names:tc:SAML:2.0:status:Requester")
519 ) {
520 const statusMatch = xml.match(/<samlp?:StatusMessage[^>]*>([^<]*)</);
521 const msg = statusMatch ? statusMatch[1].trim() : "IdP rejected the request";
522 return { ok: false, error: `SAML error: ${msg}` };
523 }
524
525 // Signature verification — only if IdP cert provided
526 if (idpCertPem && idpCertPem.trim()) {
527 const sigValid = verifySamlSignature(xml, idpCertPem);
528 if (!sigValid) {
529 return { ok: false, error: "SAML signature verification failed" };
530 }
531 }
532
533 // Extract attributes
534 const mapping = cfg.attributeMapping || {
535 email: "email",
536 name: "name",
537 username: "preferred_username",
538 };
539
540 const attrs = extractSamlAttributes(xml);
541
542 // Try to find email from NameID or attribute
543 let email = attrs[mapping.email ?? "email"]
544 || attrs["email"]
545 || attrs["mail"]
546 || attrs["emailAddress"]
547 || extractNameId(xml)
548 || "";
549
550 email = email.trim();
551 if (!email || !email.includes("@")) {
552 return { ok: false, error: "No email found in SAML assertion" };
553 }
554
555 const name =
556 attrs[mapping.name ?? "name"] ||
557 attrs["displayName"] ||
558 attrs["cn"] ||
559 attrs["givenName"] ||
560 undefined;
561
562 const username =
563 attrs[mapping.username ?? "preferred_username"] ||
564 attrs["uid"] ||
565 attrs["samaccountname"] ||
566 undefined;
567
568 const sessionIndex = extractSessionIndex(xml);
569
570 return { ok: true, email, name, username, sessionIndex };
571}
572
573/** Extract NameID value from SAML XML. */
574function extractNameId(xml: string): string {
575 const m = xml.match(/<(?:saml|saml2):NameID[^>]*>([^<]+)<\/(?:saml|saml2):NameID>/);
576 return m ? m[1].trim() : "";
577}
578
579/** Extract SessionIndex from AuthnStatement. */
580function extractSessionIndex(xml: string): string | undefined {
581 const m = xml.match(/SessionIndex="([^"]+)"/);
582 return m ? m[1] : undefined;
583}
584
585/** Extract all AttributeValue entries keyed by Name or FriendlyName. */
586function extractSamlAttributes(xml: string): Record<string, string> {
587 const attrs: Record<string, string> = {};
588 // Match <Attribute Name="..." FriendlyName="..."><AttributeValue>...</AttributeValue>
589 const attrRe =
590 /<(?:saml|saml2):Attribute\s[^>]*(?:Name|FriendlyName)="([^"]+)"[^>]*>\s*<(?:saml|saml2):AttributeValue[^>]*>([^<]+)<\/(?:saml|saml2):AttributeValue>/gi;
591 let m: RegExpExecArray | null;
592 while ((m = attrRe.exec(xml)) !== null) {
593 const key = m[1].toLowerCase().replace(/^.*\//, ""); // strip namespace URN prefix
594 attrs[key] = m[2].trim();
595 }
596 return attrs;
597}
598
599/**
600 * Verify the XML-DSig signature in the SAML response against the IdP certificate.
601 * Uses Node's built-in `crypto.createVerify` to avoid heavy deps.
602 *
603 * This is a best-effort verification: it handles the common case where the
604 * entire Response or Assertion element is signed. For full enveloped-signature
605 * support, use a library like `xml-crypto`.
606 */
607function verifySamlSignature(xml: string, certPem: string): boolean {
608 try {
609 // Extract the SignatureValue
610 const sigValueMatch = xml.match(
611 /<(?:ds:)?SignatureValue[^>]*>([\s\S]*?)<\/(?:ds:)?SignatureValue>/
612 );
613 if (!sigValueMatch) return false; // no signature at all — treat as invalid
614 const signatureValue = Buffer.from(
615 sigValueMatch[1].replace(/\s+/g, ""),
616 "base64"
617 );
618
619 // Extract the SignedInfo element (what was actually signed)
620 const signedInfoMatch = xml.match(
621 /(<(?:ds:)?SignedInfo[\s\S]*?<\/(?:ds:)?SignedInfo>)/
622 );
623 if (!signedInfoMatch) return false;
624 const signedInfoXml = signedInfoMatch[1];
625
626 // Detect algorithm from SignatureMethod
627 const algMatch = xml.match(/Algorithm="([^"]+)"/);
628 const alg = algMatch ? algMatch[1] : "";
629 let hashAlg: string;
630 if (alg.includes("sha256")) hashAlg = "SHA256";
631 else if (alg.includes("sha512")) hashAlg = "SHA512";
632 else hashAlg = "SHA1";
633
634 // Normalise the PEM cert
635 let pem = certPem.trim();
636 if (!pem.startsWith("-----BEGIN CERTIFICATE-----")) {
637 pem = `-----BEGIN CERTIFICATE-----\n${pem}\n-----END CERTIFICATE-----`;
638 }
639
640 const verifier = crypto.createVerify(`RSA-${hashAlg}`);
641 verifier.update(signedInfoXml, "utf8");
642 return verifier.verify(pem, signatureValue);
643 } catch (err) {
644 console.warn("[saml-sso] signature verification error:", err);
645 return false;
646 }
647}
648
649export default samlSso;
Addedsrc/routes/scim.tsx+479−0View fileUnifiedSplit
1/**
2 * SCIM 2.0 — System for Cross-domain Identity Management.
3 *
4 * Identity providers (Okta, Azure AD, Google Workspace) use SCIM to
5 * automatically provision and deprovision users from Gluecron orgs.
6 *
7 * Endpoints:
8 * GET /scim/v2/:orgId/Users — list users in the org
9 * POST /scim/v2/:orgId/Users — provision (create) a user
10 * GET /scim/v2/:orgId/Users/:userId — get user details
11 * PUT /scim/v2/:orgId/Users/:userId — replace user
12 * PATCH /scim/v2/:orgId/Users/:userId — partial update (deactivate, etc.)
13 * DELETE /scim/v2/:orgId/Users/:userId — deprovision (disable, keep data)
14 *
15 * Auth: Bearer token → validated against scim_tokens.token_hash (SHA-256).
16 *
17 * All responses follow RFC 7643 (SCIM Core Schema) and RFC 7644 (SCIM Protocol).
18 */
19
20import { Hono } from "hono";
21import { eq, and } from "drizzle-orm";
22import * as crypto from "crypto";
23import { db } from "../db";
24import {
25 scimTokens,
26 orgMembers,
27 organizations,
28 users,
29} from "../db/schema";
30import type { AuthEnv } from "../middleware/auth";
31
32const scim = new Hono<AuthEnv>();
33
34// ---------------------------------------------------------------------------
35// SCIM schemas / constants
36// ---------------------------------------------------------------------------
37
38const SCIM_USER_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:User";
39const SCIM_LIST_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:ListResponse";
40const SCIM_ERROR_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:Error";
41
42// ---------------------------------------------------------------------------
43// Auth middleware
44// ---------------------------------------------------------------------------
45
46async function scimAuth(
47 c: any,
48 orgId: string
49): Promise<{ ok: true; token: typeof scimTokens.$inferSelect } | { ok: false }> {
50 const authHeader = c.req.header("authorization") || "";
51 if (!authHeader.startsWith("Bearer ")) return { ok: false };
52 const rawToken = authHeader.slice(7);
53 const tokenHash = crypto
54 .createHash("sha256")
55 .update(rawToken)
56 .digest("hex");
57
58 const [token] = await db
59 .select()
60 .from(scimTokens)
61 .where(
62 and(
63 eq(scimTokens.tokenHash, tokenHash),
64 eq(scimTokens.orgId, orgId)
65 )
66 )
67 .limit(1);
68
69 if (!token) return { ok: false };
70
71 // Update last_used_at lazily (fire-and-forget)
72 db.update(scimTokens)
73 .set({ lastUsedAt: new Date() })
74 .where(eq(scimTokens.id, token.id))
75 .catch(() => {});
76
77 return { ok: true, token };
78}
79
80function scimError(c: any, status: number, detail: string, scimType?: string) {
81 return c.json(
82 {
83 schemas: [SCIM_ERROR_SCHEMA],
84 status,
85 ...(scimType ? { scimType } : {}),
86 detail,
87 },
88 status
89 );
90}
91
92/** Convert a Drizzle user row to a SCIM User resource. */
93function toScimUser(user: {
94 id: string;
95 username: string;
96 email: string;
97 displayName: string | null;
98 createdAt: Date;
99 updatedAt: Date;
100 deletedAt: Date | null;
101}) {
102 const [firstName, ...rest] = (user.displayName || user.username).split(" ");
103 const lastName = rest.join(" ") || "";
104 const base = process.env.APP_URL || process.env.BASE_URL || "https://gluecron.com";
105 return {
106 schemas: [SCIM_USER_SCHEMA],
107 id: user.id,
108 userName: user.email,
109 name: {
110 formatted: user.displayName || user.username,
111 givenName: firstName,
112 familyName: lastName,
113 },
114 emails: [{ value: user.email, primary: true }],
115 active: !user.deletedAt,
116 meta: {
117 resourceType: "User",
118 created: user.createdAt.toISOString(),
119 lastModified: user.updatedAt.toISOString(),
120 location: `${base}/scim/v2/${user.id}`,
121 },
122 };
123}
124
125// ---------------------------------------------------------------------------
126// GET /scim/v2/:orgId/Users — list users in the org
127// ---------------------------------------------------------------------------
128
129scim.get("/scim/v2/:orgId/Users", async (c) => {
130 const { orgId } = c.req.param();
131 const auth = await scimAuth(c, orgId);
132 if (!auth.ok) return scimError(c, 401, "Unauthorized");
133
134 // Verify org exists
135 const [org] = await db
136 .select({ id: organizations.id })
137 .from(organizations)
138 .where(eq(organizations.id, orgId))
139 .limit(1);
140 if (!org) return scimError(c, 404, "Organization not found");
141
142 // Pagination params
143 const startIndex = Math.max(1, parseInt(c.req.query("startIndex") || "1", 10));
144 const count = Math.min(100, Math.max(1, parseInt(c.req.query("count") || "100", 10)));
145 const offset = startIndex - 1;
146
147 // Get org members and their user data
148 const members = await db
149 .select({
150 id: users.id,
151 username: users.username,
152 email: users.email,
153 displayName: users.displayName,
154 createdAt: users.createdAt,
155 updatedAt: users.updatedAt,
156 deletedAt: users.deletedAt,
157 })
158 .from(orgMembers)
159 .innerJoin(users, eq(users.id, orgMembers.userId))
160 .where(eq(orgMembers.orgId, orgId))
161 .limit(count)
162 .offset(offset);
163
164 const resources = members.map(toScimUser);
165
166 return c.json({
167 schemas: [SCIM_LIST_SCHEMA],
168 totalResults: resources.length + offset, // approximate
169 startIndex,
170 itemsPerPage: count,
171 Resources: resources,
172 });
173});
174
175// ---------------------------------------------------------------------------
176// POST /scim/v2/:orgId/Users — provision a user
177// ---------------------------------------------------------------------------
178
179scim.post("/scim/v2/:orgId/Users", async (c) => {
180 const { orgId } = c.req.param();
181 const auth = await scimAuth(c, orgId);
182 if (!auth.ok) return scimError(c, 401, "Unauthorized");
183
184 const [org] = await db
185 .select({ id: organizations.id })
186 .from(organizations)
187 .where(eq(organizations.id, orgId))
188 .limit(1);
189 if (!org) return scimError(c, 404, "Organization not found");
190
191 let body: {
192 userName?: string;
193 name?: { formatted?: string; givenName?: string; familyName?: string };
194 emails?: Array<{ value: string; primary?: boolean }>;
195 active?: boolean;
196 displayName?: string;
197 };
198 try {
199 body = await c.req.json();
200 } catch {
201 return scimError(c, 400, "Invalid JSON", "invalidValue");
202 }
203
204 const email =
205 body.emails?.find((e) => e.primary)?.value ||
206 body.emails?.[0]?.value ||
207 body.userName ||
208 "";
209 if (!email || !email.includes("@")) {
210 return scimError(c, 400, "email is required", "invalidValue");
211 }
212
213 // Check if user already exists
214 const [existing] = await db
215 .select({ id: users.id })
216 .from(users)
217 .where(eq(users.email, email))
218 .limit(1);
219
220 let userId: string;
221
222 if (existing) {
223 userId = existing.id;
224 } else {
225 // Auto-derive a username
226 let username = email.split("@")[0].toLowerCase().replace(/[^a-z0-9_-]/g, "-").slice(0, 39);
227 const [taken] = await db
228 .select({ id: users.id })
229 .from(users)
230 .where(eq(users.username, username))
231 .limit(1);
232 if (taken) username = username + "-" + crypto.randomBytes(3).toString("hex");
233
234 const displayName =
235 body.displayName ||
236 body.name?.formatted ||
237 [body.name?.givenName, body.name?.familyName].filter(Boolean).join(" ") ||
238 username;
239
240 const [created] = await db
241 .insert(users)
242 .values({
243 username,
244 email,
245 displayName,
246 passwordHash: await Bun.password.hash(crypto.randomBytes(32).toString("hex"), {
247 algorithm: "bcrypt",
248 cost: 10,
249 }),
250 emailVerifiedAt: new Date(),
251 })
252 .returning({ id: users.id });
253
254 if (!created) return scimError(c, 500, "Failed to create user");
255 userId = created.id;
256 }
257
258 // Add to org if not already a member
259 const [isMember] = await db
260 .select({ id: orgMembers.id })
261 .from(orgMembers)
262 .where(and(eq(orgMembers.orgId, orgId), eq(orgMembers.userId, userId)))
263 .limit(1);
264
265 if (!isMember) {
266 await db.insert(orgMembers).values({
267 orgId,
268 userId,
269 role: "member",
270 });
271 }
272
273 const [userRow] = await db.select().from(users).where(eq(users.id, userId)).limit(1);
274 return c.json(toScimUser(userRow), 201);
275});
276
277// ---------------------------------------------------------------------------
278// GET /scim/v2/:orgId/Users/:userId — get a single user
279// ---------------------------------------------------------------------------
280
281scim.get("/scim/v2/:orgId/Users/:userId", async (c) => {
282 const { orgId, userId } = c.req.param();
283 const auth = await scimAuth(c, orgId);
284 if (!auth.ok) return scimError(c, 401, "Unauthorized");
285
286 const [member] = await db
287 .select({
288 id: users.id,
289 username: users.username,
290 email: users.email,
291 displayName: users.displayName,
292 createdAt: users.createdAt,
293 updatedAt: users.updatedAt,
294 deletedAt: users.deletedAt,
295 })
296 .from(orgMembers)
297 .innerJoin(users, eq(users.id, orgMembers.userId))
298 .where(and(eq(orgMembers.orgId, orgId), eq(orgMembers.userId, userId)))
299 .limit(1);
300
301 if (!member) return scimError(c, 404, "User not found");
302
303 return c.json(toScimUser(member));
304});
305
306// ---------------------------------------------------------------------------
307// PUT /scim/v2/:orgId/Users/:userId — replace a user
308// ---------------------------------------------------------------------------
309
310scim.put("/scim/v2/:orgId/Users/:userId", async (c) => {
311 const { orgId, userId } = c.req.param();
312 const auth = await scimAuth(c, orgId);
313 if (!auth.ok) return scimError(c, 401, "Unauthorized");
314
315 const [member] = await db
316 .select({ id: orgMembers.id })
317 .from(orgMembers)
318 .where(and(eq(orgMembers.orgId, orgId), eq(orgMembers.userId, userId)))
319 .limit(1);
320 if (!member) return scimError(c, 404, "User not found in this organization");
321
322 let body: {
323 displayName?: string;
324 name?: { formatted?: string; givenName?: string; familyName?: string };
325 active?: boolean;
326 };
327 try {
328 body = await c.req.json();
329 } catch {
330 return scimError(c, 400, "Invalid JSON", "invalidValue");
331 }
332
333 const displayName =
334 body.displayName ||
335 body.name?.formatted ||
336 [body.name?.givenName, body.name?.familyName].filter(Boolean).join(" ") ||
337 undefined;
338
339 const updates: Record<string, unknown> = { updatedAt: new Date() };
340 if (displayName) updates.displayName = displayName;
341 if (body.active === false) {
342 updates.deletedAt = new Date();
343 updates.deletionScheduledFor = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
344 } else if (body.active === true) {
345 updates.deletedAt = null;
346 updates.deletionScheduledFor = null;
347 }
348
349 await db.update(users).set(updates).where(eq(users.id, userId));
350
351 const [updated] = await db.select().from(users).where(eq(users.id, userId)).limit(1);
352 return c.json(toScimUser(updated));
353});
354
355// ---------------------------------------------------------------------------
356// PATCH /scim/v2/:orgId/Users/:userId — partial update
357// ---------------------------------------------------------------------------
358
359scim.patch("/scim/v2/:orgId/Users/:userId", async (c) => {
360 const { orgId, userId } = c.req.param();
361 const auth = await scimAuth(c, orgId);
362 if (!auth.ok) return scimError(c, 401, "Unauthorized");
363
364 const [member] = await db
365 .select({ id: orgMembers.id })
366 .from(orgMembers)
367 .where(and(eq(orgMembers.orgId, orgId), eq(orgMembers.userId, userId)))
368 .limit(1);
369 if (!member) return scimError(c, 404, "User not found in this organization");
370
371 let body: {
372 Operations?: Array<{
373 op: string;
374 path?: string;
375 value?: unknown;
376 }>;
377 };
378 try {
379 body = await c.req.json();
380 } catch {
381 return scimError(c, 400, "Invalid JSON", "invalidValue");
382 }
383
384 const updates: Record<string, unknown> = { updatedAt: new Date() };
385
386 for (const op of body.Operations || []) {
387 const opLower = (op.op || "").toLowerCase();
388 if (op.path === "active" || (typeof op.value === "object" && op.value !== null && "active" in (op.value as object))) {
389 const activeVal =
390 op.path === "active"
391 ? op.value
392 : (op.value as Record<string, unknown>)["active"];
393 if (opLower === "replace" || opLower === "add") {
394 if (activeVal === false || activeVal === "false") {
395 updates.deletedAt = new Date();
396 updates.deletionScheduledFor = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
397 } else {
398 updates.deletedAt = null;
399 updates.deletionScheduledFor = null;
400 }
401 }
402 }
403 if (op.path === "displayName" && (opLower === "replace" || opLower === "add")) {
404 updates.displayName = String(op.value || "");
405 }
406 }
407
408 await db.update(users).set(updates).where(eq(users.id, userId));
409
410 const [updated] = await db.select().from(users).where(eq(users.id, userId)).limit(1);
411 return c.json(toScimUser(updated));
412});
413
414// ---------------------------------------------------------------------------
415// DELETE /scim/v2/:orgId/Users/:userId — deprovision (soft-delete)
416// ---------------------------------------------------------------------------
417
418scim.delete("/scim/v2/:orgId/Users/:userId", async (c) => {
419 const { orgId, userId } = c.req.param();
420 const auth = await scimAuth(c, orgId);
421 if (!auth.ok) return scimError(c, 401, "Unauthorized");
422
423 const [member] = await db
424 .select({ id: orgMembers.id })
425 .from(orgMembers)
426 .where(and(eq(orgMembers.orgId, orgId), eq(orgMembers.userId, userId)))
427 .limit(1);
428 if (!member) return scimError(c, 404, "User not found in this organization");
429
430 // Remove from org membership (preserves user account + git history)
431 await db
432 .delete(orgMembers)
433 .where(and(eq(orgMembers.orgId, orgId), eq(orgMembers.userId, userId)));
434
435 // Soft-disable the account
436 await db.update(users).set({
437 deletedAt: new Date(),
438 deletionScheduledFor: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
439 updatedAt: new Date(),
440 }).where(eq(users.id, userId));
441
442 return c.body(null, 204);
443});
444
445// ---------------------------------------------------------------------------
446// GET /scim/v2/:orgId/ServiceProviderConfig — SCIM capability discovery
447// ---------------------------------------------------------------------------
448
449scim.get("/scim/v2/:orgId/ServiceProviderConfig", async (c) => {
450 const { orgId } = c.req.param();
451 const auth = await scimAuth(c, orgId);
452 if (!auth.ok) return scimError(c, 401, "Unauthorized");
453
454 const base = process.env.APP_URL || process.env.BASE_URL || "https://gluecron.com";
455 return c.json({
456 schemas: ["urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"],
457 patch: { supported: true },
458 bulk: { supported: false, maxOperations: 0, maxPayloadSize: 0 },
459 filter: { supported: false, maxResults: 100 },
460 changePassword: { supported: false },
461 sort: { supported: false },
462 etag: { supported: false },
463 authenticationSchemes: [
464 {
465 type: "oauthbearertoken",
466 name: "OAuth Bearer Token",
467 description: "Authentication scheme using the OAuth Bearer Token Standard",
468 specUri: "http://www.rfc-editor.org/info/rfc6750",
469 primary: true,
470 },
471 ],
472 meta: {
473 resourceType: "ServiceProviderConfig",
474 location: `${base}/scim/v2/${orgId}/ServiceProviderConfig`,
475 },
476 });
477});
478
479export default scim;
Addedsrc/routes/ship-agent.tsx+400−0View fileUnifiedSplit
1/**
2 * Ship Agent routes — autonomous AI feature implementation.
3 *
4 * POST /:owner/:repo/issues/:issueNumber/ship — start a job
5 * GET /:owner/:repo/issues/:issueNumber/ship/:jobId — progress page
6 * GET /:owner/:repo/issues/:issueNumber/ship/:jobId/status — JSON status
7 */
8
9import { Hono } from "hono";
10import { eq, and } from "drizzle-orm";
11import { db } from "../db";
12import { issues, repositories, users } from "../db/schema";
13import { Layout } from "../views/layout";
14import { softAuth, requireAuth } from "../middleware/auth";
15import { requireRepoAccess } from "../middleware/repo-access";
16import type { AuthEnv } from "../middleware/auth";
17import { startShipJob, getShipJob } from "../lib/ship-agent";
18import { isAiAvailable } from "../lib/ai-client";
19
20const shipAgentRoutes = new Hono<AuthEnv>();
21
22// ─── Styles ──────────────────────────────────────────────────────────────────
23
24const shipStyles = `
25 .ship-hero {
26 position: relative;
27 margin: 4px 0 24px;
28 padding: 28px 32px;
29 background: var(--bg-elevated);
30 border: 1px solid var(--border);
31 border-radius: 16px;
32 overflow: hidden;
33 }
34 .ship-hero::before {
35 content: '';
36 position: absolute;
37 top: 0; left: 0; right: 0;
38 height: 2px;
39 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
40 opacity: 0.7;
41 pointer-events: none;
42 }
43 .ship-title {
44 font-family: var(--font-display);
45 font-size: clamp(22px, 3vw, 30px);
46 font-weight: 700;
47 letter-spacing: -0.022em;
48 color: var(--text-strong);
49 margin: 0 0 8px;
50 }
51 .ship-subtitle {
52 font-size: 15px;
53 color: var(--text-muted);
54 margin: 0;
55 line-height: 1.5;
56 }
57 .ship-phases {
58 display: flex;
59 gap: 8px;
60 flex-wrap: wrap;
61 margin: 20px 0;
62 }
63 .ship-phase {
64 display: inline-flex;
65 align-items: center;
66 gap: 6px;
67 padding: 5px 12px;
68 border-radius: 9999px;
69 font-size: 12.5px;
70 font-weight: 600;
71 border: 1px solid var(--border);
72 background: var(--bg-elevated);
73 color: var(--text-muted);
74 transition: all 120ms ease;
75 }
76 .ship-phase.is-active {
77 background: rgba(140,109,255,0.14);
78 border-color: rgba(140,109,255,0.45);
79 color: var(--text-strong);
80 }
81 .ship-phase.is-done {
82 background: rgba(52,211,153,0.1);
83 border-color: rgba(52,211,153,0.35);
84 color: #34d399;
85 }
86 .ship-phase.is-failed {
87 background: rgba(248,113,113,0.1);
88 border-color: rgba(248,113,113,0.35);
89 color: #f87171;
90 }
91 .ship-log {
92 background: var(--bg-secondary);
93 border: 1px solid var(--border);
94 border-radius: 12px;
95 padding: 16px 18px;
96 font-family: var(--font-mono);
97 font-size: 12.5px;
98 line-height: 1.7;
99 color: var(--text-muted);
100 max-height: 400px;
101 overflow-y: auto;
102 white-space: pre-wrap;
103 word-break: break-all;
104 }
105 .ship-log-entry { display: block; }
106 .ship-log-entry.is-error { color: #f87171; }
107 .ship-log-entry.is-done { color: #34d399; }
108 .ship-result {
109 margin-top: 18px;
110 padding: 14px 18px;
111 border-radius: 12px;
112 font-size: 14px;
113 line-height: 1.55;
114 }
115 .ship-result.is-done {
116 background: rgba(52,211,153,0.08);
117 border: 1px solid rgba(52,211,153,0.3);
118 color: var(--text);
119 }
120 .ship-result.is-failed {
121 background: rgba(248,113,113,0.08);
122 border: 1px solid rgba(248,113,113,0.3);
123 color: var(--text);
124 }
125 .ship-pr-link {
126 display: inline-flex;
127 align-items: center;
128 gap: 6px;
129 margin-top: 10px;
130 padding: 8px 16px;
131 border-radius: 8px;
132 background: rgba(140,109,255,0.14);
133 border: 1px solid rgba(140,109,255,0.35);
134 color: var(--accent);
135 font-weight: 600;
136 text-decoration: none;
137 font-size: 13.5px;
138 transition: background 120ms;
139 }
140 .ship-pr-link:hover { background: rgba(140,109,255,0.22); text-decoration: none; }
141`;
142
143const PHASES: Array<{ key: string; label: string }> = [
144 { key: "planning", label: "Planning" },
145 { key: "reading", label: "Reading" },
146 { key: "coding", label: "Coding" },
147 { key: "committing", label: "Committing" },
148 { key: "opening-pr", label: "Opening PR" },
149 { key: "done", label: "Done" },
150];
151
152const PHASE_ORDER: Record<string, number> = {
153 planning: 0,
154 reading: 1,
155 coding: 2,
156 committing: 3,
157 "opening-pr": 4,
158 done: 5,
159 failed: 6,
160};
161
162// ─── Helper ──────────────────────────────────────────────────────────────────
163
164async function resolveIssue(ownerName: string, repoName: string, issueNum: number) {
165 const [owner] = await db
166 .select()
167 .from(users)
168 .where(eq(users.username, ownerName))
169 .limit(1);
170 if (!owner) return null;
171
172 const [repo] = await db
173 .select()
174 .from(repositories)
175 .where(and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName)))
176 .limit(1);
177 if (!repo) return null;
178
179 const [issue] = await db
180 .select()
181 .from(issues)
182 .where(and(eq(issues.repositoryId, repo.id), eq(issues.number, issueNum)))
183 .limit(1);
184 if (!issue) return null;
185
186 return { owner, repo, issue };
187}
188
189// ─── POST — start ship job ────────────────────────────────────────────────────
190
191shipAgentRoutes.post(
192 "/:owner/:repo/issues/:issueNumber/ship",
193 softAuth,
194 requireAuth,
195 requireRepoAccess("write"),
196 async (c) => {
197 if (!isAiAvailable()) {
198 return c.html(
199 <Layout title="Ship Agent unavailable" user={c.get("user")}>
200 <style dangerouslySetInnerHTML={{ __html: shipStyles }} />
201 <div class="ship-hero">
202 <h1 class="ship-title">Ship Agent unavailable</h1>
203 <p class="ship-subtitle">
204 ANTHROPIC_API_KEY is not configured. Ship Agent requires AI to function.
205 </p>
206 </div>
207 </Layout>,
208 503
209 );
210 }
211
212 const { owner: ownerName, repo: repoName } = c.req.param();
213 const issueNum = parseInt(c.req.param("issueNumber"), 10);
214 const user = c.get("user")!;
215
216 const resolved = await resolveIssue(ownerName, repoName, issueNum);
217 if (!resolved) {
218 return c.html(
219 <Layout title="Not Found" user={user}>
220 <div style="padding:40px;text-align:center;color:var(--text-muted)">Issue not found.</div>
221 </Layout>,
222 404
223 );
224 }
225
226 let jobId: string;
227 try {
228 jobId = await startShipJob({
229 issueId: resolved.issue.id,
230 repoId: resolved.repo.id,
231 owner: ownerName,
232 repo: repoName,
233 issueNumber: issueNum,
234 issueTitle: resolved.issue.title,
235 issueBody: resolved.issue.body || "",
236 requestedByUserId: user.id,
237 });
238 } catch (err) {
239 const msg = err instanceof Error ? err.message : String(err);
240 return c.redirect(
241 `/${ownerName}/${repoName}/issues/${issueNum}?info=${encodeURIComponent(`Ship Agent: ${msg}`)}`
242 );
243 }
244
245 return c.redirect(
246 `/${ownerName}/${repoName}/issues/${issueNum}/ship/${jobId}`
247 );
248 }
249);
250
251// ─── GET — progress page ─────────────────────────────────────────────────────
252
253shipAgentRoutes.get(
254 "/:owner/:repo/issues/:issueNumber/ship/:jobId",
255 softAuth,
256 requireRepoAccess("read"),
257 async (c) => {
258 const { owner: ownerName, repo: repoName } = c.req.param();
259 const jobId = c.req.param("jobId");
260 const user = c.get("user");
261
262 const job = getShipJob(jobId);
263 if (!job) {
264 return c.html(
265 <Layout title="Job not found" user={user}>
266 <div style="padding:40px;text-align:center;color:var(--text-muted)">
267 Ship Agent job not found. It may have been cleaned up after a server restart.
268 </div>
269 </Layout>,
270 404
271 );
272 }
273
274 const issueNum = job.issueNumber;
275 const isTerminal = job.status === "done" || job.status === "failed";
276 const currentPhaseIdx = PHASE_ORDER[job.status] ?? 0;
277
278 return c.html(
279 <Layout
280 title={`Ship Agent — ${job.issueTitle}`}
281 user={user}
282 >
283 <style dangerouslySetInnerHTML={{ __html: shipStyles }} />
284
285 <div class="ship-hero">
286 <h1 class="ship-title">
287 AI is shipping:{" "}
288 <span style="color:var(--accent)">{job.issueTitle}</span>
289 </h1>
290 <p class="ship-subtitle">
291 Issue #{issueNum} &middot; {ownerName}/{repoName} &middot;{" "}
292 <a href={`/${ownerName}/${repoName}/issues/${issueNum}`}>
293 Back to issue
294 </a>
295 </p>
296 </div>
297
298 {/* Phase progress pills */}
299 <div class="ship-phases">
300 {PHASES.map((phase) => {
301 const phaseIdx = PHASE_ORDER[phase.key];
302 const isCurrent = phase.key === job.status;
303 const isDone = phaseIdx < currentPhaseIdx && job.status !== "failed";
304 const isFailed = job.status === "failed" && phase.key === "done";
305 let cls = "ship-phase";
306 if (isDone) cls += " is-done";
307 else if (isCurrent) cls += " is-active";
308 else if (isFailed) cls += " is-failed";
309 return (
310 <span class={cls}>
311 {isDone ? "✓ " : isCurrent ? "⟳ " : ""}
312 {phase.label}
313 </span>
314 );
315 })}
316 </div>
317
318 {/* Live log */}
319 <div class="ship-log" id="ship-log">
320 {job.log.length === 0 ? (
321 <span class="ship-log-entry">Initialising…</span>
322 ) : (
323 job.log.map((entry) => (
324 <span
325 class={`ship-log-entry${entry.includes("FAILED") ? " is-error" : ""}`}
326 >
327 {entry}
328 </span>
329 ))
330 )}
331 </div>
332
333 {/* Result block */}
334 {job.status === "done" && job.prNumber && (
335 <div class="ship-result is-done">
336 <strong>Ship Agent completed!</strong> Changes are in PR #{job.prNumber} and ready for review.
337 <br />
338 <a href={`/${ownerName}/${repoName}/pulls/${job.prNumber}`} class="ship-pr-link">
339 View PR #{job.prNumber}
340 </a>
341 </div>
342 )}
343 {job.status === "failed" && (
344 <div class="ship-result is-failed">
345 <strong>Ship Agent failed.</strong>{" "}
346 {job.error}
347 <br />
348 <form method="post" action={`/${ownerName}/${repoName}/issues/${issueNum}/ship`} style="display:inline">
349 <button type="submit" class="btn btn-primary" style="margin-top:10px">
350 Try again
351 </button>
352 </form>
353 </div>
354 )}
355
356 {/* Polling script — auto-refreshes every 3s while job is running */}
357 <script
358 dangerouslySetInnerHTML={{
359 __html: `
360(function() {
361 var logEl = document.getElementById('ship-log');
362 if (logEl) logEl.scrollTop = logEl.scrollHeight;
363 ${!isTerminal ? `setTimeout(function(){ window.location.reload(); }, 3000);` : ""}
364})();
365`,
366 }}
367 />
368 </Layout>
369 );
370 }
371);
372
373// ─── GET — JSON status endpoint ────────────────────────────────────────────────
374
375shipAgentRoutes.get(
376 "/:owner/:repo/issues/:issueNumber/ship/:jobId/status",
377 softAuth,
378 requireRepoAccess("read"),
379 async (c) => {
380 const jobId = c.req.param("jobId");
381 const job = getShipJob(jobId);
382 if (!job) {
383 return c.json({ error: "Job not found" }, 404);
384 }
385 return c.json({
386 id: job.id,
387 status: job.status,
388 plan: job.plan,
389 branchName: job.branchName,
390 prNumber: job.prNumber,
391 prUrl: job.prUrl,
392 log: job.log,
393 error: job.error,
394 createdAt: job.createdAt,
395 completedAt: job.completedAt,
396 });
397 }
398);
399
400export default shipAgentRoutes;
Modifiedsrc/routes/web.tsx+384−65View fileUnifiedSplit
5858import type { HealthScore } from "../lib/health-score";
5959import { softAuth, requireAuth } from "../middleware/auth";
6060import type { AuthEnv } from "../middleware/auth";
61import { claudeSemanticSearch } from "../lib/claude-semantic-search";
62import { isAiAvailable } from "../lib/ai-client";
6163import { trackByName } from "../lib/traffic";
6264import { LandingPage, type LandingLiveFeed } from "../views/landing";
6365import { Landing2030Page } from "../views/landing-2030";
35343536 <span class="repo-ai-cta-icon" aria-hidden="true">{"⛁"}</span>
35353537 Migrations
35363538 </a>
3537 <a class="repo-ai-cta" href={`/${owner}/${repo}/semantic-search`} title="Embedding-backed code search">
3539 <a class="repo-ai-cta" href={`/${owner}/${repo}/search?mode=semantic`} title="AI-powered semantic code search — ask in plain English">
35383540 <span class="repo-ai-cta-icon" aria-hidden="true">{"✨"}</span>
3539 Semantic search
3541 AI Search
35403542 </a>
35413543 <a class="repo-ai-cta" href={`/${owner}/${repo}/releases/new`} title="Draft release notes with AI">
35423544 <span class="repo-ai-cta-icon" aria-hidden="true">{"\u{1F3F7}"}</span>
52825284 );
52835285});
52845286
5285// Search
5287// Search — keyword + optional Claude semantic mode
52865288web.get("/:owner/:repo/search", async (c) => {
52875289 const { owner, repo } = c.req.param();
52885290 const user = c.get("user");
52895291 const q = c.req.query("q") || "";
5292 const aiAvailable = isAiAvailable();
5293 // Default to semantic when Claude is available and no explicit mode set.
5294 const modeParam = c.req.query("mode");
5295 const mode: "semantic" | "keyword" =
5296 modeParam === "keyword"
5297 ? "keyword"
5298 : modeParam === "semantic"
5299 ? "semantic"
5300 : aiAvailable
5301 ? "semantic"
5302 : "keyword";
5303 const isSemantic = mode === "semantic" && aiAvailable;
52905304
52915305 if (!(await repoExists(owner, repo))) return c.notFound();
52925306
52935307 const defaultBranch = (await getDefaultBranch(owner, repo)) || "main";
5294 let results: Array<{ file: string; lineNum: number; line: string }> = [];
5308
5309 // Keyword results (always available as fallback / when in keyword mode)
5310 let keywordResults: Array<{ file: string; lineNum: number; line: string }> = [];
5311 // Semantic results (Claude-powered)
5312 type SemanticHit = import("../lib/claude-semantic-search").SemanticSearchResult;
5313 let semanticHits: SemanticHit[] = [];
5314 let semanticMode: "semantic" | "keyword" = "semantic";
5315 let quotaExceeded = false;
52955316
52965317 if (q.trim()) {
5297 results = await searchCode(owner, repo, defaultBranch, q.trim());
5318 if (isSemantic) {
5319 // Resolve repo DB id for caching
5320 const [ownerRow] = await db
5321 .select({ id: users.id })
5322 .from(users)
5323 .where(eq(users.username, owner))
5324 .limit(1);
5325 const [repoRow] = ownerRow
5326 ? await db
5327 .select({ id: repositories.id })
5328 .from(repositories)
5329 .where(
5330 and(
5331 eq(repositories.ownerId, ownerRow.id),
5332 eq(repositories.name, repo)
5333 )
5334 )
5335 .limit(1)
5336 : [];
5337
5338 const rateLimitKey = user
5339 ? `user:${user.id}`
5340 : (c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ?? "anon");
5341
5342 const searchResult = await claudeSemanticSearch(
5343 owner,
5344 repo,
5345 repoRow?.id ?? `${owner}/${repo}`,
5346 q.trim(),
5347 { branch: defaultBranch, rateLimitKey }
5348 );
5349 semanticHits = searchResult.results;
5350 semanticMode = searchResult.mode;
5351 quotaExceeded = searchResult.quotaExceeded;
5352 } else {
5353 keywordResults = await searchCode(owner, repo, defaultBranch, q.trim());
5354 }
52985355 }
52995356
5357 const aiSearchCss = `
5358 /* ─── Semantic search mode toggle ─── */
5359 .search-mode-bar {
5360 display: flex;
5361 align-items: center;
5362 gap: 8px;
5363 margin-bottom: 14px;
5364 flex-wrap: wrap;
5365 }
5366 .search-mode-label {
5367 font-size: 12.5px;
5368 color: var(--text-muted);
5369 font-weight: 500;
5370 }
5371 .search-mode-toggle {
5372 display: inline-flex;
5373 background: var(--bg-elevated);
5374 border: 1px solid var(--border);
5375 border-radius: 9999px;
5376 padding: 3px;
5377 gap: 2px;
5378 }
5379 .search-mode-btn {
5380 display: inline-flex;
5381 align-items: center;
5382 gap: 5px;
5383 padding: 5px 12px;
5384 border-radius: 9999px;
5385 font-size: 12.5px;
5386 font-weight: 500;
5387 color: var(--text-muted);
5388 text-decoration: none;
5389 transition: color 120ms ease, background 120ms ease;
5390 cursor: pointer;
5391 border: none;
5392 background: none;
5393 font: inherit;
5394 }
5395 .search-mode-btn:hover { color: var(--text-strong); text-decoration: none; }
5396 .search-mode-btn.active {
5397 background: rgba(140,109,255,0.15);
5398 color: var(--text-strong);
5399 }
5400 .search-mode-ai-badge {
5401 display: inline-flex;
5402 align-items: center;
5403 gap: 4px;
5404 padding: 2px 8px;
5405 border-radius: 9999px;
5406 font-size: 11px;
5407 font-weight: 600;
5408 background: linear-gradient(135deg, rgba(140,109,255,0.20), rgba(54,197,214,0.15));
5409 color: #c4b5fd;
5410 border: 1px solid rgba(140,109,255,0.30);
5411 margin-left: 2px;
5412 }
5413 .search-quota-warn {
5414 font-size: 12.5px;
5415 color: var(--text-muted);
5416 padding: 6px 12px;
5417 background: rgba(255,200,0,0.06);
5418 border: 1px solid rgba(255,200,0,0.20);
5419 border-radius: 8px;
5420 }
5421
5422 /* ─── Semantic result cards ─── */
5423 .sem-results { display: flex; flex-direction: column; gap: 10px; }
5424 .sem-result {
5425 padding: 14px 16px;
5426 background: var(--bg-elevated);
5427 border: 1px solid var(--border);
5428 border-radius: 12px;
5429 transition: border-color 120ms ease;
5430 }
5431 .sem-result:hover { border-color: var(--border-strong); }
5432 .sem-result-head {
5433 display: flex;
5434 align-items: flex-start;
5435 justify-content: space-between;
5436 gap: 10px;
5437 flex-wrap: wrap;
5438 margin-bottom: 6px;
5439 }
5440 .sem-result-path {
5441 font-family: var(--font-mono);
5442 font-size: 13px;
5443 font-weight: 600;
5444 color: var(--text-strong);
5445 text-decoration: none;
5446 word-break: break-all;
5447 }
5448 .sem-result-path:hover { color: #c4b5fd; text-decoration: none; }
5449 .sem-result-conf {
5450 display: inline-flex;
5451 align-items: center;
5452 gap: 4px;
5453 padding: 2px 9px;
5454 border-radius: 9999px;
5455 font-size: 11.5px;
5456 font-weight: 600;
5457 white-space: nowrap;
5458 flex-shrink: 0;
5459 }
5460 .sem-conf-strong { background: rgba(52,211,153,0.12); color: #34d399; border: 1px solid rgba(52,211,153,0.30); }
5461 .sem-conf-possible { background: rgba(140,109,255,0.12); color: #b69dff; border: 1px solid rgba(140,109,255,0.30); }
5462 .sem-conf-weak { background: rgba(255,255,255,0.04); color: var(--text-muted); border: 1px solid var(--border); }
5463 .sem-result-reason {
5464 font-size: 12.5px;
5465 color: var(--text-muted);
5466 margin-bottom: 8px;
5467 line-height: 1.5;
5468 }
5469 .sem-result-snippet {
5470 margin: 0;
5471 padding: 10px 12px;
5472 background: rgba(0,0,0,0.22);
5473 border: 1px solid var(--border);
5474 border-radius: 8px;
5475 font-family: var(--font-mono);
5476 font-size: 12px;
5477 line-height: 1.55;
5478 color: var(--text);
5479 overflow-x: auto;
5480 white-space: pre-wrap;
5481 word-break: break-word;
5482 max-height: 240px;
5483 overflow-y: auto;
5484 }
5485 `;
5486
5487 const semanticBaseUrl = `/${owner}/${repo}/search?mode=semantic`;
5488 const keywordBaseUrl = `/${owner}/${repo}/search?mode=keyword`;
5489
53005490 return c.html(
53015491 <Layout title={`Search — ${owner}/${repo}`} user={user}>
5302 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
5492 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss + aiSearchCss }} />
53035493 <RepoHeader owner={owner} repo={repo} />
53045494 <RepoNav owner={owner} repo={repo} active="code" />
53055495 <div class="search-hero">
53075497 <strong>Search</strong> · {owner}/{repo}
53085498 </div>
53095499 <h1 class="search-title">
5310 Find any line in <span class="gradient-text">{repo}</span>
5500 {isSemantic ? (
5501 <>{"Ask "}<span class="gradient-text">{repo}</span>{" anything."}</>
5502 ) : (
5503 <>{"Find any line in "}<span class="gradient-text">{repo}</span></>
5504 )}
53115505 </h1>
53125506 <form
53135507 method="get"
53155509 class="search-form"
53165510 role="search"
53175511 >
5512 <input type="hidden" name="mode" value={mode} />
53185513 <div class="search-input-wrap">
53195514 <span class="search-input-icon" aria-hidden="true">{"⌕"}</span>
53205515 <input
53215516 type="text"
53225517 name="q"
53235518 value={q}
5324 placeholder="Search code on the default branch…"
5519 placeholder={
5520 isSemantic
5521 ? "Ask a question or describe what you're looking for…"
5522 : "Search code on the default branch…"
5523 }
53255524 aria-label="Search code"
53265525 class="search-input"
53275526 autocomplete="off"
53335532 </button>
53345533 </form>
53355534 </div>
5336 {q && (
5337 <div class="search-results-head">
5338 <span class="search-results-count">
5339 <strong>{results.length}</strong> result
5340 {results.length !== 1 ? "s" : ""}
5535
5536 {/* Mode toggle */}
5537 <div class="search-mode-bar">
5538 <span class="search-mode-label">Mode:</span>
5539 <div class="search-mode-toggle" role="group" aria-label="Search mode">
5540 {aiAvailable ? (
5541 <a
5542 href={q ? `${semanticBaseUrl}&q=${encodeURIComponent(q)}` : semanticBaseUrl}
5543 class={`search-mode-btn${isSemantic ? " active" : ""}`}
5544 aria-pressed={isSemantic ? "true" : "false"}
5545 >
5546 {"✨"} Semantic AI
5547 <span class="search-mode-ai-badge">AI-powered</span>
5548 </a>
5549 ) : null}
5550 <a
5551 href={q ? `${keywordBaseUrl}&q=${encodeURIComponent(q)}` : keywordBaseUrl}
5552 class={`search-mode-btn${!isSemantic ? " active" : ""}`}
5553 aria-pressed={!isSemantic ? "true" : "false"}
5554 >
5555 {"⌕"} Keyword
5556 </a>
5557 </div>
5558 {isSemantic && aiAvailable && (
5559 <span style="font-size:12px;color:var(--text-muted)">
5560 Ask in plain English — finds code by meaning, not just exact words
53415561 </span>
5342 <span class="search-results-query">
5343 for <span class="search-results-q">"{q}"</span> on{" "}
5344 <code>{defaultBranch}</code>
5562 )}
5563 {quotaExceeded && (
5564 <span class="search-quota-warn">
5565 Semantic search quota reached — showing keyword results
53455566 </span>
5346 </div>
5567 )}
5568 </div>
5569
5570 {/* ─── Semantic results ─── */}
5571 {isSemantic && q && (
5572 <>
5573 {semanticMode === "keyword" && !quotaExceeded && semanticHits.length > 0 && (
5574 <div class="search-quota-warn" style="margin-bottom:10px">
5575 Falling back to keyword search — Claude found no relevant files.
5576 </div>
5577 )}
5578 {semanticHits.length === 0 ? (
5579 <div class="search-empty">
5580 <p>
5581 No matches for <strong>"{q}"</strong>.{" "}
5582 Try a different phrasing or{" "}
5583 <a href={`${keywordBaseUrl}&q=${encodeURIComponent(q)}`}>
5584 switch to keyword search
5585 </a>.
5586 </p>
5587 </div>
5588 ) : (
5589 <>
5590 <div class="search-results-head">
5591 <span class="search-results-count">
5592 <strong>{semanticHits.length}</strong> result
5593 {semanticHits.length !== 1 ? "s" : ""}
5594 </span>
5595 <span class="search-results-query">
5596 {semanticMode === "semantic" ? "AI-ranked files for" : "keyword matches for"}{" "}
5597 <span class="search-results-q">"{q}"</span>
5598 </span>
5599 </div>
5600 <div class="sem-results">
5601 {semanticHits.map((h) => {
5602 const confClass =
5603 h.confidence > 0.7
5604 ? "sem-conf-strong"
5605 : h.confidence > 0.4
5606 ? "sem-conf-possible"
5607 : "sem-conf-weak";
5608 const confLabel =
5609 h.confidence > 0.7
5610 ? "Strong match"
5611 : h.confidence > 0.4
5612 ? "Possible match"
5613 : "Weak match";
5614 const href = `/${owner}/${repo}/blob/${defaultBranch}/${h.file}${h.lineNumber ? `#L${h.lineNumber}` : ""}`;
5615 const preview =
5616 h.snippet.length > 800
5617 ? h.snippet.slice(0, 800) + "\n…"
5618 : h.snippet;
5619 return (
5620 <div class="sem-result">
5621 <div class="sem-result-head">
5622 <a href={href} class="sem-result-path">
5623 {h.file}
5624 {h.lineNumber ? (
5625 <span style="color:var(--text-muted);font-weight:500">
5626 :{h.lineNumber}
5627 </span>
5628 ) : null}
5629 </a>
5630 <span class={`sem-result-conf ${confClass}`}>
5631 {confLabel}
5632 </span>
5633 </div>
5634 {h.reason && (
5635 <p class="sem-result-reason">{h.reason}</p>
5636 )}
5637 {preview && (
5638 <pre class="sem-result-snippet">{preview}</pre>
5639 )}
5640 </div>
5641 );
5642 })}
5643 </div>
5644 </>
5645 )}
5646 </>
53475647 )}
5348 {q && results.length === 0 ? (
5349 <div class="search-empty">
5350 <p>
5351 No matches for <strong>"{q}"</strong>. Try a shorter query or check
5352 you're on the right branch.
5353 </p>
5354 </div>
5355 ) : results.length > 0 ? (
5356 <div class="search-results">
5357 {(() => {
5358 // Group by file
5359 const grouped: Record<
5360 string,
5361 Array<{ lineNum: number; line: string }>
5362 > = {};
5363 for (const r of results) {
5364 if (!grouped[r.file]) grouped[r.file] = [];
5365 grouped[r.file].push({ lineNum: r.lineNum, line: r.line });
5366 }
5367 return Object.entries(grouped).map(([file, matches]) => (
5368 <div class="search-file diff-file">
5369 <div class="search-file-head diff-file-header">
5370 <a
5371 href={`/${owner}/${repo}/blob/${defaultBranch}/${file}`}
5372 class="search-file-link"
5373 >
5374 {file}
5648
5649 {/* ─── Keyword results ─── */}
5650 {!isSemantic && q && (
5651 <>
5652 <div class="search-results-head">
5653 <span class="search-results-count">
5654 <strong>{keywordResults.length}</strong> result
5655 {keywordResults.length !== 1 ? "s" : ""}
5656 </span>
5657 <span class="search-results-query">
5658 for <span class="search-results-q">"{q}"</span> on{" "}
5659 <code>{defaultBranch}</code>
5660 </span>
5661 </div>
5662 {keywordResults.length === 0 ? (
5663 <div class="search-empty">
5664 <p>
5665 No matches for <strong>"{q}"</strong>. Try a shorter query or{" "}
5666 {aiAvailable && (
5667 <a href={`${semanticBaseUrl}&q=${encodeURIComponent(q)}`}>
5668 try AI semantic search
53755669 </a>
5376 <span class="search-file-count">
5377 {matches.length} match{matches.length === 1 ? "" : "es"}
5378 </span>
5379 </div>
5380 <div class="blob-code">
5381 <table>
5382 <tbody>
5383 {matches.map((m) => (
5384 <tr>
5385 <td class="line-num">{m.lineNum}</td>
5386 <td class="line-content">{m.line}</td>
5387 </tr>
5388 ))}
5389 </tbody>
5390 </table>
5391 </div>
5392 </div>
5393 ));
5394 })()}
5395 </div>
5396 ) : null}
5670 )}.
5671 </p>
5672 </div>
5673 ) : (
5674 <div class="search-results">
5675 {(() => {
5676 const grouped: Record<
5677 string,
5678 Array<{ lineNum: number; line: string }>
5679 > = {};
5680 for (const r of keywordResults) {
5681 if (!grouped[r.file]) grouped[r.file] = [];
5682 grouped[r.file].push({ lineNum: r.lineNum, line: r.line });
5683 }
5684 return Object.entries(grouped).map(([file, matches]) => (
5685 <div class="search-file diff-file">
5686 <div class="search-file-head diff-file-header">
5687 <a
5688 href={`/${owner}/${repo}/blob/${defaultBranch}/${file}`}
5689 class="search-file-link"
5690 >
5691 {file}
5692 </a>
5693 <span class="search-file-count">
5694 {matches.length} match{matches.length === 1 ? "" : "es"}
5695 </span>
5696 </div>
5697 <div class="blob-code">
5698 <table>
5699 <tbody>
5700 {matches.map((m) => (
5701 <tr>
5702 <td class="line-num">{m.lineNum}</td>
5703 <td class="line-content">{m.line}</td>
5704 </tr>
5705 ))}
5706 </tbody>
5707 </table>
5708 </div>
5709 </div>
5710 ));
5711 })()}
5712 </div>
5713 )}
5714 </>
5715 )}
53975716 </Layout>
53985717 );
53995718});
Modifiedsrc/views/components.tsx+24−1View fileUnifiedSplit
150150 | "agents"
151151 | "discussions"
152152 | "security"
153 | "settings";
153 | "deployments"
154 | "settings"
155 | "debt-map"
156 | "migrate";
154157}> = ({ owner, repo, active }) => (
155158 <div class="repo-nav">
156159 <a href={`/${owner}/${repo}`} class={active === "code" ? "active" : ""}>
216219 >
217220 Security
218221 </a>
222 <a
223 href={`/${owner}/${repo}/cloud-deployments`}
224 class={active === "deployments" ? "active" : ""}
225 >
226 Deployments
227 </a>
219228 <a
220229 href={`/${owner}/${repo}/insights`}
221230 class={active === "insights" ? "active" : ""}
252261 >
253262 {"\u2728"} Tests
254263 </a>
264 <a
265 href={`/${owner}/${repo}/debt-map`}
266 class={`repo-nav-ai${active === "debt-map" ? " active" : ""}`}
267 title="AI Debt Map \u2014 visual technical debt graph with Claude analysis"
268 >
269 {"\u2593"} Debt Map
270 </a>
271 <a
272 href={`/${owner}/${repo}/migrate`}
273 class={`repo-nav-ai${active === "migrate" ? " active" : ""}`}
274 title="AI Codebase Migration \u2014 one-click language or framework translation"
275 >
276 {"\u2728"} Migrate
277 </a>
255278 </div>
256279);
257280
Modifiedsrc/views/layout.tsx+54−0View fileUnifiedSplit
278278 <a href="/pulls" role="menuitem" class="nav-user-item">Pull requests</a>
279279 <a href="/issues" role="menuitem" class="nav-user-item">Issues</a>
280280 <a href="/activity" role="menuitem" class="nav-user-item">Activity</a>
281 <a href="/insights" role="menuitem" class="nav-user-item">Insights</a>
281282 <a href="/import" role="menuitem" class="nav-user-item">Import from GitHub</a>
282283 <a href="/import/actions" role="menuitem" class="nav-user-item">Actions importer</a>
283284 <div class="nav-user-menu-sep" />
427428 <script dangerouslySetInnerHTML={{ __html: toastScript }} />
428429 <script dangerouslySetInnerHTML={{ __html: navScript }} />
429430 <script dangerouslySetInnerHTML={{ __html: navAiDropdownScript }} />
431 {/* Bell badge poller — only rendered for authenticated users.
432 Polls /api/notifications/count every 60 s and updates the badge
433 on the inbox bell icon. Falls back gracefully if the endpoint is
434 unavailable or the user signs out mid-session. */}
435 {user && (
436 <script dangerouslySetInnerHTML={{ __html: bellPollerScript }} />
437 )}
430438 </body>
431439 </html>
432440 );
931939 })();
932940`;
933941
942// Bell poller — updates the inbox badge count every 60 s via /api/notifications/count.
943// Only injected when a user is logged in (checked in the JSX above). Uses the
944// `.nav-inbox-badge` element that the server renders inside `.nav-inbox-btn`.
945// If the badge element is absent (user not logged in, DOM mismatch) it exits
946// cleanly without error.
947export const bellPollerScript = `
948(function() {
949 var badge = document.querySelector('.nav-inbox-btn .nav-inbox-badge');
950 var btn = document.querySelector('.nav-inbox-btn');
951 function updateBadge(n) {
952 if (!btn) return;
953 if (n > 0) {
954 var label = n > 99 ? '99+' : String(n);
955 if (!badge) {
956 badge = document.createElement('span');
957 badge.className = 'nav-inbox-badge';
958 badge.setAttribute('aria-hidden', 'true');
959 btn.appendChild(badge);
960 }
961 badge.textContent = label;
962 badge.style.display = '';
963 btn.setAttribute('aria-label', 'Inbox — ' + n + ' unread');
964 } else {
965 if (badge) badge.style.display = 'none';
966 btn.setAttribute('aria-label', 'Inbox');
967 }
968 }
969 function poll() {
970 fetch('/api/notifications/count', { credentials: 'same-origin', cache: 'no-store' })
971 .then(function(r) { return r.ok ? r.json() : null; })
972 .then(function(d) {
973 if (!d) return;
974 var n = typeof d.unread === 'number' ? d.unread : (typeof d.count === 'number' ? d.count : 0);
975 updateBadge(n);
976 })
977 .catch(function() {});
978 }
979 if (document.readyState === 'loading') {
980 document.addEventListener('DOMContentLoaded', function() { poll(); setInterval(poll, 60000); });
981 } else {
982 poll();
983 setInterval(poll, 60000);
984 }
985})();
986`;
987
934988// AI dropdown — keyboard- and click-accessible menu in the top nav.
935989// CSS handles the hover-open behaviour for pointer users; this script
936990// adds click-to-toggle for touch, Escape-to-close, and outside-click-
937991