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

Merge branch 'main' into feat/stage-impact

CC LABS App committed on June 7, 2026Parents: 1bef9b6 2fef29e
108 files changed+24794112d2b3c809c409e086fb771add58f27609c8c3db75
108 changed files+24794−112
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);
Addeddrizzle/0096_recurring_patterns.sql+14−0View fileUnifiedSplit
1CREATE TABLE IF NOT EXISTS recurring_patterns (
2 id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
3 repository_id UUID NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
4 title TEXT NOT NULL,
5 occurrences INTEGER NOT NULL DEFAULT 1,
6 commit_shas JSONB NOT NULL DEFAULT '[]',
7 root_cause_hypothesis TEXT,
8 suggested_file TEXT,
9 severity TEXT NOT NULL DEFAULT 'medium',
10 detected_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
11 expires_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '24 hours'
12);
13CREATE INDEX IF NOT EXISTS idx_recurring_patterns_repo ON recurring_patterns(repository_id);
14CREATE INDEX IF NOT EXISTS idx_recurring_patterns_expires ON recurring_patterns(expires_at);
Addeddrizzle/0097_bus_factor.sql+8−0View fileUnifiedSplit
1CREATE TABLE IF NOT EXISTS bus_factor_cache (
2 id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
3 repository_id UUID NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
4 analyzed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
5 at_risk_files JSONB NOT NULL DEFAULT '[]',
6 total_files_analyzed INTEGER NOT NULL DEFAULT 0,
7 UNIQUE(repository_id)
8);
Addeddrizzle/0098_pr_visits.sql+6−0View fileUnifiedSplit
1CREATE TABLE IF NOT EXISTS pr_visits (
2 pr_id UUID NOT NULL REFERENCES pull_requests(id) ON DELETE CASCADE,
3 user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
4 visited_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
5 PRIMARY KEY (pr_id, user_id)
6);
Addeddrizzle/0099_smart_digest_pref.sql+2−0View fileUnifiedSplit
1ALTER TABLE users ADD COLUMN IF NOT EXISTS notify_smart_digest BOOLEAN NOT NULL DEFAULT true;
2ALTER TABLE users ADD COLUMN IF NOT EXISTS last_smart_digest_sent_at TIMESTAMPTZ;
Addeddrizzle/0100_repo_onboarding.sql+17−0View fileUnifiedSplit
1-- Migration 0088: Smart empty states — repo onboarding data + flag
2-- Adds onboarding_shown column to repositories and a new repo_onboarding_data
3-- table. Generated on first push to a repo; displayed as a dismissible card
4-- on the repo home page.
5
6ALTER TABLE repositories ADD COLUMN IF NOT EXISTS onboarding_shown BOOLEAN NOT NULL DEFAULT false;
7
8CREATE TABLE IF NOT EXISTS repo_onboarding_data (
9 repository_id UUID PRIMARY KEY REFERENCES repositories(id) ON DELETE CASCADE,
10 detected_language TEXT,
11 detected_framework TEXT,
12 suggested_readme TEXT,
13 suggested_labels JSONB NOT NULL DEFAULT '[]',
14 suggested_gates_config TEXT,
15 first_commit_suggestions JSONB NOT NULL DEFAULT '[]',
16 generated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
17);
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+34−0View fileUnifiedSplit
1616import pullsDashboardRoutes from "./routes/pulls-dashboard";
1717import issuesDashboardRoutes from "./routes/issues-dashboard";
1818import inboxRoutes from "./routes/inbox";
19import digestRoutes from "./routes/digest";
1920import activityRoutes from "./routes/activity";
2021import authRoutes from "./routes/auth";
2122import passwordResetRoutes from "./routes/password-reset";
2930import agentsRoutes from "./routes/agents";
3031import agentPipelinesRoutes from "./routes/agent-pipelines";
3132import issueRoutes from "./routes/issues";
33import milestonesRoutes from "./routes/milestones";
34import shipAgentRoutes from "./routes/ship-agent";
3235import commentModerationRoutes from "./routes/comment-moderation";
3336import repoSettings from "./routes/repo-settings";
3437import collaboratorRoutes from "./routes/collaborators";
6366import publicStatsRoutes from "./routes/public-stats";
6467import demoRoutes from "./routes/demo";
6568import insightRoutes from "./routes/insights";
69import engineeringInsightsRoutes from "./routes/engineering-insights";
6670import doraRoutes from "./routes/dora";
6771import dashboardRoutes from "./routes/dashboard";
6872import legalRoutes from "./routes/legal";
7882import migrateRoutes from "./routes/migrate";
7983import specsRoutes from "./routes/specs";
8084import refactorRoutes from "./routes/refactors";
85import codebaseMigratorRoutes from "./routes/codebase-migrator";
8186import webRoutes from "./routes/web";
8287import hookRoutes from "./routes/hooks";
8388import eventsRoutes from "./routes/events";
161166import signingKeysRoutes from "./routes/signing-keys";
162167import sponsorsRoutes from "./routes/sponsors";
163168import ssoRoutes from "./routes/sso";
169import samlSsoRoutes from "./routes/saml-sso";
170import scimRoutes from "./routes/scim";
171import orgSsoSettingsRoutes from "./routes/org-sso-settings";
164172import githubOauthRoutes from "./routes/github-oauth";
165173import googleOauthRoutes from "./routes/google-oauth";
166174import symbolsRoutes from "./routes/symbols";
183191import pulseRoutes from "./routes/pulse";
184192import healthScoreRoutes from "./routes/health-score";
185193import hotFilesRoutes from "./routes/hot-files";
194import debtMapRoutes from "./routes/debt-map";
195import busFactorRoutes from "./routes/bus-factor";
186196import developerProgramRoutes from "./routes/developer-program";
187197import shareRoutes from "./routes/share";
198import incidentHookRoutes from "./routes/incident-hooks";
188199import { authRateLimit, gitRateLimit, searchRateLimit } from "./middleware/rate-limit";
189200import { csrfToken, csrfProtect } from "./middleware/csrf";
190201import { noCache } from "./middleware/no-cache";
411422app.route("/", inboxRoutes);
412423// AI standup feed — daily / weekly Claude-generated team brief
413424app.route("/", standupRoutes);
425// Smart morning digest — AI-curated daily developer queue (/digest)
426app.route("/", digestRoutes);
414427
415428// Auth routes (register, login, logout)
416429app.route("/", authRoutes);
499512// Issue tracker
500513app.route("/", issueRoutes);
501514
515// Milestones — group issues + PRs toward a shared goal with due dates + progress tracking.
516// Mounted after issueRoutes so /:owner/:repo/issues/* paths win before the milestone patterns.
517app.route("/", milestonesRoutes);
518
519// Ship Agent — autonomous AI feature implementation
520app.route("/", shipAgentRoutes);
521
502522// Comment moderation queue — owner-only `/:owner/:repo/comments/pending`
503523// + per-row approve/reject/spam actions. Mounted before `pullRoutes` so
504524// the `/:owner/:repo/comments/*` paths resolve before the broader PR
656676// Insights (time-travel, dependencies, rollback)
657677app.route("/", insightRoutes);
658678
679// Engineering Intelligence Dashboard — /insights + /:owner/:repo/insights/engineering
680app.route("/", engineeringInsightsRoutes);
681
659682// DORA metrics page (/:owner/:repo/insights/dora)
660683app.route("/", doraRoutes);
661684
686709// Spec-to-PR (experimental AI-generated draft PRs)
687710app.route("/", specsRoutes);
688711app.route("/", refactorRoutes);
712app.route("/", codebaseMigratorRoutes);
689713
690714// Explore page
691715app.route("/", exploreRoutes);
780804app.route("/", healthScoreRoutes);
781805// Hot Files Heatmap — BLOCK M16 — /:owner/:repo/insights/hotfiles
782806app.route("/", hotFilesRoutes);
807// AI Technical Debt Map — /:owner/:repo/debt-map (visual debt graph + Claude analysis)
808app.route("/", debtMapRoutes);
809// Bus Factor Analysis — /:owner/:repo/insights/bus-factor
810app.route("/", busFactorRoutes);
783811// Hosted Claude tool-use loops — paste loop, get endpoint, billing meter.
784812// See src/routes/claude-deploy.tsx + src/lib/hosted-claude-loop.ts.
785813app.route("/", claudeDeployRoutes);
791819app.route("/", signingKeysRoutes);
792820app.route("/", sponsorsRoutes);
793821app.route("/", ssoRoutes);
822// Enterprise per-org SAML 2.0 + OIDC SSO routes
823app.route("/", samlSsoRoutes);
824// SCIM 2.0 user provisioning endpoints
825app.route("/", scimRoutes);
826// Per-org SSO + SCIM admin settings UI
827app.route("/", orgSsoSettingsRoutes);
794828app.route("/", githubOauthRoutes);
795829app.route("/", googleOauthRoutes);
796830app.route("/", symbolsRoutes);
Modifiedsrc/db/schema.ts+323−0View fileUnifiedSplit
1212 jsonb,
1313 numeric,
1414 customType,
15 primaryKey,
1516} from "drizzle-orm/pg-core";
1617
1718// Postgres `bytea` — drizzle-orm doesn't ship a first-class bytea column, so
131132 // drip schedule and sends any outstanding emails. Never null — defaults to
132133 // an empty array at insert time.
133134 onboardingEmailsSent: jsonb("onboarding_emails_sent").$type<string[]>().default([]).notNull(),
135 // Migration 0089 — Smart morning digest (AI-curated daily notification queue).
136 // When true, the autopilot `smart-digest` task delivers one AI-curated
137 // digest notification per day at 07:00 UTC. Independent cooldown via
138 // `lastSmartDigestSentAt` so it doesn't interact with weekly email digest.
139 notifySmartDigest: boolean("notify_smart_digest").default(true).notNull(),
140 lastSmartDigestSentAt: timestamp("last_smart_digest_sent_at", { withTimezone: true }),
134141 createdAt: timestamp("created_at").defaultNow().notNull(),
135142 updatedAt: timestamp("updated_at").defaultNow().notNull(),
136143});
236243 dataRegion: text("data_region").default("us").notNull(),
237244 previewBuildCommand: text("preview_build_command"),
238245 previewOutputDir: text("preview_output_dir").default("dist"),
246 // Migration 0077 — opt-in flag for the AI dependency auto-updater.
247 // When true, the autopilot dep-update-sweep task reads package.json,
248 // queries npm for patch/minor updates, applies them, runs GateTest,
249 // and auto-merges (pass) or opens a PR with an AI migration guide
250 // (fail). Default false — off by default because it touches branches.
251 depUpdaterEnabled: boolean("dep_updater_enabled").default(false).notNull(),
252 // Migration 0088 — smart empty states. Set to true once the onboarding
253 // card has been dismissed by the repo owner. Generated on first push.
254 onboardingShown: boolean("onboarding_shown").default(false).notNull(),
239255 },
240256 (table) => [
241257 // Partial: uniqueness only in the user namespace (org-owned rows exempt).
489505 (table) => [index("code_owners_repo").on(table.repositoryId)]
490506);
491507
508/**
509 * PR review requests — tracks reviewers auto-assigned via CODEOWNERS
510 * or manually requested. The UNIQUE constraint prevents duplicate requests.
511 * Migration 0077.
512 */
513export const prReviewRequests = pgTable(
514 "pr_review_requests",
515 {
516 id: uuid("id").primaryKey().defaultRandom(),
517 prId: uuid("pr_id")
518 .notNull()
519 .references(() => pullRequests.id, { onDelete: "cascade" }),
520 reviewerId: uuid("reviewer_id")
521 .notNull()
522 .references(() => users.id, { onDelete: "cascade" }),
523 requestedBy: uuid("requested_by").references(() => users.id),
524 createdAt: timestamp("created_at").defaultNow().notNull(),
525 },
526 (table) => [
527 index("pr_review_requests_pr").on(table.prId),
528 index("pr_review_requests_reviewer").on(table.reviewerId),
529 ]
530);
531
492532/**
493533 * Per-repo AI chat sessions — conversational repo assistant.
494534 */
618658 title: text("title").notNull(),
619659 body: text("body"),
620660 state: text("state").notNull().default("open"), // open, closed
661 // Migration 0077 — milestone grouping. Optional; ON DELETE SET NULL so
662 // deleting a milestone never removes the issue.
663 milestoneId: uuid("milestone_id").references(() => milestones.id, {
664 onDelete: "set null",
665 }),
621666 createdAt: timestamp("created_at").defaultNow().notNull(),
622667 updatedAt: timestamp("updated_at").defaultNow().notNull(),
623668 closedAt: timestamp("closed_at"),
625670 (table) => [
626671 index("issues_repo_state").on(table.repositoryId, table.state),
627672 index("issues_repo_number").on(table.repositoryId, table.number),
673 index("issues_milestone").on(table.milestoneId),
628674 ]
629675);
630676
9951041export type Reaction = typeof reactions.$inferSelect;
9961042export type PrReview = typeof prReviews.$inferSelect;
9971043export type CodeOwner = typeof codeOwners.$inferSelect;
1044export type PrReviewRequest = typeof prReviewRequests.$inferSelect;
9981045export type AiChat = typeof aiChats.$inferSelect;
9991046export type AuditLogEntry = typeof auditLog.$inferSelect;
10001047export type Deployment = typeof deployments.$inferSelect;
42544301
42554302export type PrPreview = typeof prPreviews.$inferSelect;
42564303export type NewPrPreview = typeof prPreviews.$inferInsert;
4304
4305// ----------------------------------------------------------------------------
4306// Migration 0092 — Enterprise SSO (SAML 2.0 + OIDC per-org) + SCIM
4307// ----------------------------------------------------------------------------
4308
4309/**
4310 * Per-org SSO configuration. One row per org. Supports both SAML 2.0 and OIDC.
4311 * Distinct from the site-wide `sso_config` table (Block I10 / OIDC-only).
4312 */
4313export const orgSsoConfigs = pgTable("org_sso_configs", {
4314 id: uuid("id").primaryKey().defaultRandom(),
4315 orgId: uuid("org_id")
4316 .notNull()
4317 .unique()
4318 .references(() => organizations.id, { onDelete: "cascade" }),
4319 provider: text("provider").notNull().default("saml"), // 'saml' | 'oidc'
4320 // SAML fields
4321 idpEntityId: text("idp_entity_id"),
4322 idpSsoUrl: text("idp_sso_url"),
4323 idpCertificate: text("idp_certificate"), // PEM cert from IdP
4324 spEntityId: text("sp_entity_id"), // our SP entity ID
4325 // OIDC fields
4326 oidcClientId: text("oidc_client_id"),
4327 oidcClientSecret: text("oidc_client_secret"),
4328 oidcDiscoveryUrl: text("oidc_discovery_url"),
4329 // Common
4330 domainHint: text("domain_hint"), // e.g. "acme.com" — auto-routes users from this domain
4331 attributeMapping: jsonb("attribute_mapping")
4332 .$type<Record<string, string>>()
4333 .default({ email: "email", name: "name", username: "preferred_username" }),
4334 enabled: boolean("enabled").notNull().default(false),
4335 createdAt: timestamp("created_at").defaultNow(),
4336 updatedAt: timestamp("updated_at").defaultNow(),
4337});
4338
4339/**
4340 * SCIM provisioning tokens. Hashed SHA-256 bearer tokens issued per-org.
4341 * Identity providers (Okta, Azure AD) use these to provision/deprovision users.
4342 */
4343export const scimTokens = pgTable("scim_tokens", {
4344 id: uuid("id").primaryKey().defaultRandom(),
4345 orgId: uuid("org_id")
4346 .notNull()
4347 .references(() => organizations.id, { onDelete: "cascade" }),
4348 tokenHash: text("token_hash").notNull().unique(), // SHA-256 of the token
4349 createdBy: uuid("created_by")
4350 .notNull()
4351 .references(() => users.id),
4352 createdAt: timestamp("created_at").defaultNow(),
4353 lastUsedAt: timestamp("last_used_at"),
4354});
4355
4356/**
4357 * Tracks active IdP-initiated SSO sessions per org. Allows per-org
4358 * SLO (Single Logout) and session audit.
4359 */
4360export const orgSsoSessions = pgTable(
4361 "org_sso_sessions",
4362
4363// Migration 0077 — Incident hook configs
4364// Maps a monitoring provider (PagerDuty / Datadog / Opsgenie / generic) to a
4365// specific repo. Inbound webhooks are validated against secret_hash (SHA-256
4366// of the user-chosen webhook secret passed in the ?secret= query param).
4367// ---------------------------------------------------------------------------
4368export const incidentHookConfigs = pgTable(
4369 "incident_hook_configs",
4370>>>>>>> 9953332 (feat: production incident auto-fix — PagerDuty/Datadog webhook → AI-generated fix PR)
4371
4372
4373>>>>>>> 3a845e4 (feat: enterprise SSO (SAML 2.0 + OIDC) and SCIM user provisioning)
4374 {
4375 id: uuid("id").primaryKey().defaultRandom(),
4376 userId: uuid("user_id")
4377 .notNull()
4378 .references(() => users.id, { onDelete: "cascade" }),
4379 orgId: uuid("org_id")
4380 .notNull()
4381 .references(() => organizations.id, { onDelete: "cascade" }),
4382 idpSessionId: text("idp_session_id"),
4383 createdAt: timestamp("created_at").defaultNow(),
4384 expiresAt: timestamp("expires_at").notNull(),
4385 },
4386 (table) => [
4387 index("org_sso_sessions_user").on(table.userId),
4388 index("org_sso_sessions_expires").on(table.expiresAt),
4389 ]
4390);
4391
4392export type OrgSsoConfig = typeof orgSsoConfigs.$inferSelect;
4393export type NewOrgSsoConfig = typeof orgSsoConfigs.$inferInsert;
4394export type ScimToken = typeof scimTokens.$inferSelect;
4395export type OrgSsoSession = typeof orgSsoSessions.$inferSelect;
4396
4397 repoId: uuid("repo_id")
4398 .notNull()
4399 .references(() => repositories.id, { onDelete: "cascade" }),
4400 /** 'pagerduty' | 'datadog' | 'opsgenie' | 'generic' */
4401 provider: text("provider").notNull(),
4402 /** SHA-256 hex of the user's plaintext webhook secret. */
4403 secretHash: text("secret_hash").notNull(),
4404 createdAt: timestamp("created_at").defaultNow(),
4405 },
4406 (table) => [
4407 uniqueIndex("incident_hook_configs_repo_provider").on(
4408 table.repoId,
4409 table.provider
4410 ),
4411 index("incident_hook_configs_user").on(table.userId),
4412 ]
4413);
4414
4415export type IncidentHookConfig = typeof incidentHookConfigs.$inferSelect;
4416export type NewIncidentHookConfig = typeof incidentHookConfigs.$inferInsert;
4417>>>>>>> 9953332 (feat: production incident auto-fix — PagerDuty/Datadog webhook → AI-generated fix PR)
4418
4419
4420>>>>>>> 3a845e4 (feat: enterprise SSO (SAML 2.0 + OIDC) and SCIM user provisioning)
4421
4422/**
4423 * Cloud deploy configurations — per-repo settings for push-triggered deploys
4424 * to Fly.io, Railway, Render, Vercel, Netlify, or a generic webhook URL.
4425 * Migration 0077.
4426 */
4427export const cloudDeployConfigs = pgTable(
4428 "cloud_deploy_configs",
4429 {
4430 id: uuid("id").primaryKey().defaultRandom(),
4431 repoId: uuid("repo_id")
4432 .notNull()
4433 .references(() => repositories.id, { onDelete: "cascade" }),
4434 // 'fly' | 'railway' | 'render' | 'vercel' | 'netlify' | 'webhook'
4435 provider: text("provider").notNull(),
4436 // Fly app name, Railway service ID, Render service ID, Vercel project ID, webhook URL
4437 providerAppId: text("provider_app_id").notNull(),
4438 // AES-256-GCM encrypted via SERVER_TARGETS_KEY
4439 apiTokenEncrypted: text("api_token_encrypted").notNull(),
4440 triggerBranch: text("trigger_branch").notNull().default("main"),
4441 enabled: boolean("enabled").notNull().default(true),
4442 createdAt: timestamp("created_at").defaultNow(),
4443 },
4444 (table) => [index("cloud_deploy_configs_repo").on(table.repoId)]
4445);
4446
4447/**
4448 * Cloud deployment runs — one row per triggered deployment attempt.
4449 * Status transitions: pending -> running -> success | failed | cancelled.
4450 * Migration 0077.
4451 */
4452export const cloudDeployments = pgTable(
4453 "cloud_deployments",
4454 {
4455 id: uuid("id").primaryKey().defaultRandom(),
4456 configId: uuid("config_id")
4457 .notNull()
4458 .references(() => cloudDeployConfigs.id, { onDelete: "cascade" }),
4459 repoId: uuid("repo_id")
4460 .notNull()
4461 .references(() => repositories.id, { onDelete: "cascade" }),
4462 commitSha: text("commit_sha").notNull(),
4463 // pending | running | success | failed | cancelled
4464 status: text("status").notNull().default("pending"),
4465 providerDeployId: text("provider_deploy_id"),
4466 logUrl: text("log_url"),
4467 deployUrl: text("deploy_url"),
4468 errorMessage: text("error_message"),
4469 startedAt: timestamp("started_at").defaultNow(),
4470 completedAt: timestamp("completed_at"),
4471 durationMs: integer("duration_ms"),
4472 },
4473 (table) => [
4474 index("cloud_deployments_repo").on(table.repoId, table.startedAt),
4475 index("cloud_deployments_config").on(table.configId, table.startedAt),
4476 ]
4477);
4478
4479export type CloudDeployConfig = typeof cloudDeployConfigs.$inferSelect;
4480export type NewCloudDeployConfig = typeof cloudDeployConfigs.$inferInsert;
4481export type CloudDeployment = typeof cloudDeployments.$inferSelect;
4482export type NewCloudDeployment = typeof cloudDeployments.$inferInsert;
4483>>>>>>> b11ffa9 (feat: multi-cloud deploy integration — push to main deploys to Fly/Railway/Render/Vercel)
4484// ---------------------------------------------------------------------------
4485// Recurring pattern detection (migration 0088)
4486// ---------------------------------------------------------------------------
4487
4488export const recurringPatterns = pgTable(
4489 "recurring_patterns",
4490 {
4491 id: uuid("id").primaryKey().defaultRandom(),
4492 repositoryId: uuid("repository_id")
4493 .notNull()
4494 .references(() => repositories.id, { onDelete: "cascade" }),
4495 title: text("title").notNull(),
4496 occurrences: integer("occurrences").notNull().default(1),
4497 commitShas: jsonb("commit_shas").$type<string[]>().notNull().default([]),
4498 rootCauseHypothesis: text("root_cause_hypothesis"),
4499 suggestedFile: text("suggested_file"),
4500 severity: text("severity").notNull().default("medium"),
4501 detectedAt: timestamp("detected_at", { withTimezone: true }).notNull().defaultNow(),
4502 expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
4503 },
4504 (table) => [
4505 index("idx_recurring_patterns_repo").on(table.repositoryId),
4506 index("idx_recurring_patterns_expires").on(table.expiresAt),
4507 ]
4508);
4509
4510export type RecurringPattern = typeof recurringPatterns.$inferSelect;
4511export type NewRecurringPattern = typeof recurringPatterns.$inferInsert;
4512// ---------------------------------------------------------------------------
4513// Bus Factor Cache — migration 0088
4514// Stores per-repo knowledge concentration analysis (at-risk files where one
4515// author owns >75% of commits). Refreshed on demand; 7-day soft TTL.
4516// ---------------------------------------------------------------------------
4517export const busFactorCache = pgTable("bus_factor_cache", {
4518 id: uuid("id").primaryKey().defaultRandom(),
4519 repositoryId: uuid("repository_id")
4520 .notNull()
4521 .references(() => repositories.id, { onDelete: "cascade" })
4522 .unique(),
4523 analyzedAt: timestamp("analyzed_at", { withTimezone: true })
4524 .notNull()
4525 .defaultNow(),
4526 atRiskFiles: jsonb("at_risk_files").notNull().default([]),
4527 totalFilesAnalyzed: integer("total_files_analyzed").notNull().default(0),
4528});
4529// ---------------------------------------------------------------------------
4530// Migration 0088 — PR visit tracking for context-restore feature
4531// ---------------------------------------------------------------------------
4532
4533/**
4534 * pr_visits — lightweight upsert table tracking each user's last visit to a
4535 * PR. Used by `src/lib/review-context.ts` to compute what changed since the
4536 * reviewer was last here and generate a "Welcome back" context banner.
4537 */
4538export const prVisits = pgTable(
4539 "pr_visits",
4540 {
4541 prId: uuid("pr_id")
4542 .notNull()
4543 .references(() => pullRequests.id, { onDelete: "cascade" }),
4544 userId: uuid("user_id")
4545 .notNull()
4546 .references(() => users.id, { onDelete: "cascade" }),
4547 visitedAt: timestamp("visited_at", { withTimezone: true }).notNull().defaultNow(),
4548 },
4549 (t) => [primaryKey({ columns: [t.prId, t.userId] })]
4550);
4551
4552export type PrVisit = typeof prVisits.$inferSelect;
4553// ---------------------------------------------------------------------------
4554// Migration 0088 — Smart empty states: repo onboarding data
4555// Generated by generateRepoOnboarding() on first push to a repo.
4556// ---------------------------------------------------------------------------
4557export const repoOnboardingData = pgTable("repo_onboarding_data", {
4558 repositoryId: uuid("repository_id")
4559 .primaryKey()
4560 .references(() => repositories.id, { onDelete: "cascade" }),
4561 detectedLanguage: text("detected_language"),
4562 detectedFramework: text("detected_framework"),
4563 suggestedReadme: text("suggested_readme"),
4564 suggestedLabels: jsonb("suggested_labels")
4565 .notNull()
4566 .default([])
4567 .$type<Array<{ name: string; color: string; description: string }>>(),
4568 suggestedGatesConfig: text("suggested_gates_config"),
4569 firstCommitSuggestions: jsonb("first_commit_suggestions")
4570 .notNull()
4571 .default([])
4572 .$type<string[]>(),
4573 generatedAt: timestamp("generated_at", { withTimezone: true })
4574 .notNull()
4575 .defaultNow(),
4576});
4577
4578export type RepoOnboardingData = typeof repoOnboardingData.$inferSelect;
4579export type NewRepoOnboardingData = typeof repoOnboardingData.$inferInsert;
Modifiedsrc/hooks/post-receive.ts+57−0View fileUnifiedSplit
3636 startDeployRow,
3737} from "../lib/server-target-store";
3838import { deployToTarget } from "../lib/server-targets";
39import { fireCloudDeploys } from "../lib/cloud-deploy";
40import { ensureRepoOnboarding } from "../lib/repo-onboarding";
3941
4042interface PushRef {
4143 oldSha: string;
160162 console.warn("[ai-doc-updater] dispatch error:", err)
161163 );
162164
165 // 4g. Smart empty states — repo onboarding. On the very first push to a
166 // repo's default branch (oldSha all-zeros), generate a README draft,
167 // suggested labels, and gates.yml starter using Claude Sonnet.
168 // Idempotent: ensureRepoOnboarding skips if a row already exists.
169 // Fire-and-forget; never blocks the push path.
170 void fireRepoOnboarding(owner, repo, refs).catch((err) =>
171 console.warn("[repo-onboarding] dispatch error:", err)
172 );
173
163174 // 5. Crontech deploy (BLK-016) — only fires for the configured Crontech repo
164175 // (CRONTECH_REPO, default `ccantynz-alt/crontech`) on a push to its
165176 // default branch. The branch case (`Main` vs `main`) is determined by
213224 console.warn("[server-targets] dispatch error:", err)
214225 );
215226
227 // 5c. Cloud deploy integrations (migration 0077). Fire push-triggered
228 // deploys to Fly.io, Railway, Render, Vercel, Netlify, or a generic
229 // webhook for any cloud_deploy_configs rows matching the pushed branch.
230 // Fire-and-forget; all failures are swallowed so the push path is
231 // never blocked.
232 void fireCloudDeploys(owner, repo, refs).catch((err) =>
233 console.warn("[cloud-deploy] dispatch error:", err)
234 );
235
216236 // 6. BLOCK W — Self-host. When Gluecron.com itself receives a push to
217237 // main, fire the local deploy via scripts/self-deploy.sh. The script
218238 // forks into the background, so this call returns immediately (git
780800 }
781801}
782802
803/**
804 * Migration 0088 — trigger repo onboarding on first push to the default branch.
805 * Detects "first push" by checking whether oldSha is all-zeros on a push to
806 * the repo's default branch (or any branch for a repo with no prior history).
807 * Resolves the repo DB id, then calls ensureRepoOnboarding which is idempotent.
808 * Never throws.
809 */
810async function fireRepoOnboarding(
811 owner: string,
812 repo: string,
813 refs: PushRef[]
814): Promise<void> {
815 // Only fire on first push — oldSha all-zeros means the branch is brand-new.
816 const firstPushRefs = refs.filter(
817 (r) => r.refName.startsWith("refs/heads/") && /^0+$/.test(r.oldSha) && !r.newSha.startsWith("0000")
818 );
819 if (firstPushRefs.length === 0) return;
820
821 let repositoryId = "";
822 try {
823 const [row] = await db
824 .select({ id: repositories.id })
825 .from(repositories)
826 .innerJoin(users, eq(repositories.ownerId, users.id))
827 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
828 .limit(1);
829 repositoryId = row?.id || "";
830 } catch {
831 return;
832 }
833 if (!repositoryId) return;
834
835 await ensureRepoOnboarding(repositoryId, owner, repo);
836}
837
783838/** Test-only access to internal helpers. */
784839export const __test = {
785840 triggerCrontechDeploy,
792847 fireDocDriftCheck,
793848 fireServerTargetDeploys,
794849 fireDependencyScan,
850 fireCloudDeploys,
851 fireRepoOnboarding,
795852};
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+63−0View fileUnifiedSplit
7171import { expireOldPreviews } from "./branch-previews";
7272import { getBotUserIdOrFallback } from "./bot-user";
7373import { runOnboardingDripTaskOnce } from "./onboarding-drip";
74import { runDepUpdateSweepOnce } from "./dep-updater-sweep";
75import { sendSmartDigestsToAll } from "./smart-digest";
7476
7577export interface AutopilotTaskResult {
7678 name: string;
158160 */
159161const DEV_ENV_IDLE_SWEEP_INTERVAL_MS = 5 * 60 * 1000;
160162let _lastDevEnvIdleSweepAt = 0;
163/**
164 * AI dependency auto-updater cadence (migration 0077). Once per day is
165 * plenty — the task itself caps at 10 repos and 2 candidates per repo,
166 * keeping npm registry traffic low. Skips when DEP_UPDATER_ENABLED env
167 * flag is not set to "1".
168 */
169const DEP_UPDATE_SWEEP_INTERVAL_MS = 24 * 60 * 60 * 1000;
170let _lastDepUpdateSweepAt = 0;
161171/**
162172 * Advancement scanner cadence. Designed to run weekly on Mondays at
163173 * 08:00 UTC. The task itself is the cheap gate (checks both day-of-week
185195 return Math.floor(n);
186196}
187197
198/**
199 * Smart-digest cadence. Designed to run once per day at 07:00 UTC.
200 * The task itself checks `lastSmartDigestSentAt` per user (20h cooldown),
201 * so even if the outer loop fires multiple times near 07:00, only one
202 * digest is ever sent per day per user.
203 */
204const SMART_DIGEST_INTERVAL_MS = 22 * 60 * 60 * 1000; // 22h between outer checks
205let _lastSmartDigestAt = 0;
206
188207/**
189208 * Default task set. Each task is a thin wrapper around an existing locked
190209 * helper — no gate/merge logic is duplicated here.
662681 }
663682 },
664683 },
684 {
685 // AI dependency auto-updater (migration 0077). Once per day: scans
686 // up to 10 repos with depUpdaterEnabled=true, checks for patch/minor
687 // npm updates, applies them, runs GateTest, and either auto-merges
688 // (green) or opens a PR with an AI migration guide (red). Skips
689 // when DEP_UPDATER_ENABLED env flag is not set to "1".
690 name: "dep-update-sweep",
691 run: async () => {
692 if (process.env.DEP_UPDATER_ENABLED !== "1") return;
693 const now = Date.now();
694 if (now - _lastDepUpdateSweepAt < DEP_UPDATE_SWEEP_INTERVAL_MS) {
695 return;
696 }
697 _lastDepUpdateSweepAt = now;
698 try {
699 const summary = await runDepUpdateSweepOnce();
700 console.log(
701 `[autopilot] dep-update-sweep: repos=${summary.repos} runs=${summary.runs} merged=${summary.merged} prs=${summary.prs} skipped=${summary.skipped} errors=${summary.errors}`
702 );
703 } catch (err) {
704 console.error("[autopilot] dep-update-sweep: threw:", err);
705 // Smart morning digest — AI-curated daily developer queue.
706 // Fires once per day at 07:00 UTC (or whenever the 22h outer gate
707 // next opens after 07:00). Per-user 20h cooldown in sendSmartDigest
708 // ensures no user receives more than one digest per day even if the
709 // outer gate fires multiple times. Requires ANTHROPIC_API_KEY but
710 // degrades gracefully to a rule-based prioritisation when unset.
711 name: "smart-digest",
712 run: async () => {
713 const now = Date.now();
714 const nowDate = new Date(now);
715 // Only fire at hour=7 UTC or if last run was >22h ago (catch-up)
716 const isDigestHour = nowDate.getUTCHours() === 7;
717 const pastCooldown = now - _lastSmartDigestAt >= SMART_DIGEST_INTERVAL_MS;
718 if (!isDigestHour && !pastCooldown) return;
719 _lastSmartDigestAt = now;
720 try {
721 await sendSmartDigestsToAll();
722 console.log("[autopilot] smart-digest: completed");
723 } catch (err) {
724 console.error("[autopilot] smart-digest: threw:", err);
725 }
726 },
727 },
665728 ];
666729}
667730
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/bus-factor.ts+306−0View fileUnifiedSplit
1/**
2 * Bus Factor Analysis — detect files that only one person understands.
3 *
4 * Uses git log parsing to build a commit-author map per file, then flags
5 * files where one author has >75% of commits and total commits >= 3.
6 *
7 * Risk levels:
8 * critical — >90% single author, >=5 commits, modified in last 30 days
9 * high — >80% single author, >=4 commits
10 * medium — >75% single author, >=3 commits
11 *
12 * Results are cached in the `bus_factor_cache` table (7-day TTL).
13 * No AI calls — pure git log parsing.
14 */
15
16import { join } from "path";
17import { eq } from "drizzle-orm";
18import { db } from "../db";
19import { busFactorCache } from "../db/schema";
20import { config } from "./config";
21
22// ---------------------------------------------------------------------------
23// Public types
24// ---------------------------------------------------------------------------
25
26export interface BusFactorFile {
27 path: string;
28 primaryAuthor: string; // username / display name of dominant author
29 primaryAuthorPct: number; // e.g. 87 (integer percent)
30 totalCommits: number;
31 lastModified: string; // ISO date string
32 risk: "critical" | "high" | "medium";
33}
34
35export interface BusFactorReport {
36 repoId: string;
37 analyzedAt: string;
38 atRiskFiles: BusFactorFile[]; // files with bus factor = 1
39 totalFilesAnalyzed: number;
40}
41
42// ---------------------------------------------------------------------------
43// Internal helpers
44// ---------------------------------------------------------------------------
45
46const CODE_EXTENSIONS = new Set([
47 ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs",
48 ".py", ".go", ".rs", ".java", ".rb", ".php",
49 ".c", ".cpp", ".cc", ".h", ".hpp",
50 ".cs", ".swift", ".kt", ".scala",
51 ".vue", ".svelte",
52 ".sh", ".bash", ".zsh",
53 ".sql",
54]);
55
56const SKIP_DIRS = ["node_modules", "dist", ".next", "build", "vendor", ".git", "coverage"];
57
58function isCodeFile(filePath: string): boolean {
59 // Skip generated / dependency directories
60 const parts = filePath.split("/");
61 if (parts.some((p) => SKIP_DIRS.includes(p))) return false;
62
63 const dotIdx = filePath.lastIndexOf(".");
64 if (dotIdx === -1) return false;
65 const ext = filePath.slice(dotIdx).toLowerCase();
66 return CODE_EXTENSIONS.has(ext);
67}
68
69function getRepoDir(owner: string, repo: string): string {
70 return join(config.gitReposPath, `${owner}/${repo}.git`);
71}
72
73async function spawnGit(args: string[], cwd: string): Promise<string> {
74 const proc = Bun.spawn(["git", "--git-dir", cwd, ...args], {
75 stdout: "pipe",
76 stderr: "pipe",
77 });
78 const out = await new Response(proc.stdout).text();
79 await proc.exited;
80 return out;
81}
82
83// ---------------------------------------------------------------------------
84// Core analysis
85// ---------------------------------------------------------------------------
86
87/**
88 * Parse `git log --name-only --format="%ae %an"` output into a map:
89 * Map<filePath, Map<authorIdentifier, commitCount>>
90 *
91 * Also returns a map of file → last modified date.
92 */
93function parseGitLog(raw: string): {
94 fileAuthorMap: Map<string, Map<string, number>>;
95 fileLastModified: Map<string, string>;
96} {
97 const fileAuthorMap = new Map<string, Map<string, number>>();
98 const fileLastModified = new Map<string, string>();
99
100 const lines = raw.split("\n");
101 let currentAuthor: string | null = null;
102 let currentDate: string | null = null;
103 let inFileList = false;
104
105 for (const line of lines) {
106 const trimmed = line.trim();
107 if (!trimmed) {
108 // blank line separator between commits
109 inFileList = false;
110 currentDate = null;
111 continue;
112 }
113
114 // Header line — format: "<email> <name> <date>"
115 // We use "%ae %an %ad" with --date=short
116 const headerMatch = trimmed.match(/^(\S+)\s+(.+?)\s+(\d{4}-\d{2}-\d{2})$/);
117 if (headerMatch) {
118 currentAuthor = headerMatch[2].trim(); // prefer display name
119 currentDate = headerMatch[3];
120 inFileList = true;
121 continue;
122 }
123
124 if (inFileList && currentAuthor) {
125 // This line is a file path
126 const filePath = trimmed;
127 if (!filePath || filePath.startsWith("diff") || filePath.startsWith("---")) continue;
128
129 if (!fileAuthorMap.has(filePath)) {
130 fileAuthorMap.set(filePath, new Map());
131 }
132 const authorMap = fileAuthorMap.get(filePath)!;
133 authorMap.set(currentAuthor, (authorMap.get(currentAuthor) ?? 0) + 1);
134
135 // Track most recent modification (git log is newest-first)
136 if (!fileLastModified.has(filePath) && currentDate) {
137 fileLastModified.set(filePath, currentDate);
138 }
139 }
140 }
141
142 return { fileAuthorMap, fileLastModified };
143}
144
145function computeRisk(
146 primaryPct: number,
147 totalCommits: number,
148 lastModified: string
149): "critical" | "high" | "medium" | null {
150 if (primaryPct <= 75 || totalCommits < 3) return null;
151
152 const daysSinceModified =
153 (Date.now() - new Date(lastModified).getTime()) / (1000 * 60 * 60 * 24);
154
155 if (primaryPct > 90 && totalCommits >= 5 && daysSinceModified <= 30) {
156 return "critical";
157 }
158 if (primaryPct > 80 && totalCommits >= 4) {
159 return "high";
160 }
161 return "medium";
162}
163
164// ---------------------------------------------------------------------------
165// Public API
166// ---------------------------------------------------------------------------
167
168export async function analyzeBusFactor(
169 repoId: string,
170 owner: string,
171 repo: string
172): Promise<BusFactorReport> {
173 const repoDir = getRepoDir(owner, repo);
174 const analyzedAt = new Date().toISOString();
175
176 // Fetch git log with file names in one pass — format: "email name date\nfile1\nfile2\n\n"
177 const raw = await spawnGit(
178 [
179 "log",
180 "--name-only",
181 "--format=%ae %an %ad",
182 "--date=short",
183 "--diff-filter=ACMR",
184 "-n",
185 "5000",
186 ],
187 repoDir
188 );
189
190 const { fileAuthorMap, fileLastModified } = parseGitLog(raw);
191
192 const atRiskFiles: BusFactorFile[] = [];
193 let totalFilesAnalyzed = 0;
194
195 for (const [filePath, authorMap] of fileAuthorMap) {
196 if (!isCodeFile(filePath)) continue;
197 totalFilesAnalyzed++;
198
199 const totalCommits = Array.from(authorMap.values()).reduce((a, b) => a + b, 0);
200 if (totalCommits < 3) continue;
201
202 // Find dominant author
203 let primaryAuthor = "";
204 let primaryCount = 0;
205 for (const [author, count] of authorMap) {
206 if (count > primaryCount) {
207 primaryCount = count;
208 primaryAuthor = author;
209 }
210 }
211
212 const primaryAuthorPct = Math.round((primaryCount / totalCommits) * 100);
213 const lastModified = fileLastModified.get(filePath) ?? new Date().toISOString().slice(0, 10);
214 const risk = computeRisk(primaryAuthorPct, totalCommits, lastModified);
215
216 if (risk) {
217 atRiskFiles.push({
218 path: filePath,
219 primaryAuthor,
220 primaryAuthorPct,
221 totalCommits,
222 lastModified,
223 risk,
224 });
225 }
226
227 if (atRiskFiles.length >= 50) break;
228 }
229
230 // Sort: critical first, then high, then medium
231 const riskOrder = { critical: 0, high: 1, medium: 2 };
232 atRiskFiles.sort((a, b) => riskOrder[a.risk] - riskOrder[b.risk]);
233
234 const report: BusFactorReport = {
235 repoId,
236 analyzedAt,
237 atRiskFiles,
238 totalFilesAnalyzed,
239 };
240
241 // Upsert into cache
242 try {
243 await db
244 .insert(busFactorCache)
245 .values({
246 repositoryId: repoId,
247 analyzedAt: new Date(analyzedAt),
248 atRiskFiles: atRiskFiles as unknown as object,
249 totalFilesAnalyzed,
250 })
251 .onConflictDoUpdate({
252 target: busFactorCache.repositoryId,
253 set: {
254 analyzedAt: new Date(analyzedAt),
255 atRiskFiles: atRiskFiles as unknown as object,
256 totalFilesAnalyzed,
257 },
258 });
259 } catch {
260 // Cache write failure is non-blocking
261 }
262
263 return report;
264}
265
266/**
267 * Return cached at-risk files that overlap with `changedFiles`.
268 * If the cache is older than 7 days, trigger a background re-analysis.
269 */
270export async function getBusFactorWarning(
271 repoId: string,
272 owner: string,
273 repo: string,
274 changedFiles: string[]
275): Promise<BusFactorFile[]> {
276 if (changedFiles.length === 0) return [];
277
278 try {
279 const rows = await db
280 .select()
281 .from(busFactorCache)
282 .where(eq(busFactorCache.repositoryId, repoId))
283 .limit(1);
284
285 if (rows.length === 0) {
286 // No cache yet — trigger background analysis and return empty
287 analyzeBusFactor(repoId, owner, repo).catch(() => {});
288 return [];
289 }
290
291 const cached = rows[0];
292 const ageMs = Date.now() - cached.analyzedAt.getTime();
293 const sevenDaysMs = 7 * 24 * 60 * 60 * 1000;
294
295 if (ageMs > sevenDaysMs) {
296 // Stale — refresh in background
297 analyzeBusFactor(repoId, owner, repo).catch(() => {});
298 }
299
300 const atRiskFiles = cached.atRiskFiles as BusFactorFile[];
301 const changedSet = new Set(changedFiles);
302 return atRiskFiles.filter((f) => changedSet.has(f.path));
303 } catch {
304 return [];
305 }
306}
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/ci-autofix.ts+495−0View fileUnifiedSplit
1/**
2 * CI Auto-Fix — when a gate run or workflow run fails on a PR, Claude reads
3 * the error logs, the failing test file, and the PR diff, then posts a
4 * ready-to-apply patch as a comment on the PR.
5 *
6 * Entry points:
7 * triggerCiAutofix(gateRunId) — fire-and-forget; call after a gate_run
8 * row is written with status="failed".
9 * applyAutofix(prCommentId, userId) — apply the patch from a comment onto
10 * a new branch and return the branch name.
11 *
12 * Route wiring (src/routes/pulls.tsx or src/routes/api.ts):
13 * POST /api/pr-comments/:commentId/apply-autofix → applyAutofix
14 */
15
16import { and, eq } from "drizzle-orm";
17import { mkdtemp, rm, writeFile } from "fs/promises";
18import { join } from "path";
19import { tmpdir } from "os";
20import { db } from "../db";
21import {
22 gateRuns,
23 pullRequests,
24 prComments,
25 repositories,
26 users,
27 repoCollaborators,
28} from "../db/schema";
29import { getRepoPath } from "../git/repository";
30import { getBotUserIdOrFallback } from "./bot-user";
31import {
32 getAnthropic,
33 isAiAvailable,
34 MODEL_SONNET,
35 extractText,
36 parseJsonResponse,
37} from "./ai-client";
38
39// ---------------------------------------------------------------------------
40// Types
41// ---------------------------------------------------------------------------
42
43export interface AutofixResult {
44 prNumber: number;
45 repoId: string;
46 gateRunId: string;
47 patch: string; // unified diff format
48 explanation: string; // 2-3 sentence explanation
49 confidence: "high" | "medium" | "low";
50 affectedFiles: string[];
51}
52
53interface ClaudeAutofixResponse {
54 patch: string;
55 explanation: string;
56 confidence: "high" | "medium" | "low";
57 affectedFiles: string[];
58}
59
60// ---------------------------------------------------------------------------
61// Constants
62// ---------------------------------------------------------------------------
63
64/** Idempotency marker embedded in every autofix comment. */
65export const CI_AUTOFIX_MARKER = "<!-- gluecron:ci-autofix:v1 -->";
66
67/** Max bytes of PR diff sent to Claude. */
68const MAX_DIFF_BYTES = 80 * 1024;
69
70/** Max bytes of error log sent to Claude. */
71const MAX_LOG_BYTES = 3 * 1024;
72
73/** Max bytes per test file read. */
74const MAX_FILE_BYTES = 10 * 1024;
75
76/** Max number of failing test files to read. */
77const MAX_TEST_FILES = 3;
78
79// ---------------------------------------------------------------------------
80// Helpers
81// ---------------------------------------------------------------------------
82
83async function spawnGit(
84 args: string[],
85 cwd: string
86): Promise<{ stdout: string; stderr: string; exitCode: number }> {
87 const proc = Bun.spawn(["git", ...args], {
88 cwd,
89 stdout: "pipe",
90 stderr: "pipe",
91 });
92 const [stdout, stderr] = await Promise.all([
93 new Response(proc.stdout).text(),
94 new Response(proc.stderr).text(),
95 ]);
96 const exitCode = await proc.exited;
97 return { stdout, stderr, exitCode };
98}
99
100function truncate(s: string, maxBytes: number): string {
101 const buf = Buffer.from(s, "utf8");
102 if (buf.length <= maxBytes) return s;
103 return buf.slice(0, maxBytes).toString("utf8") + "\n[truncated]";
104}
105
106/**
107 * Extract failing test file paths, error message, and stack trace from a
108 * raw CI error log string. Heuristic — good enough for most JS/TS test
109 * runners (Jest, Vitest, Bun test) and Python pytest output.
110 */
111function parseErrorLog(errorLog: string): {
112 testFiles: string[];
113 errorSummary: string;
114} {
115 const lines = errorLog.split("\n");
116
117 // Collect candidate test file paths:
118 // - lines mentioning .test.ts/.spec.ts/.test.js/.spec.js/.test.py paths
119 // - lines with "FAIL <path>" (Jest pattern)
120 const filePatterns = [
121 /(?:^|\s)([\w./\-]+\.(?:test|spec)\.[jt]sx?)/gm,
122 /(?:^|\s)([\w./\-]+_test\.py)/gm,
123 /(?:^FAIL\s+)([\w./\-]+)/gm,
124 ];
125
126 const filesSet = new Set<string>();
127 for (const pattern of filePatterns) {
128 let m: RegExpExecArray | null;
129 pattern.lastIndex = 0;
130 while ((m = pattern.exec(errorLog)) !== null) {
131 const path = m[1].trim();
132 if (path && !path.startsWith("-") && !path.startsWith("+")) {
133 filesSet.add(path);
134 }
135 }
136 }
137
138 // Error summary: first 3KB of the log (most runners put the error first).
139 const errorSummary = truncate(errorLog, MAX_LOG_BYTES);
140
141 return {
142 testFiles: Array.from(filesSet).slice(0, MAX_TEST_FILES),
143 errorSummary,
144 };
145}
146
147// ---------------------------------------------------------------------------
148// Main entry point
149// ---------------------------------------------------------------------------
150
151/**
152 * Fire-and-forget entry point called after a gate_run row is set to 'failed'.
153 * Never throws — all errors are swallowed after logging.
154 */
155export async function triggerCiAutofix(gateRunId: string): Promise<void> {
156 if (!isAiAvailable()) return;
157
158 try {
159 await _runAutofix(gateRunId);
160 } catch (err) {
161 console.error(
162 "[ci-autofix] crashed:",
163 err instanceof Error ? err.message : err
164 );
165 }
166}
167
168async function _runAutofix(gateRunId: string): Promise<void> {
169 // 1. Load the gate run
170 const [gateRun] = await db
171 .select()
172 .from(gateRuns)
173 .where(eq(gateRuns.id, gateRunId))
174 .limit(1);
175
176 if (!gateRun) return;
177 if (gateRun.status !== "failed") return;
178 if (!gateRun.pullRequestId) return;
179
180 // 2. Load the PR row
181 const [pr] = await db
182 .select()
183 .from(pullRequests)
184 .where(eq(pullRequests.id, gateRun.pullRequestId))
185 .limit(1);
186
187 if (!pr) return;
188
189 // 3. Load repo (owner/name)
190 const [repoRow] = await db
191 .select({
192 id: repositories.id,
193 name: repositories.name,
194 diskPath: repositories.diskPath,
195 ownerUsername: users.username,
196 })
197 .from(repositories)
198 .innerJoin(users, eq(repositories.ownerId, users.id))
199 .where(eq(repositories.id, gateRun.repositoryId))
200 .limit(1);
201
202 if (!repoRow) return;
203
204 // 4. Check idempotency — skip if already posted for this gateRunId
205 const idempotencyMarker = `<!-- gluecron:ci-autofix:run:${gateRunId} -->`;
206 const existing = await db
207 .select({ id: prComments.id })
208 .from(prComments)
209 .where(
210 and(
211 eq(prComments.pullRequestId, gateRun.pullRequestId),
212 // We check by looking for comments with the autofix marker.
213 // drizzle doesn't have LIKE with dynamic params easily, but
214 // we query all AI comments and filter client-side (there won't be many).
215 eq(prComments.isAiReview, true)
216 )
217 )
218 .limit(50);
219
220 for (const row of existing) {
221 // Load the body to check idempotency marker
222 const [full] = await db
223 .select({ body: prComments.body })
224 .from(prComments)
225 .where(eq(prComments.id, row.id))
226 .limit(1);
227 if (full?.body?.includes(idempotencyMarker)) return;
228 }
229
230 const repoDir = getRepoPath(repoRow.ownerUsername, repoRow.name);
231
232 // 5. Get the PR diff (max 80KB)
233 const diffResult = await spawnGit(
234 ["diff", `${pr.baseBranch}...${pr.headBranch}`],
235 repoDir
236 );
237 const prDiff = truncate(diffResult.stdout, MAX_DIFF_BYTES);
238
239 if (!prDiff.trim()) return; // nothing to work with
240
241 // 6. Parse errorLog to extract test files + error summary
242 const errorLog = gateRun.summary || gateRun.details || "";
243 const { testFiles, errorSummary } = parseErrorLog(
244 typeof errorLog === "string" ? errorLog : JSON.stringify(errorLog)
245 );
246
247 // 7. Read failing test files via git show HEAD:path
248 let testFileContent = "";
249 for (const filePath of testFiles) {
250 const showResult = await spawnGit(
251 ["show", `${pr.headBranch}:${filePath}`],
252 repoDir
253 );
254 if (showResult.exitCode === 0 && showResult.stdout) {
255 const content = truncate(showResult.stdout, MAX_FILE_BYTES);
256 testFileContent += `\n\n--- ${filePath} ---\n${content}`;
257 }
258 }
259
260 // 8. Call Claude Sonnet 4.6
261 const client = getAnthropic();
262 const prompt = `You are a senior engineer fixing a CI failure.
263
264PR diff (what changed):
265${prDiff}
266
267Failing test output:
268${errorSummary}
269
270Test file content:${testFileContent || "\n(no test files detected)"}
271
272Produce a minimal unified diff patch that fixes the CI failure. The patch must:
2731. Be valid unified diff format (--- a/file, +++ b/file, @@ lines)
2742. Fix only what's needed — no refactoring
2753. Not modify the test itself unless the test expectation is genuinely wrong
276
277Return JSON: {"patch": "...", "explanation": "...", "confidence": "high|medium|low", "affectedFiles": ["..."]}`;
278
279 const message = await client.messages.create({
280 model: MODEL_SONNET,
281 max_tokens: 4096,
282 messages: [{ role: "user", content: prompt }],
283 });
284
285 const rawText = extractText(message);
286 const parsed = parseJsonResponse<ClaudeAutofixResponse>(rawText);
287
288 if (!parsed || !parsed.patch || !parsed.explanation) return;
289
290 // 10. If confidence === 'low' → skip
291 if (parsed.confidence === "low") return;
292
293 // 11. Build and post the comment
294 const commentBody = buildAutofixComment(
295 parsed,
296 idempotencyMarker,
297 gateRunId
298 );
299
300 const botAuthorId = await getBotUserIdOrFallback(repoRow.id);
301 if (!botAuthorId) return;
302
303 await db.insert(prComments).values({
304 pullRequestId: gateRun.pullRequestId,
305 authorId: botAuthorId,
306 body: commentBody,
307 isAiReview: true,
308 });
309}
310
311function buildAutofixComment(
312 result: ClaudeAutofixResponse,
313 idempotencyMarker: string,
314 gateRunId: string
315): string {
316 const confidenceBadge =
317 result.confidence === "high"
318 ? "🟢 High confidence"
319 : result.confidence === "medium"
320 ? "🟡 Medium confidence"
321 : "🔴 Low confidence";
322
323 return `${CI_AUTOFIX_MARKER}
324${idempotencyMarker}
325
326## 🔧 AI Auto-Fix
327
328${result.explanation}
329
330**Confidence:** ${confidenceBadge}
331
332\`\`\`diff
333${result.patch}
334\`\`\`
335
336<details><summary>Apply this fix</summary>
337
338Copy the patch above or click **Apply Fix** to commit it automatically.
339
340<form method="post" action="/api/pr-comments/COMMENT_ID/apply-autofix" style="display:inline">
341 <button type="submit" style="margin-top:8px;padding:6px 14px;background:#6c63ff;color:#fff;border:none;border-radius:6px;cursor:pointer">
342 ⚡ Apply Fix
343 </button>
344</form>
345
346</details>
347
348<sub>Gate run: <code>${gateRunId}</code> · Affected files: ${result.affectedFiles.join(", ") || "see patch above"}</sub>`;
349}
350
351// ---------------------------------------------------------------------------
352// Apply autofix
353// ---------------------------------------------------------------------------
354
355/**
356 * Applies the patch from a PR comment onto a new branch.
357 * Returns the new branch name so the caller can redirect to compare view.
358 */
359export async function applyAutofix(
360 prCommentId: string,
361 userId: string
362): Promise<{ branchName: string }> {
363 // 1. Load the comment
364 const [comment] = await db
365 .select({
366 id: prComments.id,
367 pullRequestId: prComments.pullRequestId,
368 body: prComments.body,
369 isAiReview: prComments.isAiReview,
370 })
371 .from(prComments)
372 .where(eq(prComments.id, prCommentId))
373 .limit(1);
374
375 if (!comment) throw new Error("Comment not found");
376 if (!comment.body.includes(CI_AUTOFIX_MARKER)) {
377 throw new Error("Not an autofix comment");
378 }
379
380 // 2. Load PR + repo for access check
381 const [pr] = await db
382 .select()
383 .from(pullRequests)
384 .where(eq(pullRequests.id, comment.pullRequestId))
385 .limit(1);
386
387 if (!pr) throw new Error("PR not found");
388
389 const [repoRow] = await db
390 .select({
391 id: repositories.id,
392 name: repositories.name,
393 ownerId: repositories.ownerId,
394 ownerUsername: users.username,
395 })
396 .from(repositories)
397 .innerJoin(users, eq(repositories.ownerId, users.id))
398 .where(eq(repositories.id, pr.repositoryId))
399 .limit(1);
400
401 if (!repoRow) throw new Error("Repository not found");
402
403 // Verify write access: must be repo owner or collaborator
404 const isOwner = repoRow.ownerId === userId;
405 if (!isOwner) {
406 const [collab] = await db
407 .select({ id: repoCollaborators.id })
408 .from(repoCollaborators)
409 .where(
410 and(
411 eq(repoCollaborators.repositoryId, repoRow.id),
412 eq(repoCollaborators.userId, userId)
413 )
414 )
415 .limit(1);
416 if (!collab) throw new Error("Forbidden: no write access");
417 }
418
419 // 3. Extract patch from comment body
420 const patchMatch = comment.body.match(/```diff\n([\s\S]*?)```/);
421 if (!patchMatch) throw new Error("No patch found in comment");
422 const patch = patchMatch[1];
423
424 // 4. Create a new branch from the PR's head
425 const branchName = `fix/autofix-${Date.now()}`;
426 const repoDir = getRepoPath(repoRow.ownerUsername, repoRow.name);
427
428 // Create the branch at the PR head SHA
429 const headSha = await spawnGit(
430 ["rev-parse", pr.headBranch],
431 repoDir
432 );
433 if (headSha.exitCode !== 0) throw new Error("Cannot resolve head branch");
434
435 await spawnGit(
436 ["branch", branchName, headSha.stdout.trim()],
437 repoDir
438 );
439
440 // 5. Apply the patch via git apply in a temp worktree
441 const tmpDir = await mkdtemp(join(tmpdir(), "autofix-"));
442 try {
443 // Add worktree for the new branch
444 const wtResult = await spawnGit(
445 ["worktree", "add", tmpDir, branchName],
446 repoDir
447 );
448 if (wtResult.exitCode !== 0) {
449 throw new Error(`git worktree add failed: ${wtResult.stderr}`);
450 }
451
452 // Write the patch to a temp file
453 const patchFile = join(tmpDir, "autofix.patch");
454 await writeFile(patchFile, patch, "utf8");
455
456 // Apply the patch
457 const applyResult = await spawnGit(
458 ["apply", "--index", patchFile],
459 tmpDir
460 );
461 if (applyResult.exitCode !== 0) {
462 throw new Error(`git apply failed: ${applyResult.stderr}`);
463 }
464
465 // 6. Commit
466 const commitResult = await spawnGit(
467 [
468 "commit",
469 "-m",
470 "fix: apply AI autofix for CI failure",
471 "--author",
472 "gluecron[bot] <bot@gluecron.com>",
473 ],
474 tmpDir
475 );
476 if (commitResult.exitCode !== 0) {
477 throw new Error(`git commit failed: ${commitResult.stderr}`);
478 }
479
480 // 7. Push the branch back to the bare repo
481 // In a bare-repo + worktree setup the push target is the bare repo itself.
482 await spawnGit(
483 ["push", repoDir, `HEAD:refs/heads/${branchName}`],
484 tmpDir
485 );
486 } finally {
487 // Cleanup worktree
488 await spawnGit(["worktree", "remove", "--force", tmpDir], repoDir).catch(
489 () => {}
490 );
491 await rm(tmpDir, { recursive: true, force: true }).catch(() => {});
492 }
493
494 return { branchName };
495}
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/pattern-detector.ts+370−0View fileUnifiedSplit
1/**
2 * Proactive Pattern Recognition — detects when the same bug has been fixed
3 * multiple times and surfaces a warning on PR pages.
4 *
5 * Public surface:
6 * detectRecurringPatterns(repoId) — analyse last 90 days of fix commits
7 * and upsert findings into `recurring_patterns` with a 24h TTL.
8 * getPatternWarning(repoId, changedFiles) — return the highest-severity
9 * pattern whose suggestedFile overlaps with changedFiles, or null.
10 *
11 * Both functions are fire-and-forget safe and never throw.
12 */
13
14import { and, eq, gt, desc } from "drizzle-orm";
15import { db } from "../db";
16import { repositories, recurringPatterns, users } from "../db/schema";
17import { getRepoPath } from "../git/repository";
18import {
19 getAnthropic,
20 isAiAvailable,
21 MODEL_SONNET,
22 extractText,
23 parseJsonResponse,
24} from "./ai-client";
25
26// ---------------------------------------------------------------------------
27// Types
28// ---------------------------------------------------------------------------
29
30export interface Pattern {
31 id?: string;
32 title: string;
33 occurrences: number;
34 commits: string[];
35 rootCauseHypothesis: string | null;
36 suggestedFile: string | null;
37 severity: "high" | "medium" | "low";
38}
39
40interface ClaudePatternResponse {
41 title: string;
42 occurrences: number;
43 commits: string[];
44 rootCauseHypothesis: string;
45 suggestedFile: string;
46 severity: "high" | "medium" | "low";
47}
48
49// ---------------------------------------------------------------------------
50// In-memory cache (per-repo, 24h TTL)
51// ---------------------------------------------------------------------------
52
53interface CacheEntry {
54 patterns: Pattern[];
55 expiresAt: number; // epoch ms
56}
57
58const _cache = new Map<string, CacheEntry>();
59
60function getCached(repoId: string): Pattern[] | null {
61 const entry = _cache.get(repoId);
62 if (!entry) return null;
63 if (Date.now() > entry.expiresAt) {
64 _cache.delete(repoId);
65 return null;
66 }
67 return entry.patterns;
68}
69
70function setCached(repoId: string, patterns: Pattern[]): void {
71 _cache.set(repoId, {
72 patterns,
73 expiresAt: Date.now() + 24 * 60 * 60 * 1000,
74 });
75}
76
77// ---------------------------------------------------------------------------
78// Helpers
79// ---------------------------------------------------------------------------
80
81async function spawnGit(
82 args: string[],
83 cwd: string
84): Promise<{ stdout: string; stderr: string; exitCode: number }> {
85 const proc = Bun.spawn(["git", ...args], {
86 cwd,
87 stdout: "pipe",
88 stderr: "pipe",
89 });
90 const [stdout, stderr] = await Promise.all([
91 new Response(proc.stdout).text(),
92 new Response(proc.stderr).text(),
93 ]);
94 const exitCode = await proc.exited;
95 return { stdout, stderr, exitCode };
96}
97
98function truncate(s: string, maxBytes: number): string {
99 const buf = Buffer.from(s, "utf8");
100 if (buf.length <= maxBytes) return s;
101 return buf.slice(0, maxBytes).toString("utf8") + "\n[truncated]";
102}
103
104const FIX_KEYWORDS = /\b(fix|bug|patch|revert|hotfix|fixes|fixed|bugfix)\b/i;
105const MAX_FIX_COMMITS = 30;
106const MAX_DIFF_BYTES_PER_COMMIT = 2 * 1024;
107
108// ---------------------------------------------------------------------------
109// Core detection
110// ---------------------------------------------------------------------------
111
112/**
113 * Analyse the last 90 days of commits in a repo. Finds commits whose messages
114 * suggest a bug fix, gets their diffs, and asks Claude to identify recurring
115 * patterns. Results are cached in-memory for 24h AND persisted to the
116 * `recurring_patterns` table.
117 */
118export async function detectRecurringPatterns(
119 repoId: string
120): Promise<Pattern[]> {
121 if (!isAiAvailable()) return [];
122
123 // Check in-memory cache first
124 const cached = getCached(repoId);
125 if (cached) return cached;
126
127 // Check DB cache (another instance may have already run this recently)
128 const now = new Date();
129 const dbCached = await db
130 .select()
131 .from(recurringPatterns)
132 .where(
133 and(
134 eq(recurringPatterns.repositoryId, repoId),
135 gt(recurringPatterns.expiresAt, now)
136 )
137 )
138 .orderBy(desc(recurringPatterns.detectedAt))
139 .limit(5);
140
141 if (dbCached.length > 0) {
142 const patterns: Pattern[] = dbCached.map((row) => ({
143 id: row.id,
144 title: row.title,
145 occurrences: row.occurrences,
146 commits: (row.commitShas as string[]) ?? [],
147 rootCauseHypothesis: row.rootCauseHypothesis,
148 suggestedFile: row.suggestedFile,
149 severity: row.severity as "high" | "medium" | "low",
150 }));
151 setCached(repoId, patterns);
152 return patterns;
153 }
154
155 try {
156 return await _runDetection(repoId);
157 } catch (err) {
158 console.error(
159 "[pattern-detector] crashed:",
160 err instanceof Error ? err.message : err
161 );
162 return [];
163 }
164}
165
166async function _runDetection(repoId: string): Promise<Pattern[]> {
167 // Resolve repo owner/name for getRepoPath
168 const [repoRow] = await db
169 .select({
170 id: repositories.id,
171 name: repositories.name,
172 ownerUsername: users.username,
173 })
174 .from(repositories)
175 .innerJoin(users, eq(repositories.ownerId, users.id))
176 .where(eq(repositories.id, repoId))
177 .limit(1);
178
179 if (!repoRow) return [];
180
181 const repoDir = getRepoPath(repoRow.ownerUsername, repoRow.name);
182
183 // 1. Query last 90 days of commits (SHA + message, one line each)
184 const logResult = await spawnGit(
185 ["log", "--oneline", '--since=90 days ago', "--format=%H %s"],
186 repoDir
187 );
188
189 if (logResult.exitCode !== 0 || !logResult.stdout.trim()) return [];
190
191 const allCommits = logResult.stdout.trim().split("\n");
192
193 // 2. Filter to fix-related commits
194 const fixCommits = allCommits
195 .filter((line) => {
196 const [, ...msgParts] = line.split(" ");
197 return FIX_KEYWORDS.test(msgParts.join(" "));
198 })
199 .slice(0, MAX_FIX_COMMITS);
200
201 if (fixCommits.length < 2) return []; // Not enough data to detect patterns
202
203 // 3. Get diffs for fix commits
204 const commitBlocks: string[] = [];
205 for (const line of fixCommits) {
206 const [sha, ...msgParts] = line.split(" ");
207 const msg = msgParts.join(" ");
208 const diffResult = await spawnGit(
209 ["show", "--stat", "--format=", sha],
210 repoDir
211 );
212 const diff = truncate(diffResult.stdout, MAX_DIFF_BYTES_PER_COMMIT);
213 commitBlocks.push(`Commit ${sha.slice(0, 7)}: ${msg}\n${diff}`);
214 }
215
216 const commitsText = commitBlocks.join("\n\n---\n\n");
217
218 // 4. Call Claude Sonnet 4.6
219 const client = getAnthropic();
220 const prompt = `Analyze these bug-fix commits from a codebase. Identify recurring patterns — bugs that have been fixed multiple times, suggesting a deeper root cause.
221
222Commits:
223${commitsText}
224
225Return JSON array (max 5 patterns):
226[{
227 "title": "Session token not refreshed after password change",
228 "occurrences": 3,
229 "commits": ["abc123", "def456"],
230 "rootCauseHypothesis": "The auth middleware caches tokens without invalidation",
231 "suggestedFile": "src/lib/auth.ts",
232 "severity": "high|medium|low"
233}]
234
235If you cannot identify any recurring patterns, return an empty array [].`;
236
237 const message = await client.messages.create({
238 model: MODEL_SONNET,
239 max_tokens: 2048,
240 messages: [{ role: "user", content: prompt }],
241 });
242
243 const rawText = extractText(message);
244 const parsed = parseJsonResponse<ClaudePatternResponse[]>(rawText);
245
246 if (!parsed || !Array.isArray(parsed) || parsed.length === 0) return [];
247
248 // 5. Persist to DB with 24h TTL
249 const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000);
250
251 // Delete stale entries for this repo first
252 await db
253 .delete(recurringPatterns)
254 .where(eq(recurringPatterns.repositoryId, repoId));
255
256 const inserted: Pattern[] = [];
257 for (const p of parsed.slice(0, 5)) {
258 if (!p.title || typeof p.occurrences !== "number") continue;
259 const [row] = await db
260 .insert(recurringPatterns)
261 .values({
262 repositoryId: repoId,
263 title: p.title,
264 occurrences: p.occurrences,
265 commitShas: p.commits ?? [],
266 rootCauseHypothesis: p.rootCauseHypothesis || null,
267 suggestedFile: p.suggestedFile || null,
268 severity: p.severity ?? "medium",
269 expiresAt,
270 })
271 .returning();
272
273 if (row) {
274 inserted.push({
275 id: row.id,
276 title: row.title,
277 occurrences: row.occurrences,
278 commits: (row.commitShas as string[]) ?? [],
279 rootCauseHypothesis: row.rootCauseHypothesis,
280 suggestedFile: row.suggestedFile,
281 severity: row.severity as "high" | "medium" | "low",
282 });
283 }
284 }
285
286 setCached(repoId, inserted);
287 return inserted;
288}
289
290// ---------------------------------------------------------------------------
291// Pattern warning for PR pages
292// ---------------------------------------------------------------------------
293
294const SEVERITY_RANK: Record<string, number> = {
295 high: 3,
296 medium: 2,
297 low: 1,
298};
299
300/**
301 * Returns the highest-severity pattern whose suggestedFile overlaps with
302 * the list of changed files in a PR. Returns null if no overlap is found.
303 *
304 * This is designed to be called during PR page load — it hits the in-memory
305 * cache first, then the DB cache, and only triggers a full AI run if the
306 * cache is cold (which is rare for active repos).
307 */
308export async function getPatternWarning(
309 repoId: string,
310 changedFiles: string[]
311): Promise<Pattern | null> {
312 if (!isAiAvailable()) return null;
313 if (changedFiles.length === 0) return null;
314
315 let patterns = getCached(repoId);
316
317 if (!patterns) {
318 // Try DB cache (non-blocking best-effort)
319 try {
320 const now = new Date();
321 const rows = await db
322 .select()
323 .from(recurringPatterns)
324 .where(
325 and(
326 eq(recurringPatterns.repositoryId, repoId),
327 gt(recurringPatterns.expiresAt, now)
328 )
329 )
330 .limit(5);
331
332 if (rows.length > 0) {
333 patterns = rows.map((row) => ({
334 id: row.id,
335 title: row.title,
336 occurrences: row.occurrences,
337 commits: (row.commitShas as string[]) ?? [],
338 rootCauseHypothesis: row.rootCauseHypothesis,
339 suggestedFile: row.suggestedFile,
340 severity: row.severity as "high" | "medium" | "low",
341 }));
342 setCached(repoId, patterns);
343 } else {
344 // Cache is cold — trigger background detection, return null now
345 detectRecurringPatterns(repoId).catch(() => {});
346 return null;
347 }
348 } catch {
349 return null;
350 }
351 }
352
353 // Find the highest-severity pattern that overlaps with changed files
354 const matched = patterns
355 .filter((p) => {
356 if (!p.suggestedFile) return false;
357 return changedFiles.some(
358 (f) =>
359 f === p.suggestedFile ||
360 f.endsWith(p.suggestedFile!) ||
361 p.suggestedFile!.endsWith(f)
362 );
363 })
364 .sort(
365 (a, b) =>
366 (SEVERITY_RANK[b.severity] ?? 0) - (SEVERITY_RANK[a.severity] ?? 0)
367 );
368
369 return matched[0] ?? null;
370}
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/pr-splitter.ts+214−0View fileUnifiedSplit
1/**
2 * PR Split Suggestions — AI-powered guidance for decomposing large PRs.
3 *
4 * When a PR has >400 lines changed, Claude Sonnet is asked to suggest how
5 * to split it into 2-4 smaller, independently-mergeable PRs grouped by
6 * logical concern (schema / API / UI / tests etc.).
7 *
8 * Results are cached in memory for 1 hour per PR — the AI call is expensive
9 * and the diff doesn't change between page loads.
10 *
11 * Returns `null` when:
12 * - PR has <=400 changed lines
13 * - AI is unavailable (no ANTHROPIC_API_KEY)
14 * - Claude returns fewer than 2 suggestions
15 * - Any error occurs (always degrades gracefully)
16 */
17
18import { getAnthropic, MODEL_SONNET, extractText, parseJsonResponse, isAiAvailable } from "./ai-client";
19import { join } from "path";
20import { config } from "./config";
21
22// ---------------------------------------------------------------------------
23// Public types
24// ---------------------------------------------------------------------------
25
26export interface SplitPr {
27 title: string;
28 rationale: string;
29 files: string[];
30 estimatedLines: number;
31 suggestedBranch: string;
32}
33
34export interface SplitSuggestion {
35 originalPrTitle: string;
36 totalFiles: number;
37 totalLines: number;
38 suggestedPrs: SplitPr[];
39 mergeOrder: string[];
40}
41
42// ---------------------------------------------------------------------------
43// In-memory cache (1h TTL per prId)
44// ---------------------------------------------------------------------------
45
46interface CacheEntry {
47 suggestion: SplitSuggestion | null;
48 cachedAt: number;
49}
50
51const _cache = new Map<string, CacheEntry>();
52const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
53
54function getCached(prId: string): SplitSuggestion | null | undefined {
55 const entry = _cache.get(prId);
56 if (!entry) return undefined; // cache miss
57 if (Date.now() - entry.cachedAt > CACHE_TTL_MS) {
58 _cache.delete(prId);
59 return undefined;
60 }
61 return entry.suggestion;
62}
63
64function setCached(prId: string, suggestion: SplitSuggestion | null): void {
65 _cache.set(prId, { suggestion, cachedAt: Date.now() });
66}
67
68// ---------------------------------------------------------------------------
69// Diff stat parsing
70// ---------------------------------------------------------------------------
71
72interface FileStatLine {
73 path: string;
74 added: number;
75 deleted: number;
76 total: number;
77}
78
79function parseNumstat(raw: string): FileStatLine[] {
80 return raw
81 .trim()
82 .split("\n")
83 .filter(Boolean)
84 .map((line) => {
85 const parts = line.split("\t");
86 if (parts.length < 3) return null;
87 const added = parts[0] === "-" ? 0 : parseInt(parts[0], 10) || 0;
88 const deleted = parts[1] === "-" ? 0 : parseInt(parts[1], 10) || 0;
89 return { path: parts[2], added, deleted, total: added + deleted };
90 })
91 .filter((x): x is FileStatLine => x !== null);
92}
93
94function getRepoDir(owner: string, repo: string): string {
95 return join(config.gitReposPath, `${owner}/${repo}.git`);
96}
97
98async function getDiffStat(
99 owner: string,
100 repo: string,
101 baseBranch: string,
102 headBranch: string
103): Promise<FileStatLine[]> {
104 const repoDir = getRepoDir(owner, repo);
105 const proc = Bun.spawn(
106 ["git", "--git-dir", repoDir, "diff", "--numstat", `${baseBranch}...${headBranch}`],
107 { stdout: "pipe", stderr: "pipe" }
108 );
109 const raw = await new Response(proc.stdout).text();
110 await proc.exited;
111 return parseNumstat(raw);
112}
113
114// ---------------------------------------------------------------------------
115// Public API
116// ---------------------------------------------------------------------------
117
118/**
119 * Suggest how to split a large PR.
120 * Returns null when the PR is small, AI is unavailable, or any error occurs.
121 */
122export async function suggestPrSplit(
123 prId: string,
124 prTitle: string,
125 ownerName: string,
126 repoName: string,
127 baseBranch: string,
128 headBranch: string
129): Promise<SplitSuggestion | null> {
130 // Check cache first
131 const cached = getCached(prId);
132 if (cached !== undefined) return cached;
133
134 try {
135 const fileStats = await getDiffStat(ownerName, repoName, baseBranch, headBranch);
136 const totalLines = fileStats.reduce((s, f) => s + f.total, 0);
137 const totalFiles = fileStats.length;
138
139 if (totalLines < 400) {
140 setCached(prId, null);
141 return null;
142 }
143
144 if (!isAiAvailable()) {
145 setCached(prId, null);
146 return null;
147 }
148
149 const fileList = fileStats
150 .map((f) => `${f.path} +${f.added} -${f.deleted}`)
151 .join("\n");
152
153 const prompt = `This PR is too large to review effectively (${totalLines} lines across ${totalFiles} files).
154Suggest how to split it into 2-4 smaller PRs that can be reviewed and merged independently.
155
156PR title: ${prTitle}
157Files changed:
158${fileList}
159
160Return JSON with this exact shape (no extra keys, no prose outside the JSON block):
161{
162 "suggestedPrs": [
163 {
164 "title": "...",
165 "rationale": "...",
166 "files": ["..."],
167 "estimatedLines": N,
168 "suggestedBranch": "..."
169 }
170 ],
171 "mergeOrder": ["PR title 1", "PR title 2"]
172}
173
174Rules:
175- Group by logical concern (schema changes together, API layer together, UI together)
176- Each suggested PR should be independently mergeable
177- Suggest merge order to minimise conflicts
178- suggestedBranch should be kebab-case derived from the PR title (e.g. feat/auth-schema-only)
179- Return between 2 and 4 suggested PRs`;
180
181 const anthropic = getAnthropic();
182 const message = await anthropic.messages.create({
183 model: MODEL_SONNET,
184 max_tokens: 1024,
185 messages: [{ role: "user", content: prompt }],
186 });
187
188 const text = extractText(message);
189 const parsed = parseJsonResponse<{
190 suggestedPrs: SplitPr[];
191 mergeOrder: string[];
192 }>(text);
193
194 if (!parsed || !Array.isArray(parsed.suggestedPrs) || parsed.suggestedPrs.length < 2) {
195 setCached(prId, null);
196 return null;
197 }
198
199 const suggestion: SplitSuggestion = {
200 originalPrTitle: prTitle,
201 totalFiles,
202 totalLines,
203 suggestedPrs: parsed.suggestedPrs,
204 mergeOrder: Array.isArray(parsed.mergeOrder) ? parsed.mergeOrder : [],
205 };
206
207 setCached(prId, suggestion);
208 return suggestion;
209 } catch {
210 // Always degrade gracefully
211 setCached(prId, null);
212 return null;
213 }
214}
Addedsrc/lib/repo-onboarding.ts+492−0View fileUnifiedSplit
1/**
2 * Smart empty states — repo onboarding generation.
3 *
4 * generateRepoOnboarding() — analyse a repo's file tree + detect language/
5 * framework, then call Claude Sonnet to produce a
6 * README draft, suggested labels, a gates.yml
7 * starter, and first-commit suggestions.
8 *
9 * ensureRepoOnboarding() — idempotent wrapper called on first push;
10 * skips if onboarding_shown is already set OR if
11 * the repo_onboarding_data row already exists.
12 *
13 * All external calls are swallowed — a missing AI key or DB error must never
14 * break the push path.
15 */
16
17import { eq } from "drizzle-orm";
18import { db } from "../db";
19import { repositories, repoOnboardingData } from "../db/schema";
20import { getAnthropic, isAiAvailable, MODEL_SONNET, extractText, parseJsonResponse } from "./ai-client";
21import { getRepoPath } from "../git/repository";
22
23// ---------------------------------------------------------------------------
24// Types
25// ---------------------------------------------------------------------------
26
27export interface RepoOnboarding {
28 detectedLanguage: string;
29 detectedFramework?: string;
30 suggestedReadme: string;
31 suggestedLabels: Array<{ name: string; color: string; description: string }>;
32 suggestedGatesConfig: string;
33 firstCommitSuggestions: string[];
34}
35
36// ---------------------------------------------------------------------------
37// Language + framework detection helpers
38// ---------------------------------------------------------------------------
39
40function detectLanguage(files: string[]): string {
41 const counts: Record<string, number> = {};
42 for (const f of files) {
43 const ext = f.split(".").pop()?.toLowerCase() ?? "";
44 if (ext === "ts" || ext === "tsx") counts.TypeScript = (counts.TypeScript ?? 0) + 1;
45 else if (ext === "js" || ext === "jsx" || ext === "mjs" || ext === "cjs") counts.JavaScript = (counts.JavaScript ?? 0) + 1;
46 else if (ext === "py") counts.Python = (counts.Python ?? 0) + 1;
47 else if (ext === "go") counts.Go = (counts.Go ?? 0) + 1;
48 else if (ext === "rs") counts.Rust = (counts.Rust ?? 0) + 1;
49 else if (ext === "java") counts.Java = (counts.Java ?? 0) + 1;
50 else if (ext === "rb") counts.Ruby = (counts.Ruby ?? 0) + 1;
51 else if (ext === "php") counts.PHP = (counts.PHP ?? 0) + 1;
52 else if (ext === "cs") counts["C#"] = (counts["C#"] ?? 0) + 1;
53 else if (ext === "cpp" || ext === "cc" || ext === "cxx") counts["C++"] = (counts["C++"] ?? 0) + 1;
54 else if (ext === "c" || ext === "h") counts.C = (counts.C ?? 0) + 1;
55 else if (ext === "swift") counts.Swift = (counts.Swift ?? 0) + 1;
56 else if (ext === "kt") counts.Kotlin = (counts.Kotlin ?? 0) + 1;
57 }
58 const sorted = Object.entries(counts).sort((a, b) => b[1] - a[1]);
59 return sorted[0]?.[0] ?? "Unknown";
60}
61
62async function detectFramework(
63 ownerName: string,
64 repoName: string,
65 files: string[]
66): Promise<string | undefined> {
67 const fileSet = new Set(files.map((f) => f.toLowerCase()));
68
69 // Check next.config.* — Next.js
70 if (files.some((f) => /next\.config\.(js|ts|mjs|cjs)/.test(f))) return "Next.js";
71
72 // Check Cargo.toml for Actix / Axum / Warp
73 if (fileSet.has("cargo.toml")) {
74 const content = await readFileFromGit(ownerName, repoName, "Cargo.toml");
75 if (content) {
76 if (/actix-web/.test(content)) return "Actix";
77 if (/axum/.test(content)) return "Axum";
78 if (/warp/.test(content)) return "Warp";
79 }
80 }
81
82 // Check requirements.txt for FastAPI / Django / Flask
83 if (fileSet.has("requirements.txt")) {
84 const content = await readFileFromGit(ownerName, repoName, "requirements.txt");
85 if (content) {
86 if (/fastapi/i.test(content)) return "FastAPI";
87 if (/django/i.test(content)) return "Django";
88 if (/flask/i.test(content)) return "Flask";
89 }
90 }
91
92 // Check package.json for various JS frameworks
93 if (fileSet.has("package.json")) {
94 const content = await readFileFromGit(ownerName, repoName, "package.json");
95 if (content) {
96 try {
97 const pkg = JSON.parse(content) as Record<string, unknown>;
98 const deps = {
99 ...((pkg.dependencies as Record<string, string>) ?? {}),
100 ...((pkg.devDependencies as Record<string, string>) ?? {}),
101 };
102 if ("hono" in deps) return "Hono";
103 if ("next" in deps) return "Next.js";
104 if ("express" in deps) return "Express";
105 if ("fastify" in deps) return "Fastify";
106 if ("@nestjs/core" in deps) return "NestJS";
107 if ("nuxt" in deps) return "Nuxt";
108 if ("svelte" in deps) return "SvelteKit";
109 if ("remix" in deps || "@remix-run/node" in deps) return "Remix";
110 if ("react" in deps) return "React";
111 if ("vue" in deps) return "Vue";
112 } catch {
113 /* ignore parse error */
114 }
115 }
116 }
117
118 // go.mod check
119 if (fileSet.has("go.mod")) {
120 const content = await readFileFromGit(ownerName, repoName, "go.mod");
121 if (content) {
122 if (/gin-gonic\/gin/.test(content)) return "Gin";
123 if (/labstack\/echo/.test(content)) return "Echo";
124 if (/gofiber\/fiber/.test(content)) return "Fiber";
125 }
126 }
127
128 return undefined;
129}
130
131// ---------------------------------------------------------------------------
132// Git helpers
133// ---------------------------------------------------------------------------
134
135async function listAllFiles(ownerName: string, repoName: string): Promise<string[]> {
136 const cwd = getRepoPath(ownerName, repoName);
137 try {
138 const proc = Bun.spawn(
139 ["git", "ls-tree", "-r", "--name-only", "HEAD"],
140 { cwd, stdout: "pipe", stderr: "pipe" }
141 );
142 const text = await new Response(proc.stdout).text();
143 await proc.exited;
144 return text.split("\n").map((s) => s.trim()).filter(Boolean);
145 } catch {
146 return [];
147 }
148}
149
150async function readFileFromGit(
151 ownerName: string,
152 repoName: string,
153 path: string
154): Promise<string | null> {
155 const cwd = getRepoPath(ownerName, repoName);
156 try {
157 const proc = Bun.spawn(
158 ["git", "show", `HEAD:${path}`],
159 { cwd, stdout: "pipe", stderr: "pipe" }
160 );
161 const text = await new Response(proc.stdout).text();
162 const exit = await proc.exited;
163 return exit === 0 ? text : null;
164 } catch {
165 return null;
166 }
167}
168
169// ---------------------------------------------------------------------------
170// Default labels by language (no AI required)
171// ---------------------------------------------------------------------------
172
173const DEFAULT_LABELS: Array<{ name: string; color: string; description: string }> = [
174 { name: "bug", color: "d73a4a", description: "Something isn't working" },
175 { name: "feature", color: "0075ca", description: "New feature or request" },
176 { name: "docs", color: "0052cc", description: "Improvements or additions to documentation" },
177 { name: "performance", color: "e4e669", description: "Performance improvement" },
178 { name: "security", color: "e11d48", description: "Security vulnerability or fix" },
179 { name: "breaking-change", color: "b60205", description: "Breaking change that requires version bump" },
180];
181
182const LANGUAGE_LABELS: Record<string, Array<{ name: string; color: string; description: string }>> = {
183 TypeScript: [
184 { name: "types", color: "3178c6", description: "TypeScript type definitions or fixes" },
185 { name: "deps", color: "0366d6", description: "Dependency update" },
186 ],
187 Python: [
188 { name: "pep8", color: "ffd43b", description: "Code style: PEP 8 compliance" },
189 { name: "deps", color: "0366d6", description: "Dependency update" },
190 ],
191 Go: [
192 { name: "goroutine", color: "00add8", description: "Concurrency / goroutine related" },
193 { name: "deps", color: "0366d6", description: "Go module dependency" },
194 ],
195 Rust: [
196 { name: "unsafe", color: "e83e8c", description: "Involves unsafe Rust code" },
197 { name: "deps", color: "0366d6", description: "Cargo dependency" },
198 ],
199};
200
201function buildDefaultLabels(
202 language: string
203): Array<{ name: string; color: string; description: string }> {
204 const extras = LANGUAGE_LABELS[language] ?? [];
205 return [...DEFAULT_LABELS, ...extras];
206}
207
208// ---------------------------------------------------------------------------
209// gates.yml starters
210// ---------------------------------------------------------------------------
211
212function buildDefaultGatesConfig(language: string, framework?: string): string {
213 const lang = language.toLowerCase();
214 if (lang === "typescript" || lang === "javascript") {
215 return `version: 1
216gates:
217 - id: type-check
218 run: npx tsc --noEmit
219 on: push
220 - id: lint
221 run: npx eslint . --ext .ts,.tsx,.js,.jsx
222 on: push
223 - id: test
224 run: ${framework === "Hono" || lang === "typescript" ? "bun test" : "npm test"}
225 on: push
226`;
227 }
228 if (lang === "python") {
229 return `version: 1
230gates:
231 - id: lint
232 run: ruff check .
233 on: push
234 - id: type-check
235 run: mypy .
236 on: push
237 - id: test
238 run: pytest
239 on: push
240`;
241 }
242 if (lang === "go") {
243 return `version: 1
244gates:
245 - id: vet
246 run: go vet ./...
247 on: push
248 - id: test
249 run: go test ./...
250 on: push
251`;
252 }
253 if (lang === "rust") {
254 return `version: 1
255gates:
256 - id: check
257 run: cargo check
258 on: push
259 - id: clippy
260 run: cargo clippy -- -D warnings
261 on: push
262 - id: test
263 run: cargo test
264 on: push
265`;
266 }
267 return `version: 1
268gates:
269 - id: test
270 run: echo "Add your test command here"
271 on: push
272`;
273}
274
275// ---------------------------------------------------------------------------
276// First-commit suggestions
277// ---------------------------------------------------------------------------
278
279function buildFirstCommitSuggestions(language: string): string[] {
280 const lang = language.toLowerCase();
281 const suggestions = [`Add a .gitignore for ${language}`];
282 if (lang === "typescript" || lang === "javascript") {
283 suggestions.push("Add a tsconfig.json");
284 suggestions.push("Add ESLint + Prettier config");
285 suggestions.push("Add a README.md");
286 suggestions.push("Add tests/ directory with a sample test");
287 } else if (lang === "python") {
288 suggestions.push("Add requirements.txt or pyproject.toml");
289 suggestions.push("Add a README.md");
290 suggestions.push("Add tests/ directory with pytest");
291 } else if (lang === "go") {
292 suggestions.push("Initialize go.mod (go mod init)");
293 suggestions.push("Add a README.md");
294 suggestions.push("Add *_test.go files");
295 } else if (lang === "rust") {
296 suggestions.push("Cargo.toml is auto-generated — add a README.md");
297 suggestions.push("Add integration tests in tests/");
298 } else {
299 suggestions.push("Add a README.md");
300 suggestions.push("Add tests/");
301 }
302 return suggestions;
303}
304
305// ---------------------------------------------------------------------------
306// Main generation function
307// ---------------------------------------------------------------------------
308
309export async function generateRepoOnboarding(
310 repoId: string,
311 ownerName: string,
312 repoName: string
313): Promise<RepoOnboarding> {
314 const files = await listAllFiles(ownerName, repoName);
315 const language = detectLanguage(files);
316 const framework = await detectFramework(ownerName, repoName, files);
317
318 const existingReadme =
319 (await readFileFromGit(ownerName, repoName, "README.md")) ||
320 (await readFileFromGit(ownerName, repoName, "readme.md")) ||
321 (await readFileFromGit(ownerName, repoName, "README.txt")) ||
322 null;
323
324 const defaultLabels = buildDefaultLabels(language);
325 const defaultGatesConfig = buildDefaultGatesConfig(language, framework);
326 const defaultSuggestions = buildFirstCommitSuggestions(language);
327
328 // Without AI: return sensible defaults immediately
329 if (!isAiAvailable()) {
330 const readmeDraft = existingReadme ?? buildFallbackReadme(repoName, language, framework);
331 return {
332 detectedLanguage: language,
333 detectedFramework: framework,
334 suggestedReadme: readmeDraft,
335 suggestedLabels: defaultLabels,
336 suggestedGatesConfig: defaultGatesConfig,
337 firstCommitSuggestions: defaultSuggestions,
338 };
339 }
340
341 // With AI: call Claude Sonnet 4.6 for richer content
342 try {
343 const fileTree = files.slice(0, 120).join("\n");
344 const readmeContext = existingReadme
345 ? existingReadme.slice(0, 2000)
346 : "(none)";
347
348 const prompt = `Generate onboarding content for a new ${language}${framework ? ` ${framework}` : ""} repository named "${repoName}".
349
350File tree (if any):
351${fileTree || "(empty — no commits yet)"}
352
353Existing README (if any):
354${readmeContext}
355
356Return ONLY valid JSON (no prose, no fenced block) with this exact shape:
357{
358 "suggestedReadme": "# ${repoName}\\n\\n...",
359 "suggestedLabels": [{"name": "bug", "color": "d73a4a", "description": "Something isn't working"}, ...],
360 "suggestedGatesConfig": "version: 1\\ngates:\\n ...",
361 "firstCommitSuggestions": ["Add a .gitignore for ${language}", ...]
362}
363
364Rules:
365- suggestedReadme must include ## About, ## Installation, ## Usage, ## Contributing sections. Use placeholder content where specifics are unknown.
366- suggestedLabels: 6-8 labels appropriate for this type of project. Include bug, feature, docs, security, breaking-change and 1-3 language-specific ones.
367- suggestedGatesConfig: a real gates.yml starter for ${language}${framework ? ` / ${framework}` : ""} with lint + test gates.
368- firstCommitSuggestions: 3-5 practical next steps for this language/framework.`;
369
370 const anthropic = getAnthropic();
371 const message = await anthropic.messages.create({
372 model: MODEL_SONNET,
373 max_tokens: 2048,
374 messages: [{ role: "user", content: prompt }],
375 });
376
377 const text = extractText(message);
378 const parsed = parseJsonResponse<{
379 suggestedReadme?: string;
380 suggestedLabels?: Array<{ name: string; color: string; description: string }>;
381 suggestedGatesConfig?: string;
382 firstCommitSuggestions?: string[];
383 }>(text);
384
385 if (parsed) {
386 return {
387 detectedLanguage: language,
388 detectedFramework: framework,
389 suggestedReadme: parsed.suggestedReadme ?? buildFallbackReadme(repoName, language, framework),
390 suggestedLabels: parsed.suggestedLabels ?? defaultLabels,
391 suggestedGatesConfig: parsed.suggestedGatesConfig ?? defaultGatesConfig,
392 firstCommitSuggestions: parsed.firstCommitSuggestions ?? defaultSuggestions,
393 };
394 }
395 } catch (err) {
396 console.warn("[repo-onboarding] AI generation failed:", err instanceof Error ? err.message : err);
397 }
398
399 // AI failed — fall back to defaults
400 return {
401 detectedLanguage: language,
402 detectedFramework: framework,
403 suggestedReadme: existingReadme ?? buildFallbackReadme(repoName, language, framework),
404 suggestedLabels: defaultLabels,
405 suggestedGatesConfig: defaultGatesConfig,
406 firstCommitSuggestions: defaultSuggestions,
407 };
408}
409
410function buildFallbackReadme(repoName: string, language: string, framework?: string): string {
411 const stack = framework ? `${language} / ${framework}` : language;
412 return `# ${repoName}
413
414> A ${stack} project.
415
416## About
417
418TODO: Describe what this project does and why it exists.
419
420## Installation
421
422\`\`\`bash
423# TODO: Add installation steps
424\`\`\`
425
426## Usage
427
428\`\`\`bash
429# TODO: Add usage examples
430\`\`\`
431
432## Contributing
433
4341. Fork the repository
4352. Create a feature branch (\`git checkout -b feat/my-feature\`)
4363. Commit your changes
4374. Push and open a pull request
438
439## License
440
441TODO: Add license
442`;
443}
444
445// ---------------------------------------------------------------------------
446// Idempotent store / retrieve
447// ---------------------------------------------------------------------------
448
449/**
450 * ensureRepoOnboarding — called fire-and-forget on first push.
451 * Generates onboarding data and stores it if not already present.
452 * Never throws.
453 */
454export async function ensureRepoOnboarding(
455 repoId: string,
456 ownerName: string,
457 repoName: string
458): Promise<void> {
459 try {
460 // Check if already generated
461 const [existing] = await db
462 .select({ repositoryId: repoOnboardingData.repositoryId })
463 .from(repoOnboardingData)
464 .where(eq(repoOnboardingData.repositoryId, repoId))
465 .limit(1);
466 if (existing) return; // already generated
467
468 const onboarding = await generateRepoOnboarding(repoId, ownerName, repoName);
469
470 await db
471 .insert(repoOnboardingData)
472 .values({
473 repositoryId: repoId,
474 detectedLanguage: onboarding.detectedLanguage,
475 detectedFramework: onboarding.detectedFramework ?? null,
476 suggestedReadme: onboarding.suggestedReadme,
477 suggestedLabels: onboarding.suggestedLabels,
478 suggestedGatesConfig: onboarding.suggestedGatesConfig,
479 firstCommitSuggestions: onboarding.firstCommitSuggestions,
480 })
481 .onConflictDoNothing();
482
483 console.log(
484 `[repo-onboarding] generated for ${ownerName}/${repoName}: ${onboarding.detectedLanguage}${onboarding.detectedFramework ? ` / ${onboarding.detectedFramework}` : ""}`
485 );
486 } catch (err) {
487 console.warn(
488 `[repo-onboarding] ensureRepoOnboarding failed for ${ownerName}/${repoName}:`,
489 err instanceof Error ? err.message : err
490 );
491 }
492}
Addedsrc/lib/review-context.ts+293−0View fileUnifiedSplit
1/**
2 * Context Restore for Stale Reviews.
3 *
4 * When a user opens a PR they previously reviewed (or started reviewing),
5 * this module provides an AI-generated summary of what changed since their
6 * last visit and where they left off. Shown as a dismissible banner on the
7 * PR detail page.
8 *
9 * Tracks visits via the `pr_visits` table (migration 0088). On each PR page
10 * load we upsert the visit timestamp so the next visit can compute the delta.
11 *
12 * The AI summary is only generated when:
13 * - The user has visited this PR before (non-first-visit)
14 * - The last visit was >4 hours ago
15 * - There are commits or new comments since the last visit
16 *
17 * Never throws — all paths are wrapped in try/catch.
18 */
19
20import { and, eq, gt, sql } from "drizzle-orm";
21import { db } from "../db";
22import { prComments, prVisits } from "../db/schema";
23import {
24 getAnthropic,
25 isAiAvailable,
26 MODEL_SONNET,
27 extractText,
28} from "./ai-client";
29import { commitsBetween } from "../git/repository";
30
31// ---------------------------------------------------------------------------
32// Public interface
33// ---------------------------------------------------------------------------
34
35export interface ReviewContext {
36 /** ISO string of the user's last visit */
37 lastVisitedAt: string;
38 /** Number of commits pushed since last visit */
39 commitsSince: number;
40 /** Number of new comments since last visit */
41 newComments: number;
42 /** Number of comments with unresolved threads */
43 unresolvedThreads: number;
44 /** AI-written 2-3 sentence summary of what changed */
45 summary: string;
46 /** Optional file:line hint for where to start reviewing */
47 suggestedStartLine?: string;
48}
49
50/** Minimum hours since last visit before we show the context banner. */
51const MIN_STALENESS_HOURS = 4;
52
53// ---------------------------------------------------------------------------
54// Visit tracking
55// ---------------------------------------------------------------------------
56
57/**
58 * Record (or refresh) the user's visit to this PR. Call on every PR page load
59 * for authenticated users. Uses an upsert so the row always reflects the
60 * most recent visit.
61 *
62 * Never throws.
63 */
64export async function recordPrVisit(
65 prId: string,
66 userId: string
67): Promise<void> {
68 try {
69 await db
70 .insert(prVisits)
71 .values({ prId, userId, visitedAt: new Date() })
72 .onConflictDoUpdate({
73 target: [prVisits.prId, prVisits.userId],
74 set: { visitedAt: new Date() },
75 });
76 } catch (err) {
77 console.error("[review-context] recordPrVisit error:", err);
78 }
79}
80
81// ---------------------------------------------------------------------------
82// Context computation
83// ---------------------------------------------------------------------------
84
85/**
86 * Compute context for a returning visitor. Returns null when:
87 * - No previous visit exists (first-time visit)
88 * - Visit was too recent (<4h ago)
89 * - No changes since last visit
90 * - AI unavailable AND no new comments (nothing to say)
91 *
92 * NOTE: Call `recordPrVisit` AFTER this function so the previous timestamp
93 * is still available when computing the delta.
94 */
95export async function getReviewContext(
96 prId: string,
97 userId: string,
98 opts?: {
99 ownerName?: string;
100 repoName?: string;
101 baseBranch?: string;
102 headBranch?: string;
103 }
104): Promise<ReviewContext | null> {
105 try {
106 // --- Look up previous visit ---
107 const [visit] = await db
108 .select()
109 .from(prVisits)
110 .where(and(eq(prVisits.prId, prId), eq(prVisits.userId, userId)))
111 .limit(1);
112
113 if (!visit) return null; // First visit
114
115 const lastVisitedAt = new Date(visit.visitedAt);
116 const hoursSince =
117 (Date.now() - lastVisitedAt.getTime()) / 3_600_000;
118
119 if (hoursSince < MIN_STALENESS_HOURS) return null; // Too recent
120
121 // --- Count new comments since last visit ---
122 let newComments = 0;
123 try {
124 const [countRow] = await db
125 .select({ count: sql<number>`count(*)::int` })
126 .from(prComments)
127 .where(
128 and(
129 eq(prComments.pullRequestId, prId),
130 gt(prComments.createdAt, lastVisitedAt)
131 )
132 );
133 newComments = countRow?.count || 0;
134 } catch {
135 /* non-fatal */
136 }
137
138 // --- Count commits since last visit (via git log) ---
139 let commitsSince = 0;
140 let changedFiles: string[] = [];
141 if (opts?.ownerName && opts?.repoName && opts?.baseBranch && opts?.headBranch) {
142 try {
143 const allCommits = await commitsBetween(
144 opts.ownerName,
145 opts.repoName,
146 opts.baseBranch,
147 opts.headBranch
148 );
149 const newCommits = allCommits.filter(
150 (c) => new Date(c.date) > lastVisitedAt
151 );
152 commitsSince = newCommits.length;
153 // Rough file list from commit messages (best-effort)
154 changedFiles = newCommits
155 .flatMap((c) => (c.message || "").split("\n"))
156 .filter((l) => l.startsWith("M\t") || l.startsWith("A\t") || l.startsWith("D\t"))
157 .map((l) => l.slice(2))
158 .filter(Boolean)
159 .slice(0, 5);
160 } catch {
161 /* swallow — git ops can fail */
162 }
163 }
164
165 // Nothing changed — skip the banner
166 if (commitsSince === 0 && newComments === 0) return null;
167
168 // --- AI summary (optional) ---
169 let summary = buildFallbackSummary(commitsSince, newComments, hoursSince);
170 let suggestedStartLine: string | undefined;
171
172 if (isAiAvailable() && (commitsSince > 0 || newComments > 0)) {
173 try {
174 const aiResult = await callClaudeForContext({
175 commitsSince,
176 newComments,
177 changedFiles,
178 hoursSince,
179 lastVisitedAt: lastVisitedAt.toISOString(),
180 });
181 if (aiResult) {
182 summary = aiResult.summary;
183 suggestedStartLine = aiResult.suggestedStartLine;
184 }
185 } catch (err) {
186 console.error("[review-context] Claude call failed:", err);
187 // Keep fallback summary
188 }
189 }
190
191 // Unresolved threads — comments without a resolution marker (heuristic)
192 const unresolvedThreads = newComments; // simple proxy for now
193
194 return {
195 lastVisitedAt: lastVisitedAt.toISOString(),
196 commitsSince,
197 newComments,
198 unresolvedThreads,
199 summary,
200 suggestedStartLine,
201 };
202 } catch (err) {
203 console.error("[review-context] getReviewContext error:", err);
204 return null;
205 }
206}
207
208// ---------------------------------------------------------------------------
209// Helpers
210// ---------------------------------------------------------------------------
211
212function buildFallbackSummary(
213 commitsSince: number,
214 newComments: number,
215 hoursSince: number
216): string {
217 const parts: string[] = [];
218 const hoursLabel =
219 hoursSince >= 24
220 ? `${Math.floor(hoursSince / 24)} day${Math.floor(hoursSince / 24) === 1 ? "" : "s"}`
221 : `${Math.round(hoursSince)} hour${Math.round(hoursSince) === 1 ? "" : "s"}`;
222
223 if (commitsSince > 0) {
224 parts.push(
225 `${commitsSince} new commit${commitsSince === 1 ? " was" : "s were"} pushed since your last visit ${hoursLabel} ago`
226 );
227 }
228 if (newComments > 0) {
229 parts.push(
230 `${newComments} new comment${newComments === 1 ? " was" : "s were"} added`
231 );
232 }
233 return parts.join(". ") + ".";
234}
235
236interface ClaudeContextResponse {
237 summary: string;
238 suggestedStartLine?: string;
239}
240
241async function callClaudeForContext(input: {
242 commitsSince: number;
243 newComments: number;
244 changedFiles: string[];
245 hoursSince: number;
246 lastVisitedAt: string;
247}): Promise<ClaudeContextResponse | null> {
248 const client = getAnthropic();
249 const hoursLabel =
250 input.hoursSince >= 24
251 ? `${Math.floor(input.hoursSince / 24)} day${Math.floor(input.hoursSince / 24) === 1 ? "" : "s"}`
252 : `${Math.round(input.hoursSince)} hours`;
253
254 const prompt = `A developer is returning to a pull request they last reviewed ${hoursLabel} ago.
255
256Changes since their last visit:
257- New commits pushed: ${input.commitsSince}
258- New comments added: ${input.newComments}
259${input.changedFiles.length > 0 ? `- Files changed: ${input.changedFiles.join(", ")}` : ""}
260
261Write a brief 2-3 sentence "welcome back" summary telling the reviewer what happened.
262If there are changed files, suggest one as the best starting point.
263
264Respond with JSON:
265{"summary": "...", "suggestedStartLine": "src/foo.ts:42 — reason (optional, omit if no files)"}
266
267Rules:
268- summary must be specific and human, not generic
269- No more than 3 sentences
270- If no changed files, omit suggestedStartLine
271- Return only valid JSON, no prose`;
272
273 const msg = await client.messages.create({
274 model: MODEL_SONNET,
275 max_tokens: 500,
276 messages: [{ role: "user", content: prompt }],
277 });
278
279 const text = extractText(msg);
280
281 // Extract JSON from response
282 try {
283 const jsonMatch = text.match(/\{[\s\S]*\}/);
284 if (jsonMatch) {
285 const parsed = JSON.parse(jsonMatch[0]) as ClaudeContextResponse;
286 if (typeof parsed.summary === "string") return parsed;
287 }
288 } catch {
289 /* fall through */
290 }
291
292 return null;
293}
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}
Addedsrc/lib/smart-digest.ts+589−0View fileUnifiedSplit
1/**
2 * Smart Morning Digest — AI-curated daily developer notification queue.
3 *
4 * Replaces notification spam with a single, prioritised digest delivered
5 * once per day via the in-app notification system AND surfaced on /digest.
6 *
7 * Data sources (last 48h / 24h where noted):
8 * 1. Unread notifications (48h)
9 * 2. Open PRs where the user is a requested reviewer
10 * 3. Open PRs authored by the user with unread comments
11 * 4. Failed gate runs for repos the user owns (24h)
12 * 5. Dependency-update PRs on user's repos
13 *
14 * Claude Sonnet 4.6 prioritises the items and writes the headline + insight.
15 * Never throws — every path is wrapped in try/catch.
16 */
17
18import { and, desc, eq, gte, inArray, lt, ne, sql } from "drizzle-orm";
19import { db } from "../db";
20import {
21 gateRuns,
22 issues,
23 notifications,
24 prComments,
25 prReviews,
26 pullRequests,
27 repositories,
28 users,
29} from "../db/schema";
30import {
31 getAnthropic,
32 isAiAvailable,
33 MODEL_SONNET,
34 extractText,
35 parseJsonResponse,
36} from "./ai-client";
37import { config } from "./config";
38
39// ---------------------------------------------------------------------------
40// Public interfaces
41// ---------------------------------------------------------------------------
42
43export interface DigestItem {
44 priority: "blocking" | "important" | "fyi";
45 type:
46 | "pr_review"
47 | "pr_comment"
48 | "ci_failure"
49 | "mention"
50 | "dep_update"
51 | "new_issue";
52 title: string;
53 /** e.g. "waiting 2 days · 3 files changed" */
54 subtitle: string;
55 url: string;
56 repoName: string;
57}
58
59export interface SmartDigest {
60 userId: string;
61 generatedAt: string;
62 headline: string;
63 queue: DigestItem[];
64 stats: {
65 prsReviewed: number;
66 issuesClosed: number;
67 commitsThisWeek: number;
68 };
69 insight?: string;
70}
71
72// ---------------------------------------------------------------------------
73// Internal data loader
74// ---------------------------------------------------------------------------
75
76interface RawItem {
77 type: DigestItem["type"];
78 title: string;
79 subtitle: string;
80 url: string;
81 repoName: string;
82 createdAt: Date;
83}
84
85async function loadRawItems(
86 userId: string,
87 now: Date
88): Promise<RawItem[]> {
89 const base = config.appBaseUrl || "https://gluecron.com";
90 const items: RawItem[] = [];
91
92 // --- 1. Unread notifications (last 48h) ---
93 const since48h = new Date(now.getTime() - 48 * 60 * 60 * 1000);
94 try {
95 const notifs = await db
96 .select()
97 .from(notifications)
98 .where(
99 and(
100 eq(notifications.userId, userId),
101 gte(notifications.createdAt, since48h),
102 sql`${notifications.readAt} IS NULL`
103 )
104 )
105 .orderBy(desc(notifications.createdAt))
106 .limit(20);
107
108 for (const n of notifs) {
109 const ageH = Math.round(
110 (now.getTime() - new Date(n.createdAt).getTime()) / 3_600_000
111 );
112 items.push({
113 type: n.kind === "mention" ? "mention" : n.kind === "gate_failed" ? "ci_failure" : "pr_review",
114 title: n.title || "(untitled)",
115 subtitle: `${n.kind} · ${ageH}h ago`,
116 url: n.url ? (n.url.startsWith("http") ? n.url : `${base}${n.url}`) : `${base}/inbox`,
117 repoName: "—",
118 createdAt: new Date(n.createdAt),
119 });
120 }
121 } catch (err) {
122 console.error("[smart-digest] notifications query failed:", err);
123 }
124
125 // --- 2. PRs where user is requested reviewer (open) ---
126 try {
127 // We use pr_reviews to detect review requests: look for PRs where user
128 // has a review_requested state and the PR is still open.
129 const reviewRows = await db
130 .select({
131 pr: pullRequests,
132 repoName: repositories.name,
133 ownerUsername: users.username,
134 })
135 .from(prReviews)
136 .innerJoin(pullRequests, eq(pullRequests.id, prReviews.pullRequestId))
137 .innerJoin(
138 repositories,
139 eq(repositories.id, pullRequests.repositoryId)
140 )
141 .innerJoin(users, eq(users.id, repositories.ownerId))
142 .where(
143 and(
144 eq(prReviews.reviewerId, userId),
145 eq(pullRequests.state, "open"),
146 // Only include PRs they haven't reviewed yet (no approved/changes_requested)
147 sql`${prReviews.state} NOT IN ('approved','changes_requested')`
148 )
149 )
150 .orderBy(desc(pullRequests.createdAt))
151 .limit(10);
152
153 for (const row of reviewRows) {
154 const ageH = Math.round(
155 (now.getTime() - new Date(row.pr.createdAt).getTime()) / 3_600_000
156 );
157 const ageDays = Math.floor(ageH / 24);
158 const ageLabel = ageDays >= 1 ? `waiting ${ageDays}d` : `waiting ${ageH}h`;
159 items.push({
160 type: "pr_review",
161 title: `Review requested: ${row.pr.title}`,
162 subtitle: `${ageLabel} · ${row.repoName}`,
163 url: `${base}/${row.ownerUsername}/${row.repoName}/pulls/${row.pr.number}`,
164 repoName: row.repoName,
165 createdAt: new Date(row.pr.createdAt),
166 });
167 }
168 } catch (err) {
169 console.error("[smart-digest] review-requested query failed:", err);
170 }
171
172 // --- 3. Open PRs authored by user with unread comments ---
173 try {
174 const authoredPrs = await db
175 .select({
176 pr: pullRequests,
177 repoName: repositories.name,
178 ownerUsername: users.username,
179 commentCount: sql<number>`count(${prComments.id})::int`,
180 })
181 .from(pullRequests)
182 .innerJoin(
183 repositories,
184 eq(repositories.id, pullRequests.repositoryId)
185 )
186 .innerJoin(users, eq(users.id, repositories.ownerId))
187 .innerJoin(prComments, eq(prComments.pullRequestId, pullRequests.id))
188 .where(
189 and(
190 eq(pullRequests.authorId, userId),
191 eq(pullRequests.state, "open"),
192 ne(prComments.authorId, userId),
193 gte(prComments.createdAt, since48h)
194 )
195 )
196 .groupBy(pullRequests.id, repositories.name, users.username)
197 .orderBy(desc(pullRequests.updatedAt))
198 .limit(10);
199
200 for (const row of authoredPrs) {
201 const count = row.commentCount || 0;
202 if (count === 0) continue;
203 items.push({
204 type: "pr_comment",
205 title: `New comments on your PR: ${row.pr.title}`,
206 subtitle: `${count} new comment${count === 1 ? "" : "s"} · ${row.repoName}`,
207 url: `${base}/${row.ownerUsername}/${row.repoName}/pulls/${row.pr.number}`,
208 repoName: row.repoName,
209 createdAt: new Date(row.pr.updatedAt),
210 });
211 }
212 } catch (err) {
213 console.error("[smart-digest] pr-comments query failed:", err);
214 }
215
216 // --- 4. Failed gate runs for repos the user owns (last 24h) ---
217 const since24h = new Date(now.getTime() - 24 * 60 * 60 * 1000);
218 try {
219 const ownedRepos = await db
220 .select({ id: repositories.id, name: repositories.name })
221 .from(repositories)
222 .where(eq(repositories.ownerId, userId));
223
224 if (ownedRepos.length > 0) {
225 const repoIds = ownedRepos.map((r) => r.id);
226 const repoById = new Map(ownedRepos.map((r) => [r.id, r.name]));
227
228 const failedGates = await db
229 .select()
230 .from(gateRuns)
231 .where(
232 and(
233 inArray(gateRuns.repositoryId, repoIds),
234 eq(gateRuns.status, "failed"),
235 gte(gateRuns.createdAt, since24h)
236 )
237 )
238 .orderBy(desc(gateRuns.createdAt))
239 .limit(10);
240
241 for (const gate of failedGates) {
242 const repoName = repoById.get(gate.repositoryId) || "?";
243 items.push({
244 type: "ci_failure",
245 title: `Gate failed: ${gate.gateName} in ${repoName}`,
246 subtitle: `commit ${gate.commitSha.slice(0, 7)} · ${repoName}`,
247 url: `${base}/${userId}/${repoName}/pulls`,
248 repoName,
249 createdAt: new Date(gate.createdAt),
250 });
251 }
252
253 // --- 5. Dependency update PRs ---
254 if (repoIds.length > 0) {
255 const depPrs = await db
256 .select({
257 pr: pullRequests,
258 repoName: repositories.name,
259 })
260 .from(pullRequests)
261 .innerJoin(
262 repositories,
263 eq(repositories.id, pullRequests.repositoryId)
264 )
265 .where(
266 and(
267 inArray(pullRequests.repositoryId, repoIds),
268 eq(pullRequests.state, "open"),
269 sql`${pullRequests.headBranch} LIKE 'gluecron/dep-update%'`
270 )
271 )
272 .orderBy(desc(pullRequests.createdAt))
273 .limit(5);
274
275 for (const row of depPrs) {
276 items.push({
277 type: "dep_update",
278 title: `Dependency update ready: ${row.pr.title}`,
279 subtitle: `auto-PR · ${row.repoName}`,
280 url: `${base}/${userId}/${row.repoName}/pulls/${row.pr.number}`,
281 repoName: row.repoName,
282 createdAt: new Date(row.pr.createdAt),
283 });
284 }
285 }
286 }
287 } catch (err) {
288 console.error("[smart-digest] gate/dep-update query failed:", err);
289 }
290
291 return items;
292}
293
294// ---------------------------------------------------------------------------
295// Stats loader
296// ---------------------------------------------------------------------------
297
298async function loadStats(
299 userId: string,
300 now: Date
301): Promise<SmartDigest["stats"]> {
302 const since7d = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
303
304 let prsReviewed = 0;
305 let issuesClosed = 0;
306 const commitsThisWeek = 0; // Git log not easily queryable via SQL; default 0
307
308 try {
309 const [reviewCountRow] = await db
310 .select({ count: sql<number>`count(*)::int` })
311 .from(prReviews)
312 .where(
313 and(
314 eq(prReviews.reviewerId, userId),
315 gte(prReviews.createdAt, since7d),
316 sql`${prReviews.state} IN ('approved','changes_requested')`
317 )
318 );
319 prsReviewed = reviewCountRow?.count || 0;
320 } catch {
321 /* swallow */
322 }
323
324 try {
325 const [issueCountRow] = await db
326 .select({ count: sql<number>`count(*)::int` })
327 .from(issues)
328 .where(
329 and(
330 gte(issues.updatedAt, since7d),
331 eq(issues.state, "closed")
332 )
333 );
334 issuesClosed = issueCountRow?.count || 0;
335 } catch {
336 /* swallow */
337 }
338
339 return { prsReviewed, issuesClosed, commitsThisWeek };
340}
341
342// ---------------------------------------------------------------------------
343// Claude call
344// ---------------------------------------------------------------------------
345
346interface ClaudeDigestResponse {
347 headline: string;
348 queue: Array<{
349 priority: "blocking" | "important" | "fyi";
350 type: DigestItem["type"];
351 title: string;
352 subtitle: string;
353 url: string;
354 repoName: string;
355 }>;
356 insight?: string;
357}
358
359async function callClaude(
360 rawItems: RawItem[],
361 stats: SmartDigest["stats"],
362 username: string
363): Promise<ClaudeDigestResponse | null> {
364 if (!isAiAvailable()) return null;
365 try {
366 const client = getAnthropic();
367 const itemsJson = JSON.stringify(
368 rawItems.map((i) => ({
369 type: i.type,
370 title: i.title,
371 subtitle: i.subtitle,
372 url: i.url,
373 repoName: i.repoName,
374 })),
375 null,
376 2
377 );
378 const statsJson = JSON.stringify(stats, null, 2);
379
380 const prompt = `You are a developer productivity assistant for ${username}. Create a morning digest.
381
382Their pending items:
383${itemsJson}
384
385Their recent stats (last 7 days):
386${statsJson}
387
388Return JSON only (no markdown wrapper):
389{
390 "headline": "...",
391 "queue": [{"priority": "blocking"|"important"|"fyi", "type": "pr_review"|"pr_comment"|"ci_failure"|"mention"|"dep_update"|"new_issue", "title": "...", "subtitle": "...", "url": "...", "repoName": "..."}, ...],
392 "insight": "..."
393}
394
395Rules:
396- Max 8 items in queue
397- blocking = someone explicitly waiting on this person, or a CI failure blocking a deploy
398- important = needs action today
399- fyi = nice to know
400- headline should be specific and human ("PR #45 is blocking a deploy" not "You have notifications")
401- insight should be personal and brief (skip if nothing interesting)
402- If no items, headline = "All caught up — nothing needs your attention right now"
403- Return valid JSON only, no commentary`;
404
405 const msg = await client.messages.create({
406 model: MODEL_SONNET,
407 max_tokens: 1024,
408 messages: [{ role: "user", content: prompt }],
409 });
410
411 const text = extractText(msg);
412 const parsed = parseJsonResponse<ClaudeDigestResponse>(text);
413 return parsed;
414 } catch (err) {
415 console.error("[smart-digest] Claude call failed:", err);
416 return null;
417 }
418}
419
420// ---------------------------------------------------------------------------
421// Fallback (no AI)
422// ---------------------------------------------------------------------------
423
424function buildFallbackDigest(
425 rawItems: RawItem[],
426 stats: SmartDigest["stats"],
427 userId: string
428): Omit<SmartDigest, "userId" | "generatedAt" | "stats"> {
429 const queue: DigestItem[] = rawItems.slice(0, 8).map((item) => ({
430 priority:
431 item.type === "ci_failure"
432 ? "blocking"
433 : item.type === "pr_review"
434 ? "important"
435 : "fyi",
436 type: item.type,
437 title: item.title,
438 subtitle: item.subtitle,
439 url: item.url,
440 repoName: item.repoName,
441 }));
442
443 const blockingCount = queue.filter((i) => i.priority === "blocking").length;
444 const importantCount = queue.filter((i) => i.priority === "important").length;
445 let headline = "All caught up — nothing needs your attention right now";
446 if (queue.length > 0) {
447 if (blockingCount > 0) {
448 headline = `${blockingCount} blocking item${blockingCount === 1 ? "" : "s"} need${blockingCount === 1 ? "s" : ""} your attention`;
449 } else if (importantCount > 0) {
450 headline = `${importantCount} item${importantCount === 1 ? "" : "s"} to action today`;
451 } else {
452 headline = `${queue.length} item${queue.length === 1 ? "" : "s"} in your queue`;
453 }
454 }
455
456 return { headline, queue };
457}
458
459// ---------------------------------------------------------------------------
460// Public API
461// ---------------------------------------------------------------------------
462
463/**
464 * Compose a smart digest for the given user. Never throws.
465 * Returns null if the user is not found or any fatal error occurs.
466 */
467export async function composeSmartDigest(
468 userId: string
469): Promise<SmartDigest | null> {
470 try {
471 const [user] = await db
472 .select()
473 .from(users)
474 .where(eq(users.id, userId))
475 .limit(1);
476 if (!user) return null;
477
478 const now = new Date();
479 const [rawItems, stats] = await Promise.all([
480 loadRawItems(userId, now),
481 loadStats(userId, now),
482 ]);
483
484 let headline = "All caught up — nothing needs your attention right now";
485 let queue: DigestItem[] = [];
486 let insight: string | undefined;
487
488 const claudeResult = await callClaude(rawItems, stats, user.username);
489 if (claudeResult) {
490 headline = claudeResult.headline || headline;
491 queue = (claudeResult.queue || []).slice(0, 8) as DigestItem[];
492 insight = claudeResult.insight || undefined;
493 } else {
494 const fallback = buildFallbackDigest(rawItems, stats, userId);
495 headline = fallback.headline;
496 queue = fallback.queue;
497 }
498
499 return {
500 userId,
501 generatedAt: now.toISOString(),
502 headline,
503 queue,
504 stats,
505 insight,
506 };
507 } catch (err) {
508 console.error("[smart-digest] composeSmartDigest error:", err);
509 return null;
510 }
511}
512
513/** Minimum hours between digests. */
514const SMART_DIGEST_COOLDOWN_HOURS = 20;
515
516/**
517 * Compose + store a single notification for the user.
518 * Updates `last_smart_digest_sent_at`. Respects the 20h cooldown.
519 * Never throws.
520 */
521export async function sendSmartDigest(userId: string): Promise<void> {
522 try {
523 const [user] = await db
524 .select()
525 .from(users)
526 .where(eq(users.id, userId))
527 .limit(1);
528 if (!user) return;
529
530 // Cooldown check — skip if sent recently
531 const lastSent = user.lastSmartDigestSentAt as Date | null;
532 if (lastSent) {
533 const hoursSince =
534 (Date.now() - new Date(lastSent).getTime()) / 3_600_000;
535 if (hoursSince < SMART_DIGEST_COOLDOWN_HOURS) {
536 return;
537 }
538 }
539
540 const digest = await composeSmartDigest(userId);
541 if (!digest) return;
542
543 // Insert a single 'digest' notification with full JSON in body
544 await db.insert(notifications).values({
545 userId,
546 kind: "digest",
547 title: digest.headline,
548 body: JSON.stringify(digest),
549 url: "/digest",
550 });
551
552 // Update timestamp
553 await db
554 .update(users)
555 .set({ lastSmartDigestSentAt: new Date() })
556 .where(eq(users.id, userId));
557 } catch (err) {
558 console.error("[smart-digest] sendSmartDigest error:", err);
559 }
560}
561
562/**
563 * Send smart digests to all opted-in users. Called from the autopilot loop.
564 * Never throws.
565 */
566export async function sendSmartDigestsToAll(): Promise<void> {
567 try {
568 const candidates = await db
569 .select({ id: users.id })
570 .from(users)
571 .where(
572 sql`(${users.notifyEmailDigestWeekly} = true OR ${users.notifySmartDigest} = true)`
573 )
574 .limit(200);
575
576 for (const candidate of candidates) {
577 try {
578 await sendSmartDigest(candidate.id);
579 } catch (err) {
580 console.error(
581 `[smart-digest] per-user error for user=${candidate.id}:`,
582 err
583 );
584 }
585 }
586 } catch (err) {
587 console.error("[smart-digest] sendSmartDigestsToAll error:", err);
588 }
589}
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/bus-factor.tsx+488−0View fileUnifiedSplit
1/**
2 * Bus Factor Report — /:owner/:repo/insights/bus-factor
3 *
4 * Lists files where knowledge is concentrated in a single author.
5 * Includes a pure-CSS bar chart per file and a "Re-analyze" button
6 * (owner-only) that triggers a fresh background analysis.
7 *
8 * GET /:owner/:repo/insights/bus-factor — report page
9 * POST /:owner/:repo/insights/bus-factor/reanalyze — trigger fresh analysis
10 */
11
12import { Hono } from "hono";
13import { db } from "../db";
14import { repositories, users, busFactorCache } from "../db/schema";
15import { and, eq } from "drizzle-orm";
16import type { AuthEnv } from "../middleware/auth";
17import { softAuth, requireAuth } from "../middleware/auth";
18import { requireRepoAccess } from "../middleware/repo-access";
19import { Layout } from "../views/layout";
20import { RepoHeader, RepoNav } from "../views/components";
21import { getUnreadCount } from "../lib/unread";
22import {
23 analyzeBusFactor,
24 type BusFactorFile,
25 type BusFactorReport,
26} from "../lib/bus-factor";
27
28const busFactorRoutes = new Hono<AuthEnv>();
29
30// ─── CSS ──────────────────────────────────────────────────────────────────────
31
32const styles = `
33 .bf-wrap {
34 max-width: 1080px;
35 margin: 0 auto;
36 padding: var(--space-5) var(--space-4);
37 }
38
39 /* Insights sub-navigation */
40 .bf-subnav {
41 display: flex;
42 gap: 4px;
43 margin-bottom: var(--space-5);
44 border-bottom: 1px solid var(--border);
45 padding-bottom: 0;
46 }
47 .bf-subnav-link {
48 padding: 8px 14px;
49 font-size: 13px;
50 font-weight: 500;
51 color: var(--text-muted);
52 text-decoration: none;
53 border-bottom: 2px solid transparent;
54 margin-bottom: -1px;
55 transition: color 120ms ease, border-color 120ms ease;
56 border-radius: 4px 4px 0 0;
57 }
58 .bf-subnav-link:hover { color: var(--text); }
59 .bf-subnav-link.active {
60 color: var(--accent, #5865f2);
61 border-bottom-color: var(--accent, #5865f2);
62 }
63
64 /* Hero */
65 .bf-hero {
66 position: relative;
67 margin-bottom: var(--space-5);
68 padding: var(--space-5) var(--space-6);
69 background: var(--bg-elevated);
70 border: 1px solid var(--border);
71 border-radius: 16px;
72 overflow: hidden;
73 }
74 .bf-hero::before {
75 content: '';
76 position: absolute;
77 top: 0; left: 0; right: 0;
78 height: 2px;
79 background: linear-gradient(90deg, transparent 0%, #f59e0b 30%, #ef4444 70%, transparent 100%);
80 opacity: 0.75;
81 pointer-events: none;
82 }
83 .bf-hero-orb {
84 position: absolute;
85 inset: -30% -15% auto auto;
86 width: 460px; height: 460px;
87 background: radial-gradient(circle, rgba(245,158,11,0.16), rgba(239,68,68,0.08) 45%, transparent 70%);
88 filter: blur(80px);
89 opacity: 0.75;
90 pointer-events: none;
91 z-index: 0;
92 }
93 .bf-hero-inner { position: relative; z-index: 1; max-width: 760px; }
94 .bf-hero-eyebrow {
95 display: inline-flex; align-items: center; gap: 6px;
96 font-size: 11px; font-weight: 700; letter-spacing: 0.07em;
97 text-transform: uppercase;
98 color: #f59e0b;
99 margin-bottom: 10px;
100 }
101 .bf-hero-title {
102 font-family: var(--font-display);
103 font-size: clamp(22px, 3vw, 30px);
104 font-weight: 800;
105 letter-spacing: -0.025em;
106 line-height: 1.1;
107 margin: 0 0 8px;
108 color: var(--text-strong);
109 }
110 .bf-hero-sub {
111 font-size: 14px;
112 color: var(--text-muted);
113 margin: 0;
114 line-height: 1.5;
115 }
116 .bf-hero-actions {
117 display: flex;
118 gap: 10px;
119 align-items: center;
120 margin-top: 16px;
121 flex-wrap: wrap;
122 }
123
124 /* Stats bar */
125 .bf-stats {
126 display: flex;
127 gap: 16px;
128 margin-bottom: var(--space-5);
129 flex-wrap: wrap;
130 }
131 .bf-stat-card {
132 flex: 1 1 160px;
133 padding: 14px 18px;
134 background: var(--bg-elevated);
135 border: 1px solid var(--border);
136 border-radius: 12px;
137 min-width: 120px;
138 }
139 .bf-stat-value {
140 font-family: var(--font-display);
141 font-size: 26px;
142 font-weight: 800;
143 line-height: 1;
144 margin-bottom: 4px;
145 color: var(--text-strong);
146 font-variant-numeric: tabular-nums;
147 }
148 .bf-stat-label {
149 font-size: 12px;
150 color: var(--text-muted);
151 font-weight: 500;
152 }
153 .bf-stat-card.is-critical .bf-stat-value { color: #ef4444; }
154 .bf-stat-card.is-high .bf-stat-value { color: #f97316; }
155 .bf-stat-card.is-medium .bf-stat-value { color: #f59e0b; }
156
157 /* File list */
158 .bf-list {
159 display: flex;
160 flex-direction: column;
161 gap: 10px;
162 }
163 .bf-file-card {
164 padding: 14px 18px;
165 background: var(--bg-elevated);
166 border: 1px solid var(--border);
167 border-radius: 12px;
168 transition: border-color 120ms ease;
169 }
170 .bf-file-card:hover { border-color: rgba(245,158,11,0.4); }
171 .bf-file-card.risk-critical { border-left: 3px solid #ef4444; }
172 .bf-file-card.risk-high { border-left: 3px solid #f97316; }
173 .bf-file-card.risk-medium { border-left: 3px solid #f59e0b; }
174
175 .bf-file-header {
176 display: flex;
177 align-items: center;
178 gap: 10px;
179 margin-bottom: 10px;
180 flex-wrap: wrap;
181 }
182 .bf-file-path {
183 flex: 1 1 auto;
184 font-family: var(--font-mono);
185 font-size: 13px;
186 font-weight: 600;
187 color: var(--text-strong);
188 word-break: break-all;
189 }
190 .bf-risk-badge {
191 display: inline-flex;
192 align-items: center;
193 padding: 2px 9px;
194 border-radius: 9999px;
195 font-size: 10.5px;
196 font-weight: 700;
197 letter-spacing: 0.04em;
198 text-transform: uppercase;
199 flex-shrink: 0;
200 }
201 .bf-risk-badge.is-critical { color: #ef4444; background: rgba(239,68,68,0.12); border: 1px solid rgba(239,68,68,0.3); }
202 .bf-risk-badge.is-high { color: #f97316; background: rgba(249,115,22,0.12); border: 1px solid rgba(249,115,22,0.3); }
203 .bf-risk-badge.is-medium { color: #f59e0b; background: rgba(245,158,11,0.12); border: 1px solid rgba(245,158,11,0.3); }
204
205 .bf-file-meta {
206 display: flex;
207 gap: 16px;
208 font-size: 12px;
209 color: var(--text-muted);
210 margin-bottom: 10px;
211 flex-wrap: wrap;
212 }
213
214 /* CSS bar chart — no JS */
215 .bf-bar-wrap {
216 display: flex;
217 align-items: center;
218 gap: 10px;
219 }
220 .bf-bar-label {
221 font-size: 12px;
222 color: var(--text-muted);
223 white-space: nowrap;
224 min-width: 140px;
225 overflow: hidden;
226 text-overflow: ellipsis;
227 }
228 .bf-bar-track {
229 flex: 1;
230 height: 10px;
231 background: var(--bg-tertiary, rgba(255,255,255,0.06));
232 border-radius: 99px;
233 overflow: hidden;
234 }
235 .bf-bar-fill {
236 height: 100%;
237 border-radius: 99px;
238 background: linear-gradient(90deg, #f59e0b, #ef4444);
239 transition: width 300ms ease;
240 }
241 .bf-bar-pct {
242 font-size: 12px;
243 font-weight: 600;
244 color: var(--text-muted);
245 min-width: 40px;
246 text-align: right;
247 font-variant-numeric: tabular-nums;
248 }
249
250 /* Empty state */
251 .bf-empty {
252 text-align: center;
253 padding: 64px 24px;
254 color: var(--text-muted);
255 }
256 .bf-empty-icon { font-size: 40px; margin-bottom: 12px; }
257 .bf-empty-title { font-size: 18px; font-weight: 700; color: var(--text-strong); margin-bottom: 6px; }
258 .bf-empty-sub { font-size: 14px; }
259
260 /* Action button */
261 .bf-reanalyze-btn {
262 display: inline-flex; align-items: center; gap: 6px;
263 padding: 8px 16px;
264 border-radius: 8px;
265 font-size: 13px; font-weight: 600;
266 color: #fff;
267 background: linear-gradient(135deg, #f59e0b 0%, #ef4444 130%);
268 border: none;
269 cursor: pointer;
270 transition: opacity 120ms ease;
271 }
272 .bf-reanalyze-btn:hover { opacity: 0.85; }
273
274 .bf-analyzed-at {
275 font-size: 12px;
276 color: var(--text-muted);
277 margin-top: 16px;
278 }
279`;
280
281// ─── Route: GET /:owner/:repo/insights/bus-factor ─────────────────────────────
282
283busFactorRoutes.use("/:owner/:repo/insights/bus-factor", softAuth);
284
285busFactorRoutes.get("/:owner/:repo/insights/bus-factor", requireRepoAccess("read"), async (c) => {
286 const user = c.get("user") ?? null;
287 const params = c.req.param() as { owner: string; repo: string };
288 const ownerName = params.owner;
289 const repoName = params.repo;
290
291 // Load repo
292 const repoRows = await db
293 .select({ id: repositories.id, isPrivate: repositories.isPrivate, ownerId: repositories.ownerId })
294 .from(repositories)
295 .innerJoin(users, eq(repositories.ownerId, users.id))
296 .where(and(eq(users.username, ownerName), eq(repositories.name, repoName)))
297 .limit(1);
298
299 if (!repoRows.length) return c.notFound();
300 const repo = repoRows[0];
301 const isOwner = !!user && user.id === repo.ownerId;
302
303 const unreadCount = user ? await getUnreadCount(user.id) : 0;
304
305 // Load cached report
306 let report: BusFactorReport | null = null;
307 const cacheRows = await db
308 .select()
309 .from(busFactorCache)
310 .where(eq(busFactorCache.repositoryId, repo.id))
311 .limit(1);
312
313 if (cacheRows.length > 0) {
314 const cached = cacheRows[0];
315 report = {
316 repoId: repo.id,
317 analyzedAt: cached.analyzedAt.toISOString(),
318 atRiskFiles: cached.atRiskFiles as BusFactorFile[],
319 totalFilesAnalyzed: cached.totalFilesAnalyzed,
320 };
321 }
322
323 const criticalCount = report?.atRiskFiles.filter((f) => f.risk === "critical").length ?? 0;
324 const highCount = report?.atRiskFiles.filter((f) => f.risk === "high").length ?? 0;
325 const mediumCount = report?.atRiskFiles.filter((f) => f.risk === "medium").length ?? 0;
326
327 return c.html(
328 <Layout
329 title={`Bus Factor — ${ownerName}/${repoName}`}
330 user={user}
331 notificationCount={unreadCount}
332 >
333 <style dangerouslySetInnerHTML={{ __html: styles }} />
334 <div class="bf-wrap">
335 <RepoHeader owner={ownerName} repo={repoName} />
336 <RepoNav owner={ownerName} repo={repoName} active="insights" />
337
338 {/* Sub-navigation */}
339 <nav class="bf-subnav">
340 <a class="bf-subnav-link" href={`/${ownerName}/${repoName}/insights`}>Overview</a>
341 <a class="bf-subnav-link" href={`/${ownerName}/${repoName}/insights/health`}>Health</a>
342 <a class="bf-subnav-link" href={`/${ownerName}/${repoName}/insights/velocity`}>Velocity</a>
343 <a class="bf-subnav-link" href={`/${ownerName}/${repoName}/insights/hotfiles`}>Hot Files</a>
344 <a class="bf-subnav-link active" href={`/${ownerName}/${repoName}/insights/bus-factor`}>Bus Factor</a>
345 </nav>
346
347 {/* Hero */}
348 <div class="bf-hero">
349 <div class="bf-hero-orb" aria-hidden="true" />
350 <div class="bf-hero-inner">
351 <div class="bf-hero-eyebrow">⚠ Knowledge Risk</div>
352 <h1 class="bf-hero-title">Bus Factor Analysis</h1>
353 <p class="bf-hero-sub">
354 Files where a single author owns more than 75% of commits.
355 If that person leaves, the team loses critical context.
356 </p>
357 <div class="bf-hero-actions">
358 {isOwner && (
359 <form method="post" action={`/${ownerName}/${repoName}/insights/bus-factor/reanalyze`}>
360 <button type="submit" class="bf-reanalyze-btn">
361 ↻ Re-analyze
362 </button>
363 </form>
364 )}
365 {report && (
366 <span class="bf-analyzed-at">
367 Last analyzed {new Date(report.analyzedAt).toLocaleDateString()} ·{" "}
368 {report.totalFilesAnalyzed} code files scanned
369 </span>
370 )}
371 </div>
372 </div>
373 </div>
374
375 {report ? (
376 <>
377 {/* Stats */}
378 <div class="bf-stats">
379 <div class="bf-stat-card is-critical">
380 <div class="bf-stat-value">{criticalCount}</div>
381 <div class="bf-stat-label">Critical risk files</div>
382 </div>
383 <div class="bf-stat-card is-high">
384 <div class="bf-stat-value">{highCount}</div>
385 <div class="bf-stat-label">High risk files</div>
386 </div>
387 <div class="bf-stat-card is-medium">
388 <div class="bf-stat-value">{mediumCount}</div>
389 <div class="bf-stat-label">Medium risk files</div>
390 </div>
391 <div class="bf-stat-card">
392 <div class="bf-stat-value">{report.atRiskFiles.length}</div>
393 <div class="bf-stat-label">Total at-risk files</div>
394 </div>
395 </div>
396
397 {/* File list */}
398 {report.atRiskFiles.length === 0 ? (
399 <div class="bf-empty">
400 <div class="bf-empty-icon"></div>
401 <div class="bf-empty-title">No knowledge concentration detected</div>
402 <p class="bf-empty-sub">
403 All analyzed files have healthy authorship spread.
404 </p>
405 </div>
406 ) : (
407 <div class="bf-list">
408 {report.atRiskFiles.map((file) => (
409 <div class={`bf-file-card risk-${file.risk}`}>
410 <div class="bf-file-header">
411 <code class="bf-file-path">{file.path}</code>
412 <span class={`bf-risk-badge is-${file.risk}`}>
413 {file.risk}
414 </span>
415 </div>
416 <div class="bf-file-meta">
417 <span>{file.totalCommits} commits</span>
418 <span>Last modified {file.lastModified}</span>
419 </div>
420 <div class="bf-bar-wrap">
421 <span class="bf-bar-label" title={file.primaryAuthor}>
422 {file.primaryAuthor}
423 </span>
424 <div class="bf-bar-track">
425 <div
426 class="bf-bar-fill"
427 style={`width:${file.primaryAuthorPct}%`}
428 />
429 </div>
430 <span class="bf-bar-pct">{file.primaryAuthorPct}%</span>
431 </div>
432 </div>
433 ))}
434 </div>
435 )}
436 </>
437 ) : (
438 <div class="bf-empty">
439 <div class="bf-empty-icon">📊</div>
440 <div class="bf-empty-title">No analysis yet</div>
441 <p class="bf-empty-sub">
442 {isOwner
443 ? "Click Re-analyze to run the bus factor scan on this repository."
444 : "The repository owner hasn't run a bus factor scan yet."}
445 </p>
446 </div>
447 )}
448 </div>
449 </Layout>
450 );
451});
452
453// ─── Route: POST /:owner/:repo/insights/bus-factor/reanalyze ─────────────────
454
455busFactorRoutes.use("/:owner/:repo/insights/bus-factor/reanalyze", requireAuth);
456
457busFactorRoutes.post(
458 "/:owner/:repo/insights/bus-factor/reanalyze",
459 requireRepoAccess("write"),
460 async (c) => {
461 const user = c.get("user")!;
462 const params = c.req.param() as { owner: string; repo: string };
463 const ownerName = params.owner;
464 const repoName = params.repo;
465
466 // Only repo owner may trigger re-analysis
467 const repoRows = await db
468 .select({ id: repositories.id, ownerId: repositories.ownerId })
469 .from(repositories)
470 .innerJoin(users, eq(repositories.ownerId, users.id))
471 .where(and(eq(users.username, ownerName), eq(repositories.name, repoName)))
472 .limit(1);
473
474 if (!repoRows.length) return c.notFound();
475 const repo = repoRows[0];
476
477 if (user.id !== repo.ownerId) {
478 return c.text("Forbidden", 403);
479 }
480
481 // Fire-and-forget background analysis
482 analyzeBusFactor(repo.id, ownerName, repoName).catch(() => {});
483
484 return c.redirect(`/${ownerName}/${repoName}/insights/bus-factor`);
485 }
486);
487
488export default busFactorRoutes;
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/digest.tsx+508−0View fileUnifiedSplit
1/**
2 * /digest — Smart Morning Digest page.
3 *
4 * GET /digest — personal AI-curated digest (requireAuth)
5 * POST /digest/refresh — regenerate digest for current user, then redirect
6 *
7 * Shows the user's most recent digest notification (type='digest') or
8 * auto-generates one on first load. Displays:
9 * - AI-written headline
10 * - Priority-sorted queue (blocking=red, important=amber, fyi=grey)
11 * - Stats row (PRs reviewed / issues closed / commits)
12 * - Optional AI insight
13 * - "Regenerate" button
14 */
15
16import { Hono } from "hono";
17import { eq, and, desc } from "drizzle-orm";
18import { db } from "../db";
19import { notifications, users } from "../db/schema";
20import { Layout } from "../views/layout";
21import { requireAuth, softAuth } from "../middleware/auth";
22import type { AuthEnv } from "../middleware/auth";
23import { composeSmartDigest, sendSmartDigest, type SmartDigest, type DigestItem } from "../lib/smart-digest";
24import { formatRelative } from "../views/ui";
25
26const digest = new Hono<AuthEnv>();
27
28// ---------------------------------------------------------------------------
29// Styles
30// ---------------------------------------------------------------------------
31
32const DIGEST_STYLES = `
33 .digest-hero {
34 position: relative;
35 margin: 0 0 var(--space-5, 24px);
36 padding: 24px 28px 26px;
37 background: var(--bg-elevated, #f8f9fa);
38 border: 1px solid var(--border, #e1e4e8);
39 border-radius: 16px;
40 overflow: hidden;
41 }
42 .digest-hero::before {
43 content: '';
44 position: absolute; top: 0; left: 0; right: 0;
45 height: 2px;
46 background: linear-gradient(90deg, transparent 0%, #f7971e 30%, #ffd200 70%, transparent 100%);
47 opacity: 0.8;
48 pointer-events: none;
49 }
50 .digest-hero-icon {
51 font-size: 28px;
52 margin-bottom: 10px;
53 display: block;
54 }
55 .digest-headline {
56 font-size: clamp(20px, 2.8vw, 28px);
57 font-weight: 800;
58 letter-spacing: -0.02em;
59 color: var(--text-strong, #111);
60 margin: 0 0 8px;
61 line-height: 1.15;
62 }
63 .digest-meta {
64 font-size: 12.5px;
65 color: var(--text-muted, #777);
66 margin: 0;
67 }
68 .digest-actions {
69 display: flex;
70 gap: 8px;
71 align-items: center;
72 flex-wrap: wrap;
73 margin-top: 16px;
74 }
75 .digest-regenerate-btn {
76 display: inline-flex;
77 align-items: center;
78 gap: 6px;
79 padding: 8px 14px;
80 background: var(--bg, #fff);
81 border: 1px solid var(--border, #e1e4e8);
82 border-radius: 8px;
83 font-size: 13px;
84 font-weight: 500;
85 color: var(--text, #333);
86 cursor: pointer;
87 text-decoration: none;
88 }
89 .digest-regenerate-btn:hover {
90 background: var(--bg-elevated, #f8f9fa);
91 border-color: var(--accent, #0070f3);
92 }
93
94 /* Queue */
95 .digest-queue {
96 display: flex;
97 flex-direction: column;
98 gap: 8px;
99 margin-bottom: 24px;
100 }
101 .digest-item {
102 display: flex;
103 align-items: center;
104 gap: 12px;
105 padding: 12px 16px;
106 background: var(--bg-elevated, #f8f9fa);
107 border: 1px solid var(--border, #e1e4e8);
108 border-radius: 10px;
109 border-left: 3px solid transparent;
110 text-decoration: none;
111 color: inherit;
112 transition: box-shadow 0.15s;
113 }
114 .digest-item:hover {
115 box-shadow: 0 2px 8px rgba(0,0,0,0.08);
116 }
117 .digest-item.blocking {
118 border-left-color: #ef4444;
119 background: #fef2f2;
120 }
121 .digest-item.important {
122 border-left-color: #f59e0b;
123 background: #fffbeb;
124 }
125 .digest-item.fyi {
126 border-left-color: #94a3b8;
127 }
128 .digest-item-icon {
129 font-size: 18px;
130 flex-shrink: 0;
131 width: 28px;
132 text-align: center;
133 }
134 .digest-item-content {
135 flex: 1;
136 min-width: 0;
137 }
138 .digest-item-title {
139 font-size: 14px;
140 font-weight: 600;
141 color: var(--text-strong, #111);
142 margin: 0 0 2px;
143 white-space: nowrap;
144 overflow: hidden;
145 text-overflow: ellipsis;
146 }
147 .digest-item-subtitle {
148 font-size: 12px;
149 color: var(--text-muted, #777);
150 margin: 0;
151 }
152 .digest-item-priority {
153 font-size: 11px;
154 font-weight: 600;
155 padding: 2px 7px;
156 border-radius: 99px;
157 flex-shrink: 0;
158 }
159 .digest-item-priority.blocking {
160 background: #fee2e2;
161 color: #b91c1c;
162 }
163 .digest-item-priority.important {
164 background: #fef3c7;
165 color: #92400e;
166 }
167 .digest-item-priority.fyi {
168 background: var(--bg, #f1f5f9);
169 color: var(--text-muted, #64748b);
170 }
171 .digest-item-go {
172 font-size: 13px;
173 color: var(--accent, #0070f3);
174 flex-shrink: 0;
175 font-weight: 500;
176 }
177
178 /* Stats row */
179 .digest-stats {
180 display: flex;
181 gap: 16px;
182 flex-wrap: wrap;
183 margin-bottom: 24px;
184 }
185 .digest-stat-card {
186 flex: 1;
187 min-width: 120px;
188 padding: 14px 18px;
189 background: var(--bg-elevated, #f8f9fa);
190 border: 1px solid var(--border, #e1e4e8);
191 border-radius: 10px;
192 text-align: center;
193 }
194 .digest-stat-value {
195 font-size: 28px;
196 font-weight: 800;
197 color: var(--text-strong, #111);
198 letter-spacing: -0.03em;
199 line-height: 1;
200 margin-bottom: 4px;
201 }
202 .digest-stat-label {
203 font-size: 12px;
204 color: var(--text-muted, #777);
205 }
206
207 /* Insight */
208 .digest-insight {
209 padding: 16px 20px;
210 background: linear-gradient(135deg, #f0f4ff 0%, #fdf4ff 100%);
211 border: 1px solid #c7d2fe;
212 border-radius: 12px;
213 margin-bottom: 24px;
214 display: flex;
215 gap: 12px;
216 align-items: flex-start;
217 }
218 .digest-insight-icon {
219 font-size: 20px;
220 flex-shrink: 0;
221 margin-top: 2px;
222 }
223 .digest-insight-text {
224 font-size: 14px;
225 color: #3730a3;
226 margin: 0;
227 line-height: 1.5;
228 }
229
230 /* Empty state */
231 .digest-empty {
232 text-align: center;
233 padding: 48px 24px;
234 color: var(--text-muted, #777);
235 }
236 .digest-empty-icon { font-size: 40px; margin-bottom: 12px; }
237 .digest-empty-text { font-size: 16px; font-weight: 600; margin-bottom: 8px; color: var(--text-strong, #111); }
238 .digest-empty-sub { font-size: 14px; margin: 0; }
239
240 /* Spinner */
241 .digest-spinner-wrap {
242 text-align: center;
243 padding: 48px 24px;
244 }
245 .digest-spinner {
246 display: inline-block;
247 width: 32px;
248 height: 32px;
249 border: 3px solid var(--border, #e1e4e8);
250 border-top-color: var(--accent, #0070f3);
251 border-radius: 50%;
252 animation: digest-spin 0.8s linear infinite;
253 margin-bottom: 12px;
254 }
255 @keyframes digest-spin { to { transform: rotate(360deg); } }
256
257 /* Section header */
258 .digest-section-title {
259 font-size: 13px;
260 font-weight: 700;
261 letter-spacing: 0.06em;
262 text-transform: uppercase;
263 color: var(--text-muted, #777);
264 margin: 0 0 12px;
265 }
266`;
267
268// ---------------------------------------------------------------------------
269// Helpers
270// ---------------------------------------------------------------------------
271
272type Priority = DigestItem["priority"];
273type ItemType = DigestItem["type"];
274
275function priorityLabel(p: Priority): string {
276 if (p === "blocking") return "Blocking";
277 if (p === "important") return "Important";
278 return "FYI";
279}
280
281function itemIcon(t: ItemType): string {
282 if (t === "pr_review") return "\u{1F50D}";
283 if (t === "pr_comment") return "\u{1F4AC}";
284 if (t === "ci_failure") return "\u{274C}";
285 if (t === "mention") return "\u{1F514}";
286 if (t === "dep_update") return "\u{1F4E6}";
287 if (t === "new_issue") return "\u{1F41B}";
288 return "\u{2022}";
289}
290
291// ---------------------------------------------------------------------------
292// GET /digest
293// ---------------------------------------------------------------------------
294
295digest.get("/digest", softAuth, requireAuth, async (c) => {
296 const user = c.get("user")!;
297 const generating = c.req.query("generating") === "1";
298
299 // Look up the most recent digest notification
300 const [latestDigestNotif] = await db
301 .select()
302 .from(notifications)
303 .where(
304 and(
305 eq(notifications.userId, user.id),
306 eq(notifications.kind, "digest")
307 )
308 )
309 .orderBy(desc(notifications.createdAt))
310 .limit(1)
311 .catch(() => []);
312
313 let digestData: SmartDigest | null = null;
314 let isToday = false;
315
316 if (latestDigestNotif?.body) {
317 try {
318 digestData = JSON.parse(latestDigestNotif.body) as SmartDigest;
319 const digestDate = new Date(digestData.generatedAt);
320 const now = new Date();
321 isToday =
322 digestDate.getFullYear() === now.getFullYear() &&
323 digestDate.getMonth() === now.getMonth() &&
324 digestDate.getDate() === now.getDate();
325 } catch {
326 /* malformed JSON */
327 }
328 }
329
330 // Auto-generate if no digest today and not already generating
331 if (!isToday && !generating) {
332 // Fire-and-forget then redirect with ?generating=1 to show spinner
333 void (async () => {
334 try {
335 await sendSmartDigest(user.id);
336 } catch {
337 /* swallow */
338 }
339 })();
340 return c.redirect("/digest?generating=1");
341 }
342
343 // If generating, show spinner page that polls
344 if (generating && !isToday) {
345 return c.html(
346 <Layout title="Morning Digest" user={user}>
347 <style dangerouslySetInnerHTML={{ __html: DIGEST_STYLES }} />
348 <div style="max-width:720px;margin:0 auto;padding:24px 16px">
349 <h1 class="digest-headline" style="margin-bottom:24px">
350 Morning Digest
351 </h1>
352 <div class="digest-spinner-wrap">
353 <div class="digest-spinner" />
354 <p style="font-size:15px;color:var(--text-muted);margin:0">
355 Generating your digest...
356 </p>
357 </div>
358 <script
359 dangerouslySetInnerHTML={{
360 __html: `
361 setTimeout(function() {
362 window.location.href = '/digest';
363 }, 3000);
364 `,
365 }}
366 />
367 </div>
368 </Layout>
369 );
370 }
371
372 return c.html(
373 <Layout title="Morning Digest" user={user}>
374 <style dangerouslySetInnerHTML={{ __html: DIGEST_STYLES }} />
375 <div style="max-width:720px;margin:0 auto;padding:24px 16px">
376
377 {/* Hero */}
378 <div class="digest-hero">
379 <span class="digest-hero-icon" aria-hidden="true">{"☀"}</span>
380 <h1 class="digest-headline">
381 {digestData?.headline || "No digest available"}
382 </h1>
383 {digestData && (
384 <p class="digest-meta">
385 Generated {formatRelative(digestData.generatedAt)}
386 </p>
387 )}
388 <div class="digest-actions">
389 <form method="post" action="/digest/refresh" style="display:inline">
390 <button type="submit" class="digest-regenerate-btn">
391 {"↻"} Regenerate
392 </button>
393 </form>
394 <a href="/inbox" class="digest-regenerate-btn">
395 View all notifications
396 </a>
397 </div>
398 </div>
399
400 {/* AI Insight */}
401 {digestData?.insight && (
402 <div>
403 <p class="digest-section-title">AI Insight</p>
404 <div class="digest-insight">
405 <span class="digest-insight-icon" aria-hidden="true">{"✨"}</span>
406 <p class="digest-insight-text">{digestData.insight}</p>
407 </div>
408 </div>
409 )}
410
411 {/* Stats row */}
412 {digestData?.stats && (
413 <div>
414 <p class="digest-section-title">This week</p>
415 <div class="digest-stats" style="margin-bottom:24px">
416 <div class="digest-stat-card">
417 <div class="digest-stat-value">{digestData.stats.prsReviewed}</div>
418 <div class="digest-stat-label">PRs reviewed</div>
419 </div>
420 <div class="digest-stat-card">
421 <div class="digest-stat-value">{digestData.stats.issuesClosed}</div>
422 <div class="digest-stat-label">Issues closed</div>
423 </div>
424 <div class="digest-stat-card">
425 <div class="digest-stat-value">{digestData.stats.commitsThisWeek}</div>
426 <div class="digest-stat-label">Commits</div>
427 </div>
428 </div>
429 </div>
430 )}
431
432 {/* Queue */}
433 {digestData && digestData.queue.length > 0 ? (
434 <div>
435 <p class="digest-section-title">Action queue</p>
436 <div class="digest-queue">
437 {digestData.queue.map((item, idx) => (
438 <a
439 key={idx}
440 href={item.url}
441 class={`digest-item ${item.priority}`}
442 >
443 <span class="digest-item-icon" aria-hidden="true">
444 {itemIcon(item.type)}
445 </span>
446 <div class="digest-item-content">
447 <p class="digest-item-title">{item.title}</p>
448 <p class="digest-item-subtitle">{item.subtitle}</p>
449 </div>
450 <span class={`digest-item-priority ${item.priority}`}>
451 {priorityLabel(item.priority)}
452 </span>
453 <span class="digest-item-go" aria-hidden="true">{"→"}</span>
454 </a>
455 ))}
456 </div>
457 </div>
458 ) : digestData ? (
459 <div class="digest-empty">
460 <div class="digest-empty-icon" aria-hidden="true">{"🎉"}</div>
461 <p class="digest-empty-text">All caught up!</p>
462 <p class="digest-empty-sub">No action items for today.</p>
463 </div>
464 ) : (
465 <div class="digest-empty">
466 <div class="digest-empty-icon" aria-hidden="true">{"📋"}</div>
467 <p class="digest-empty-text">No digest yet</p>
468 <p class="digest-empty-sub">
469 Use the Regenerate button above to create your first digest.
470 </p>
471 </div>
472 )}
473
474 </div>
475 </Layout>
476 );
477});
478
479// ---------------------------------------------------------------------------
480// POST /digest/refresh
481// ---------------------------------------------------------------------------
482
483digest.post("/digest/refresh", softAuth, requireAuth, async (c) => {
484 const user = c.get("user")!;
485 // Force a fresh digest by clearing cooldown temporarily — just compose + insert
486 try {
487 const freshDigest = await composeSmartDigest(user.id);
488 if (freshDigest) {
489 await db.insert(notifications).values({
490 userId: user.id,
491 kind: "digest",
492 title: freshDigest.headline,
493 body: JSON.stringify(freshDigest),
494 url: "/digest",
495 });
496 // Update last sent timestamp
497 await db
498 .update(users)
499 .set({ lastSmartDigestSentAt: new Date() })
500 .where(eq(users.id, user.id));
501 }
502 } catch (err) {
503 console.error("[digest] refresh error:", err);
504 }
505 return c.redirect("/digest");
506});
507
508export default digest;
Addedsrc/routes/engineering-insights.tsx+1826−0View fileUnifiedSplit
Large file (1,826 lines). Load full file
Modifiedsrc/routes/hooks.ts+88−0View fileUnifiedSplit
3333import {
3434 apiTokens,
3535 gateRuns,
36 prComments,
3637 pullRequests,
3738 repositories,
3839 users,
4344 severityAtOrAboveMedium,
4445 type GateTestFinding,
4546} from "../lib/ai-patch-generator";
47import { triggerCiAutofix, applyAutofix } from "../lib/ci-autofix";
4648import { config } from "../lib/config";
4749
4850const hooks = new Hono();
307309 }
308310 }
309311
312 // CI auto-fix — if the gate failed on a PR, post a ready-to-apply patch
313 // comment. Fire-and-forget; never blocks the webhook response.
314 if (normalisedStatus === "failed" && gateRunId && pullRequestId) {
315 triggerCiAutofix(gateRunId).catch((err) =>
316 console.error(
317 "[hooks/gatetest] ci-autofix crashed:",
318 err instanceof Error ? err.message : err
319 )
320 );
321 }
322
310323 return c.json({ ok: true, gateRunId });
311324});
312325
555568 }
556569 }
557570
571 // CI auto-fix — post a ready-to-apply patch comment on the PR.
572 if (normalisedStatus === "failed" && gateRunId && pullRequestId) {
573 triggerCiAutofix(gateRunId).catch((err) =>
574 console.error(
575 "[hooks/backup] ci-autofix crashed:",
576 err instanceof Error ? err.message : err
577 )
578 );
579 }
580
558581 return c.json({ ok: true, gateRunId });
559582});
560583
593616 }
594617});
595618
619/**
620 * POST /api/pr-comments/:commentId/apply-autofix
621 *
622 * Applies the patch embedded in a CI autofix comment to a new branch, then
623 * redirects to the compare view. Authenticated via a personal access token
624 * (same mechanism as /api/v1/gate-runs).
625 *
626 * Response on success (JSON): { ok: true, branchName, compareUrl }
627 * Response on failure (JSON): { ok: false, error }
628 */
629hooks.post("/api/pr-comments/:commentId/apply-autofix", async (c) => {
630 const auth = await verifyPatAuth(c);
631 if (!auth.ok || !auth.userId) {
632 return c.json({ ok: false, error: auth.error || "Unauthorized" }, 401);
633 }
634
635 const { commentId } = c.req.param();
636 if (!commentId) {
637 return c.json({ ok: false, error: "commentId param required" }, 400);
638 }
639
640 let result: { branchName: string };
641 try {
642 result = await applyAutofix(commentId, auth.userId);
643 } catch (err) {
644 const msg = err instanceof Error ? err.message : "Unknown error";
645 return c.json({ ok: false, error: msg }, 400);
646 }
647
648 // Look up the PR to build the compare URL
649 const commentRows = await db
650 .select({ pullRequestId: prComments.pullRequestId })
651 .from(prComments)
652 .where(eq(prComments.id, commentId))
653 .limit(1);
654
655 let compareUrl: string | null = null;
656 if (commentRows[0]) {
657 const prRows = await db
658 .select({
659 repositoryId: pullRequests.repositoryId,
660 number: pullRequests.number,
661 baseBranch: pullRequests.baseBranch,
662 })
663 .from(pullRequests)
664 .where(eq(pullRequests.id, commentRows[0].pullRequestId))
665 .limit(1);
666
667 if (prRows[0]) {
668 const repoRows = await db
669 .select({ name: repositories.name, ownerUsername: users.username })
670 .from(repositories)
671 .innerJoin(users, eq(repositories.ownerId, users.id))
672 .where(eq(repositories.id, prRows[0].repositoryId))
673 .limit(1);
674
675 if (repoRows[0]) {
676 compareUrl = `/${repoRows[0].ownerUsername}/${repoRows[0].name}/compare/${result.branchName}`;
677 }
678 }
679 }
680
681 return c.json({ ok: true, branchName: result.branchName, compareUrl });
682});
683
596684export default hooks;
Addedsrc/routes/incident-hooks.tsx+1300−0View fileUnifiedSplit
Large file (1,300 lines). Load full file
Modifiedsrc/routes/issues.tsx+193−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
16161711 </form>
16171712 )}
16181713 </div>
1714 {/* Issue keyboard hints bar */}
1715 <div class="kbd-hints" aria-label="Keyboard shortcuts for this issue">
1716 <kbd>c</kbd> comment &middot; <kbd>e</kbd> edit title &middot; <kbd>x</kbd> close/reopen &middot; <kbd>?</kbd> shortcuts
1717 </div>
1718 <style dangerouslySetInnerHTML={{ __html: `
1719 .kbd-hints {
1720 position: fixed;
1721 bottom: 0;
1722 left: 0;
1723 right: 0;
1724 z-index: 90;
1725 padding: 6px 24px;
1726 background: var(--bg-secondary);
1727 border-top: 1px solid var(--border);
1728 font-size: 12px;
1729 color: var(--text-muted);
1730 display: flex;
1731 align-items: center;
1732 gap: 8px;
1733 flex-wrap: wrap;
1734 }
1735 .kbd-hints kbd {
1736 font-family: var(--font-mono);
1737 font-size: 10px;
1738 background: var(--bg-elevated);
1739 border: 1px solid var(--border);
1740 border-bottom-width: 2px;
1741 border-radius: 4px;
1742 padding: 1px 5px;
1743 color: var(--text);
1744 line-height: 1.5;
1745 }
1746 main { padding-bottom: 40px; }
1747 ` }} />
1748 {/* Repo context commands for command palette */}
1749 <script
1750 id="cmdk-repo-context"
1751 dangerouslySetInnerHTML={{
1752 __html: `window.__CMDK_REPO_COMMANDS = ${JSON.stringify([
1753 { label: `New issue in ${repoName}`, href: `/${ownerName}/${repoName}/issues/new`, kw: "create add bug" },
1754 { label: `New pull request in ${repoName}`, href: `/${ownerName}/${repoName}/pulls/new`, kw: "pr branch merge" },
1755 { label: `Browse code — ${ownerName}/${repoName}`, href: `/${ownerName}/${repoName}`, kw: "files tree" },
1756 { label: `Issues — ${ownerName}/${repoName}`, href: `/${ownerName}/${repoName}/issues`, kw: "bugs tasks" },
1757 { label: `Pull requests — ${ownerName}/${repoName}`, href: `/${ownerName}/${repoName}/pulls`, kw: "prs reviews" },
1758 ])};`,
1759 }}
1760 />
1761 {/* Issue keyboard shortcuts */}
1762 <script dangerouslySetInnerHTML={{ __html: `
1763 (function(){
1764 function isTyping(t){
1765 t = t || {};
1766 var tag = (t.tagName || '').toLowerCase();
1767 return tag === 'input' || tag === 'textarea' || t.isContentEditable;
1768 }
1769 document.addEventListener('keydown', function(e){
1770 if (isTyping(e.target)) return;
1771 if (e.metaKey || e.ctrlKey || e.altKey) return;
1772 if (e.key === 'c') {
1773 e.preventDefault();
1774 var box = document.querySelector('textarea[name="body"]');
1775 if (box) { box.focus(); box.scrollIntoView({block:'center'}); }
1776 }
1777 if (e.key === 'e') {
1778 e.preventDefault();
1779 // Focus the issue title and make it editable if possible
1780 var titleEl = document.querySelector('.issues-title');
1781 if (titleEl) titleEl.scrollIntoView({block:'center'});
1782 }
1783 if (e.key === 'x') {
1784 e.preventDefault();
1785 var closeBtn = document.querySelector('button[formaction*="/close"], button[formaction*="/reopen"]');
1786 if (closeBtn) {
1787 var confirmed = window.confirm('Close/reopen this issue?');
1788 if (confirmed) closeBtn.click();
1789 }
1790 }
1791 if (e.key === 'Escape') {
1792 var focused = document.activeElement;
1793 if (focused) focused.blur();
1794 }
1795 });
1796 })();
1797 ` }} />
16191798 </Layout>
16201799 );
16211800});
16991878 } catch {
17001879 /* SSE is best-effort */
17011880 }
1881 // Notify the issue author — fire-and-forget, never blocks the response.
1882 if (issue.authorId && issue.authorId !== user.id) {
1883 void import("../lib/notify").then(({ createNotification }) =>
1884 createNotification({
1885 userId: issue.authorId,
1886 type: "issue_comment",
1887 title: `New comment on "${issue.title}"`,
1888 body: commentBody.length > 200 ? commentBody.slice(0, 200) + "…" : commentBody,
1889 url: `/${ownerName}/${repoName}/issues/${issueNum}`,
1890 repoId: resolved.repo.id,
1891 })
1892 ).catch(() => { /* never block the response */ });
1893 }
17021894 }
17031895
17041896 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+733−8View fileUnifiedSplit
2020 pullRequests,
2121 prComments,
2222 prReviews,
23 prReviewRequests,
2324 repositories,
2425 users,
2526 issues,
6667import { triggerPrTriage } from "../lib/pr-triage";
6768import { generatePrSummary } from "../lib/ai-generators";
6869import { isAiAvailable } from "../lib/ai-client";
70import { getPatternWarning, type Pattern } from "../lib/pattern-detector";
71import { getReviewContext, recordPrVisit, type ReviewContext } from "../lib/review-context";
6972import {
7073 computePrRiskForPullRequest,
7174 getCachedPrRisk,
16501653 * All endpoint URLs are JSON-escaped via safe replacements so they
16511654 * can't break out of the <script> tag.
16521655 */
1656
1657/**
1658 * Figma-style collaborative PR presence client (WebSocket).
1659 *
1660 * Connects to `GET /:owner/:repo/pulls/:number/presence` (WebSocket upgrade).
1661 * On connect the server sends `{type:"init", users:[...]}` so the bar renders
1662 * immediately. Subsequent messages from the server drive the presence bar and
1663 * per-line cursor pills in the diff.
1664 *
1665 * Outbound messages:
1666 * {type:"cursor", line: N} — user hovered a diff line
1667 * {type:"typing", line: N, typing: bool} — textarea focus/blur in diff
1668 * {type:"ping"} — keep-alive every 10s
1669 *
1670 * Inbound messages:
1671 * {type:"init", users:[{sessionId,username,colour,line,typing}]}
1672 * {type:"join", user:{sessionId,username,colour,line,typing}}
1673 * {type:"leave", sessionId}
1674 * {type:"cursor", sessionId, username, colour, line}
1675 * {type:"typing", sessionId, username, colour, line, typing}
1676 */
1677function PR_PRESENCE_SCRIPT(owner: string, repo: string, prNum: number): string {
1678 const wsPath = JSON.stringify(`/${owner}/${repo}/pulls/${prNum}/presence`)
1679 .split("<").join("\\u003C")
1680 .split(">").join("\\u003E")
1681 .split("&").join("\\u0026");
1682 return `(function(){
1683try{
1684var wsPath=${wsPath};
1685var proto=location.protocol==='https:'?'wss:':'ws:';
1686var url=proto+'//'+location.host+wsPath;
1687var mySessionId=null;
1688// sessionId -> {username, colour, line, typing}
1689var peers={};
1690var ws=null;
1691var pingTimer=null;
1692var reconnectDelay=1500;
1693var reconnectTimer=null;
1694
1695function esc(s){return String(s==null?'':s).replace(/[&<>"']/g,function(c){return{'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c];});}
1696
1697// ── Toast ──────────────────────────────────────────────────────────────
1698var toastWrap=document.getElementById('presence-toasts');
1699function toast(msg){
1700 if(!toastWrap)return;
1701 var t=document.createElement('div');
1702 t.className='presence-toast';
1703 t.textContent=msg;
1704 toastWrap.appendChild(t);
1705 setTimeout(function(){t.classList.add('fading');setTimeout(function(){if(t.parentNode)t.parentNode.removeChild(t);},420);},2500);
1706}
1707
1708// ── Presence bar ───────────────────────────────────────────────────────
1709var avEl=document.getElementById('presence-avatars');
1710var countEl=document.getElementById('presence-count');
1711function renderBar(){
1712 if(!avEl)return;
1713 var ids=Object.keys(peers);
1714 var html='';
1715 for(var i=0;i<ids.length&&i<8;i++){
1716 var p=peers[ids[i]];
1717 var initials=(p.username||'?').slice(0,2).toUpperCase();
1718 html+='<span class="presence-avatar" style="background:'+esc(p.colour)+'" title="'+esc(p.username)+'">';
1719 html+='<span class="presence-avatar-dot">'+esc(initials)+'</span>';
1720 html+=esc(p.username);
1721 html+='</span>';
1722 }
1723 avEl.innerHTML=html;
1724 if(countEl){
1725 var n=ids.length;
1726 countEl.textContent=n===0?'No other reviewers':n===1?'1 reviewer online':n+' reviewers online';
1727 }
1728}
1729
1730// ── Diff cursor pills ──────────────────────────────────────────────────
1731// data-line value is like "12:x:5" or "12:5:x" — pull numeric line only
1732function lineNumFromKey(key){var m=String(key).match(/(\d+)/);return m?parseInt(m[1],10):null;}
1733function findDiffRow(line){return document.querySelector('[data-line]') &&
1734 (function(){var rows=document.querySelectorAll('[data-line]');
1735 for(var i=0;i<rows.length;i++){var n=lineNumFromKey(rows[i].getAttribute('data-line')||'');if(n===line)return rows[i];}
1736 return null;
1737 })();}
1738function removePill(sessionId){var old=document.querySelector('[data-presence-sid="'+sessionId+'"]');if(old&&old.parentNode)old.parentNode.removeChild(old);}
1739function placePill(sessionId,username,colour,line,typing){
1740 removePill(sessionId);
1741 if(line==null)return;
1742 var row=findDiffRow(line);if(!row)return;
1743 var pill=document.createElement('span');
1744 pill.className='presence-line-pill'+(typing?' is-typing':'');
1745 pill.setAttribute('data-presence-sid',sessionId);
1746 pill.style.background=colour||'#8c6dff';
1747 pill.textContent=(username||'?').slice(0,12)+(typing?' typing':'');
1748 row.appendChild(pill);
1749}
1750function clearPeer(sessionId){removePill(sessionId);delete peers[sessionId];}
1751
1752// ── Inbound message handler ────────────────────────────────────────────
1753function onMsg(raw){
1754 var d;try{d=JSON.parse(raw);}catch(e){return;}
1755 if(!d||!d.type)return;
1756 if(d.type==='init'){
1757 mySessionId=d.sessionId||null;
1758 peers={};
1759 (d.users||[]).forEach(function(u){
1760 if(u.sessionId===mySessionId)return;
1761 peers[u.sessionId]={username:u.username,colour:u.colour,line:u.line,typing:u.typing};
1762 placePill(u.sessionId,u.username,u.colour,u.line,u.typing);
1763 });
1764 renderBar();
1765 } else if(d.type==='join'){
1766 if(d.user&&d.user.sessionId!==mySessionId){
1767 peers[d.user.sessionId]={username:d.user.username,colour:d.user.colour,line:d.user.line,typing:d.user.typing};
1768 renderBar();
1769 toast(esc(d.user.username)+' joined the review');
1770 }
1771 } else if(d.type==='leave'){
1772 if(d.sessionId&&d.sessionId!==mySessionId){
1773 var name=peers[d.sessionId]&&peers[d.sessionId].username;
1774 clearPeer(d.sessionId);
1775 renderBar();
1776 if(name)toast(esc(name)+' left the review');
1777 }
1778 } else if(d.type==='cursor'){
1779 if(d.sessionId&&d.sessionId!==mySessionId){
1780 if(peers[d.sessionId]){peers[d.sessionId].line=d.line;peers[d.sessionId].typing=false;}
1781 placePill(d.sessionId,d.username,d.colour,d.line,false);
1782 }
1783 } else if(d.type==='typing'){
1784 if(d.sessionId&&d.sessionId!==mySessionId){
1785 if(peers[d.sessionId]){peers[d.sessionId].line=d.line;peers[d.sessionId].typing=d.typing;}
1786 placePill(d.sessionId,d.username,d.colour,d.line,d.typing);
1787 }
1788 }
1789}
1790
1791// ── Outbound helpers ───────────────────────────────────────────────────
1792function send(obj){try{if(ws&&ws.readyState===1)ws.send(JSON.stringify(obj));}catch(e){}}
1793
1794// ── Mouse hover on diff rows ───────────────────────────────────────────
1795var hoverTimer=null;
1796document.addEventListener('mouseover',function(ev){
1797 var row=ev.target&&ev.target.closest&&ev.target.closest('[data-line]');
1798 if(!row)return;
1799 if(hoverTimer)clearTimeout(hoverTimer);
1800 hoverTimer=setTimeout(function(){
1801 var key=row.getAttribute('data-line')||'';
1802 var line=lineNumFromKey(key);
1803 if(line!=null)send({type:'cursor',line:line});
1804 },80);
1805});
1806
1807// ── Typing detection in diff comment textareas ─────────────────────────
1808document.addEventListener('focusin',function(ev){
1809 var ta=ev.target;
1810 if(!ta||ta.tagName!=='TEXTAREA')return;
1811 var row=ta.closest&&ta.closest('[data-line]');if(!row)return;
1812 var line=lineNumFromKey(row.getAttribute('data-line')||'');
1813 if(line!=null)send({type:'typing',line:line,typing:true});
1814});
1815document.addEventListener('focusout',function(ev){
1816 var ta=ev.target;
1817 if(!ta||ta.tagName!=='TEXTAREA')return;
1818 var row=ta.closest&&ta.closest('[data-line]');if(!row)return;
1819 var line=lineNumFromKey(row.getAttribute('data-line')||'');
1820 if(line!=null)send({type:'typing',line:line,typing:false});
1821});
1822
1823// ── WebSocket lifecycle ────────────────────────────────────────────────
1824function connect(){
1825 if(reconnectTimer){clearTimeout(reconnectTimer);reconnectTimer=null;}
1826 try{ws=new WebSocket(url);}catch(e){scheduleReconnect();return;}
1827 ws.onopen=function(){
1828 reconnectDelay=1500;
1829 pingTimer=setInterval(function(){send({type:'ping'});},10000);
1830 };
1831 ws.onmessage=function(ev){onMsg(ev.data);};
1832 ws.onclose=function(){
1833 if(pingTimer){clearInterval(pingTimer);pingTimer=null;}
1834 scheduleReconnect();
1835 };
1836 ws.onerror=function(){try{ws.close();}catch(e){}};
1837}
1838function scheduleReconnect(){
1839 reconnectTimer=setTimeout(function(){connect();},reconnectDelay);
1840 reconnectDelay=Math.min(reconnectDelay*2,30000);
1841}
1842
1843connect();
1844}catch(e){}})();`;
1845}
1846
16531847function LIVE_COEDIT_SCRIPT(prId: string): string {
16541848 const idJson = JSON.stringify(prId)
16551849 .split("<").join("\\u003C")
33823576 })
33833577 .returning();
33843578
3579 // CODEOWNERS — auto-request reviewers based on changed files.
3580 // Fire-and-forget; errors never block PR creation.
3581 (async () => {
3582 try {
3583 const repoDir = getRepoPath(ownerName, repoName);
3584 // Get list of changed files between base and head
3585 const diffProc = Bun.spawn(
3586 ["git", "diff", "--name-only", `${baseBranch}...${headBranch}`],
3587 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
3588 );
3589 const rawDiff = await new Response(diffProc.stdout).text();
3590 await diffProc.exited;
3591 const changedFiles = rawDiff.trim().split("\n").filter(Boolean);
3592
3593 if (changedFiles.length > 0) {
3594 // Get CODEOWNERS from the default branch of the repo
3595 const rules = await getCodeownersForRepo(
3596 ownerName,
3597 repoName,
3598 resolved.repo.defaultBranch
3599 );
3600 if (rules.length > 0) {
3601 const ownerUsernames = await reviewersForChangedFiles(
3602 resolved.repo.id,
3603 changedFiles
3604 );
3605 // Filter out the PR author
3606 const filteredOwners = ownerUsernames.filter(
3607 (u) => u !== resolved.owner.username
3608 );
3609
3610 if (filteredOwners.length > 0) {
3611 // Look up user IDs for the owner usernames
3612 const reviewerUsers = await db
3613 .select({ id: users.id, username: users.username })
3614 .from(users)
3615 .where(
3616 inArray(
3617 users.username,
3618 filteredOwners
3619 )
3620 );
3621
3622 // Create review request rows (UNIQUE constraint prevents dupes)
3623 if (reviewerUsers.length > 0) {
3624 await db
3625 .insert(prReviewRequests)
3626 .values(
3627 reviewerUsers.map((u) => ({
3628 prId: pr.id,
3629 reviewerId: u.id,
3630 requestedBy: null as string | null,
3631 }))
3632 )
3633 .onConflictDoNothing();
3634
3635 // Add a PR comment announcing the auto-assigned reviewers
3636 const mentionList = reviewerUsers
3637 .map((u) => `@${u.username}`)
3638 .join(", ");
3639 await db.insert(prComments).values({
3640 pullRequestId: pr.id,
3641 authorId: user.id,
3642 body: `AI: Requested review from ${mentionList} based on CODEOWNERS`,
3643 isAiReview: true,
3644 });
3645 }
3646 }
3647 }
3648 }
3649 } catch (err) {
3650 console.warn("[codeowners] auto-assign failed:", err instanceof Error ? err.message : err);
3651 }
3652 })();
3653
33853654 // Skip AI review on drafts — it runs again when the PR is marked ready.
33863655 if (!isDraft && isAiReviewEnabled()) {
33873656 triggerAiReview(ownerName, repoName, pr.id, title, prBody, baseBranch, headBranch).catch(
35393808 const changesRequested = [...latestReviewByReviewer.values()].filter(r => r.state === "changes_requested");
35403809 const viewerHasReviewed = user ? latestReviewByReviewer.has(user.id) : false;
35413810
3811 // Requested reviewers from CODEOWNERS auto-assign (migration 0077).
3812 const requestedReviewerRows = await db
3813 .select({
3814 reviewerUsername: users.username,
3815 reviewerId: prReviewRequests.reviewerId,
3816 createdAt: prReviewRequests.createdAt,
3817 })
3818 .from(prReviewRequests)
3819 .innerJoin(users, eq(prReviewRequests.reviewerId, users.id))
3820 .where(eq(prReviewRequests.prId, pr.id))
3821 .orderBy(asc(prReviewRequests.createdAt))
3822 .catch(() => [] as { reviewerUsername: string; reviewerId: string; createdAt: Date }[]);
3823
35423824 // Suggested reviewers — best-effort, never throws
35433825 let reviewerSuggestions: ReviewerCandidate[] = [];
35443826 try {
37634045 }));
37644046 }
37654047
4048 // Proactive pattern warning — get changed file paths and check for recurring
4049 // bug patterns. Fire-and-forget safe; returns null on any error or cache miss.
4050 let patternWarning: Pattern | null = null;
4051 try {
4052 const repoDir = getRepoPath(ownerName, repoName);
4053 const nameOnlyProc = Bun.spawn(
4054 ["git", "diff", "--name-only", `${pr.baseBranch}...${pr.headBranch}`],
4055 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
4056 );
4057 const nameOnlyRaw = await new Response(nameOnlyProc.stdout).text();
4058 await nameOnlyProc.exited;
4059 const prChangedFiles = nameOnlyRaw.trim().split("\n").filter(Boolean);
4060 if (prChangedFiles.length > 0) {
4061 patternWarning = await getPatternWarning(resolved.repo.id, prChangedFiles);
4062 }
4063 } catch {
4064 // Non-blocking — swallow
4065 }
4066
37664067 // ─── Derived visual state ───
37674068 const stateKey =
37684069 pr.state === "open"
38014102 prCommits = await commitsBetween(ownerName, repoName, pr.baseBranch, pr.headBranch).catch(() => []);
38024103 }
38034104
4105 // Review context restore — compute BEFORE recording the visit so the
4106 // previous timestamp is available for the delta calculation.
4107 let reviewCtx: ReviewContext | null = null;
4108 if (user) {
4109 reviewCtx = await getReviewContext(pr.id, user.id, {
4110 ownerName,
4111 repoName,
4112 baseBranch: pr.baseBranch,
4113 headBranch: pr.headBranch,
4114 });
4115 // Fire-and-forget: record the visit AFTER computing context
4116 void recordPrVisit(pr.id, user.id);
4117 }
4118
38044119 return c.html(
38054120 <Layout
38064121 title={`${pr.title} #${pr.number} — ${ownerName}/${repoName}`}
38334148 }}
38344149 />
38354150
4151 {/* Review context restore banner — shown when returning after changes */}
4152 {reviewCtx && (
4153 <div
4154 class="context-restore-banner"
4155 id="review-context"
4156 style="display:flex;align-items:flex-start;gap:12px;padding:12px 16px;margin:0 0 12px;background:var(--bg-elevated,#f8f9fa);border:1px solid var(--border,#e1e4e8);border-left:3px solid var(--accent,#0070f3);border-radius:10px"
4157 >
4158 <span class="context-icon" style="font-size:18px;flex-shrink:0;margin-top:2px" aria-hidden="true">{"↩"}</span>
4159 <div style="flex:1;min-width:0">
4160 <strong style="font-size:13.5px;color:var(--text-strong,#111)">Welcome back</strong>
4161 <p style="margin:4px 0 0;font-size:13px;color:var(--text,#333);line-height:1.5">{reviewCtx.summary}</p>
4162 <small style="font-size:11.5px;color:var(--text-muted,#777)">
4163 Last visited {formatRelative(new Date(reviewCtx.lastVisitedAt))}
4164 {reviewCtx.commitsSince > 0 && ` · ${reviewCtx.commitsSince} new commit${reviewCtx.commitsSince === 1 ? "" : "s"}`}
4165 {reviewCtx.newComments > 0 && ` · ${reviewCtx.newComments} new comment${reviewCtx.newComments === 1 ? "" : "s"}`}
4166 </small>
4167 {reviewCtx.suggestedStartLine && (
4168 <p style="margin:6px 0 0;font-size:12px;color:var(--accent,#0070f3)">
4169 Start at: <code style="font-size:11px">{reviewCtx.suggestedStartLine}</code>
4170 </p>
4171 )}
4172 </div>
4173 <button
4174 type="button"
4175 onclick="this.closest('.context-restore-banner').remove()"
4176 style="flex-shrink:0;background:none;border:none;cursor:pointer;font-size:18px;color:var(--text-muted,#777);padding:0;line-height:1"
4177 aria-label="Dismiss"
4178 >
4179 {"×"}
4180 </button>
4181 </div>
4182 )}
4183
38364184 <div class="prs-detail-hero">
38374185 <div class="prs-edit-title-wrap">
38384186 <h1 class="prs-detail-title" id="pr-title-display">
40054353 <script dangerouslySetInnerHTML={{ __html: markdownPreviewScript() }} />
40064354 <script dangerouslySetInnerHTML={{ __html: ctrlEnterSubmitScript() + codeBlockCopyScript() }} />
40074355
4356 {/* Presence styles + bar (shown only on the files tab so cursor pills work) */}
4357 <style dangerouslySetInnerHTML={{ __html: PRESENCE_STYLES }} />
4358 {/* Toast container — always present for join/leave toasts */}
4359 <div id="presence-toasts" class="presence-toast-wrap" aria-live="polite" />
4360 {user && (
4361 <>
4362 <div class="presence-bar" id="presence-bar">
4363 <span class="presence-bar-label">Live reviewers</span>
4364 <div class="presence-avatars" id="presence-avatars" />
4365 <span class="presence-count" id="presence-count">Loading…</span>
4366 </div>
4367 <script
4368 dangerouslySetInnerHTML={{
4369 __html: PR_PRESENCE_SCRIPT(ownerName, repoName, pr.number),
4370 }}
4371 />
4372 </>
4373 )}
4374
40084375 <nav class="prs-detail-tabs" aria-label="Pull request sections">
40094376 <a
40104377 class={`prs-detail-tab${tab === "conversation" ? " is-active" : ""}`}
40334400 </a>
40344401 </nav>
40354402
4403 {/* Proactive pattern warning — shown when a known recurring bug pattern
4404 overlaps with the files changed in this PR. */}
4405 {patternWarning && (
4406 <div class="pattern-warning" style="margin:0 0 16px;padding:12px 16px;border-radius:8px;background:var(--bg-elevated);border:1px solid #f59e0b;border-left:4px solid #f59e0b;font-size:13px;line-height:1.5">
4407 <span style="font-size:15px;margin-right:6px" aria-hidden="true">⚠️</span>
4408 <strong>Recurring pattern detected: {patternWarning.title}</strong>
4409 <span style="color:var(--fg-muted)">
4410 {" — "}
4411 This area has had {patternWarning.occurrences} similar fix
4412 {patternWarning.occurrences === 1 ? "" : "es"}.
4413 {patternWarning.rootCauseHypothesis && (
4414 <> Root cause may be in <code style="font-size:12px">{patternWarning.suggestedFile}</code>.</>
4415 )}
4416 </span>
4417 </div>
4418 )}
4419
40364420 {tab === "commits" ? (
40374421 <div class="prs-commits-list">
40384422 {prCommits.length === 0 ? (
40604444 )}
40614445 </div>
40624446 ) : tab === "files" ? (
4063 <DiffView
4064 raw={diffRaw}
4065 files={diffFiles}
4066 viewFileBase={`/${ownerName}/${repoName}/blob/${pr.headBranch}`}
4067 inlineComments={diffInlineComments}
4068 commentActionUrl={user ? `/${ownerName}/${repoName}/pulls/${pr.number}/comment` : undefined}
4069 applySuggestionUrl={user ? `/${ownerName}/${repoName}/pulls/${pr.number}/apply-suggestion` : undefined}
4070 />
4447 <>
4448 {/* PR Split Suggestion — shown when PR has >400 changed lines */}
4449 {splitSuggestion && (
4450 <div class="split-suggestion" id="pr-split-banner">
4451 <div class="split-header">
4452 <span class="split-icon" aria-hidden="true">✂️</span>
4453 <strong>This PR may be too large to review effectively</strong>
4454 <span class="split-stat">
4455 {splitSuggestion.totalLines} lines · {splitSuggestion.totalFiles} files
4456 </span>
4457 <button
4458 class="split-toggle"
4459 type="button"
4460 onclick="const b=document.getElementById('pr-split-body');const hidden=b.hasAttribute('hidden');b.toggleAttribute('hidden');this.textContent=hidden?'Hide split suggestion':'Show split suggestion';"
4461 >
4462 Show split suggestion
4463 </button>
4464 </div>
4465 <div class="split-body" id="pr-split-body" hidden>
4466 <p class="split-intro">
4467 AI suggests splitting into {splitSuggestion.suggestedPrs.length} PRs:
4468 </p>
4469 {splitSuggestion.suggestedPrs.map((sp, i) => (
4470 <div class="split-pr">
4471 <div class="split-pr-num">{i + 1}</div>
4472 <div class="split-pr-body">
4473 <strong>{sp.title}</strong>
4474 <p>{sp.rationale}</p>
4475 <code>{sp.files.join(", ")}</code>
4476 <span class="split-lines">~{sp.estimatedLines} lines</span>
4477 </div>
4478 </div>
4479 ))}
4480 {splitSuggestion.mergeOrder.length > 0 && (
4481 <p class="split-order">
4482 Suggested merge order:{" "}
4483 <strong>{splitSuggestion.mergeOrder.join(" → ")}</strong>
4484 </p>
4485 )}
4486 </div>
4487 </div>
4488 )}
4489
4490 {/* Bus Factor Warning — shown when changed files overlap at-risk files */}
4491 {busRiskFiles.length > 0 && (() => {
4492 const topRisk = busRiskFiles.some((f) => f.risk === "critical")
4493 ? "critical"
4494 : busRiskFiles.some((f) => f.risk === "high")
4495 ? "high"
4496 : "medium";
4497 return (
4498 <div class={`busfactor-panel busfactor-${topRisk}`}>
4499 <span class="busfactor-icon" aria-hidden="true">⚠️</span>
4500 <div class="busfactor-body">
4501 <strong>Knowledge concentration warning</strong>
4502 <p>
4503 {busRiskFiles.length} file{busRiskFiles.length !== 1 ? "s" : ""} in
4504 this PR {busRiskFiles.length !== 1 ? "are" : "is"} primarily
4505 maintained by one person. Consider pairing on this review.
4506 </p>
4507 <ul>
4508 {busRiskFiles.map((f) => (
4509 <li>
4510 <code>{f.path}</code> —{" "}
4511 <strong>{f.primaryAuthorPct}%</strong> by {f.primaryAuthor}
4512 </li>
4513 ))}
4514 </ul>
4515 </div>
4516 </div>
4517 );
4518 })()}
4519
4520 <DiffView
4521 raw={diffRaw}
4522 files={diffFiles}
4523 viewFileBase={`/${ownerName}/${repoName}/blob/${pr.headBranch}`}
4524 inlineComments={diffInlineComments}
4525 commentActionUrl={user ? `/${ownerName}/${repoName}/pulls/${pr.number}/comment` : undefined}
4526 applySuggestionUrl={user ? `/${ownerName}/${repoName}/pulls/${pr.number}/apply-suggestion` : undefined}
4527 />
4528 </>
40714529 ) : (
40724530 <>
40734531 {pr.body && (
45535011 )}
45545012 </>
45555013 )}
5014 {/* Keyboard hint bar — shown at the bottom of PR pages */}
5015 <div class="kbd-hints" aria-label="Keyboard shortcuts for this pull request">
5016 <kbd>c</kbd> comment &middot; <kbd>e</kbd> edit title &middot; <kbd>m</kbd> merge &middot; <kbd>a</kbd> approve &middot; <kbd>r</kbd> request changes &middot; <kbd>?</kbd> shortcuts
5017 </div>
5018 <style dangerouslySetInnerHTML={{ __html: `
5019 .kbd-hints {
5020 position: fixed;
5021 bottom: 0;
5022 left: 0;
5023 right: 0;
5024 z-index: 90;
5025 padding: 6px 24px;
5026 background: var(--bg-secondary);
5027 border-top: 1px solid var(--border);
5028 font-size: 12px;
5029 color: var(--text-muted);
5030 display: flex;
5031 align-items: center;
5032 gap: 8px;
5033 flex-wrap: wrap;
5034 }
5035 .kbd-hints kbd {
5036 font-family: var(--font-mono);
5037 font-size: 10px;
5038 background: var(--bg-elevated);
5039 border: 1px solid var(--border);
5040 border-bottom-width: 2px;
5041 border-radius: 4px;
5042 padding: 1px 5px;
5043 color: var(--text);
5044 line-height: 1.5;
5045 }
5046 /* Padding so the page footer doesn't overlap the hint bar */
5047 main { padding-bottom: 40px; }
5048 ` }} />
5049 {/* Repo context commands for command palette */}
5050 <script
5051 id="cmdk-repo-context"
5052 dangerouslySetInnerHTML={{
5053 __html: `window.__CMDK_REPO_COMMANDS = ${JSON.stringify([
5054 { label: `New issue in ${repoName}`, href: `/${ownerName}/${repoName}/issues/new`, kw: "create add bug" },
5055 { label: `New pull request in ${repoName}`, href: `/${ownerName}/${repoName}/pulls/new`, kw: "pr branch merge" },
5056 { label: `Browse code — ${ownerName}/${repoName}`, href: `/${ownerName}/${repoName}`, kw: "files tree" },
5057 { label: `View commits — ${ownerName}/${repoName}`, href: `/${ownerName}/${repoName}/commits`, kw: "history log" },
5058 { label: `Issues — ${ownerName}/${repoName}`, href: `/${ownerName}/${repoName}/issues`, kw: "bugs tasks" },
5059 { label: `Pull requests — ${ownerName}/${repoName}`, href: `/${ownerName}/${repoName}/pulls`, kw: "prs reviews" },
5060 ])};`,
5061 }}
5062 />
5063 {/* PR keyboard shortcuts script */}
5064 <script dangerouslySetInnerHTML={{ __html: `
5065 (function(){
5066 var commentBox = document.querySelector('textarea[name="body"]');
5067 var mergeBtn = document.querySelector('[data-merge-btn], .prs-merge-btn, button[form*="merge"], form[action*="/merge"] button[type="submit"]');
5068 var editBtn = document.getElementById('pr-edit-toggle');
5069 var approveUrl = ${JSON.stringify(`/${ownerName}/${repoName}/pulls/${pr.number}/review`)};
5070
5071 function isTyping(t){
5072 t = t || {};
5073 var tag = (t.tagName || '').toLowerCase();
5074 return tag === 'input' || tag === 'textarea' || t.isContentEditable;
5075 }
5076
5077 document.addEventListener('keydown', function(e){
5078 if (isTyping(e.target)) return;
5079 if (e.metaKey || e.ctrlKey || e.altKey) return;
5080 if (e.key === 'c') {
5081 e.preventDefault();
5082 if (commentBox) { commentBox.focus(); commentBox.scrollIntoView({block:'center'}); }
5083 }
5084 if (e.key === 'e') {
5085 e.preventDefault();
5086 if (editBtn) { editBtn.click(); }
5087 }
5088 if (e.key === 'm') {
5089 e.preventDefault();
5090 var mBtn = document.querySelector('.prs-merge-btn, form[action*="/merge"] button[type="submit"]');
5091 if (mBtn) { mBtn.focus(); mBtn.scrollIntoView({block:'center'}); }
5092 }
5093 if (e.key === 'a') {
5094 e.preventDefault();
5095 // Navigate to approve review page
5096 window.location.href = approveUrl + '?action=approve';
5097 }
5098 if (e.key === 'r') {
5099 e.preventDefault();
5100 window.location.href = approveUrl + '?action=request_changes';
5101 }
5102 if (e.key === 'Escape') {
5103 var focused = document.activeElement;
5104 if (focused) focused.blur();
5105 }
5106 });
5107 })();
5108 ` }} />
45565109 </Layout>
45575110 );
45585111});
47585311 } catch {
47595312 /* SSE is best-effort */
47605313 }
5314 // Notify the PR author — fire-and-forget, never blocks the response.
5315 if (pr.authorId && pr.authorId !== user.id) {
5316 void import("../lib/notify").then(({ createNotification }) =>
5317 createNotification({
5318 userId: pr.authorId,
5319 type: "pr_comment",
5320 title: `New comment on "${pr.title}"`,
5321 body: commentBody.length > 200 ? commentBody.slice(0, 200) + "…" : commentBody,
5322 url: `/${ownerName}/${repoName}/pulls/${prNum}`,
5323 repoId: resolved.repo.id,
5324 })
5325 ).catch(() => { /* never block the response */ });
5326 }
47615327 }
47625328
47635329 if (decision.status === "pending") {
50485614 );
50495615 }
50505616
5617 // Required reviews check — branch-protection `required_approvals` gate.
5618 // Evaluated before running expensive gate checks so the feedback is fast.
5619 {
5620 const eligibility = await checkMergeEligible(pr.id, resolved.repo.id, pr.baseBranch);
5621 if (!eligibility.eligible && eligibility.reason) {
5622 return c.redirect(
5623 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(eligibility.reason)}`
5624 );
5625 }
5626 }
5627
50515628 // Resolve head SHA
50525629 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
50535630 if (!headSha) {
56286205 }
56296206);
56306207
6208// ─── WebSocket presence endpoint ─────────────────────────────────────────────
6209//
6210// GET /:owner/:repo/pulls/:number/presence (WebSocket upgrade)
6211//
6212// Unauthenticated connections are rejected with 401. On connect:
6213// → server sends {type:"init", sessionId, users:[...]}
6214// → server broadcasts {type:"join", user} to all other sessions in the room
6215//
6216// Accepted client messages:
6217// {type:"cursor", line: number} — user hovering a diff line
6218// {type:"typing", line: number, typing: bool} — textarea focus/blur
6219// {type:"ping"} — keep-alive (updates lastSeen)
6220//
6221// The WS `data` payload we store on each socket carries everything needed in
6222// the event handlers so no closure tricks are required.
6223
6224pulls.get(
6225 "/:owner/:repo/pulls/:number/presence",
6226 softAuth,
6227 upgradeWebSocket(async (c) => {
6228 const { owner: ownerName, repo: repoName, number: prNumStr } = c.req.param();
6229 const prNum = parseInt(prNumStr ?? "0", 10);
6230 const user = c.get("user");
6231
6232 // Auth check — no anonymous presence
6233 if (!user) {
6234 // upgradeWebSocket doesn't support returning a non-101 directly;
6235 // we return a dummy handler that immediately closes with 4001.
6236 return {
6237 onOpen(_evt: Event, ws: import("hono/ws").WSContext) {
6238 ws.close(4001, "Unauthorized");
6239 },
6240 onMessage() {},
6241 onClose() {},
6242 };
6243 }
6244
6245 // Resolve repo to get its numeric id for the room key
6246 const resolved = await resolveRepo(ownerName, repoName);
6247 if (!resolved || isNaN(prNum)) {
6248 return {
6249 onOpen(_evt: Event, ws: import("hono/ws").WSContext) {
6250 ws.close(4004, "Not found");
6251 },
6252 onMessage() {},
6253 onClose() {},
6254 };
6255 }
6256
6257 const prId = `${resolved.repo.id}:${prNum}`;
6258 const sessionId = `${user.id}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
6259
6260 return {
6261 onOpen(_evt: Event, ws: import("hono/ws").WSContext) {
6262 // Register and join room
6263 registerSocket(prId, sessionId, {
6264 send: (data: string) => ws.send(data),
6265 readyState: ws.readyState,
6266 });
6267 const presenceUser = joinRoom(prId, sessionId, {
6268 userId: user.id,
6269 username: user.username,
6270 });
6271
6272 // Send init snapshot to the new joiner
6273 const currentUsers = getRoomUsers(prId);
6274 ws.send(
6275 JSON.stringify({
6276 type: "init",
6277 sessionId,
6278 users: currentUsers,
6279 })
6280 );
6281
6282 // Broadcast join to all OTHER sessions
6283 broadcastToRoom(
6284 prId,
6285 {
6286 type: "join",
6287 user: { ...presenceUser, sessionId },
6288 },
6289 sessionId
6290 );
6291 },
6292
6293 onMessage(evt: MessageEvent, _ws: import("hono/ws").WSContext) {
6294 let msg: { type: string; line?: number; typing?: boolean };
6295 try {
6296 msg = JSON.parse(typeof evt.data === "string" ? evt.data : String(evt.data));
6297 } catch {
6298 return;
6299 }
6300
6301 if (msg.type === "ping") {
6302 pingSession(prId, sessionId);
6303 return;
6304 }
6305
6306 if (msg.type === "cursor") {
6307 const line = typeof msg.line === "number" ? msg.line : null;
6308 const updated = updatePresence(prId, sessionId, line, false);
6309 if (updated) {
6310 broadcastToRoom(
6311 prId,
6312 {
6313 type: "cursor",
6314 sessionId,
6315 username: updated.username,
6316 colour: updated.colour,
6317 line,
6318 },
6319 sessionId
6320 );
6321 }
6322 return;
6323 }
6324
6325 if (msg.type === "typing") {
6326 const line = typeof msg.line === "number" ? msg.line : null;
6327 const typing = !!msg.typing;
6328 const updated = updatePresence(prId, sessionId, line, typing);
6329 if (updated) {
6330 broadcastToRoom(
6331 prId,
6332 {
6333 type: "typing",
6334 sessionId,
6335 username: updated.username,
6336 colour: updated.colour,
6337 line,
6338 typing,
6339 },
6340 sessionId
6341 );
6342 }
6343 return;
6344 }
6345 },
6346
6347 onClose() {
6348 leaveRoom(prId, sessionId);
6349 unregisterSocket(prId, sessionId);
6350 broadcastToRoom(prId, { type: "leave", sessionId });
6351 },
6352 };
6353 })
6354);
6355
56316356export 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;
Modifiedsrc/routes/search.tsx+14−3View fileUnifiedSplit
943943 const unread = user ? await getUnreadCount(user.id) : 0;
944944 const shortcuts: Array<{ keys: string; desc: string; section?: string }> = [
945945 { keys: "/", desc: "Focus global search", section: "Global" },
946 { keys: "Cmd/Ctrl + K", desc: "Open command palette / AI assistant" },
946 { keys: "Cmd/Ctrl + K", desc: "Open command palette (merges repo-context commands on repo pages)" },
947947 { keys: "?", desc: "Show keyboard shortcuts" },
948 { keys: "n", desc: "New repository" },
949 { keys: "g d", desc: "Go to dashboard" },
948 { keys: "n", desc: "New repository (or wait for chord)" },
949 { keys: "n i", desc: "New issue in current repo (on repo pages)", section: "Repo chords" },
950 { keys: "n p", desc: "New pull request in current repo (on repo pages)" },
951 { keys: "g d", desc: "Go to dashboard", section: "Global" },
950952 { keys: "g n", desc: "Go to notifications" },
951953 { keys: "g e", desc: "Go to explore" },
952954 { keys: "g a", desc: "Go to AI ask" },
954956 { keys: "k", desc: "Move selection up on list pages" },
955957 { keys: "Enter", desc: "Open selected item" },
956958 { keys: "x", desc: "Toggle select on focused item" },
959 { keys: "c", desc: "Focus comment textarea", section: "Pull requests" },
960 { keys: "e", desc: "Edit PR title" },
961 { keys: "m", desc: "Focus merge button" },
962 { keys: "a", desc: "Approve (navigate to review page with approve action)" },
963 { keys: "r", desc: "Request changes (navigate to review page)" },
964 { keys: "Escape", desc: "Blur current focus" },
965 { keys: "c", desc: "Focus comment textarea", section: "Issues" },
966 { keys: "e", desc: "Scroll to issue title" },
967 { keys: "x", desc: "Close/reopen issue (with confirm)" },
957968 ];
958969
959970 const sections = [...new Set(shortcuts.map((s) => s.section ?? "Global"))];
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+813−65View fileUnifiedSplit
1919 issues,
2020 labels,
2121 issueLabels,
22 repoOnboardingData,
2223} from "../db/schema";
2324import { Layout } from "../views/layout";
2425import { PendingCommentsBanner as RepoHomePendingBanner } from "../views/pending-comments-banner";
5859import type { HealthScore } from "../lib/health-score";
5960import { softAuth, requireAuth } from "../middleware/auth";
6061import type { AuthEnv } from "../middleware/auth";
62import { claudeSemanticSearch } from "../lib/claude-semantic-search";
63import { isAiAvailable } from "../lib/ai-client";
6164import { trackByName } from "../lib/traffic";
6265import { LandingPage, type LandingLiveFeed } from "../views/landing";
6366import { Landing2030Page } from "../views/landing-2030";
23472350 return c.redirect(`/${ownerName}/${repoName}`);
23482351});
23492352
2353// ---------------------------------------------------------------------------
2354// Onboarding card CSS (injected inline on repo home, scoped to .ob-*)
2355// ---------------------------------------------------------------------------
2356const obCss = `
2357 .ob-card {
2358 position: relative;
2359 margin: 14px 0 18px;
2360 background: linear-gradient(135deg, rgba(140,109,255,0.08), rgba(54,197,214,0.05));
2361 border: 1px solid rgba(140,109,255,0.30);
2362 border-radius: 14px;
2363 overflow: hidden;
2364 }
2365 .ob-card::before {
2366 content: '';
2367 position: absolute;
2368 top: 0; left: 0; right: 0;
2369 height: 2px;
2370 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
2371 pointer-events: none;
2372 }
2373 .ob-header {
2374 padding: 16px 20px 10px;
2375 border-bottom: 1px solid var(--border);
2376 }
2377 .ob-header h3 {
2378 font-size: 16px;
2379 font-weight: 700;
2380 margin: 0 0 4px;
2381 color: var(--text-strong);
2382 }
2383 .ob-header p {
2384 font-size: 13px;
2385 color: var(--text-muted);
2386 margin: 0;
2387 }
2388 .ob-sections {
2389 display: grid;
2390 grid-template-columns: repeat(3, 1fr);
2391 gap: 0;
2392 }
2393 @media (max-width: 760px) {
2394 .ob-sections { grid-template-columns: 1fr; }
2395 }
2396 .ob-section {
2397 padding: 14px 20px;
2398 border-right: 1px solid var(--border);
2399 }
2400 .ob-section:last-child { border-right: none; }
2401 .ob-section h4 {
2402 font-size: 12px;
2403 font-weight: 700;
2404 text-transform: uppercase;
2405 letter-spacing: 0.08em;
2406 color: var(--accent);
2407 margin: 0 0 8px;
2408 }
2409 .ob-preview {
2410 font-family: var(--font-mono);
2411 font-size: 11.5px;
2412 background: var(--bg);
2413 border: 1px solid var(--border);
2414 border-radius: 6px;
2415 padding: 8px 10px;
2416 color: var(--text-muted);
2417 line-height: 1.55;
2418 margin-bottom: 8px;
2419 white-space: pre-wrap;
2420 overflow: hidden;
2421 max-height: 80px;
2422 position: relative;
2423 }
2424 .ob-preview::after {
2425 content: '';
2426 position: absolute;
2427 bottom: 0; left: 0; right: 0;
2428 height: 24px;
2429 background: linear-gradient(transparent, var(--bg));
2430 pointer-events: none;
2431 }
2432 .ob-labels {
2433 display: flex;
2434 flex-wrap: wrap;
2435 gap: 5px;
2436 margin-bottom: 8px;
2437 }
2438 .ob-label-chip {
2439 display: inline-flex;
2440 align-items: center;
2441 padding: 2px 8px;
2442 border-radius: 9999px;
2443 font-size: 11.5px;
2444 font-weight: 600;
2445 line-height: 1.5;
2446 border: 1px solid transparent;
2447 }
2448 .ob-section ul {
2449 margin: 0;
2450 padding: 0 0 0 16px;
2451 font-size: 13px;
2452 color: var(--text-muted);
2453 line-height: 1.7;
2454 }
2455 .ob-section ul li { margin-bottom: 2px; }
2456 .ob-footer {
2457 display: flex;
2458 align-items: center;
2459 justify-content: flex-end;
2460 padding: 10px 20px;
2461 border-top: 1px solid var(--border);
2462 gap: 10px;
2463 }
2464 .ob-dismiss {
2465 appearance: none;
2466 background: transparent;
2467 border: 1px solid var(--border);
2468 color: var(--text-muted);
2469 border-radius: 6px;
2470 padding: 5px 12px;
2471 font-size: 12.5px;
2472 font-family: inherit;
2473 cursor: pointer;
2474 transition: background var(--t-fast), color var(--t-fast);
2475 }
2476 .ob-dismiss:hover { background: var(--bg-hover); color: var(--text); }
2477 .btn-sm {
2478 appearance: none;
2479 background: var(--bg-elevated);
2480 border: 1px solid var(--border);
2481 color: var(--text-strong);
2482 border-radius: 6px;
2483 padding: 5px 12px;
2484 font-size: 12.5px;
2485 font-weight: 600;
2486 font-family: inherit;
2487 cursor: pointer;
2488 text-decoration: none;
2489 display: inline-flex;
2490 align-items: center;
2491 transition: background var(--t-fast);
2492 }
2493 .btn-sm:hover { background: var(--bg-hover); }
2494 .btn-sm.btn-primary {
2495 background: var(--accent);
2496 color: #fff;
2497 border-color: var(--accent);
2498 }
2499 .btn-sm.btn-primary:hover { background: var(--accent-hover); }
2500`;
2501
2502// Onboarding card component — shown to repo owner until dismissed
2503function RepoOnboardingCard({
2504 owner,
2505 repo,
2506 data,
2507}: {
2508 owner: string;
2509 repo: string;
2510 data: typeof repoOnboardingData.$inferSelect;
2511}) {
2512 const labels = (data.suggestedLabels ?? []) as Array<{
2513 name: string;
2514 color: string;
2515 description: string;
2516 }>;
2517 const suggestions = (data.firstCommitSuggestions ?? []) as string[];
2518 const readmePreview = (data.suggestedReadme ?? "").slice(0, 200);
2519
2520 return (
2521 <>
2522 <style dangerouslySetInnerHTML={{ __html: obCss }} />
2523 <div class="ob-card" id="repo-onboarding">
2524 <div class="ob-header">
2525 <h3>Get started with {owner}/{repo}</h3>
2526 <p>
2527 Detected: {data.detectedLanguage ?? "Unknown"}
2528 {data.detectedFramework ? ` / ${data.detectedFramework}` : ""}.
2529 Here&rsquo;s what we suggest to hit the ground running.
2530 </p>
2531 </div>
2532 <div class="ob-sections">
2533 <div class="ob-section">
2534 <h4>Suggested README</h4>
2535 <div class="ob-preview">{readmePreview}</div>
2536 <button
2537 class="btn-sm"
2538 type="button"
2539 onclick={`(function(){var t=${JSON.stringify(data.suggestedReadme ?? "")};try{navigator.clipboard.writeText(t).then(function(){var b=document.querySelector('.ob-copy-btn');if(b){b.textContent='Copied!';setTimeout(function(){b.textContent='Copy to clipboard';},2000);}});}catch(_){};})()`}
2540 aria-label="Copy suggested README to clipboard"
2541 >
2542 Copy to clipboard
2543 </button>
2544 </div>
2545 <div class="ob-section">
2546 <h4>Suggested labels ({labels.length})</h4>
2547 <div class="ob-labels">
2548 {labels.slice(0, 6).map((l) => (
2549 <span
2550 class="ob-label-chip"
2551 style={`background:#${l.color}22;color:#${l.color};border-color:#${l.color}55`}
2552 title={l.description}
2553 >
2554 {l.name}
2555 </span>
2556 ))}
2557 </div>
2558 <form method="post" action={`/${owner}/${repo}/setup/labels`}>
2559 <button class="btn-sm btn-primary" type="submit">
2560 Create all labels
2561 </button>
2562 </form>
2563 </div>
2564 <div class="ob-section">
2565 <h4>First steps</h4>
2566 <ul>
2567 {suggestions.map((s) => (
2568 <li>{s}</li>
2569 ))}
2570 </ul>
2571 </div>
2572 </div>
2573 <div class="ob-footer">
2574 <form method="post" action={`/${owner}/${repo}/setup/dismiss`}>
2575 <button class="ob-dismiss" type="submit">
2576 Dismiss
2577 </button>
2578 </form>
2579 </div>
2580 <script
2581 dangerouslySetInnerHTML={{
2582 __html: `
2583 (function(){
2584 var card = document.getElementById('repo-onboarding');
2585 if (!card) return;
2586 document.addEventListener('keydown', function(e){
2587 if (e.key === 'Escape' && card && card.style.display !== 'none') {
2588 card.style.display = 'none';
2589 }
2590 });
2591 })();
2592 `,
2593 }}
2594 />
2595 </div>
2596 </>
2597 );
2598}
2599
2600// ---------------------------------------------------------------------------
2601// Setup routes — create suggested labels + dismiss onboarding
2602// ---------------------------------------------------------------------------
2603
2604// POST /:owner/:repo/setup/labels — creates all suggested labels for the repo.
2605// requireAuth + write access. Idempotent (label UPSERT by name).
2606web.post("/:owner/:repo/setup/labels", softAuth, requireAuth, async (c) => {
2607 const { owner, repo } = c.req.param();
2608 const user = c.get("user")!;
2609
2610 // Resolve repo + verify write access
2611 let repoRow: { id: string; ownerId: string } | null = null;
2612 try {
2613 const [ownerUser] = await db
2614 .select({ id: users.id })
2615 .from(users)
2616 .where(eq(users.username, owner))
2617 .limit(1);
2618 if (!ownerUser) return c.redirect(`/${owner}/${repo}?error=Not+found`);
2619 const [r] = await db
2620 .select({ id: repositories.id, ownerId: repositories.ownerId })
2621 .from(repositories)
2622 .where(
2623 and(
2624 eq(repositories.ownerId, ownerUser.id),
2625 eq(repositories.name, repo)
2626 )
2627 )
2628 .limit(1);
2629 repoRow = r ?? null;
2630 } catch {
2631 return c.redirect(`/${owner}/${repo}?error=Database+error`);
2632 }
2633 if (!repoRow) return c.redirect(`/${owner}/${repo}?error=Not+found`);
2634 if (repoRow.ownerId !== user.id) {
2635 return c.redirect(`/${owner}/${repo}?error=Forbidden`);
2636 }
2637
2638 // Fetch the onboarding data
2639 let obRow: (typeof repoOnboardingData.$inferSelect) | null = null;
2640 try {
2641 const [r] = await db
2642 .select()
2643 .from(repoOnboardingData)
2644 .where(eq(repoOnboardingData.repositoryId, repoRow.id))
2645 .limit(1);
2646 obRow = r ?? null;
2647 } catch {
2648 return c.redirect(`/${owner}/${repo}?error=Onboarding+data+not+found`);
2649 }
2650 if (!obRow) {
2651 return c.redirect(`/${owner}/${repo}?toast=info:No+onboarding+data+found`);
2652 }
2653
2654 const suggestedLabels = (obRow.suggestedLabels ?? []) as Array<{
2655 name: string;
2656 color: string;
2657 description: string;
2658 }>;
2659
2660 // Create labels — import the labels table which was already imported
2661 let created = 0;
2662 for (const l of suggestedLabels) {
2663 try {
2664 await db
2665 .insert(labels)
2666 .values({
2667 repositoryId: repoRow.id,
2668 name: l.name,
2669 color: l.color,
2670 description: l.description ?? "",
2671 })
2672 .onConflictDoNothing();
2673 created++;
2674 } catch {
2675 /* skip duplicates */
2676 }
2677 }
2678
2679 // Mark onboarding dismissed
2680 try {
2681 await db
2682 .update(repositories)
2683 .set({ onboardingShown: true })
2684 .where(eq(repositories.id, repoRow.id));
2685 } catch {
2686 /* ignore */
2687 }
2688
2689 return c.redirect(
2690 `/${owner}/${repo}?success=${encodeURIComponent(`Created ${created} labels`)}`
2691 );
2692});
2693
2694// POST /:owner/:repo/setup/dismiss — marks onboarding as shown.
2695web.post("/:owner/:repo/setup/dismiss", softAuth, requireAuth, async (c) => {
2696 const { owner, repo } = c.req.param();
2697 const user = c.get("user")!;
2698
2699 try {
2700 const [ownerUser] = await db
2701 .select({ id: users.id })
2702 .from(users)
2703 .where(eq(users.username, owner))
2704 .limit(1);
2705 if (ownerUser) {
2706 const [r] = await db
2707 .select({ id: repositories.id, ownerId: repositories.ownerId })
2708 .from(repositories)
2709 .where(
2710 and(
2711 eq(repositories.ownerId, ownerUser.id),
2712 eq(repositories.name, repo)
2713 )
2714 )
2715 .limit(1);
2716 if (r && r.ownerId === user.id) {
2717 await db
2718 .update(repositories)
2719 .set({ onboardingShown: true })
2720 .where(eq(repositories.id, r.id));
2721 }
2722 }
2723 } catch {
2724 /* swallow — dismiss should never fail visibly */
2725 }
2726
2727 return c.redirect(`/${owner}/${repo}`);
2728});
2729
23502730// Repository overview — file tree at HEAD
23512731web.get("/:owner/:repo", async (c) => {
23522732 const { owner, repo } = c.req.param();
25842964 }
25852965 }
25862966
2967 // Onboarding card — shown to repo owner until dismissed. Best-effort;
2968 // if the DB is down the card simply won't appear. Only the owner sees it.
2969 let onboardingRow: (typeof repoOnboardingData.$inferSelect) | null = null;
2970 let showOnboarding = false;
2971 if (repoId && user && repoOwnerId && user.id === repoOwnerId) {
2972 try {
2973 // Check if onboarding_shown is still false (not yet dismissed)
2974 const [repoRow2] = await db
2975 .select({ onboardingShown: repositories.onboardingShown })
2976 .from(repositories)
2977 .where(eq(repositories.id, repoId))
2978 .limit(1);
2979 if (repoRow2 && !repoRow2.onboardingShown) {
2980 const [obRow] = await db
2981 .select()
2982 .from(repoOnboardingData)
2983 .where(eq(repoOnboardingData.repositoryId, repoId))
2984 .limit(1);
2985 onboardingRow = obRow ?? null;
2986 showOnboarding = !!onboardingRow;
2987 }
2988 } catch {
2989 /* swallow — onboarding is optional */
2990 }
2991 }
2992
25872993 // Repo-home polish — shared style block (Block 2.A — parallel session 2.A).
25882994 // Scoped via .repo-home-* class prefix to prevent bleed into other surfaces.
25892995 const repoHomeCss = `
33413747 twitterCard="summary"
33423748 >
33433749 <style dangerouslySetInnerHTML={{ __html: repoHomeCss }} />
3750 {/* Repo-context commands for the command palette (Feature 2) */}
3751 <script
3752 id="cmdk-repo-context"
3753 dangerouslySetInnerHTML={{
3754 __html: `window.__CMDK_REPO_COMMANDS = ${JSON.stringify([
3755 { label: `New issue in ${repo}`, href: `/${owner}/${repo}/issues/new`, kw: "create add bug" },
3756 { label: `New pull request in ${repo}`, href: `/${owner}/${repo}/pulls/new`, kw: "pr branch merge" },
3757 { label: `Browse code — ${owner}/${repo}`, href: `/${owner}/${repo}`, kw: "files tree" },
3758 { label: `View commits — ${owner}/${repo}`, href: `/${owner}/${repo}/commits`, kw: "history log" },
3759 { label: `Search code — ${owner}/${repo}`, href: `/${owner}/${repo}/search`, kw: "grep find" },
3760 { label: `AI explain codebase — ${owner}/${repo}`, href: `/${owner}/${repo}/explain`, kw: "understand" },
3761 { label: `Debt map — ${owner}/${repo}`, href: `/${owner}/${repo}/debt-map`, kw: "technical debt" },
3762 { label: `Repo settings — ${owner}/${repo}`, href: `/${owner}/${repo}/settings`, kw: "config" },
3763 { label: `Issues — ${owner}/${repo}`, href: `/${owner}/${repo}/issues`, kw: "bugs tasks" },
3764 { label: `Pull requests — ${owner}/${repo}`, href: `/${owner}/${repo}/pulls`, kw: "prs reviews" },
3765 ])};`,
3766 }}
3767 />
33443768 <div class="repo-home-hero">
33453769 <div class="repo-home-hero-orb-wrap" aria-hidden="true">
33463770 <div class="repo-home-hero-orb" />
34513875 repo={repo}
34523876 count={repoHomePendingCount}
34533877 />
3878 {showOnboarding && onboardingRow && (
3879 <RepoOnboardingCard
3880 owner={owner}
3881 repo={repo}
3882 data={onboardingRow}
3883 />
3884 )}
34543885 {/* ─── Per-repo AI surfaces — RepoNav is locked, so the discovery
34553886 row sits just below the nav as a slim CTA strip. Scoped under
34563887 `.repo-ai-cta-` so the styles can't bleed onto other pages. ─── */}
35343965 <span class="repo-ai-cta-icon" aria-hidden="true">{"⛁"}</span>
35353966 Migrations
35363967 </a>
3537 <a class="repo-ai-cta" href={`/${owner}/${repo}/semantic-search`} title="Embedding-backed code search">
3968 <a class="repo-ai-cta" href={`/${owner}/${repo}/search?mode=semantic`} title="AI-powered semantic code search — ask in plain English">
35383969 <span class="repo-ai-cta-icon" aria-hidden="true">{"✨"}</span>
3539 Semantic search
3970 AI Search
35403971 </a>
35413972 <a class="repo-ai-cta" href={`/${owner}/${repo}/releases/new`} title="Draft release notes with AI">
35423973 <span class="repo-ai-cta-icon" aria-hidden="true">{"\u{1F3F7}"}</span>
52825713 );
52835714});
52845715
5285// Search
5716// Search — keyword + optional Claude semantic mode
52865717web.get("/:owner/:repo/search", async (c) => {
52875718 const { owner, repo } = c.req.param();
52885719 const user = c.get("user");
52895720 const q = c.req.query("q") || "";
5721 const aiAvailable = isAiAvailable();
5722 // Default to semantic when Claude is available and no explicit mode set.
5723 const modeParam = c.req.query("mode");
5724 const mode: "semantic" | "keyword" =
5725 modeParam === "keyword"
5726 ? "keyword"
5727 : modeParam === "semantic"
5728 ? "semantic"
5729 : aiAvailable
5730 ? "semantic"
5731 : "keyword";
5732 const isSemantic = mode === "semantic" && aiAvailable;
52905733
52915734 if (!(await repoExists(owner, repo))) return c.notFound();
52925735
52935736 const defaultBranch = (await getDefaultBranch(owner, repo)) || "main";
5294 let results: Array<{ file: string; lineNum: number; line: string }> = [];
5737
5738 // Keyword results (always available as fallback / when in keyword mode)
5739 let keywordResults: Array<{ file: string; lineNum: number; line: string }> = [];
5740 // Semantic results (Claude-powered)
5741 type SemanticHit = import("../lib/claude-semantic-search").SemanticSearchResult;
5742 let semanticHits: SemanticHit[] = [];
5743 let semanticMode: "semantic" | "keyword" = "semantic";
5744 let quotaExceeded = false;
52955745
52965746 if (q.trim()) {
5297 results = await searchCode(owner, repo, defaultBranch, q.trim());
5747 if (isSemantic) {
5748 // Resolve repo DB id for caching
5749 const [ownerRow] = await db
5750 .select({ id: users.id })
5751 .from(users)
5752 .where(eq(users.username, owner))
5753 .limit(1);
5754 const [repoRow] = ownerRow
5755 ? await db
5756 .select({ id: repositories.id })
5757 .from(repositories)
5758 .where(
5759 and(
5760 eq(repositories.ownerId, ownerRow.id),
5761 eq(repositories.name, repo)
5762 )
5763 )
5764 .limit(1)
5765 : [];
5766
5767 const rateLimitKey = user
5768 ? `user:${user.id}`
5769 : (c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ?? "anon");
5770
5771 const searchResult = await claudeSemanticSearch(
5772 owner,
5773 repo,
5774 repoRow?.id ?? `${owner}/${repo}`,
5775 q.trim(),
5776 { branch: defaultBranch, rateLimitKey }
5777 );
5778 semanticHits = searchResult.results;
5779 semanticMode = searchResult.mode;
5780 quotaExceeded = searchResult.quotaExceeded;
5781 } else {
5782 keywordResults = await searchCode(owner, repo, defaultBranch, q.trim());
5783 }
52985784 }
52995785
5786 const aiSearchCss = `
5787 /* ─── Semantic search mode toggle ─── */
5788 .search-mode-bar {
5789 display: flex;
5790 align-items: center;
5791 gap: 8px;
5792 margin-bottom: 14px;
5793 flex-wrap: wrap;
5794 }
5795 .search-mode-label {
5796 font-size: 12.5px;
5797 color: var(--text-muted);
5798 font-weight: 500;
5799 }
5800 .search-mode-toggle {
5801 display: inline-flex;
5802 background: var(--bg-elevated);
5803 border: 1px solid var(--border);
5804 border-radius: 9999px;
5805 padding: 3px;
5806 gap: 2px;
5807 }
5808 .search-mode-btn {
5809 display: inline-flex;
5810 align-items: center;
5811 gap: 5px;
5812 padding: 5px 12px;
5813 border-radius: 9999px;
5814 font-size: 12.5px;
5815 font-weight: 500;
5816 color: var(--text-muted);
5817 text-decoration: none;
5818 transition: color 120ms ease, background 120ms ease;
5819 cursor: pointer;
5820 border: none;
5821 background: none;
5822 font: inherit;
5823 }
5824 .search-mode-btn:hover { color: var(--text-strong); text-decoration: none; }
5825 .search-mode-btn.active {
5826 background: rgba(140,109,255,0.15);
5827 color: var(--text-strong);
5828 }
5829 .search-mode-ai-badge {
5830 display: inline-flex;
5831 align-items: center;
5832 gap: 4px;
5833 padding: 2px 8px;
5834 border-radius: 9999px;
5835 font-size: 11px;
5836 font-weight: 600;
5837 background: linear-gradient(135deg, rgba(140,109,255,0.20), rgba(54,197,214,0.15));
5838 color: #c4b5fd;
5839 border: 1px solid rgba(140,109,255,0.30);
5840 margin-left: 2px;
5841 }
5842 .search-quota-warn {
5843 font-size: 12.5px;
5844 color: var(--text-muted);
5845 padding: 6px 12px;
5846 background: rgba(255,200,0,0.06);
5847 border: 1px solid rgba(255,200,0,0.20);
5848 border-radius: 8px;
5849 }
5850
5851 /* ─── Semantic result cards ─── */
5852 .sem-results { display: flex; flex-direction: column; gap: 10px; }
5853 .sem-result {
5854 padding: 14px 16px;
5855 background: var(--bg-elevated);
5856 border: 1px solid var(--border);
5857 border-radius: 12px;
5858 transition: border-color 120ms ease;
5859 }
5860 .sem-result:hover { border-color: var(--border-strong); }
5861 .sem-result-head {
5862 display: flex;
5863 align-items: flex-start;
5864 justify-content: space-between;
5865 gap: 10px;
5866 flex-wrap: wrap;
5867 margin-bottom: 6px;
5868 }
5869 .sem-result-path {
5870 font-family: var(--font-mono);
5871 font-size: 13px;
5872 font-weight: 600;
5873 color: var(--text-strong);
5874 text-decoration: none;
5875 word-break: break-all;
5876 }
5877 .sem-result-path:hover { color: #c4b5fd; text-decoration: none; }
5878 .sem-result-conf {
5879 display: inline-flex;
5880 align-items: center;
5881 gap: 4px;
5882 padding: 2px 9px;
5883 border-radius: 9999px;
5884 font-size: 11.5px;
5885 font-weight: 600;
5886 white-space: nowrap;
5887 flex-shrink: 0;
5888 }
5889 .sem-conf-strong { background: rgba(52,211,153,0.12); color: #34d399; border: 1px solid rgba(52,211,153,0.30); }
5890 .sem-conf-possible { background: rgba(140,109,255,0.12); color: #b69dff; border: 1px solid rgba(140,109,255,0.30); }
5891 .sem-conf-weak { background: rgba(255,255,255,0.04); color: var(--text-muted); border: 1px solid var(--border); }
5892 .sem-result-reason {
5893 font-size: 12.5px;
5894 color: var(--text-muted);
5895 margin-bottom: 8px;
5896 line-height: 1.5;
5897 }
5898 .sem-result-snippet {
5899 margin: 0;
5900 padding: 10px 12px;
5901 background: rgba(0,0,0,0.22);
5902 border: 1px solid var(--border);
5903 border-radius: 8px;
5904 font-family: var(--font-mono);
5905 font-size: 12px;
5906 line-height: 1.55;
5907 color: var(--text);
5908 overflow-x: auto;
5909 white-space: pre-wrap;
5910 word-break: break-word;
5911 max-height: 240px;
5912 overflow-y: auto;
5913 }
5914 `;
5915
5916 const semanticBaseUrl = `/${owner}/${repo}/search?mode=semantic`;
5917 const keywordBaseUrl = `/${owner}/${repo}/search?mode=keyword`;
5918
53005919 return c.html(
53015920 <Layout title={`Search — ${owner}/${repo}`} user={user}>
5302 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
5921 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss + aiSearchCss }} />
53035922 <RepoHeader owner={owner} repo={repo} />
53045923 <RepoNav owner={owner} repo={repo} active="code" />
53055924 <div class="search-hero">
53075926 <strong>Search</strong> · {owner}/{repo}
53085927 </div>
53095928 <h1 class="search-title">
5310 Find any line in <span class="gradient-text">{repo}</span>
5929 {isSemantic ? (
5930 <>{"Ask "}<span class="gradient-text">{repo}</span>{" anything."}</>
5931 ) : (
5932 <>{"Find any line in "}<span class="gradient-text">{repo}</span></>
5933 )}
53115934 </h1>
53125935 <form
53135936 method="get"
53155938 class="search-form"
53165939 role="search"
53175940 >
5941 <input type="hidden" name="mode" value={mode} />
53185942 <div class="search-input-wrap">
53195943 <span class="search-input-icon" aria-hidden="true">{"⌕"}</span>
53205944 <input
53215945 type="text"
53225946 name="q"
53235947 value={q}
5324 placeholder="Search code on the default branch…"
5948 placeholder={
5949 isSemantic
5950 ? "Ask a question or describe what you're looking for…"
5951 : "Search code on the default branch…"
5952 }
53255953 aria-label="Search code"
53265954 class="search-input"
53275955 autocomplete="off"
53335961 </button>
53345962 </form>
53355963 </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" : ""}
5964
5965 {/* Mode toggle */}
5966 <div class="search-mode-bar">
5967 <span class="search-mode-label">Mode:</span>
5968 <div class="search-mode-toggle" role="group" aria-label="Search mode">
5969 {aiAvailable ? (
5970 <a
5971 href={q ? `${semanticBaseUrl}&q=${encodeURIComponent(q)}` : semanticBaseUrl}
5972 class={`search-mode-btn${isSemantic ? " active" : ""}`}
5973 aria-pressed={isSemantic ? "true" : "false"}
5974 >
5975 {"✨"} Semantic AI
5976 <span class="search-mode-ai-badge">AI-powered</span>
5977 </a>
5978 ) : null}
5979 <a
5980 href={q ? `${keywordBaseUrl}&q=${encodeURIComponent(q)}` : keywordBaseUrl}
5981 class={`search-mode-btn${!isSemantic ? " active" : ""}`}
5982 aria-pressed={!isSemantic ? "true" : "false"}
5983 >
5984 {"⌕"} Keyword
5985 </a>
5986 </div>
5987 {isSemantic && aiAvailable && (
5988 <span style="font-size:12px;color:var(--text-muted)">
5989 Ask in plain English — finds code by meaning, not just exact words
53415990 </span>
5342 <span class="search-results-query">
5343 for <span class="search-results-q">"{q}"</span> on{" "}
5344 <code>{defaultBranch}</code>
5991 )}
5992 {quotaExceeded && (
5993 <span class="search-quota-warn">
5994 Semantic search quota reached — showing keyword results
53455995 </span>
5346 </div>
5996 )}
5997 </div>
5998
5999 {/* ─── Semantic results ─── */}
6000 {isSemantic && q && (
6001 <>
6002 {semanticMode === "keyword" && !quotaExceeded && semanticHits.length > 0 && (
6003 <div class="search-quota-warn" style="margin-bottom:10px">
6004 Falling back to keyword search — Claude found no relevant files.
6005 </div>
6006 )}
6007 {semanticHits.length === 0 ? (
6008 <div class="search-empty">
6009 <p>
6010 No matches for <strong>"{q}"</strong>.{" "}
6011 Try a different phrasing or{" "}
6012 <a href={`${keywordBaseUrl}&q=${encodeURIComponent(q)}`}>
6013 switch to keyword search
6014 </a>.
6015 </p>
6016 </div>
6017 ) : (
6018 <>
6019 <div class="search-results-head">
6020 <span class="search-results-count">
6021 <strong>{semanticHits.length}</strong> result
6022 {semanticHits.length !== 1 ? "s" : ""}
6023 </span>
6024 <span class="search-results-query">
6025 {semanticMode === "semantic" ? "AI-ranked files for" : "keyword matches for"}{" "}
6026 <span class="search-results-q">"{q}"</span>
6027 </span>
6028 </div>
6029 <div class="sem-results">
6030 {semanticHits.map((h) => {
6031 const confClass =
6032 h.confidence > 0.7
6033 ? "sem-conf-strong"
6034 : h.confidence > 0.4
6035 ? "sem-conf-possible"
6036 : "sem-conf-weak";
6037 const confLabel =
6038 h.confidence > 0.7
6039 ? "Strong match"
6040 : h.confidence > 0.4
6041 ? "Possible match"
6042 : "Weak match";
6043 const href = `/${owner}/${repo}/blob/${defaultBranch}/${h.file}${h.lineNumber ? `#L${h.lineNumber}` : ""}`;
6044 const preview =
6045 h.snippet.length > 800
6046 ? h.snippet.slice(0, 800) + "\n…"
6047 : h.snippet;
6048 return (
6049 <div class="sem-result">
6050 <div class="sem-result-head">
6051 <a href={href} class="sem-result-path">
6052 {h.file}
6053 {h.lineNumber ? (
6054 <span style="color:var(--text-muted);font-weight:500">
6055 :{h.lineNumber}
6056 </span>
6057 ) : null}
6058 </a>
6059 <span class={`sem-result-conf ${confClass}`}>
6060 {confLabel}
6061 </span>
6062 </div>
6063 {h.reason && (
6064 <p class="sem-result-reason">{h.reason}</p>
6065 )}
6066 {preview && (
6067 <pre class="sem-result-snippet">{preview}</pre>
6068 )}
6069 </div>
6070 );
6071 })}
6072 </div>
6073 </>
6074 )}
6075 </>
53476076 )}
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}
6077
6078 {/* ─── Keyword results ─── */}
6079 {!isSemantic && q && (
6080 <>
6081 <div class="search-results-head">
6082 <span class="search-results-count">
6083 <strong>{keywordResults.length}</strong> result
6084 {keywordResults.length !== 1 ? "s" : ""}
6085 </span>
6086 <span class="search-results-query">
6087 for <span class="search-results-q">"{q}"</span> on{" "}
6088 <code>{defaultBranch}</code>
6089 </span>
6090 </div>
6091 {keywordResults.length === 0 ? (
6092 <div class="search-empty">
6093 <p>
6094 No matches for <strong>"{q}"</strong>. Try a shorter query or{" "}
6095 {aiAvailable && (
6096 <a href={`${semanticBaseUrl}&q=${encodeURIComponent(q)}`}>
6097 try AI semantic search
53756098 </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}
6099 )}.
6100 </p>
6101 </div>
6102 ) : (
6103 <div class="search-results">
6104 {(() => {
6105 const grouped: Record<
6106 string,
6107 Array<{ lineNum: number; line: string }>
6108 > = {};
6109 for (const r of keywordResults) {
6110 if (!grouped[r.file]) grouped[r.file] = [];
6111 grouped[r.file].push({ lineNum: r.lineNum, line: r.line });
6112 }
6113 return Object.entries(grouped).map(([file, matches]) => (
6114 <div class="search-file diff-file">
6115 <div class="search-file-head diff-file-header">
6116 <a
6117 href={`/${owner}/${repo}/blob/${defaultBranch}/${file}`}
6118 class="search-file-link"
6119 >
6120 {file}
6121 </a>
6122 <span class="search-file-count">
6123 {matches.length} match{matches.length === 1 ? "" : "es"}
6124 </span>
6125 </div>
6126 <div class="blob-code">
6127 <table>
6128 <tbody>
6129 {matches.map((m) => (
6130 <tr>
6131 <td class="line-num">{m.lineNum}</td>
6132 <td class="line-content">{m.line}</td>
6133 </tr>
6134 ))}
6135 </tbody>
6136 </table>
6137 </div>
6138 </div>
6139 ));
6140 })()}
6141 </div>
6142 )}
6143 </>
6144 )}
53976145 </Layout>
53986146 );
53996147});
Modifiedsrc/views/components.tsx+15−1View fileUnifiedSplit
150150 | "agents"
151151 | "discussions"
152152 | "security"
153 | "settings";
153 | "settings"
154 | "debt-map";
154155}> = ({ owner, repo, active }) => (
155156 <div class="repo-nav">
156157 <a href={`/${owner}/${repo}`} class={active === "code" ? "active" : ""}>
216217 >
217218 Security
218219 </a>
220 <a
221 href={`/${owner}/${repo}/cloud-deployments`}
222 class={active === "deployments" ? "active" : ""}
223 >
224 Deployments
225 </a>
219226 <a
220227 href={`/${owner}/${repo}/insights`}
221228 class={active === "insights" ? "active" : ""}
252259 >
253260 {"\u2728"} Tests
254261 </a>
262 <a
263 href={`/${owner}/${repo}/debt-map`}
264 class={`repo-nav-ai${active === "debt-map" ? " active" : ""}`}
265 title="AI Debt Map \u2014 visual technical debt graph with Claude analysis"
266 >
267 {"\u2593"} Debt Map
268 </a>
255269 </div>
256270);
257271
Modifiedsrc/views/layout.tsx+92−3View fileUnifiedSplit
235235 </a>
236236 </div>
237237 </div>
238 {/* Smart morning digest link */}
239 <a href="/digest" class="nav-link" title="Morning Digest" aria-label="Morning Digest">
240 {"☀"}
241 </a>
238242 {/* Inbox bell with unread badge */}
239243 <a
240244 href="/inbox"
278282 <a href="/pulls" role="menuitem" class="nav-user-item">Pull requests</a>
279283 <a href="/issues" role="menuitem" class="nav-user-item">Issues</a>
280284 <a href="/activity" role="menuitem" class="nav-user-item">Activity</a>
285 <a href="/insights" role="menuitem" class="nav-user-item">Insights</a>
281286 <a href="/import" role="menuitem" class="nav-user-item">Import from GitHub</a>
282287 <a href="/import/actions" role="menuitem" class="nav-user-item">Actions importer</a>
283288 <div class="nav-user-menu-sep" />
427432 <script dangerouslySetInnerHTML={{ __html: toastScript }} />
428433 <script dangerouslySetInnerHTML={{ __html: navScript }} />
429434 <script dangerouslySetInnerHTML={{ __html: navAiDropdownScript }} />
435 {/* Bell badge poller — only rendered for authenticated users.
436 Polls /api/notifications/count every 60 s and updates the badge
437 on the inbox bell icon. Falls back gracefully if the endpoint is
438 unavailable or the user signs out mid-session. */}
439 {user && (
440 <script dangerouslySetInnerHTML={{ __html: bellPollerScript }} />
441 )}
430442 </body>
431443 </html>
432444 );
808820 list.innerHTML = html;
809821 }
810822
823 function getAllCommands(){
824 // Merge global COMMANDS with repo-context commands injected by repo pages.
825 var extra = (window.__CMDK_REPO_COMMANDS && Array.isArray(window.__CMDK_REPO_COMMANDS))
826 ? window.__CMDK_REPO_COMMANDS : [];
827 return COMMANDS.concat(extra);
828 }
829
811830 function openPalette(){
812831 backdrop = document.getElementById('cmdk-backdrop');
813832 panel = document.getElementById('cmdk-panel');
818837 panel.style.display = 'block';
819838 input.value = '';
820839 selected = 0;
821 filtered = COMMANDS.slice();
840 filtered = getAllCommands();
822841 render();
823842 input.focus();
824843 }
838857 document.addEventListener('input', function(e){
839858 if (e.target && e.target.id === 'cmdk-input') {
840859 var q = e.target.value;
841 filtered = COMMANDS.filter(function(c){ return fuzzyMatch(c, q); });
860 filtered = getAllCommands().filter(function(c){ return fuzzyMatch(c, q); });
842861 selected = 0;
843862 render();
844863 }
884903 e.preventDefault(); window.location.href = '/shortcuts'; return;
885904 }
886905 if (e.key === 'n' && !e.ctrlKey && !e.metaKey) {
887 e.preventDefault(); window.location.href = '/new'; return;
906 // Start 'n' chord — wait 1.2s for next key (i → issue, p → PR).
907 // If no second key arrives, navigate to /new (create repo).
908 chord = 'n';
909 clearTimeout(chordTimer);
910 chordTimer = setTimeout(function(){
911 if (chord === 'n') { window.location.href = '/new'; }
912 chord = null;
913 }, 1200);
914 return;
888915 }
889916 // "g" chord
890917 if (e.key === 'g' && !e.ctrlKey && !e.metaKey) {
900927 else if (e.key === 'a') { e.preventDefault(); window.location.href = '/ask'; }
901928 chord = null;
902929 }
930 // "n" chord — new issue / new PR (repo-context-aware)
931 if (chord === 'n') {
932 var repoCtx = window.__CMDK_REPO_COMMANDS;
933 var newIssuePath = repoCtx && repoCtx.length ? repoCtx[0].href.replace('/issues/new', '/issues/new') : null;
934 // Find the "new issue" and "new PR" hrefs from repo context commands
935 var issueCmd = repoCtx && repoCtx.find(function(c){ return c.href && c.href.indexOf('/issues/new') !== -1; });
936 var prCmd = repoCtx && repoCtx.find(function(c){ return c.href && c.href.indexOf('/pulls/new') !== -1; });
937 if (e.key === 'i' && issueCmd) {
938 e.preventDefault(); clearTimeout(chordTimer); chord = null;
939 window.location.href = issueCmd.href; return;
940 }
941 if (e.key === 'p' && prCmd) {
942 e.preventDefault(); clearTimeout(chordTimer); chord = null;
943 window.location.href = prCmd.href; return;
944 }
945 }
903946 // j/k list navigation — move through .prs-row, .issue-row, .notif-item rows
904947 if (e.key === 'j' || e.key === 'k') {
905948 var selectors = '.prs-row, .issue-row, .issue-list-item, .notif-item, .repo-item, .exp-repo-card, .disc-row';
931974 })();
932975`;
933976
977// Bell poller — updates the inbox badge count every 60 s via /api/notifications/count.
978// Only injected when a user is logged in (checked in the JSX above). Uses the
979// `.nav-inbox-badge` element that the server renders inside `.nav-inbox-btn`.
980// If the badge element is absent (user not logged in, DOM mismatch) it exits
981// cleanly without error.
982export const bellPollerScript = `
983(function() {
984 var badge = document.querySelector('.nav-inbox-btn .nav-inbox-badge');
985 var btn = document.querySelector('.nav-inbox-btn');
986 function updateBadge(n) {
987 if (!btn) return;
988 if (n > 0) {
989 var label = n > 99 ? '99+' : String(n);
990 if (!badge) {
991 badge = document.createElement('span');
992 badge.className = 'nav-inbox-badge';
993 badge.setAttribute('aria-hidden', 'true');
994 btn.appendChild(badge);
995 }
996 badge.textContent = label;
997 badge.style.display = '';
998 btn.setAttribute('aria-label', 'Inbox — ' + n + ' unread');
999 } else {
1000 if (badge) badge.style.display = 'none';
1001 btn.setAttribute('aria-label', 'Inbox');
1002 }
1003 }
1004 function poll() {
1005 fetch('/api/notifications/count', { credentials: 'same-origin', cache: 'no-store' })
1006 .then(function(r) { return r.ok ? r.json() : null; })
1007 .then(function(d) {
1008 if (!d) return;
1009 var n = typeof d.unread === 'number' ? d.unread : (typeof d.count === 'number' ? d.count : 0);
1010 updateBadge(n);
1011 })
1012 .catch(function() {});
1013 }
1014 if (document.readyState === 'loading') {
1015 document.addEventListener('DOMContentLoaded', function() { poll(); setInterval(poll, 60000); });
1016 } else {
1017 poll();
1018 setInterval(poll, 60000);
1019 }
1020})();
1021`;
1022
9341023// AI dropdown — keyboard- and click-accessible menu in the top nav.
9351024// CSS handles the hover-open behaviour for pointer users; this script
9361025// adds click-to-toggle for touch, Escape-to-close, and outside-click-
9371026