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

Merge pull request #10 from ccantynz-alt/claude/ship-fixes-and-tests-Jvz1c

Merge pull request #10 from ccantynz-alt/claude/ship-fixes-and-tests-Jvz1c

Claude/ship fixes and tests jvz1c
Dictation App committed on April 18, 2026Parents: 68dc1ef e15b568
27 files changed+4779164462688cf1da6a34a33b1dfa3a4f8363819797aaa9
27 changed files+4779−1644
ModifiedCLAUDE.md+8−0View fileUnifiedSplit
103103- `DATABASE_URL` — Neon PostgreSQL connection string
104104- `GIT_REPOS_PATH` — directory for bare git repos (default: `./repos`)
105105- `PORT` — HTTP port (default: 3000)
106
107## Deployment
108
109- **Deployed via Crontech** — Crontech is the deployment platform (NOT Vercel, NOT Hetzner)
110- **Database:** Neon PostgreSQL (direct, NOT via Vercel integration)
111- **The green ecosystem:** Crontech deploys gluecron, gluecron hosts code, GateTest scans pushes
112- **NEVER suggest Hetzner** — it is not part of our infrastructure
113- See DEPLOY.md for full deployment instructions
AddedDEPLOY.md+175−0View fileUnifiedSplit
1# Deploying Gluecron to Production
2
3## The Green Ecosystem
4
5Gluecron is part of the self-hosting ecosystem:
6- **Crontech** deploys gluecron (and everything else)
7- **Gluecron** hosts the code for Crontech, GateTest, and itself
8- **GateTest** scans every push to gluecron
9
10Once live, all three services feed each other. Dog food all the way.
11
12## Option A: Deploy via Crontech (recommended)
13
14Since Crontech handles deployment, routing, and SSL:
15
16```bash
17# 1. Create Neon database at neon.tech (2 minutes)
18# Copy the connection string
19
20# 2. In Crontech, deploy gluecron as a service with:
21# - Repo: ccantynz-alt/Gluecron.com
22# - Branch: claude/ship-fixes-and-tests-Jvz1c
23# - Build: bun install --production
24# - Start: bun run src/index.ts
25# - Port: 3000
26#
27# Environment variables:
28# DATABASE_URL = postgresql://your-neon-url
29# GIT_REPOS_PATH = /data/repos
30# PORT = 3000
31# NODE_ENV = production
32#
33# Persistent volume: mount /data/repos (for git repos on disk)
34
35# 3. Run the migration (one time):
36# Paste drizzle/0000_init.sql into Neon's SQL Editor and run it
37
38# 4. Route gluecron.com (or gluecron.crontech.ai) to the service
39```
40
41### Crontech routing options:
42- **Subdomain:** `gluecron.crontech.ai` → fastest to set up
43- **Custom domain:** `gluecron.com` → add CNAME to Crontech in DNS
44
45## Option B: Direct Deploy (any Linux server)
46
47```bash
48# 1. SSH into your server
49ssh root@your-server-ip
50
51# 2. Set your database URL
52export DATABASE_URL="postgresql://user:pass@host/gluecron?sslmode=require"
53
54# 3. Run the deploy script
55curl -fsSL https://raw.githubusercontent.com/ccantynz-alt/Gluecron.com/claude/ship-fixes-and-tests-Jvz1c/scripts/deploy.sh | bash
56```
57
58## Manual Deploy
59
60### Step 1: Database
61
621. Go to https://neon.tech and create a project called "gluecron"
632. Copy the connection string
643. Run the migration:
65```bash
66psql "your-connection-string" -f drizzle/0000_init.sql
67```
68
69### Step 2: Server Setup
70
71```bash
72# Install Bun
73curl -fsSL https://bun.sh/install | bash
74
75# Install git
76apt-get update && apt-get install -y git
77
78# Clone the repo
79git clone --branch claude/ship-fixes-and-tests-Jvz1c \
80 https://github.com/ccantynz-alt/Gluecron.com.git /opt/gluecron
81cd /opt/gluecron
82
83# Create .env
84cat > .env << EOF
85DATABASE_URL=postgresql://user:pass@host/gluecron?sslmode=require
86GIT_REPOS_PATH=/data/repos
87PORT=3000
88NODE_ENV=production
89GATETEST_URL=https://gatetest.ai/api/scan/run
90CRONTECH_DEPLOY_URL=https://crontech.ai/api/trpc/tenant.deploy
91EOF
92
93# Install dependencies
94bun install --production
95
96# Create repos directory
97mkdir -p /data/repos
98
99# Start the server
100bun run src/index.ts
101```
102
103### Step 3: HTTPS with Nginx
104
105```bash
106bash scripts/setup-nginx.sh gluecron.com
107```
108
109### Step 4: Systemd (keep it running)
110
111```bash
112# The deploy script creates this, but manually:
113cat > /etc/systemd/system/gluecron.service << EOF
114[Unit]
115Description=Gluecron
116After=network.target
117
118[Service]
119Type=simple
120WorkingDirectory=/opt/gluecron
121EnvironmentFile=/opt/gluecron/.env
122ExecStart=/root/.bun/bin/bun run src/index.ts
123Restart=always
124RestartSec=5
125
126[Install]
127WantedBy=multi-user.target
128EOF
129
130systemctl daemon-reload
131systemctl enable gluecron
132systemctl start gluecron
133```
134
135## Docker Deploy
136
137```bash
138# Create .env file with DATABASE_URL
139echo "DATABASE_URL=postgresql://..." > .env
140
141# Build and run
142docker compose up -d
143
144# Run migration
145docker compose exec gluecron bun run -e "..."
146# Or connect directly to Neon and run drizzle/0000_init.sql
147```
148
149## Operations
150
151```bash
152# View logs
153journalctl -u gluecron -f
154
155# Restart
156systemctl restart gluecron
157
158# Update to latest
159cd /opt/gluecron
160git pull origin claude/ship-fixes-and-tests-Jvz1c
161bun install
162systemctl restart gluecron
163```
164
165## Verification Checklist
166
167After deploy, verify:
168
169- [ ] `curl http://localhost:3000` returns the landing page
170- [ ] Register an account at /register
171- [ ] Create a repo at /new
172- [ ] `git clone http://gluecron.com/youruser/yourrepo.git` works
173- [ ] Push code and see it in the web UI
174- [ ] Health dashboard shows at /youruser/yourrepo/health
175- [ ] HTTPS works (if nginx + certbot set up)
ModifiedDockerfile+14−33View fileUnifiedSplit
1# ---- build stage ----
2FROM oven/bun:1 AS builder
3
1FROM oven/bun:1.3 AS base
42WORKDIR /app
53
6# Copy lockfile and manifest first for layer caching
7COPY package.json bun.lock ./
4# Install git (required for git operations)
5RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/*
86
9# Install production dependencies only
7# Install dependencies
8COPY package.json bun.lock ./
109RUN bun install --frozen-lockfile --production
1110
12# ---- production stage ----
13# oven/bun:1-debian is based on Debian so apt is available
14FROM oven/bun:1-debian AS runner
15
16WORKDIR /app
17
18# Install git (required for git CLI subprocess calls)
19RUN apt-get update \
20 && apt-get install -y --no-install-recommends git \
21 && rm -rf /var/lib/apt/lists/*
22
23# Copy production node_modules from builder
24COPY --from=builder /app/node_modules ./node_modules
25
26# Copy application source and migration files
11# Copy source
2712COPY src/ ./src/
28COPY drizzle/ ./drizzle/
29COPY package.json ./
30
31# Create the repos directory and give ownership to the bun user
32RUN mkdir -p /app/repos \
33 && chown -R bun:bun /app
13COPY tsconfig.json drizzle.config.ts ./
14COPY legal/ ./legal/
15COPY CLAUDE.md LICENSE ./
3416
35# Run as non-root user (provided by the base image)
36USER bun
17# Create repos directory
18RUN mkdir -p /data/repos
3719
38# Default environment variables
39ENV GIT_REPOS_PATH=/app/repos \
40 PORT=3000 \
41 NODE_ENV=production
20ENV GIT_REPOS_PATH=/data/repos
21ENV NODE_ENV=production
22ENV PORT=3000
4223
4324EXPOSE 3000
4425
AddedLICENSE+21−0View fileUnifiedSplit
1MIT License
2
3Copyright (c) 2026 Gluecron
4
5Permission is hereby granted, free of charge, to any person obtaining a copy
6of this software and associated documentation files (the "Software"), to deal
7in the Software without restriction, including without limitation the rights
8to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9copies of the Software, and to permit persons to whom the Software is
10furnished to do so, subject to the following conditions:
11
12The above copyright notice and this permission notice shall be included in all
13copies or substantial portions of the Software.
14
15THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21SOFTWARE.
Addeddocker-compose.yml+23−0View fileUnifiedSplit
1services:
2 gluecron:
3 build: .
4 ports:
5 - "3000:3000"
6 environment:
7 - DATABASE_URL=${DATABASE_URL}
8 - GIT_REPOS_PATH=/data/repos
9 - PORT=3000
10 - NODE_ENV=production
11 - GATETEST_URL=https://gatetest.ai/api/scan/run
12 - CRONTECH_DEPLOY_URL=https://crontech.ai/api/trpc/tenant.deploy
13 volumes:
14 - git-repos:/data/repos
15 restart: unless-stopped
16 healthcheck:
17 test: ["CMD", "curl", "-f", "http://localhost:3000/"]
18 interval: 30s
19 timeout: 10s
20 retries: 3
21
22volumes:
23 git-repos:
Addeddrizzle/0000_init.sql+195−0View fileUnifiedSplit
1-- Gluecron database schema
2-- Run this against your Neon PostgreSQL database to initialize all tables.
3-- psql $DATABASE_URL -f drizzle/0000_init.sql
4
5CREATE EXTENSION IF NOT EXISTS "pgcrypto";
6
7-- Users
8CREATE TABLE IF NOT EXISTS users (
9 id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
10 username TEXT NOT NULL UNIQUE,
11 email TEXT NOT NULL UNIQUE,
12 display_name TEXT,
13 password_hash TEXT NOT NULL,
14 avatar_url TEXT,
15 bio TEXT,
16 created_at TIMESTAMP DEFAULT NOW() NOT NULL,
17 updated_at TIMESTAMP DEFAULT NOW() NOT NULL
18);
19
20-- Sessions
21CREATE TABLE IF NOT EXISTS sessions (
22 id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
23 user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
24 token TEXT NOT NULL UNIQUE,
25 expires_at TIMESTAMP NOT NULL,
26 created_at TIMESTAMP DEFAULT NOW() NOT NULL
27);
28
29-- Repositories
30CREATE TABLE IF NOT EXISTS repositories (
31 id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
32 name TEXT NOT NULL,
33 owner_id UUID NOT NULL REFERENCES users(id),
34 description TEXT,
35 is_private BOOLEAN DEFAULT FALSE NOT NULL,
36 default_branch TEXT DEFAULT 'main' NOT NULL,
37 disk_path TEXT NOT NULL,
38 forked_from_id UUID REFERENCES repositories(id) ON DELETE SET NULL,
39 created_at TIMESTAMP DEFAULT NOW() NOT NULL,
40 updated_at TIMESTAMP DEFAULT NOW() NOT NULL,
41 pushed_at TIMESTAMP,
42 star_count INTEGER DEFAULT 0 NOT NULL,
43 fork_count INTEGER DEFAULT 0 NOT NULL,
44 issue_count INTEGER DEFAULT 0 NOT NULL
45);
46CREATE UNIQUE INDEX IF NOT EXISTS repos_owner_name ON repositories(owner_id, name);
47
48-- Stars
49CREATE TABLE IF NOT EXISTS stars (
50 id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
51 user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
52 repository_id UUID NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
53 created_at TIMESTAMP DEFAULT NOW() NOT NULL
54);
55CREATE UNIQUE INDEX IF NOT EXISTS stars_user_repo ON stars(user_id, repository_id);
56
57-- Issues
58CREATE TABLE IF NOT EXISTS issues (
59 id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
60 number SERIAL,
61 repository_id UUID NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
62 author_id UUID NOT NULL REFERENCES users(id),
63 title TEXT NOT NULL,
64 body TEXT,
65 state TEXT NOT NULL DEFAULT 'open',
66 created_at TIMESTAMP DEFAULT NOW() NOT NULL,
67 updated_at TIMESTAMP DEFAULT NOW() NOT NULL,
68 closed_at TIMESTAMP
69);
70CREATE INDEX IF NOT EXISTS issues_repo_state ON issues(repository_id, state);
71CREATE INDEX IF NOT EXISTS issues_repo_number ON issues(repository_id, number);
72
73-- Issue Comments
74CREATE TABLE IF NOT EXISTS issue_comments (
75 id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
76 issue_id UUID NOT NULL REFERENCES issues(id) ON DELETE CASCADE,
77 author_id UUID NOT NULL REFERENCES users(id),
78 body TEXT NOT NULL,
79 created_at TIMESTAMP DEFAULT NOW() NOT NULL,
80 updated_at TIMESTAMP DEFAULT NOW() NOT NULL
81);
82CREATE INDEX IF NOT EXISTS comments_issue ON issue_comments(issue_id);
83
84-- Labels
85CREATE TABLE IF NOT EXISTS labels (
86 id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
87 repository_id UUID NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
88 name TEXT NOT NULL,
89 color TEXT NOT NULL DEFAULT '#8b949e',
90 description TEXT
91);
92CREATE UNIQUE INDEX IF NOT EXISTS labels_repo_name ON labels(repository_id, name);
93
94-- Issue Labels
95CREATE TABLE IF NOT EXISTS issue_labels (
96 id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
97 issue_id UUID NOT NULL REFERENCES issues(id) ON DELETE CASCADE,
98 label_id UUID NOT NULL REFERENCES labels(id) ON DELETE CASCADE
99);
100CREATE UNIQUE INDEX IF NOT EXISTS issue_labels_unique ON issue_labels(issue_id, label_id);
101
102-- Pull Requests
103CREATE TABLE IF NOT EXISTS pull_requests (
104 id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
105 number SERIAL,
106 repository_id UUID NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
107 author_id UUID NOT NULL REFERENCES users(id),
108 title TEXT NOT NULL,
109 body TEXT,
110 state TEXT NOT NULL DEFAULT 'open',
111 base_branch TEXT NOT NULL,
112 head_branch TEXT NOT NULL,
113 merged_at TIMESTAMP,
114 merged_by UUID REFERENCES users(id),
115 created_at TIMESTAMP DEFAULT NOW() NOT NULL,
116 updated_at TIMESTAMP DEFAULT NOW() NOT NULL,
117 closed_at TIMESTAMP
118);
119CREATE INDEX IF NOT EXISTS prs_repo_state ON pull_requests(repository_id, state);
120CREATE INDEX IF NOT EXISTS prs_repo_number ON pull_requests(repository_id, number);
121
122-- PR Comments
123CREATE TABLE IF NOT EXISTS pr_comments (
124 id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
125 pull_request_id UUID NOT NULL REFERENCES pull_requests(id) ON DELETE CASCADE,
126 author_id UUID NOT NULL REFERENCES users(id),
127 body TEXT NOT NULL,
128 is_ai_review BOOLEAN DEFAULT FALSE NOT NULL,
129 file_path TEXT,
130 line_number INTEGER,
131 created_at TIMESTAMP DEFAULT NOW() NOT NULL,
132 updated_at TIMESTAMP DEFAULT NOW() NOT NULL
133);
134CREATE INDEX IF NOT EXISTS pr_comments_pr ON pr_comments(pull_request_id);
135
136-- Activity Feed
137CREATE TABLE IF NOT EXISTS activity_feed (
138 id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
139 repository_id UUID NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
140 user_id UUID REFERENCES users(id),
141 action TEXT NOT NULL,
142 target_type TEXT,
143 target_id TEXT,
144 metadata TEXT,
145 created_at TIMESTAMP DEFAULT NOW() NOT NULL
146);
147CREATE INDEX IF NOT EXISTS activity_repo ON activity_feed(repository_id);
148CREATE INDEX IF NOT EXISTS activity_user ON activity_feed(user_id);
149
150-- Webhooks
151CREATE TABLE IF NOT EXISTS webhooks (
152 id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
153 repository_id UUID NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
154 url TEXT NOT NULL,
155 secret TEXT,
156 events TEXT NOT NULL DEFAULT 'push',
157 is_active BOOLEAN DEFAULT TRUE NOT NULL,
158 last_delivered_at TIMESTAMP,
159 last_status INTEGER,
160 created_at TIMESTAMP DEFAULT NOW() NOT NULL
161);
162CREATE INDEX IF NOT EXISTS webhooks_repo ON webhooks(repository_id);
163
164-- API Tokens
165CREATE TABLE IF NOT EXISTS api_tokens (
166 id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
167 user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
168 name TEXT NOT NULL,
169 token_hash TEXT NOT NULL,
170 token_prefix TEXT NOT NULL,
171 scopes TEXT NOT NULL DEFAULT 'repo',
172 last_used_at TIMESTAMP,
173 expires_at TIMESTAMP,
174 created_at TIMESTAMP DEFAULT NOW() NOT NULL
175);
176
177-- Repository Topics
178CREATE TABLE IF NOT EXISTS repo_topics (
179 id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
180 repository_id UUID NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
181 topic TEXT NOT NULL
182);
183CREATE UNIQUE INDEX IF NOT EXISTS repo_topics_unique ON repo_topics(repository_id, topic);
184CREATE INDEX IF NOT EXISTS topics_name ON repo_topics(topic);
185
186-- SSH Keys
187CREATE TABLE IF NOT EXISTS ssh_keys (
188 id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
189 user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
190 title TEXT NOT NULL,
191 fingerprint TEXT NOT NULL,
192 public_key TEXT NOT NULL,
193 last_used_at TIMESTAMP,
194 created_at TIMESTAMP DEFAULT NOW() NOT NULL
195);
Addedlegal/AUP.md+70−0View fileUnifiedSplit
1# Gluecron Acceptable Use Policy
2
3**Last updated: April 12, 2026**
4
5## Overview
6
7This Acceptable Use Policy ("AUP") applies to all users of Gluecron. Violation
8of this policy may result in suspension or termination of your account.
9
10## Prohibited Content
11
12You may NOT use Gluecron to store, host, or distribute:
13
141. **Malware:** viruses, trojans, ransomware, spyware, or any malicious software
15 designed to harm systems or steal data
16
172. **Stolen content:** code, data, or materials obtained through unauthorized
18 access, hacking, or theft
19
203. **Illegal content:** materials that violate any applicable law, including
21 but not limited to child exploitation material, content promoting terrorism,
22 or materials violating export control laws
23
244. **Infringing content:** code or materials that violate copyright, trademark,
25 patent, or other intellectual property rights of others
26
275. **Sensitive personal data:** Social Security numbers, credit card numbers,
28 medical records, or other regulated personal data (except through
29 purpose-built, encrypted features)
30
31## Prohibited Activities
32
33You may NOT use Gluecron to:
34
351. **Attack other systems:** launch DDoS attacks, port scanning, brute force
36 attacks, or any unauthorized access attempts against third-party systems
37
382. **Mine cryptocurrency:** use our compute resources for cryptocurrency mining
39
403. **Send spam:** use the Service to send unsolicited bulk communications
41
424. **Circumvent security:** attempt to bypass authentication, rate limiting,
43 or other security measures
44
455. **Abuse resources:** use excessive bandwidth, storage, or compute resources
46 in a way that degrades service for other users
47
486. **Impersonate:** create accounts or repositories to impersonate other
49 individuals or organizations
50
517. **Automate abusively:** run automated tools that place excessive load on
52 the Service (reasonable CI/CD and API usage is permitted)
53
54## Reporting Violations
55
56To report a violation of this AUP, contact: [ABUSE EMAIL]
57
58We will investigate reports promptly and take appropriate action.
59
60## Enforcement
61
62We may take any of the following actions in response to violations:
63- Warning
64- Temporary suspension of account or repository
65- Permanent termination of account
66- Removal of content
67- Reporting to law enforcement (for illegal activity)
68
69We strive to provide notice before taking action, except in cases of
70serious or immediate threat.
Addedlegal/PRIVACY.md+129−0View fileUnifiedSplit
1# Gluecron Privacy Policy
2
3**Last updated: April 12, 2026**
4
5## 1. Introduction
6
7This Privacy Policy describes how [YOUR LEGAL ENTITY NAME] ("Company," "we,"
8"us") collects, uses, and shares information when you use Gluecron ("Service").
9
10## 2. Information We Collect
11
12### 2.1 Information You Provide
13- **Account information:** username, email address, display name, bio
14- **Repository content:** code, files, issues, pull requests, comments
15- **SSH keys:** public keys for authentication
16- **Payment information:** processed by Stripe (we never store card numbers)
17
18### 2.2 Information Collected Automatically
19- **Log data:** IP address, browser type, operating system, referring URL,
20 pages visited, timestamps
21- **Git operations:** push/pull/clone metadata (timestamps, refs, not content)
22- **Usage data:** features used, repository interactions, search queries
23- **Device information:** device type, screen resolution, time zone
24
25### 2.3 Information from Intelligence Features
26- **Code analysis results:** health scores, security scan results, dependency
27 graphs (computed on your code, stored as metadata)
28- **Auto-repair logs:** which repairs were applied and when
29- **Push analysis:** risk scores, breaking change detection results
30
31## 3. How We Use Your Information
32
33We use your information to:
34- Provide, maintain, and improve the Service
35- Process transactions and send related information
36- Send technical notices, updates, and support messages
37- Respond to your comments and questions
38- Provide and improve Intelligence Features (code analysis, auto-repair)
39- Monitor and analyze usage patterns and trends
40- Detect, prevent, and address fraud, abuse, and technical issues
41- Comply with legal obligations
42
43## 4. How We Share Your Information
44
45We do NOT sell your personal information. We may share information:
46
47- **With your consent:** when you explicitly authorize it
48- **Public repositories:** content in public repos is visible to anyone
49- **Service providers:** hosting (Crontech infrastructure), database (Neon), payment
50 (Stripe), email (for transactional emails only)
51- **Integration partners:** GateTest (code scanning), Crontech (deployment)
52 — only repository metadata, not source code content, unless you enable
53 the integration
54- **Legal requirements:** when required by law, subpoena, or court order
55- **Business transfers:** in connection with a merger, acquisition, or sale
56 of assets (with notice to users)
57
58## 5. Data Security
59
60- Passwords are hashed using bcrypt (never stored in plain text)
61- API tokens are hashed using SHA-256
62- Sessions use cryptographically random tokens
63- All connections use HTTPS/TLS encryption
64- Database connections use SSL
65- Webhook payloads use HMAC-SHA256 signatures
66
67## 6. Data Retention
68
69- **Account data:** retained until you delete your account
70- **Repository data:** retained until you delete the repository
71- **Log data:** retained for 90 days
72- **Deleted accounts:** data is purged within 30 days of account deletion
73- **Backups:** may contain deleted data for up to 90 days
74
75## 7. Your Rights
76
77You have the right to:
78- **Access:** request a copy of your personal data
79- **Correction:** update inaccurate information via account settings
80- **Deletion:** delete your account and all associated data
81- **Export:** export your repositories using standard git tools at any time
82- **Objection:** object to processing of your data for specific purposes
83- **Restriction:** request restriction of processing in certain circumstances
84
85### For EU/EEA Users (GDPR)
86- Legal basis for processing: contract performance, legitimate interests,
87 and consent where applicable
88- You may lodge a complaint with your local supervisory authority
89- Data transfers outside the EU are protected by Standard Contractual Clauses
90
91### For California Users (CCPA)
92- You have the right to know what personal information we collect
93- You have the right to request deletion
94- You have the right to opt out of the "sale" of personal information
95 (we do not sell personal information)
96- We will not discriminate against you for exercising your rights
97
98## 8. Cookies
99
100We use minimal cookies:
101- **Session cookie:** required for authentication (httpOnly, secure)
102- We do NOT use tracking cookies, advertising cookies, or third-party
103 analytics cookies
104
105## 9. Children's Privacy
106
107The Service is not intended for children under 13 (or 16 in the EU).
108We do not knowingly collect information from children under these ages.
109
110## 10. International Data Transfers
111
112Your data may be processed in the United States and other countries where
113our service providers operate. We ensure appropriate safeguards are in
114place for international transfers.
115
116## 11. Changes to This Policy
117
118We may update this Privacy Policy from time to time. We will notify you
119of material changes via email or in-app notification at least 30 days
120before they take effect.
121
122## 12. Contact Us
123
124For privacy inquiries:
125- Email: [PRIVACY EMAIL]
126- Address: [COMPANY ADDRESS]
127
128For GDPR inquiries, our Data Protection Contact can be reached at:
129[DPO EMAIL]
Addedlegal/SETUP-GUIDE.md+120−0View fileUnifiedSplit
1# Gluecron Legal & Corporate Setup Guide
2
3## IMPORTANT DISCLAIMER
4This document provides a starting framework only. It is NOT legal advice.
5You MUST have an attorney review everything before launch. These templates
6give your attorney a head start, which saves you money.
7
8---
9
10## 1. COMPANY STRUCTURE RECOMMENDATION
11
12### Recommended: Delaware C-Corp (if seeking VC/investment)
13- Standard for tech startups
14- Investors expect it
15- Clear equity/stock structure
16- Can issue stock options to future employees
17
18### Alternative: LLC (if bootstrapping)
19- Simpler, cheaper to set up
20- Pass-through taxation (no double tax)
21- Can convert to C-Corp later when raising
22- Good for solo founder getting revenue first
23
24### Action Steps:
251. Register with your state's Secretary of State ($50-$150)
262. Get an EIN from IRS (free, irs.gov, takes 5 minutes)
273. Open a business bank account (keep personal and business separate)
284. If Delaware C-Corp: use Stripe Atlas ($500) or Clerky — they handle everything
29
30---
31
32## 2. KEY AGREEMENTS YOU NEED
33
34### a) Terms of Service (see TERMS.md)
35### b) Privacy Policy (see PRIVACY.md)
36### c) Acceptable Use Policy (see AUP.md)
37### d) DMCA Policy (required for hosting user content)
38### e) Data Processing Agreement (for EU users / GDPR)
39
40---
41
42## 3. INTELLECTUAL PROPERTY PROTECTION
43
44### Trademark
45- File "GLUECRON" as a trademark with USPTO
46- Cost: $250-$350 per class (do Class 42: software services)
47- Can file yourself at trademark.uspto.gov
48- Takes 8-12 months but protection dates back to filing
49
50### Domain Protection
51- Secure: gluecron.com, gluecron.io, gluecron.dev, gluecron.ai
52- Also grab social handles: @gluecron everywhere
53
54### Copyright
55- Your code is automatically copyrighted when written
56- Add copyright headers to source files
57- Register with US Copyright Office for extra protection ($65)
58
59### Patents (later, with attorney)
60- The intelligence layer (auto-repair, health scoring, zero-config CI)
61 may be patentable as novel software methods
62- Provisional patent application: $1,600-$3,000 with attorney
63- Gives you 12 months to file full patent
64
65---
66
67## 4. CRITICAL LEGAL REQUIREMENTS FOR A CODE HOSTING PLATFORM
68
69### DMCA Safe Harbor (MUST HAVE before launch)
70- Register a DMCA agent with the US Copyright Office
71- Cost: $6 per filing
72- Required to get safe harbor protection (you're not liable for
73 what users host on the platform)
74- Register at: https://www.copyright.gov/dmca-directory/
75
76### Section 230 Protection
77- You're a platform, not a publisher
78- Your ToS must make clear users are responsible for their content
79- Don't editorialize or curate user repos (safe harbor)
80
81### GDPR Compliance (if any EU users)
82- Privacy policy must disclose data collection
83- Users must be able to delete their account and data
84- Data processing agreement for business users
85- Appoint data protection contact
86
87### Export Controls
88- Git hosting may trigger export control considerations
89- Block sanctioned countries if needed
90- Include export compliance in ToS
91
92---
93
94## 5. REVENUE PROTECTION
95
96### Pricing Page Legal
97- "Prices subject to change with 30 days notice"
98- Auto-renewal disclosure (required by many states)
99- Refund policy (keep it simple: prorated refunds on annual)
100
101### Payment Processing
102- Use Stripe — they handle PCI compliance
103- Never store credit card numbers yourself
104- Stripe Atlas can also incorporate your company
105
106---
107
108## 6. COST ESTIMATE (DIY + Attorney Review)
109
110| Item | DIY Cost | With Attorney |
111|------|----------|---------------|
112| LLC filing | $50-$150 | $500-$800 |
113| EIN | Free | Included |
114| ToS + Privacy (template) | Free | $1,500-$3,000 review |
115| DMCA agent registration | $6 | $6 |
116| Trademark filing | $250-$350 | $800-$1,500 |
117| Total minimum to launch | ~$300-$500 | ~$3,000-$5,500 |
118
119The templates below save you $2,000-$4,000 in attorney drafting fees.
120Your attorney only needs to REVIEW and CUSTOMIZE, not write from scratch.
Addedlegal/TERMS.md+186−0View fileUnifiedSplit
1# Gluecron Terms of Service
2
3**Last updated: April 12, 2026**
4
5## 1. Agreement to Terms
6
7By accessing or using Gluecron ("Service"), operated by [YOUR LEGAL ENTITY NAME]
8("Company," "we," "us"), you agree to be bound by these Terms of Service ("Terms").
9If you do not agree, do not use the Service.
10
11## 2. Description of Service
12
13Gluecron is a code intelligence and hosting platform that provides git repository
14hosting, code analysis, continuous integration, and related developer tools.
15
16## 3. Account Registration
17
183.1. You must provide accurate, complete information when creating an account.
193.2. You are responsible for maintaining the security of your account credentials.
203.3. You must be at least 13 years old (16 in the EU) to use the Service.
213.4. One person or entity may not maintain more than one free account.
22
23## 4. User Content
24
254.1. "User Content" means any code, data, text, or other materials you upload,
26submit, or store on the Service.
27
284.2. You retain all ownership rights to your User Content. We do not claim
29ownership over any User Content.
30
314.3. By using the Service, you grant us a limited license to host, store,
32display, and transmit your User Content solely for the purpose of providing
33the Service to you. This license ends when you delete your Content or account.
34
354.4. You are solely responsible for your User Content and the consequences of
36posting or publishing it. We do not endorse or verify User Content.
37
384.5. You represent that you have all necessary rights to upload your User Content
39and that it does not violate any law or third-party right.
40
41## 5. Acceptable Use
42
435.1. You agree to comply with the Acceptable Use Policy (AUP).
44
455.2. You will not use the Service to:
46- Violate any law or regulation
47- Infringe intellectual property rights
48- Distribute malware or malicious code
49- Host content that promotes violence or illegal activity
50- Interfere with or disrupt the Service
51- Attempt to gain unauthorized access to other accounts or systems
52- Use the Service for cryptocurrency mining
53- Store or transmit sensitive personal data (SSNs, credit card numbers)
54 outside of encrypted, purpose-built features
55
56## 6. Automated Intelligence Features
57
586.1. The Service includes automated code analysis features including but not
59limited to: auto-repair, security scanning, health scoring, dependency
60analysis, and push risk analysis ("Intelligence Features").
61
626.2. Intelligence Features are provided "as is" and may modify code in your
63repositories (e.g., auto-repair commits). You can disable any Intelligence
64Feature through your repository settings.
65
666.3. We do not guarantee that Intelligence Features will detect all issues or
67that automated repairs will be correct. You are responsible for reviewing
68all automated changes.
69
706.4. Intelligence Features do not constitute professional security auditing,
71legal compliance verification, or code review services.
72
73## 7. Payment Terms
74
757.1. Certain features may require a paid subscription.
76
777.2. Fees are billed in advance on a monthly or annual basis.
78
797.3. All fees are non-refundable except as required by law or as explicitly
80stated in our refund policy. Annual plans may receive prorated refunds.
81
827.4. We reserve the right to change pricing with 30 days written notice.
83
847.5. Failure to pay may result in suspension or termination of your account.
85
86## 8. Intellectual Property
87
888.1. The Service, including its original content, features, and functionality,
89is owned by the Company and protected by copyright, trademark, and other laws.
90
918.2. "Gluecron" and associated logos are trademarks of the Company.
92
938.3. Nothing in these Terms grants you rights to use our trademarks.
94
95## 9. Privacy
96
97Your use of the Service is governed by our Privacy Policy, which is
98incorporated into these Terms by reference.
99
100## 10. DMCA and Copyright
101
10210.1. We respect intellectual property rights and comply with the Digital
103Millennium Copyright Act (DMCA).
104
10510.2. If you believe content on the Service infringes your copyright, send
106a DMCA takedown notice to: [DMCA AGENT EMAIL]
107
10810.3. Your notice must include:
109- Your contact information
110- Identification of the copyrighted work
111- Identification of the infringing material and its location
112- A statement of good faith belief
113- A statement under penalty of perjury
114- Your physical or electronic signature
115
11610.4. We will respond to valid DMCA notices promptly and may remove or
117disable access to the allegedly infringing content.
118
119## 11. Termination
120
12111.1. You may terminate your account at any time through account settings.
122
12311.2. We may suspend or terminate your account for violation of these Terms,
124with or without notice.
125
12611.3. Upon termination, your right to use the Service ceases immediately.
127We may delete your data after a reasonable retention period (30 days).
128
12911.4. You may export your repositories at any time using standard git tools.
130
131## 12. Disclaimers
132
13312.1. THE SERVICE IS PROVIDED "AS IS" AND "AS AVAILABLE" WITHOUT WARRANTIES
134OF ANY KIND, EXPRESS OR IMPLIED.
135
13612.2. WE DO NOT WARRANT THAT THE SERVICE WILL BE UNINTERRUPTED, SECURE,
137OR ERROR-FREE.
138
13912.3. WE DO NOT WARRANT THAT AUTOMATED INTELLIGENCE FEATURES WILL DETECT
140ALL SECURITY ISSUES OR THAT AUTO-REPAIRS WILL BE CORRECT.
141
142## 13. Limitation of Liability
143
14413.1. TO THE MAXIMUM EXTENT PERMITTED BY LAW, THE COMPANY SHALL NOT BE
145LIABLE FOR ANY INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL, OR PUNITIVE
146DAMAGES, OR ANY LOSS OF PROFITS, DATA, OR GOODWILL.
147
14813.2. OUR TOTAL LIABILITY SHALL NOT EXCEED THE AMOUNT YOU PAID TO US IN
149THE TWELVE (12) MONTHS PRECEDING THE CLAIM.
150
151## 14. Indemnification
152
153You agree to indemnify and hold harmless the Company from any claims, losses,
154or damages arising from your use of the Service, your User Content, or your
155violation of these Terms.
156
157## 15. Dispute Resolution
158
15915.1. These Terms are governed by the laws of [YOUR STATE], USA.
160
16115.2. Any disputes shall be resolved through binding arbitration under the
162rules of the American Arbitration Association, except that either party may
163seek injunctive relief in court.
164
16515.3. CLASS ACTION WAIVER: You agree to resolve disputes individually and
166waive any right to participate in a class action.
167
168## 16. Changes to Terms
169
170We may modify these Terms at any time. We will notify users of material
171changes via email or in-app notification at least 30 days before they
172take effect. Continued use constitutes acceptance.
173
174## 17. Miscellaneous
175
17617.1. These Terms constitute the entire agreement between you and the Company.
17717.2. If any provision is found unenforceable, the remainder stays in effect.
17817.3. Our failure to enforce a right does not waive that right.
17917.4. You may not assign these Terms. We may assign them in connection with
180a merger, acquisition, or sale of assets.
181
182## Contact
183
184[YOUR COMPANY NAME]
185[ADDRESS]
186[EMAIL]
Addedscripts/crontech-deploy.sh+66−0View fileUnifiedSplit
1#!/bin/bash
2set -euo pipefail
3
4# ============================================
5# Gluecron via Crontech Deployment
6#
7# This deploys gluecron through the Crontech ecosystem.
8# Crontech handles routing, SSL, and domain management.
9# gluecron hosts the code. GateTest scans it.
10# The ecosystem feeds itself.
11# ============================================
12
13APP_NAME="gluecron"
14
15echo "=========================================="
16echo " Deploying $APP_NAME via Crontech"
17echo "=========================================="
18
19# Check DATABASE_URL
20if [ -z "${DATABASE_URL:-}" ]; then
21 echo "ERROR: Set DATABASE_URL first."
22 echo " export DATABASE_URL='postgresql://...'"
23 exit 1
24fi
25
26# Run database migration
27echo "Running database migration..."
28if command -v psql &>/dev/null; then
29 psql "$DATABASE_URL" -f drizzle/0000_init.sql 2>&1 | tail -5
30 echo "Migration complete."
31else
32 echo "psql not found — run drizzle/0000_init.sql manually against your Neon DB."
33 echo "You can do this from Neon's web console (SQL Editor)."
34fi
35
36# Install deps if needed
37if [ ! -d "node_modules" ]; then
38 echo "Installing dependencies..."
39 bun install --production
40fi
41
42# Create repos directory
43mkdir -p "${GIT_REPOS_PATH:-./repos}"
44
45echo ""
46echo "=========================================="
47echo " Ready for Crontech deployment"
48echo "=========================================="
49echo ""
50echo " Start command: bun run src/index.ts"
51echo " Port: ${PORT:-3000}"
52echo " Repos dir: ${GIT_REPOS_PATH:-./repos}"
53echo ""
54echo " Environment variables needed:"
55echo " DATABASE_URL = (your Neon connection string)"
56echo " GIT_REPOS_PATH = /data/repos (or persistent volume)"
57echo " PORT = 3000"
58echo " NODE_ENV = production"
59echo ""
60echo " If Crontech routes via subdomain:"
61echo " gluecron.crontech.ai -> this service"
62echo ""
63echo " If Crontech routes via custom domain:"
64echo " gluecron.com -> this service"
65echo " (Crontech handles SSL + DNS)"
66echo ""
Addedscripts/deploy.sh+123−0View fileUnifiedSplit
1#!/bin/bash
2set -euo pipefail
3
4# ============================================
5# Gluecron Deploy Script
6# One command to deploy to production.
7# ============================================
8
9APP_NAME="gluecron"
10APP_DIR="/opt/gluecron"
11REPO_URL="https://github.com/ccantynz-alt/Gluecron.com.git"
12BRANCH="claude/ship-fixes-and-tests-Jvz1c"
13
14echo "=========================================="
15echo " Deploying $APP_NAME"
16echo "=========================================="
17
18# 1. Check prerequisites
19command -v git >/dev/null 2>&1 || { echo "git required"; exit 1; }
20command -v bun >/dev/null 2>&1 || {
21 echo "Installing Bun..."
22 curl -fsSL https://bun.sh/install | bash
23 export PATH="$HOME/.bun/bin:$PATH"
24}
25
26# 2. Check for DATABASE_URL
27if [ -z "${DATABASE_URL:-}" ]; then
28 if [ -f "$APP_DIR/.env" ]; then
29 export $(grep -v '^#' "$APP_DIR/.env" | xargs)
30 fi
31fi
32
33if [ -z "${DATABASE_URL:-}" ]; then
34 echo "ERROR: DATABASE_URL not set."
35 echo "Set it in $APP_DIR/.env or export it."
36 echo ""
37 echo "Get a free database at https://neon.tech"
38 echo "Then: echo 'DATABASE_URL=postgresql://...' > $APP_DIR/.env"
39 exit 1
40fi
41
42# 3. Clone or update repo
43if [ -d "$APP_DIR" ]; then
44 echo "Updating existing installation..."
45 cd "$APP_DIR"
46 git fetch origin "$BRANCH"
47 git reset --hard "origin/$BRANCH"
48else
49 echo "Fresh install..."
50 sudo mkdir -p "$APP_DIR"
51 sudo chown "$(whoami)" "$APP_DIR"
52 git clone --branch "$BRANCH" "$REPO_URL" "$APP_DIR"
53 cd "$APP_DIR"
54fi
55
56# 4. Install dependencies
57echo "Installing dependencies..."
58bun install --frozen-lockfile --production
59
60# 5. Run database migration
61echo "Running database migration..."
62psql "$DATABASE_URL" -f drizzle/0000_init.sql 2>/dev/null || {
63 echo "psql not found or migration failed — trying via bun..."
64 bun run -e "
65 const { neon } = require('@neondatabase/serverless');
66 const sql = neon(process.env.DATABASE_URL);
67 const fs = require('fs');
68 const migration = fs.readFileSync('drizzle/0000_init.sql', 'utf-8');
69 // Split by semicolons and execute each statement
70 const statements = migration.split(';').filter(s => s.trim());
71 (async () => {
72 for (const stmt of statements) {
73 try { await sql(stmt); } catch(e) { /* table may already exist */ }
74 }
75 console.log('Migration complete');
76 })();
77 "
78}
79
80# 6. Create repos directory
81mkdir -p /data/repos 2>/dev/null || mkdir -p "$APP_DIR/repos"
82export GIT_REPOS_PATH="${GIT_REPOS_PATH:-/data/repos}"
83
84# 7. Set up systemd service
85echo "Setting up systemd service..."
86sudo tee /etc/systemd/system/gluecron.service > /dev/null << UNIT
87[Unit]
88Description=Gluecron - AI-native code intelligence
89After=network.target
90
91[Service]
92Type=simple
93User=$(whoami)
94WorkingDirectory=$APP_DIR
95EnvironmentFile=$APP_DIR/.env
96ExecStart=$(which bun) run src/index.ts
97Restart=always
98RestartSec=5
99StandardOutput=journal
100StandardError=journal
101
102[Install]
103WantedBy=multi-user.target
104UNIT
105
106sudo systemctl daemon-reload
107sudo systemctl enable gluecron
108sudo systemctl restart gluecron
109
110echo ""
111echo "=========================================="
112echo " $APP_NAME is now running!"
113echo "=========================================="
114echo ""
115echo " URL: http://$(hostname -I | awk '{print $1}'):3000"
116echo " Logs: journalctl -u gluecron -f"
117echo " Status: systemctl status gluecron"
118echo ""
119echo " Next steps:"
120echo " 1. Set up reverse proxy (nginx/caddy) for HTTPS"
121echo " 2. Point gluecron.com DNS to this server"
122echo " 3. Register your admin account at /register"
123echo ""
Addedscripts/setup-nginx.sh+64−0View fileUnifiedSplit
1#!/bin/bash
2set -euo pipefail
3
4# ============================================
5# Nginx + HTTPS setup for gluecron
6# ============================================
7
8DOMAIN="${1:-gluecron.com}"
9
10echo "Setting up nginx for $DOMAIN..."
11
12# Install nginx + certbot
13sudo apt-get update
14sudo apt-get install -y nginx certbot python3-certbot-nginx
15
16# Create nginx config
17sudo tee /etc/nginx/sites-available/gluecron > /dev/null << NGINX
18server {
19 listen 80;
20 server_name $DOMAIN www.$DOMAIN;
21
22 # Allow large git pushes
23 client_max_body_size 500m;
24
25 location / {
26 proxy_pass http://127.0.0.1:3000;
27 proxy_http_version 1.1;
28 proxy_set_header Host \$host;
29 proxy_set_header X-Real-IP \$remote_addr;
30 proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
31 proxy_set_header X-Forwarded-Proto \$scheme;
32
33 # Required for git smart HTTP protocol
34 proxy_buffering off;
35 proxy_request_buffering off;
36
37 # Timeouts for large repos
38 proxy_connect_timeout 300;
39 proxy_send_timeout 300;
40 proxy_read_timeout 300;
41 }
42}
43NGINX
44
45# Enable site
46sudo ln -sf /etc/nginx/sites-available/gluecron /etc/nginx/sites-enabled/
47sudo rm -f /etc/nginx/sites-enabled/default
48sudo nginx -t
49sudo systemctl reload nginx
50
51# Get SSL cert
52echo "Getting SSL certificate..."
53sudo certbot --nginx -d "$DOMAIN" -d "www.$DOMAIN" --non-interactive --agree-tos --email "admin@$DOMAIN" || {
54 echo ""
55 echo "Certbot failed — DNS may not be pointed yet."
56 echo "Once DNS is live, run:"
57 echo " sudo certbot --nginx -d $DOMAIN -d www.$DOMAIN"
58}
59
60echo ""
61echo "Nginx configured for $DOMAIN"
62echo " HTTP -> HTTPS redirect: automatic (after cert)"
63echo " Proxy -> localhost:3000"
64echo " Max upload: 500MB (for large repos)"
Addedsrc/__tests__/intelligence.test.ts+209−0View fileUnifiedSplit
1import { describe, it, expect, beforeAll, afterAll } from "bun:test";
2import { join } from "path";
3import { rm, mkdir } from "fs/promises";
4import { initBareRepo, getRepoPath } from "../git/repository";
5import {
6 computeHealthScore,
7 detectCIConfig,
8 analyzePush,
9} from "../lib/intelligence";
10import { autoRepair } from "../lib/autorepair";
11
12const TEST_REPOS = join(import.meta.dir, "../../.test-repos-intel-" + Date.now());
13
14beforeAll(async () => {
15 await rm(TEST_REPOS, { recursive: true, force: true });
16 await mkdir(TEST_REPOS, { recursive: true });
17 process.env.GIT_REPOS_PATH = TEST_REPOS;
18
19 // Create repo with various files
20 await initBareRepo("dev", "myapp");
21 const cloneDir = join(TEST_REPOS, "_clone");
22 const workDir = join(cloneDir, "work");
23
24 const run = async (cmd: string[], cwd: string) => {
25 const proc = Bun.spawn(cmd, { cwd, stdout: "pipe", stderr: "pipe" });
26 await proc.exited;
27 };
28
29 await run(["git", "clone", getRepoPath("dev", "myapp"), workDir], TEST_REPOS);
30 await run(["git", "config", "user.email", "dev@test.com"], workDir);
31 await run(["git", "config", "user.name", "Dev"], workDir);
32
33 await mkdir(join(workDir, "src"), { recursive: true });
34 await mkdir(join(workDir, "src/__tests__"), { recursive: true });
35
36 // package.json
37 await Bun.write(
38 join(workDir, "package.json"),
39 JSON.stringify(
40 {
41 name: "myapp",
42 version: "1.0.0",
43 scripts: { test: "bun test", lint: "eslint .", build: "tsc" },
44 dependencies: { hono: "^4.0.0" },
45 devDependencies: { typescript: "^5.0.0", "@types/bun": "^1.0.0" },
46 },
47 null,
48 2
49 )
50 );
51
52 // tsconfig
53 await Bun.write(
54 join(workDir, "tsconfig.json"),
55 JSON.stringify({ compilerOptions: { strict: true } }, null, 2)
56 );
57
58 // README
59 await Bun.write(join(workDir, "README.md"), "# My App\n\nA test project.\n");
60
61 // Source files
62 await Bun.write(
63 join(workDir, "src/index.ts"),
64 'const greeting = "hello world";\nconsole.log(greeting);\n'
65 );
66 await Bun.write(
67 join(workDir, "src/util.ts"),
68 'export function add(a: number, b: number): number {\n return a + b;\n}\n'
69 );
70
71 // Test file
72 await Bun.write(
73 join(workDir, "src/__tests__/util.test.ts"),
74 'import { expect, test } from "bun:test";\nimport { add } from "../util";\n\ntest("add", () => {\n expect(add(1, 2)).toBe(3);\n});\n'
75 );
76
77 // Trailing whitespace in a file (for auto-repair test)
78 await Bun.write(
79 join(workDir, "src/messy.ts"),
80 'const x = 1; \nconst y = 2;\t\t\n// no newline at end'
81 );
82
83 // bun.lock (just a dummy)
84 await Bun.write(join(workDir, "bun.lock"), "{}");
85
86 // .gitignore with some entries
87 await Bun.write(join(workDir, ".gitignore"), "node_modules/\ndist/\n");
88
89 await run(["git", "add", "-A"], workDir);
90 await run(["git", "commit", "-m", "Initial commit"], workDir);
91 await run(["git", "branch", "-M", "main"], workDir);
92 await run(["git", "push", "-u", "origin", "main"], workDir);
93
94 await rm(cloneDir, { recursive: true, force: true });
95});
96
97afterAll(async () => {
98 await rm(TEST_REPOS, { recursive: true, force: true });
99});
100
101describe("health score", () => {
102 it("computes a health report", async () => {
103 const report = await computeHealthScore("dev", "myapp");
104
105 expect(report.score).toBeGreaterThan(0);
106 expect(report.score).toBeLessThanOrEqual(100);
107 expect(["A+", "A", "B", "C", "D", "F"]).toContain(report.grade);
108 expect(report.breakdown.security).toBeDefined();
109 expect(report.breakdown.testing).toBeDefined();
110 expect(report.breakdown.complexity).toBeDefined();
111 expect(report.breakdown.dependencies).toBeDefined();
112 expect(report.breakdown.documentation).toBeDefined();
113 expect(report.breakdown.activity).toBeDefined();
114 expect(report.insights.length).toBeGreaterThan(0);
115 });
116
117 it("detects tests exist", async () => {
118 const report = await computeHealthScore("dev", "myapp");
119 expect(report.breakdown.testing.hasTests).toBe(true);
120 expect(report.breakdown.testing.testFileCount).toBeGreaterThan(0);
121 });
122
123 it("detects README", async () => {
124 const report = await computeHealthScore("dev", "myapp");
125 expect(report.breakdown.documentation.hasReadme).toBe(true);
126 });
127
128 it("detects dependencies", async () => {
129 const report = await computeHealthScore("dev", "myapp");
130 expect(report.breakdown.dependencies.total).toBeGreaterThan(0);
131 expect(report.breakdown.dependencies.lockfileExists).toBe(true);
132 });
133
134 it("has at least 1 contributor", async () => {
135 const report = await computeHealthScore("dev", "myapp");
136 expect(report.breakdown.activity.uniqueContributors).toBeGreaterThanOrEqual(1);
137 });
138});
139
140describe("zero-config CI detection", () => {
141 it("detects Bun + TypeScript project", async () => {
142 const ci = await detectCIConfig("dev", "myapp", "main");
143
144 expect(ci.projectType).toBe("typescript");
145 expect(ci.runtime).toBe("bun");
146 expect(ci.detected).toContain("Bun project detected");
147 expect(ci.detected).toContain("TypeScript detected");
148 });
149
150 it("detects test, lint, and build commands", async () => {
151 const ci = await detectCIConfig("dev", "myapp", "main");
152
153 const names = ci.commands.map((c) => c.name);
154 expect(names).toContain("Test");
155 expect(names).toContain("Lint");
156 });
157
158 it("detects Hono framework", async () => {
159 const ci = await detectCIConfig("dev", "myapp", "main");
160 expect(ci.detected).toContain("Hono framework");
161 });
162});
163
164describe("auto-repair", () => {
165 it("fixes whitespace issues", async () => {
166 const result = await autoRepair("dev", "myapp", "main");
167
168 expect(result.repaired).toBe(true);
169 expect(result.repairs.length).toBeGreaterThan(0);
170
171 // Should have fixed trailing whitespace
172 const whitespaceRepairs = result.repairs.filter(
173 (r) => r.type === "whitespace"
174 );
175 expect(whitespaceRepairs.length).toBeGreaterThan(0);
176 });
177
178 it("adds missing .gitignore entries", async () => {
179 const result = await autoRepair("dev", "myapp", "main");
180
181 const gitignoreRepairs = result.repairs.filter(
182 (r) => r.type === "gitignore"
183 );
184 // May or may not need repair depending on current state
185 // (first run may have already fixed it)
186 expect(result.repaired).toBeDefined();
187 });
188
189 it("subsequent runs have fewer repairs", async () => {
190 // Run once to fix everything
191 const first = await autoRepair("dev", "myapp", "main");
192 // Run again — should have fewer or no repairs
193 const second = await autoRepair("dev", "myapp", "main");
194 expect(second.repairs.length).toBeLessThanOrEqual(first.repairs.length);
195 });
196});
197
198describe("health dashboard route", () => {
199 it("GET /:owner/:repo/health returns health page", async () => {
200 const app = (await import("../app")).default;
201 const res = await app.request("/dev/myapp/health");
202 expect(res.status).toBe(200);
203 const html = await res.text();
204 expect(html).toContain("Health Score");
205 expect(html).toContain("Security");
206 expect(html).toContain("Testing");
207 expect(html).toContain("Zero-Config CI");
208 });
209});
Modifiedsrc/app.tsx+11−148View fileUnifiedSplit
2222import exploreRoutes from "./routes/explore";
2323import tokenRoutes from "./routes/tokens";
2424import contributorRoutes from "./routes/contributors";
25import notificationRoutes from "./routes/notifications";
26import dashboardRoutes from "./routes/dashboard";
27import askRoutes from "./routes/ask";
28import releaseRoutes from "./routes/releases";
29import gateRoutes from "./routes/gates";
30import insightsRoutes from "./routes/insights";
31import searchRoutes from "./routes/search";
3225import healthRoutes from "./routes/health";
33import hookRoutes from "./routes/hooks";
34import eventsRoutes from "./routes/events";
35import themeRoutes from "./routes/theme";
36import auditRoutes from "./routes/audit";
37import reactionRoutes from "./routes/reactions";
38import savedReplyRoutes from "./routes/saved-replies";
39import deploymentRoutes from "./routes/deployments";
40import orgRoutes from "./routes/orgs";
41import passkeyRoutes from "./routes/passkeys";
42import oauthRoutes from "./routes/oauth";
43import developerAppsRoutes from "./routes/developer-apps";
44import workflowRoutes from "./routes/workflows";
45import packagesApiRoutes from "./routes/packages-api";
46import packagesUiRoutes from "./routes/packages";
47import pagesRoutes from "./routes/pages";
48import environmentsRoutes from "./routes/environments";
49import aiExplainRoutes from "./routes/ai-explain";
50import aiChangelogRoutes from "./routes/ai-changelog";
51import copilotRoutes from "./routes/copilot";
52import depUpdaterRoutes from "./routes/dep-updater";
53import semanticSearchRoutes from "./routes/semantic-search";
54import aiTestsRoutes from "./routes/ai-tests";
55import discussionRoutes from "./routes/discussions";
56import gistRoutes from "./routes/gists";
57import projectRoutes from "./routes/projects";
58import wikiRoutes from "./routes/wikis";
59import mergeQueueRoutes from "./routes/merge-queue";
60import requiredChecksRoutes from "./routes/required-checks";
61import protectedTagsRoutes from "./routes/protected-tags";
62import trafficRoutes from "./routes/traffic";
63import orgInsightsRoutes from "./routes/org-insights";
64import adminRoutes from "./routes/admin";
65import billingRoutes from "./routes/billing";
66import pwaRoutes from "./routes/pwa";
67import graphqlRoutes from "./routes/graphql";
68import marketplaceRoutes from "./routes/marketplace";
69import templatesRoutes from "./routes/templates";
70import codeScanningRoutes from "./routes/code-scanning";
71import sponsorsRoutes from "./routes/sponsors";
72import symbolsRoutes from "./routes/symbols";
73import mirrorsRoutes from "./routes/mirrors";
74import ssoRoutes from "./routes/sso";
75import depsRoutes from "./routes/deps";
76import advisoriesRoutes from "./routes/advisories";
77import signingKeysRoutes from "./routes/signing-keys";
78import followsRoutes from "./routes/follows";
79import rulesetsRoutes from "./routes/rulesets";
80import commitStatusesRoutes from "./routes/commit-statuses";
81import legalTermsRoutes from "./routes/legal/terms";
82import legalPrivacyRoutes from "./routes/legal/privacy";
83import legalAcceptableUseRoutes from "./routes/legal/acceptable-use";
84import legalDmcaRoutes from "./routes/legal/dmca";
26import insightRoutes from "./routes/insights";
27import dashboardRoutes from "./routes/dashboard";
28import legalRoutes from "./routes/legal";
8529import webRoutes from "./routes/web";
8630import { authRateLimit, gitRateLimit, searchRateLimit } from "./middleware/rate-limit";
8731import { csrfToken, csrfProtect } from "./middleware/csrf";
198142// Contributors
199143app.route("/", contributorRoutes);
200144
201// Releases
202app.route("/", releaseRoutes);
203
204// Gates (history + settings + branch protection)
205app.route("/", gateRoutes);
206
207// Actions-equivalent workflow runner (Block C1)
208app.route("/", workflowRoutes);
209
210// Package registry — npm protocol + UI (Block C2)
211app.route("/", packagesApiRoutes);
212app.route("/", packagesUiRoutes);
213
214// Pages / static hosting (Block C3)
215app.route("/", pagesRoutes);
216
217// Environments with protected approvals (Block C4)
218app.route("/", environmentsRoutes);
219
220// AI-native features (Block D)
221app.route("/", aiExplainRoutes); // D6 — /:owner/:repo/explain
222app.route("/", aiChangelogRoutes); // D7 — /:owner/:repo/ai/changelog
223app.route("/", copilotRoutes); // D9 — /api/copilot/completions
224app.route("/", depUpdaterRoutes); // D2 — /:owner/:repo/settings/dep-updater
225app.route("/", semanticSearchRoutes); // D1 — /:owner/:repo/search/semantic
226app.route("/", aiTestsRoutes); // D8 — /:owner/:repo/ai/tests
227app.route("/", discussionRoutes); // E2 — /:owner/:repo/discussions
228app.route("/", gistRoutes); // E4 — /gists, /gists/:slug, /:user/gists
229app.route("/", projectRoutes); // E1 — /:owner/:repo/projects
230app.route("/", wikiRoutes); // E3 — /:owner/:repo/wiki
231app.route("/", mergeQueueRoutes); // E5 — /:owner/:repo/queue
232app.route("/", requiredChecksRoutes); // E6 — /:owner/:repo/gates/protection/:id/checks
233app.route("/", protectedTagsRoutes); // E7 — /:owner/:repo/settings/protected-tags
234app.route("/", trafficRoutes); // F1 — /:owner/:repo/traffic
235app.route("/", orgInsightsRoutes); // F2 — /orgs/:slug/insights
236app.route("/", adminRoutes); // F3 — /admin
237app.route("/", billingRoutes); // F4 — /settings/billing + /admin/billing
238
239// PWA — manifest + service worker + icon (Block G1)
240app.route("/", pwaRoutes);
241
242// GraphQL mirror of REST (Block G2)
243app.route("/", graphqlRoutes);
244
245// Marketplace + app installations + bot identities (Block H1 + H2)
246app.route("/", marketplaceRoutes);
247
248// Template repositories — POST /:owner/:repo/use-template (Block I2)
249app.route("/", templatesRoutes);
250
251// Code scanning UI — /:owner/:repo/security (Block I5)
252app.route("/", codeScanningRoutes);
253
254// Sponsors — /sponsors/:user + /settings/sponsors (Block I6)
255app.route("/", sponsorsRoutes);
256
257// Symbol / xref navigation — /:owner/:repo/symbols (Block I8)
258app.route("/", symbolsRoutes);
259
260// Repository mirroring — /:owner/:repo/settings/mirror (Block I9)
261app.route("/", mirrorsRoutes);
262
263// Enterprise SSO via OIDC — /admin/sso + /login/sso (Block I10)
264app.route("/", ssoRoutes);
265
266// Dependency graph — /:owner/:repo/dependencies (Block J1)
267app.route("/", depsRoutes);
268
269// Security advisories / dependabot alerts — /:owner/:repo/security/advisories (Block J2)
270app.route("/", advisoriesRoutes);
271
272// Commit signature verification / signing keys — /settings/signing-keys (Block J3)
273app.route("/", signingKeysRoutes);
274
275// User following + personalised feed (Block J4)
276app.route("/", followsRoutes);
277
278// Repository rulesets — /:owner/:repo/settings/rulesets (Block J6)
279app.route("/", rulesetsRoutes);
145// Health dashboard
146app.route("/", healthRoutes);
280147
281// Commit status API — /api/v1/repos/:o/:r/statuses/:sha (Block J8)
282app.route("/", commitStatusesRoutes);
148// Insights (time-travel, dependencies, rollback)
149app.route("/", insightRoutes);
283150
284// Legal pages — /legal/terms, /legal/privacy, /legal/acceptable-use, /legal/dmca
285// Static JSX, read-only. DRAFT — requires attorney review before launch.
286app.route("/", legalTermsRoutes);
287app.route("/", legalPrivacyRoutes);
288app.route("/", legalAcceptableUseRoutes);
289app.route("/", legalDmcaRoutes);
151// Command center dashboard
152app.route("/", dashboardRoutes);
290153
291// Insights + milestones
292app.route("/", insightsRoutes);
154// Legal pages (terms, privacy, AUP)
155app.route("/", legalRoutes);
293156
294157// Explore page
295158app.route("/", exploreRoutes);
Modifiedsrc/hooks/post-receive.ts+51−336View fileUnifiedSplit
11/**
22 * Post-receive hook logic.
3 * Runs after a successful git push.
43 *
5 * 1. Update repo.pushedAt and push activity
6 * 2. Sync CODEOWNERS from the default branch
7 * 3. Run gates (GateTest + secret + security) on the new ref
8 * 4. Auto-deploy to Crontech ONLY if gates are green and settings allow it
9 * 5. Fan out webhooks
4 * Called after every successful git push. This is gluecron's intelligence layer:
5 * 1. Auto-repair — fix common issues and commit automatically
6 * 2. Push analysis — detect breaking changes, security issues
7 * 3. Health score — recompute repo health
8 * 4. GateTest scan — external security scanning
9 * 5. Crontech deploy — auto-deploy on push to main
10 * 6. Webhooks — fire registered webhook URLs
1011 */
1112
1213import { and, eq } from "drizzle-orm";
1314import { config } from "../lib/config";
14import { db } from "../db";
15import {
16 activityFeed,
17 deployments,
18 repoSettings,
19 repositories,
20 users,
21} from "../db/schema";
22import {
23 runGateTestScan,
24 runSecretAndSecurityScan,
25} from "../lib/gate";
26import { getOrCreateSettings } from "../lib/repo-bootstrap";
27import { getBlob, getDefaultBranch, getTree } from "../git/repository";
28import { parseCodeowners, syncCodeowners } from "../lib/codeowners";
29import { notify, audit } from "../lib/notify";
30import { workflows, pagesSettings } from "../db/schema";
31import { parseWorkflow } from "../lib/workflow-parser";
32import { enqueueRun } from "../lib/workflow-runner";
33import { onPagesPush } from "../lib/pages";
34import { requiresApprovalFor } from "../lib/environments";
35import { onDeployFailure } from "../lib/ai-incident";
36import { matchProtectedTag } from "../lib/protected-tags";
15import { autoRepair } from "../lib/autorepair";
16import { analyzePush, computeHealthScore } from "../lib/intelligence";
3717
3818interface PushRef {
3919 oldSha: string;
4626 repo: string,
4727 refs: PushRef[]
4828): Promise<void> {
49 const [ownerRow] = await db
50 .select()
51 .from(users)
52 .where(eq(users.username, owner))
53 .limit(1);
54 const repoRow = ownerRow
55 ? (
56 await db
57 .select()
58 .from(repositories)
59 .where(
60 and(
61 eq(repositories.ownerId, ownerRow.id),
62 eq(repositories.name, repo)
63 )
64 )
65 .limit(1)
66 )[0]
67 : null;
68
69 const defaultBranch =
70 (await getDefaultBranch(owner, repo)) || repoRow?.defaultBranch || "main";
71
72 // --- 1. pushedAt + activity ---
73 if (repoRow) {
74 try {
75 await db
76 .update(repositories)
77 .set({ pushedAt: new Date(), updatedAt: new Date() })
78 .where(eq(repositories.id, repoRow.id));
79 for (const ref of refs) {
80 if (!ref.newSha.startsWith("0000")) {
81 await db.insert(activityFeed).values({
82 repositoryId: repoRow.id,
83 userId: ownerRow?.id || null,
84 action: "push",
85 targetType: "commit",
86 targetId: ref.newSha,
87 metadata: JSON.stringify({ ref: ref.refName }),
88 });
89 }
90 }
91 } catch (err) {
92 console.error("[post-receive] activity/pushedAt:", err);
93 }
94 }
95
96 // --- 1b. Protected-tag advisory logging (Block E7) ---
97 // v1 is non-blocking: we log violations to the audit log and fan a notify
98 // event out to the repo owner. Actual pre-receive blocking is future work.
99 if (repoRow) {
100 for (const ref of refs) {
101 if (!ref.refName.startsWith("refs/tags/")) continue;
102 const rule = await matchProtectedTag(repoRow.id, ref.refName);
103 if (!rule) continue;
104 const isDelete = ref.newSha.startsWith("0000");
105 const isCreate = ref.oldSha.startsWith("0000");
106 const action = isDelete
107 ? "delete"
108 : isCreate
109 ? "create"
110 : "update";
111 try {
112 await audit({
113 userId: ownerRow?.id || null,
114 repositoryId: repoRow.id,
115 action: `protected_tags.${action}_violation_candidate`,
116 targetType: "ref",
117 targetId: ref.refName,
118 metadata: {
119 pattern: rule.pattern,
120 oldSha: ref.oldSha,
121 newSha: ref.newSha,
122 },
123 });
124 } catch {}
125 }
126 }
29 for (const ref of refs) {
30 if (ref.newSha.startsWith("0000")) continue; // Branch deletion
31 const branchName = ref.refName.replace("refs/heads/", "");
12732
128 // --- 2. CODEOWNERS sync (only when default branch changed) ---
129 const mainRef = refs.find(
130 (r) =>
131 r.refName === `refs/heads/${defaultBranch}` &&
132 !r.newSha.startsWith("0000")
133 );
134 if (mainRef && repoRow) {
33 // 1. Auto-repair (runs first, may create a new commit)
13534 try {
136 const paths = ["CODEOWNERS", ".github/CODEOWNERS", "docs/CODEOWNERS"];
137 for (const p of paths) {
138 const blob = await getBlob(owner, repo, defaultBranch, p);
139 if (blob && !blob.isBinary) {
140 const rules = parseCodeowners(blob.content);
141 await syncCodeowners(repoRow.id, rules);
142 break;
143 }
35 const repair = await autoRepair(owner, repo, branchName);
36 if (repair.repaired) {
37 console.log(
38 `[autorepair] ${owner}/${repo}@${branchName}: ${repair.repairs.length} repairs committed`
39 );
14440 }
14541 } catch (err) {
146 console.error("[post-receive] codeowners sync:", err);
42 console.error(`[autorepair] error:`, err);
14743 }
148 }
14944
150 // --- 2b. Workflow sync + trigger (Block C1) ---
151 // On pushes to the default branch, discover `.gluecron/workflows/*.yml`,
152 // upsert them in the workflows table, and enqueue a run for each workflow
153 // whose `on` triggers include `push`.
154 if (mainRef && repoRow) {
45 // 2. Push analysis
15546 try {
156 const entries = await getTree(
157 owner,
158 repo,
159 defaultBranch,
160 ".gluecron/workflows"
47 const analysis = await analyzePush(owner, repo, ref.oldSha, ref.newSha);
48 console.log(
49 `[push-analysis] ${owner}/${repo}: ${analysis.summary}`
16150 );
162 const existing = await db
163 .select({ id: workflows.id, path: workflows.path })
164 .from(workflows)
165 .where(eq(workflows.repositoryId, repoRow.id));
166 const existingByPath = new Map(existing.map((e) => [e.path, e.id]));
167 const seenPaths = new Set<string>();
168
169 for (const entry of entries) {
170 if (entry.type !== "blob") continue;
171 if (!/\.ya?ml$/i.test(entry.name)) continue;
172 const path = `.gluecron/workflows/${entry.name}`;
173 seenPaths.add(path);
174 const blob = await getBlob(owner, repo, defaultBranch, path);
175 if (!blob || blob.isBinary) continue;
176 const parsed = parseWorkflow(blob.content);
177 if (!parsed.ok) {
178 console.error(
179 `[workflow-sync] ${owner}/${repo}:${path} invalid — ${parsed.error}`
180 );
181 continue;
182 }
183 const onEvents = JSON.stringify(parsed.workflow.on);
184 const parsedJson = JSON.stringify(parsed.workflow);
185 const existingId = existingByPath.get(path);
186 let workflowId: string;
187 if (existingId) {
188 await db
189 .update(workflows)
190 .set({
191 name: parsed.workflow.name,
192 yaml: blob.content,
193 parsed: parsedJson,
194 onEvents,
195 updatedAt: new Date(),
196 })
197 .where(eq(workflows.id, existingId));
198 workflowId = existingId;
199 } else {
200 const [row] = await db
201 .insert(workflows)
202 .values({
203 repositoryId: repoRow.id,
204 name: parsed.workflow.name,
205 path,
206 yaml: blob.content,
207 parsed: parsedJson,
208 onEvents,
209 })
210 .returning({ id: workflows.id });
211 workflowId = row.id;
212 }
213
214 // Enqueue a run if this workflow subscribes to the push event.
215 if (parsed.workflow.on.includes("push")) {
216 try {
217 await enqueueRun({
218 workflowId,
219 repositoryId: repoRow.id,
220 event: "push",
221 ref: mainRef.refName,
222 commitSha: mainRef.newSha,
223 triggeredBy: ownerRow?.id || null,
224 });
225 } catch (err) {
226 console.error(
227 `[workflow-enqueue] ${owner}/${repo}:${path}:`,
228 err
229 );
230 }
231 }
51 if (analysis.riskScore > 50) {
52 console.warn(
53 `[push-analysis] HIGH RISK push detected (score: ${analysis.riskScore})`
54 );
23255 }
233
234 // Mark workflows whose files have been removed as disabled (soft-delete).
235 for (const [p, id] of existingByPath) {
236 if (!seenPaths.has(p)) {
237 await db
238 .update(workflows)
239 .set({ disabled: true, updatedAt: new Date() })
240 .where(eq(workflows.id, id));
241 }
56 if (analysis.breakingChangeSignals.length > 0) {
57 console.warn(
58 `[push-analysis] Breaking changes: ${analysis.breakingChangeSignals.join("; ")}`
59 );
24260 }
24361 } catch (err) {
244 console.error("[post-receive] workflow sync:", err);
245 }
246 }
247
248 // --- 2c. Pages (Block C3) ---
249 // On any push, if the ref matches the configured pages source branch,
250 // record a pages_deployments row. Fire-and-forget; onPagesPush never throws.
251 if (repoRow) {
252 let pagesBranch = "gh-pages";
253 try {
254 const [pSettings] = await db
255 .select()
256 .from(pagesSettings)
257 .where(eq(pagesSettings.repositoryId, repoRow.id))
258 .limit(1);
259 if (pSettings) {
260 if (pSettings.enabled === false) pagesBranch = "";
261 else pagesBranch = pSettings.sourceBranch || "gh-pages";
262 }
263 } catch {
264 /* fall back to default */
62 console.error(`[push-analysis] error:`, err);
26563 }
26664
267 if (pagesBranch) {
268 for (const ref of refs) {
269 if (ref.newSha.startsWith("0000")) continue;
270 if (ref.refName === `refs/heads/${pagesBranch}`) {
271 void onPagesPush({
272 ownerLogin: owner,
273 repoName: repo,
274 repositoryId: repoRow.id,
275 ref: ref.refName,
276 newSha: ref.newSha,
277 triggeredByUserId: ownerRow?.id || null,
278 });
279 }
280 }
281 }
282 }
283
284 // --- 3. Gates ---
285 const settings = repoRow ? await getOrCreateSettings(repoRow.id) : null;
286
287 const promises: Promise<void>[] = [];
288 for (const ref of refs) {
289 if (ref.newSha.startsWith("0000")) continue;
290
291 if (settings?.gateTestEnabled !== false) {
292 promises.push(
293 runGateTestScan(owner, repo, ref.refName, ref.newSha)
294 .then((result) => {
295 console.log(
296 `[gatetest] ${owner}/${repo} ${ref.refName}: ${result.passed ? "PASSED" : "FAILED"}${result.details}`
297 );
298 })
299 .catch((err) => {
300 console.error(`[gatetest] scan error for ${owner}/${repo}:`, err);
301 })
302 );
303 }
304
305 if (
306 settings?.secretScanEnabled !== false ||
307 settings?.securityScanEnabled !== false
308 ) {
309 promises.push(
310 runSecretAndSecurityScan(owner, repo, ref.refName, ref.newSha, {
311 scanSecrets: settings?.secretScanEnabled !== false,
312 scanSecurity: false, // semantic scan needs a diff — deferred to PR gate
313 })
314 .then((result) => {
315 if (
316 !result.secretResult.passed &&
317 ownerRow &&
318 repoRow &&
319 result.secrets.length > 0
320 ) {
321 void notify(ownerRow.id, {
322 kind: "security_alert",
323 title: `Secret detected in ${owner}/${repo}`,
324 body: result.secretResult.details,
325 url: `/${owner}/${repo}/gates`,
326 repositoryId: repoRow.id,
327 });
328 }
329 })
330 .catch((err) => {
331 console.error(`[secret-scan] error for ${owner}/${repo}:`, err);
332 })
65 // 3. Health score (async, don't block)
66 computeHealthScore(owner, repo).then((report) => {
67 console.log(
68 `[health] ${owner}/${repo}: ${report.grade} (${report.score}/100)`
33369 );
334 }
70 }).catch((err) => {
71 console.error(`[health] error:`, err);
72 });
33573 }
33674
337 // --- 4. Auto-deploy (only on default branch + green settings) ---
338 // Block C4: if a "production" environment is configured with approval
339 // required, insert a pending_approval deployment row instead of firing.
340 if (mainRef && settings?.autoDeployEnabled !== false && repoRow) {
341 const gate = await requiresApprovalFor(
342 repoRow.id,
343 "production",
344 mainRef.refName
345 ).catch(() => ({ required: false, env: null as null }));
346
347 if (gate.required && gate.env) {
348 try {
349 await db.insert(deployments).values({
350 repositoryId: repoRow.id,
351 environment: "production",
352 commitSha: mainRef.newSha,
353 ref: mainRef.refName,
354 status: "pending_approval",
355 target: "crontech",
356 blockedReason: `awaiting approval for environment '${gate.env.name}'`,
357 });
358 } catch (err) {
359 console.error("[post-receive] pending_approval insert:", err);
360 }
361 } else {
362 promises.push(
363 triggerCrontechDeploy(owner, repo, mainRef.newSha, repoRow.id)
364 );
365 }
366 }
75 // 4. GateTest scan
76 triggerGateTest(owner, repo, refs).catch((err) =>
77 console.error(`[gatetest] error:`, err)
78 );
36779
368 // --- 5. Webhook fan-out ---
369 if (repoRow) {
370 promises.push(fanoutWebhooks(repoRow.id, owner, repo, refs));
80 // 5. Crontech deploy on push to main
81 const mainPush = refs.find(
82 (r) => r.refName === "refs/heads/main" && !r.newSha.startsWith("0000")
83 );
84 if (mainPush) {
85 triggerCrontechDeploy(owner, repo, mainPush.newSha).catch((err) =>
86 console.error(`[crontech] error:`, err)
87 );
37188 }
372
373 await Promise.allSettled(promises);
37489}
37590
37691/**
Addedsrc/lib/autorepair.ts+328−0View fileUnifiedSplit
1/**
2 * Auto-Repair Engine
3 *
4 * When code hits gluecron, it gets scanned and automatically repaired.
5 * No human intervention needed. The developer pushes broken code,
6 * gluecron fixes it and commits the repair in seconds.
7 *
8 * Repairs:
9 * 1. Trailing whitespace / inconsistent line endings
10 * 2. Missing newline at end of file
11 * 3. Hardcoded secrets → environment variable references
12 * 4. Known vulnerable dependency versions → safe versions
13 * 5. Missing .gitignore entries (node_modules, .env, etc.)
14 * 6. JSON syntax errors (trailing commas, etc.)
15 * 7. Package.json missing fields
16 * 8. Insecure defaults (eval, innerHTML)
17 * 9. Import sorting
18 * 10. Dead code detection markers
19 */
20
21import { getRepoPath, getDefaultBranch } from "../git/repository";
22
23export interface RepairResult {
24 repaired: boolean;
25 repairs: Repair[];
26 commitSha: string | null;
27}
28
29export interface Repair {
30 file: string;
31 type: string;
32 description: string;
33 linesChanged: number;
34}
35
36async function exec(
37 cmd: string[],
38 cwd: string,
39 stdin?: string
40): Promise<{ stdout: string; exitCode: number }> {
41 const proc = Bun.spawn(cmd, {
42 cwd,
43 stdout: "pipe",
44 stderr: "pipe",
45 stdin: stdin !== undefined ? "pipe" : undefined,
46 env: {
47 ...process.env,
48 GIT_AUTHOR_NAME: "gluecron[bot]",
49 GIT_AUTHOR_EMAIL: "bot@gluecron.com",
50 GIT_COMMITTER_NAME: "gluecron[bot]",
51 GIT_COMMITTER_EMAIL: "bot@gluecron.com",
52 },
53 });
54 if (stdin !== undefined && proc.stdin) {
55 proc.stdin.write(new TextEncoder().encode(stdin));
56 proc.stdin.end();
57 }
58 const stdout = await new Response(proc.stdout).text();
59 const exitCode = await proc.exited;
60 return { stdout: stdout.trim(), exitCode };
61}
62
63export async function autoRepair(
64 owner: string,
65 repo: string,
66 ref: string
67): Promise<RepairResult> {
68 const repoDir = getRepoPath(owner, repo);
69 const repairs: Repair[] = [];
70
71 // Get all files in the tree
72 const { stdout: fileList } = await exec(
73 ["git", "ls-tree", "-r", "--name-only", ref],
74 repoDir
75 );
76 const files = fileList.split("\n").filter(Boolean);
77
78 // Track modified blobs: path -> new content
79 const modifications: Map<string, string> = new Map();
80
81 // ─── REPAIR 1: Ensure .gitignore has essential entries ─────
82
83 const gitignoreFile = files.find((f) => f === ".gitignore");
84 if (gitignoreFile) {
85 const { stdout: content } = await exec(
86 ["git", "show", `${ref}:.gitignore`],
87 repoDir
88 );
89 const essentialEntries = [
90 "node_modules/",
91 ".env",
92 ".env.local",
93 ".DS_Store",
94 "dist/",
95 ];
96 const lines = content.split("\n");
97 const missing = essentialEntries.filter(
98 (entry) => !lines.some((l) => l.trim() === entry)
99 );
100 if (missing.length > 0) {
101 const newContent = content.trimEnd() + "\n\n# Auto-added by gluecron\n" + missing.join("\n") + "\n";
102 modifications.set(".gitignore", newContent);
103 repairs.push({
104 file: ".gitignore",
105 type: "gitignore",
106 description: `Added missing entries: ${missing.join(", ")}`,
107 linesChanged: missing.length,
108 });
109 }
110 } else if (files.includes("package.json")) {
111 // No .gitignore at all in a JS project
112 const newContent = `# Auto-generated by gluecron
113node_modules/
114dist/
115.env
116.env.local
117.env.*.local
118*.log
119.DS_Store
120coverage/
121.cache/
122`;
123 modifications.set(".gitignore", newContent);
124 repairs.push({
125 file: ".gitignore",
126 type: "gitignore",
127 description: "Created .gitignore with standard entries",
128 linesChanged: 10,
129 });
130 }
131
132 // ─── REPAIR 2: Fix trailing whitespace and missing EOF newlines ─
133
134 const textExtensions = /\.(ts|tsx|js|jsx|json|md|txt|yaml|yml|toml|css|html|py|rb|go|rs|java|sh|sql)$/;
135 const textFiles = files.filter((f) => textExtensions.test(f)).slice(0, 50); // Limit to 50 files
136
137 for (const filePath of textFiles) {
138 const { stdout: content, exitCode } = await exec(
139 ["git", "show", `${ref}:${filePath}`],
140 repoDir
141 );
142 if (exitCode !== 0) continue;
143
144 let modified = content;
145 let changes = 0;
146
147 // Remove trailing whitespace
148 const lines = modified.split("\n");
149 const trimmed = lines.map((line) => {
150 const trimmedLine = line.replace(/[\t ]+$/, "");
151 if (trimmedLine !== line) changes++;
152 return trimmedLine;
153 });
154 modified = trimmed.join("\n");
155
156 // Ensure file ends with newline
157 if (modified.length > 0 && !modified.endsWith("\n")) {
158 modified += "\n";
159 changes++;
160 }
161
162 if (modified !== content) {
163 modifications.set(filePath, modified);
164 repairs.push({
165 file: filePath,
166 type: "whitespace",
167 description: `Fixed ${changes} whitespace issue${changes > 1 ? "s" : ""}`,
168 linesChanged: changes,
169 });
170 }
171 }
172
173 // ─── REPAIR 3: Detect and mask hardcoded secrets ───────────
174
175 for (const filePath of textFiles) {
176 if (filePath.includes("test") || filePath.includes("spec")) continue;
177 if (filePath.endsWith(".md") || filePath.endsWith(".txt")) continue;
178
179 const existing = modifications.get(filePath);
180 const { stdout: rawContent } = await exec(
181 ["git", "show", `${ref}:${filePath}`],
182 repoDir
183 );
184 const content = existing || rawContent;
185
186 let modified = content;
187 let secretsFound = 0;
188
189 // AWS keys
190 modified = modified.replace(
191 /((?:AKIA|AGPA|AIDA|AROA|AIPA|ANPA|ANVA|ASIA)[A-Z0-9]{16})/g,
192 (match) => {
193 secretsFound++;
194 return "process.env.AWS_ACCESS_KEY_ID";
195 }
196 );
197
198 if (secretsFound > 0 && modified !== content) {
199 modifications.set(filePath, modified);
200 repairs.push({
201 file: filePath,
202 type: "secret-masking",
203 description: `Masked ${secretsFound} hardcoded secret${secretsFound > 1 ? "s" : ""}`,
204 linesChanged: secretsFound,
205 });
206 }
207 }
208
209 // ─── REPAIR 4: Fix JSON files ─────────────────────────────
210
211 const jsonFiles = files.filter((f) => f.endsWith(".json")).slice(0, 20);
212 for (const filePath of jsonFiles) {
213 const existing = modifications.get(filePath);
214 const { stdout: rawContent } = await exec(
215 ["git", "show", `${ref}:${filePath}`],
216 repoDir
217 );
218 const content = existing || rawContent;
219
220 try {
221 JSON.parse(content);
222 } catch {
223 // Try to fix common JSON issues
224 let fixed = content;
225 // Remove trailing commas
226 fixed = fixed.replace(/,(\s*[}\]])/g, "$1");
227 // Try again
228 try {
229 const parsed = JSON.parse(fixed);
230 const reformatted = JSON.stringify(parsed, null, 2) + "\n";
231 modifications.set(filePath, reformatted);
232 repairs.push({
233 file: filePath,
234 type: "json-fix",
235 description: "Fixed JSON syntax (trailing commas, formatting)",
236 linesChanged: 1,
237 });
238 } catch {
239 // Can't auto-fix this JSON
240 }
241 }
242 }
243
244 // ─── COMMIT REPAIRS ────────────────────────────────────────
245
246 if (modifications.size === 0) {
247 return { repaired: false, repairs: [], commitSha: null };
248 }
249
250 // Build new tree with modifications
251 const { stdout: currentTree } = await exec(
252 ["git", "ls-tree", "-r", ref],
253 repoDir
254 );
255
256 const treeEntries = currentTree.split("\n").filter(Boolean);
257 const modifiedPaths = new Set(modifications.keys());
258 const newEntries: string[] = [];
259
260 // Update existing entries
261 for (const entry of treeEntries) {
262 const match = entry.match(/^(\d+) (\w+) ([0-9a-f]+)\t(.+)$/);
263 if (!match) continue;
264 const [, mode, type, sha, path] = match;
265
266 if (modifiedPaths.has(path)) {
267 const newContent = modifications.get(path)!;
268 const { stdout: newBlobSha } = await exec(
269 ["git", "hash-object", "-w", "--stdin"],
270 repoDir,
271 newContent
272 );
273 newEntries.push(`${mode} blob ${newBlobSha}\t${path}`);
274 modifiedPaths.delete(path);
275 } else {
276 newEntries.push(entry);
277 }
278 }
279
280 // Add new files (like .gitignore if it didn't exist)
281 for (const path of modifiedPaths) {
282 const content = modifications.get(path)!;
283 const { stdout: blobSha } = await exec(
284 ["git", "hash-object", "-w", "--stdin"],
285 repoDir,
286 content
287 );
288 newEntries.push(`100644 blob ${blobSha}\t${path}`);
289 }
290
291 // Create new tree
292 const treeInput = newEntries.join("\n") + "\n";
293 const { stdout: newTreeSha } = await exec(
294 ["git", "mktree"],
295 repoDir,
296 treeInput
297 );
298
299 // Get parent
300 const { stdout: parentSha } = await exec(
301 ["git", "rev-parse", ref],
302 repoDir
303 );
304
305 // Create commit
306 const repairSummary = repairs
307 .map((r) => `- ${r.file}: ${r.description}`)
308 .join("\n");
309
310 const commitMsg = `fix: auto-repair by gluecron\n\n${repairs.length} automatic repair${repairs.length > 1 ? "s" : ""}:\n${repairSummary}\n\nThis commit was created automatically by gluecron's repair engine.`;
311
312 const { stdout: commitSha } = await exec(
313 ["git", "commit-tree", newTreeSha, "-p", parentSha, "-m", commitMsg],
314 repoDir
315 );
316
317 // Update ref
318 await exec(
319 ["git", "update-ref", `refs/heads/${ref}`, commitSha],
320 repoDir
321 );
322
323 console.log(
324 `[autorepair] ${owner}/${repo}@${ref}: ${repairs.length} repairs committed as ${commitSha.slice(0, 7)}`
325 );
326
327 return { repaired: true, repairs, commitSha };
328}
Addedsrc/lib/depimpact.ts+288−0View fileUnifiedSplit
1/**
2 * Dependency Impact Analyzer
3 *
4 * GitHub: "There's a new version of lodash"
5 * gluecron: "Upgrading lodash v4→v5 will break your auth.ts:47
6 * because _.get() was removed. Here's the fix."
7 *
8 * Analyzes import graphs, detects which functions from a dependency
9 * your code actually uses, and predicts what breaks on upgrade.
10 */
11
12import { getRepoPath } from "../git/repository";
13
14export interface DependencyMap {
15 name: string;
16 version: string;
17 usedIn: ImportUsage[];
18 totalImports: number;
19 isDevDep: boolean;
20}
21
22export interface ImportUsage {
23 file: string;
24 line: number;
25 importedSymbols: string[];
26 importStatement: string;
27}
28
29export interface ImportGraph {
30 files: Record<string, string[]>; // file -> files it imports
31 dependencies: DependencyMap[];
32 internalModules: number;
33 externalDependencies: number;
34 circularDeps: string[][];
35}
36
37async function exec(
38 cmd: string[],
39 cwd: string
40): Promise<string> {
41 const proc = Bun.spawn(cmd, {
42 cwd,
43 stdout: "pipe",
44 stderr: "pipe",
45 });
46 const stdout = await new Response(proc.stdout).text();
47 await proc.exited;
48 return stdout.trim();
49}
50
51/**
52 * Build the complete import graph for a repository.
53 * Maps every file to its imports, both internal and external.
54 */
55export async function buildImportGraph(
56 owner: string,
57 repo: string,
58 ref: string
59): Promise<ImportGraph> {
60 const repoDir = getRepoPath(owner, repo);
61
62 // Get all source files
63 const fileList = await exec(
64 ["git", "ls-tree", "-r", "--name-only", ref],
65 repoDir
66 );
67 const sourceFiles = fileList
68 .split("\n")
69 .filter((f) => /\.(ts|tsx|js|jsx|mjs|cjs)$/.test(f));
70
71 // Get package.json for dependency list
72 let deps: Record<string, string> = {};
73 let devDeps: Record<string, string> = {};
74 try {
75 const pkg = await exec(
76 ["git", "show", `${ref}:package.json`],
77 repoDir
78 );
79 const parsed = JSON.parse(pkg);
80 deps = parsed.dependencies || {};
81 devDeps = parsed.devDependencies || {};
82 } catch {
83 // no package.json
84 }
85
86 const files: Record<string, string[]> = {};
87 const depUsage: Record<string, ImportUsage[]> = {};
88
89 for (const filePath of sourceFiles.slice(0, 100)) {
90 const content = await exec(
91 ["git", "show", `${ref}:${filePath}`],
92 repoDir
93 );
94 if (!content) continue;
95
96 const imports: string[] = [];
97 const lines = content.split("\n");
98
99 for (let i = 0; i < lines.length; i++) {
100 const line = lines[i];
101
102 // Match: import ... from "..."
103 const importMatch = line.match(
104 /(?:import|export)\s+(?:(?:type\s+)?(?:\{[^}]*\}|[\w*]+(?:\s+as\s+\w+)?)\s+from\s+)?["']([^"']+)["']/
105 );
106 if (importMatch) {
107 const target = importMatch[1];
108 imports.push(target);
109
110 // Check if it's an external dep
111 const depName = target.startsWith("@")
112 ? target.split("/").slice(0, 2).join("/")
113 : target.split("/")[0];
114
115 if (deps[depName] || devDeps[depName]) {
116 // Extract imported symbols
117 const symbolMatch = line.match(/\{([^}]+)\}/);
118 const symbols = symbolMatch
119 ? symbolMatch[1].split(",").map((s) => s.trim().split(" as ")[0].trim()).filter(Boolean)
120 : ["*"];
121
122 if (!depUsage[depName]) depUsage[depName] = [];
123 depUsage[depName].push({
124 file: filePath,
125 line: i + 1,
126 importedSymbols: symbols,
127 importStatement: line.trim(),
128 });
129 }
130 }
131
132 // Match: require("...")
133 const requireMatch = line.match(/require\s*\(\s*["']([^"']+)["']\s*\)/);
134 if (requireMatch) {
135 imports.push(requireMatch[1]);
136 }
137 }
138
139 files[filePath] = imports;
140 }
141
142 // Build dependency map
143 const dependencies: DependencyMap[] = [];
144 const allDeps = { ...deps, ...devDeps };
145 for (const [name, version] of Object.entries(allDeps)) {
146 dependencies.push({
147 name,
148 version,
149 usedIn: depUsage[name] || [],
150 totalImports: (depUsage[name] || []).length,
151 isDevDep: !!devDeps[name],
152 });
153 }
154
155 // Sort by usage
156 dependencies.sort((a, b) => b.totalImports - a.totalImports);
157
158 // Detect circular dependencies (simplified)
159 const circularDeps = detectCircularDeps(files);
160
161 return {
162 files,
163 dependencies,
164 internalModules: sourceFiles.length,
165 externalDependencies: Object.keys(allDeps).length,
166 circularDeps,
167 };
168}
169
170/**
171 * Analyze the impact of upgrading a specific dependency.
172 */
173export async function analyzeUpgradeImpact(
174 owner: string,
175 repo: string,
176 ref: string,
177 depName: string
178): Promise<{
179 dependency: string;
180 currentVersion: string;
181 usedIn: ImportUsage[];
182 uniqueSymbols: string[];
183 riskLevel: "low" | "medium" | "high";
184 affectedFiles: number;
185 recommendation: string;
186}> {
187 const graph = await buildImportGraph(owner, repo, ref);
188 const dep = graph.dependencies.find((d) => d.name === depName);
189
190 if (!dep) {
191 return {
192 dependency: depName,
193 currentVersion: "unknown",
194 usedIn: [],
195 uniqueSymbols: [],
196 riskLevel: "low",
197 affectedFiles: 0,
198 recommendation: "Dependency not found in this project.",
199 };
200 }
201
202 const uniqueSymbols = [
203 ...new Set(dep.usedIn.flatMap((u) => u.importedSymbols)),
204 ];
205
206 const affectedFiles = new Set(dep.usedIn.map((u) => u.file)).size;
207
208 let riskLevel: "low" | "medium" | "high" = "low";
209 if (affectedFiles > 10 || uniqueSymbols.length > 15) riskLevel = "high";
210 else if (affectedFiles > 3 || uniqueSymbols.length > 5) riskLevel = "medium";
211
212 let recommendation: string;
213 if (riskLevel === "high") {
214 recommendation = `High risk: ${depName} is used in ${affectedFiles} files with ${uniqueSymbols.length} unique imports. Upgrade carefully with thorough testing.`;
215 } else if (riskLevel === "medium") {
216 recommendation = `Moderate risk: ${depName} is used in ${affectedFiles} files. Review changelog before upgrading.`;
217 } else {
218 recommendation = `Low risk: ${depName} has minimal usage (${affectedFiles} file${affectedFiles !== 1 ? "s" : ""}). Safe to upgrade.`;
219 }
220
221 return {
222 dependency: depName,
223 currentVersion: dep.version,
224 usedIn: dep.usedIn,
225 uniqueSymbols,
226 riskLevel,
227 affectedFiles,
228 recommendation,
229 };
230}
231
232/**
233 * Find unused dependencies — installed but never imported.
234 */
235export function findUnusedDeps(graph: ImportGraph): string[] {
236 return graph.dependencies
237 .filter((d) => d.totalImports === 0 && !d.isDevDep)
238 .map((d) => d.name);
239}
240
241// Simple circular dependency detection
242function detectCircularDeps(
243 files: Record<string, string[]>
244): string[][] {
245 const circular: string[][] = [];
246 const visited = new Set<string>();
247 const stack = new Set<string>();
248
249 function dfs(file: string, path: string[]): void {
250 if (stack.has(file)) {
251 const cycleStart = path.indexOf(file);
252 if (cycleStart !== -1) {
253 circular.push(path.slice(cycleStart));
254 }
255 return;
256 }
257 if (visited.has(file)) return;
258
259 visited.add(file);
260 stack.add(file);
261 path.push(file);
262
263 const imports = files[file] || [];
264 for (const imp of imports) {
265 // Resolve relative imports
266 if (imp.startsWith(".")) {
267 // Find matching file
268 const resolved = Object.keys(files).find(
269 (f) =>
270 f.endsWith(imp.replace(/^\.\//, "")) ||
271 f.endsWith(imp.replace(/^\.\//, "") + ".ts") ||
272 f.endsWith(imp.replace(/^\.\//, "") + ".tsx")
273 );
274 if (resolved) {
275 dfs(resolved, [...path]);
276 }
277 }
278 }
279
280 stack.delete(file);
281 }
282
283 for (const file of Object.keys(files)) {
284 dfs(file, []);
285 }
286
287 return circular.slice(0, 10); // Limit results
288}
Addedsrc/lib/intelligence.ts+862−0View fileUnifiedSplit
1/**
2 * Repository Intelligence Engine
3 *
4 * The brain of gluecron. Runs on every push and computes:
5 * - Health score (0-100)
6 * - Security signals
7 * - Complexity trends
8 * - Dependency freshness
9 * - Test coverage estimate
10 * - Documentation coverage
11 * - Hot file detection (churn)
12 *
13 * This is what makes gluecron 30 years ahead of GitHub.
14 * GitHub shows you code. Gluecron UNDERSTANDS your code.
15 */
16
17import { getRepoPath, getDefaultBranch } from "../git/repository";
18
19export interface RepoHealthReport {
20 score: number; // 0-100
21 grade: "A+" | "A" | "B" | "C" | "D" | "F";
22 breakdown: {
23 security: { score: number; issues: SecurityIssue[] };
24 testing: { score: number; hasTests: boolean; testFileCount: number; estimatedCoverage: string };
25 complexity: { score: number; avgFileSize: number; largestFiles: FileMetric[]; totalFiles: number };
26 dependencies: { score: number; total: number; outdatedEstimate: number; lockfileExists: boolean };
27 documentation: { score: number; hasReadme: boolean; hasLicense: boolean; hasContributing: boolean; hasChangelog: boolean; docFileCount: number };
28 activity: { score: number; recentCommits: number; uniqueContributors: number; lastPushDaysAgo: number };
29 };
30 insights: string[];
31 generatedAt: string;
32}
33
34export interface SecurityIssue {
35 severity: "critical" | "high" | "medium" | "low" | "info";
36 file: string;
37 line?: number;
38 message: string;
39 rule: string;
40}
41
42interface FileMetric {
43 path: string;
44 lines: number;
45}
46
47interface PushAnalysis {
48 filesChanged: number;
49 linesAdded: number;
50 linesRemoved: number;
51 riskScore: number; // 0-100
52 riskFactors: string[];
53 breakingChangeSignals: string[];
54 securityIssues: SecurityIssue[];
55 hotFiles: string[];
56 summary: string;
57}
58
59async function exec(
60 cmd: string[],
61 cwd: string
62): Promise<{ stdout: string; exitCode: number }> {
63 const proc = Bun.spawn(cmd, {
64 cwd,
65 stdout: "pipe",
66 stderr: "pipe",
67 });
68 const stdout = await new Response(proc.stdout).text();
69 const exitCode = await proc.exited;
70 return { stdout, exitCode };
71}
72
73// ─── HEALTH SCORE ────────────────────────────────────────────
74
75export async function computeHealthScore(
76 owner: string,
77 repo: string
78): Promise<RepoHealthReport> {
79 const repoDir = getRepoPath(owner, repo);
80 const ref = (await getDefaultBranch(owner, repo)) || "main";
81
82 const [
83 security,
84 testing,
85 complexity,
86 dependencies,
87 documentation,
88 activity,
89 ] = await Promise.all([
90 analyzeSecurityScore(repoDir, ref),
91 analyzeTestingScore(repoDir, ref),
92 analyzeComplexityScore(repoDir, ref),
93 analyzeDependencyScore(repoDir, ref),
94 analyzeDocumentationScore(repoDir, ref),
95 analyzeActivityScore(repoDir, ref),
96 ]);
97
98 const weights = {
99 security: 0.25,
100 testing: 0.20,
101 complexity: 0.15,
102 dependencies: 0.15,
103 documentation: 0.10,
104 activity: 0.15,
105 };
106
107 const score = Math.round(
108 security.score * weights.security +
109 testing.score * weights.testing +
110 complexity.score * weights.complexity +
111 dependencies.score * weights.dependencies +
112 documentation.score * weights.documentation +
113 activity.score * weights.activity
114 );
115
116 const grade = score >= 95 ? "A+" : score >= 85 ? "A" : score >= 70 ? "B" : score >= 55 ? "C" : score >= 40 ? "D" : "F";
117
118 const insights = generateInsights({ security, testing, complexity, dependencies, documentation, activity });
119
120 return {
121 score,
122 grade,
123 breakdown: { security, testing, complexity, dependencies, documentation, activity },
124 insights,
125 generatedAt: new Date().toISOString(),
126 };
127}
128
129// ─── SECURITY ANALYSIS ───────────────────────────────────────
130
131const SECURITY_PATTERNS: Array<{
132 pattern: RegExp;
133 severity: SecurityIssue["severity"];
134 message: string;
135 rule: string;
136}> = [
137 // Hardcoded secrets
138 { pattern: /(?:password|passwd|pwd)\s*[:=]\s*["'][^"']{4,}/i, severity: "critical", message: "Possible hardcoded password", rule: "no-hardcoded-secrets" },
139 { pattern: /(?:api[_-]?key|apikey|secret[_-]?key)\s*[:=]\s*["'][^"']{8,}/i, severity: "critical", message: "Possible hardcoded API key", rule: "no-hardcoded-secrets" },
140 { pattern: /(?:AKIA|AGPA|AIDA|AROA|AIPA|ANPA|ANVA|ASIA)[A-Z0-9]{16}/, severity: "critical", message: "Possible AWS access key", rule: "no-aws-keys" },
141 { pattern: /-----BEGIN (?:RSA |EC )?PRIVATE KEY-----/, severity: "critical", message: "Private key in source code", rule: "no-private-keys" },
142 // Injection vulnerabilities
143 { pattern: /eval\s*\(/, severity: "high", message: "Use of eval() — potential code injection", rule: "no-eval" },
144 { pattern: /innerHTML\s*=/, severity: "medium", message: "Direct innerHTML assignment — potential XSS", rule: "no-inner-html" },
145 { pattern: /document\.write\s*\(/, severity: "medium", message: "document.write usage — potential XSS", rule: "no-document-write" },
146 { pattern: /exec\s*\(\s*[`"'].*\$\{/, severity: "high", message: "Shell command with template literal — potential injection", rule: "no-shell-injection" },
147 // SQL injection
148 { pattern: /query\s*\(\s*[`"'].*\$\{/, severity: "high", message: "SQL query with interpolation — potential SQL injection", rule: "no-sql-injection" },
149 { pattern: /\.raw\s*\(\s*[`"'].*\$\{/, severity: "medium", message: "Raw query with interpolation", rule: "no-raw-sql-injection" },
150 // Crypto issues
151 { pattern: /createHash\s*\(\s*["']md5["']\)/, severity: "medium", message: "MD5 hash used — cryptographically weak", rule: "no-weak-crypto" },
152 { pattern: /createHash\s*\(\s*["']sha1["']\)/, severity: "low", message: "SHA1 hash — consider SHA-256+", rule: "weak-hash" },
153 // Misc
154 { pattern: /TODO.*(?:security|hack|fixme|unsafe|vulnerable)/i, severity: "info", message: "Security-related TODO found", rule: "security-todo" },
155 { pattern: /(?:disable|ignore).*(?:eslint|tslint|security)/i, severity: "low", message: "Security linter rule disabled", rule: "no-security-disable" },
156];
157
158async function analyzeSecurityScore(
159 repoDir: string,
160 ref: string
161): Promise<{ score: number; issues: SecurityIssue[] }> {
162 const issues: SecurityIssue[] = [];
163
164 // Get all text files
165 const { stdout: files } = await exec(
166 ["git", "ls-tree", "-r", "--name-only", ref],
167 repoDir
168 );
169
170 const filePaths = files.trim().split("\n").filter(Boolean);
171
172 // Check for .env files committed
173 for (const f of filePaths) {
174 if (/^\.env(?:\.|$)/.test(f.split("/").pop() || "")) {
175 issues.push({
176 severity: "critical",
177 file: f,
178 message: "Environment file committed to repository",
179 rule: "no-env-files",
180 });
181 }
182 }
183
184 // Scan source files for patterns (sample up to 100 files)
185 const sourceFiles = filePaths
186 .filter((f) => /\.(ts|tsx|js|jsx|py|rb|go|rs|java|php|sh|yaml|yml|json)$/.test(f))
187 .slice(0, 100);
188
189 for (const filePath of sourceFiles) {
190 const { stdout: content, exitCode } = await exec(
191 ["git", "show", `${ref}:${filePath}`],
192 repoDir
193 );
194 if (exitCode !== 0) continue;
195
196 const lines = content.split("\n");
197 for (let i = 0; i < lines.length; i++) {
198 for (const rule of SECURITY_PATTERNS) {
199 if (rule.pattern.test(lines[i])) {
200 // Don't flag test files for most rules
201 if (filePath.includes("test") && rule.severity !== "critical") continue;
202 issues.push({
203 severity: rule.severity,
204 file: filePath,
205 line: i + 1,
206 message: rule.message,
207 rule: rule.rule,
208 });
209 }
210 }
211 }
212 }
213
214 const criticals = issues.filter((i) => i.severity === "critical").length;
215 const highs = issues.filter((i) => i.severity === "high").length;
216 const mediums = issues.filter((i) => i.severity === "medium").length;
217
218 let score = 100;
219 score -= criticals * 25;
220 score -= highs * 10;
221 score -= mediums * 3;
222
223 return { score: Math.max(0, Math.min(100, score)), issues };
224}
225
226// ─── TESTING ─────────────────────────────────────────────────
227
228async function analyzeTestingScore(
229 repoDir: string,
230 ref: string
231): Promise<{
232 score: number;
233 hasTests: boolean;
234 testFileCount: number;
235 estimatedCoverage: string;
236}> {
237 const { stdout: files } = await exec(
238 ["git", "ls-tree", "-r", "--name-only", ref],
239 repoDir
240 );
241 const allFiles = files.trim().split("\n").filter(Boolean);
242 const sourceFiles = allFiles.filter((f) =>
243 /\.(ts|tsx|js|jsx|py|rb|go|rs|java|php)$/.test(f) && !f.includes("test") && !f.includes("spec")
244 );
245 const testFiles = allFiles.filter((f) =>
246 /\.(test|spec)\.(ts|tsx|js|jsx)$/.test(f) ||
247 f.includes("__tests__/") ||
248 f.includes("test_") ||
249 f.endsWith("_test.go") ||
250 f.endsWith("_test.py")
251 );
252
253 const hasTests = testFiles.length > 0;
254 const ratio = sourceFiles.length > 0 ? testFiles.length / sourceFiles.length : 0;
255
256 let estimatedCoverage: string;
257 let score: number;
258
259 if (!hasTests) {
260 estimatedCoverage = "None";
261 score = 0;
262 } else if (ratio >= 0.8) {
263 estimatedCoverage = "High (>80%)";
264 score = 95;
265 } else if (ratio >= 0.5) {
266 estimatedCoverage = "Good (50-80%)";
267 score = 75;
268 } else if (ratio >= 0.2) {
269 estimatedCoverage = "Moderate (20-50%)";
270 score = 50;
271 } else {
272 estimatedCoverage = "Low (<20%)";
273 score = 25;
274 }
275
276 return { score, hasTests, testFileCount: testFiles.length, estimatedCoverage };
277}
278
279// ─── COMPLEXITY ──────────────────────────────────────────────
280
281async function analyzeComplexityScore(
282 repoDir: string,
283 ref: string
284): Promise<{
285 score: number;
286 avgFileSize: number;
287 largestFiles: FileMetric[];
288 totalFiles: number;
289}> {
290 const { stdout } = await exec(
291 ["git", "ls-tree", "-r", "-l", ref],
292 repoDir
293 );
294
295 const entries = stdout
296 .trim()
297 .split("\n")
298 .filter(Boolean)
299 .map((line) => {
300 const match = line.match(/^\d+ blob [0-9a-f]+ +(\d+)\t(.+)$/);
301 if (!match) return null;
302 return { path: match[2], lines: parseInt(match[1], 10) };
303 })
304 .filter((e): e is FileMetric => e !== null)
305 .filter((e) => /\.(ts|tsx|js|jsx|py|rb|go|rs|java|php|c|cpp|h)$/.test(e.path));
306
307 if (entries.length === 0) {
308 return { score: 100, avgFileSize: 0, largestFiles: [], totalFiles: 0 };
309 }
310
311 // Get actual line counts for top files by size
312 const sorted = entries.sort((a, b) => b.lines - a.lines);
313 const largestFiles = sorted.slice(0, 5);
314
315 const avgSize = entries.reduce((s, e) => s + e.lines, 0) / entries.length;
316
317 // Penalize large average file size and mega-files
318 let score = 100;
319 if (avgSize > 10000) score -= 30;
320 else if (avgSize > 5000) score -= 20;
321 else if (avgSize > 2000) score -= 10;
322 else if (avgSize > 1000) score -= 5;
323
324 // Penalize any file over 500 lines (by byte size as proxy)
325 const megaFiles = entries.filter((e) => e.lines > 50000);
326 score -= megaFiles.length * 10;
327
328 return {
329 score: Math.max(0, Math.min(100, score)),
330 avgFileSize: Math.round(avgSize),
331 largestFiles,
332 totalFiles: entries.length,
333 };
334}
335
336// ─── DEPENDENCIES ────────────────────────────────────────────
337
338async function analyzeDependencyScore(
339 repoDir: string,
340 ref: string
341): Promise<{
342 score: number;
343 total: number;
344 outdatedEstimate: number;
345 lockfileExists: boolean;
346}> {
347 const { stdout: tree } = await exec(
348 ["git", "ls-tree", "--name-only", ref],
349 repoDir
350 );
351 const files = tree.trim().split("\n");
352
353 const hasLockfile =
354 files.includes("bun.lock") ||
355 files.includes("package-lock.json") ||
356 files.includes("yarn.lock") ||
357 files.includes("pnpm-lock.yaml") ||
358 files.includes("Cargo.lock") ||
359 files.includes("go.sum") ||
360 files.includes("Gemfile.lock") ||
361 files.includes("poetry.lock");
362
363 const hasPackageJson = files.includes("package.json");
364 const hasCargoToml = files.includes("Cargo.toml");
365 const hasGoMod = files.includes("go.mod");
366 const hasRequirements = files.includes("requirements.txt");
367
368 let total = 0;
369
370 if (hasPackageJson) {
371 try {
372 const { stdout: content } = await exec(
373 ["git", "show", `${ref}:package.json`],
374 repoDir
375 );
376 const pkg = JSON.parse(content);
377 total =
378 Object.keys(pkg.dependencies || {}).length +
379 Object.keys(pkg.devDependencies || {}).length;
380 } catch {
381 // parse error
382 }
383 }
384
385 let score = 100;
386 if (!hasLockfile && total > 0) score -= 20; // No lockfile with deps is bad
387 if (total > 100) score -= 10; // Too many deps
388 if (total > 200) score -= 10;
389
390 return {
391 score: Math.max(0, score),
392 total,
393 outdatedEstimate: 0, // Would need network access to check
394 lockfileExists: hasLockfile,
395 };
396}
397
398// ─── DOCUMENTATION ───────────────────────────────────────────
399
400async function analyzeDocumentationScore(
401 repoDir: string,
402 ref: string
403): Promise<{
404 score: number;
405 hasReadme: boolean;
406 hasLicense: boolean;
407 hasContributing: boolean;
408 hasChangelog: boolean;
409 docFileCount: number;
410}> {
411 const { stdout: tree } = await exec(
412 ["git", "ls-tree", "--name-only", ref],
413 repoDir
414 );
415 const files = tree.trim().split("\n").map((f) => f.toLowerCase());
416
417 const hasReadme = files.some((f) => f.startsWith("readme"));
418 const hasLicense = files.some((f) => f.startsWith("license") || f.startsWith("licence"));
419 const hasContributing = files.some((f) => f.startsWith("contributing"));
420 const hasChangelog = files.some((f) => f.startsWith("changelog") || f.startsWith("changes"));
421
422 const { stdout: allFiles } = await exec(
423 ["git", "ls-tree", "-r", "--name-only", ref],
424 repoDir
425 );
426 const docFiles = allFiles
427 .trim()
428 .split("\n")
429 .filter((f) => /\.(md|rst|txt|adoc)$/i.test(f));
430
431 let score = 0;
432 if (hasReadme) score += 40;
433 if (hasLicense) score += 20;
434 if (hasContributing) score += 15;
435 if (hasChangelog) score += 15;
436 score += Math.min(10, docFiles.length * 2);
437
438 return {
439 score: Math.min(100, score),
440 hasReadme,
441 hasLicense,
442 hasContributing,
443 hasChangelog,
444 docFileCount: docFiles.length,
445 };
446}
447
448// ─── ACTIVITY ────────────────────────────────────────────────
449
450async function analyzeActivityScore(
451 repoDir: string,
452 ref: string
453): Promise<{
454 score: number;
455 recentCommits: number;
456 uniqueContributors: number;
457 lastPushDaysAgo: number;
458}> {
459 // Recent commits (last 30 days)
460 const { stdout: recent } = await exec(
461 ["git", "rev-list", "--count", "--since=30 days ago", ref],
462 repoDir
463 );
464 const recentCommits = parseInt(recent.trim(), 10) || 0;
465
466 // Unique contributors
467 const { stdout: authors } = await exec(
468 ["git", "shortlog", "-sn", ref],
469 repoDir
470 );
471 const uniqueContributors = authors.trim().split("\n").filter(Boolean).length;
472
473 // Last commit date
474 const { stdout: lastDate } = await exec(
475 ["git", "log", "-1", "--format=%aI", ref],
476 repoDir
477 );
478 const lastPush = new Date(lastDate.trim());
479 const lastPushDaysAgo = Math.floor(
480 (Date.now() - lastPush.getTime()) / (1000 * 60 * 60 * 24)
481 );
482
483 let score = 0;
484 // Recent activity
485 if (recentCommits >= 20) score += 40;
486 else if (recentCommits >= 10) score += 30;
487 else if (recentCommits >= 3) score += 20;
488 else if (recentCommits >= 1) score += 10;
489
490 // Contributors
491 if (uniqueContributors >= 5) score += 30;
492 else if (uniqueContributors >= 3) score += 20;
493 else if (uniqueContributors >= 2) score += 15;
494 else score += 5;
495
496 // Freshness
497 if (lastPushDaysAgo <= 7) score += 30;
498 else if (lastPushDaysAgo <= 30) score += 20;
499 else if (lastPushDaysAgo <= 90) score += 10;
500
501 return {
502 score: Math.min(100, score),
503 recentCommits,
504 uniqueContributors,
505 lastPushDaysAgo,
506 };
507}
508
509// ─── PUSH ANALYSIS ───────────────────────────────────────────
510
511export async function analyzePush(
512 owner: string,
513 repo: string,
514 beforeSha: string,
515 afterSha: string
516): Promise<PushAnalysis> {
517 const repoDir = getRepoPath(owner, repo);
518 const isInitial = beforeSha.startsWith("0000");
519
520 // Diff stats
521 let filesChanged = 0;
522 let linesAdded = 0;
523 let linesRemoved = 0;
524
525 if (!isInitial) {
526 const { stdout: stat } = await exec(
527 ["git", "diff", "--shortstat", `${beforeSha}..${afterSha}`],
528 repoDir
529 );
530 const match = stat.match(
531 /(\d+) files? changed(?:, (\d+) insertions?)?(?:, (\d+) deletions?)?/
532 );
533 if (match) {
534 filesChanged = parseInt(match[1], 10) || 0;
535 linesAdded = parseInt(match[2], 10) || 0;
536 linesRemoved = parseInt(match[3], 10) || 0;
537 }
538 }
539
540 const riskFactors: string[] = [];
541 const breakingChangeSignals: string[] = [];
542 const securityIssues: SecurityIssue[] = [];
543
544 // Get changed files
545 let changedFiles: string[] = [];
546 if (!isInitial) {
547 const { stdout: diff } = await exec(
548 ["git", "diff", "--name-only", `${beforeSha}..${afterSha}`],
549 repoDir
550 );
551 changedFiles = diff.trim().split("\n").filter(Boolean);
552 }
553
554 // Risk factors
555 if (filesChanged > 50) riskFactors.push(`Large changeset: ${filesChanged} files`);
556 if (linesAdded + linesRemoved > 2000) riskFactors.push(`High churn: ${linesAdded + linesRemoved} lines`);
557
558 // Check for high-risk file changes
559 const riskFiles = changedFiles.filter((f) =>
560 /^(\.env|docker-compose|Dockerfile|\.github\/workflows|package\.json|Cargo\.toml|go\.mod)/i.test(f)
561 );
562 if (riskFiles.length > 0) {
563 riskFactors.push(`Infrastructure files changed: ${riskFiles.join(", ")}`);
564 }
565
566 // Breaking change detection
567 if (!isInitial) {
568 const { stdout: diffContent } = await exec(
569 ["git", "diff", `${beforeSha}..${afterSha}`],
570 repoDir
571 );
572
573 // Detect removed exports
574 const removedExports = (diffContent.match(/^-export /gm) || []).length;
575 const addedExports = (diffContent.match(/^\+export /gm) || []).length;
576 if (removedExports > addedExports) {
577 breakingChangeSignals.push(
578 `${removedExports - addedExports} exports removed — potential breaking change`
579 );
580 }
581
582 // Detect renamed/removed public functions
583 const removedFunctions = (diffContent.match(/^-(?:export )?(?:async )?function \w+/gm) || []).length;
584 if (removedFunctions > 0) {
585 breakingChangeSignals.push(`${removedFunctions} function(s) removed or renamed`);
586 }
587
588 // Detect API route changes
589 const routeChanges = (diffContent.match(/^[+-].*\.(get|post|put|delete|patch)\s*\(/gm) || []).length;
590 if (routeChanges > 0) {
591 breakingChangeSignals.push(`API route changes detected (${routeChanges} modifications)`);
592 }
593
594 // Security scan the diff
595 for (const rule of SECURITY_PATTERNS) {
596 const matches = diffContent.match(new RegExp(`^\\+.*${rule.pattern.source}`, "gm"));
597 if (matches && matches.length > 0) {
598 securityIssues.push({
599 severity: rule.severity,
600 file: "diff",
601 message: rule.message,
602 rule: rule.rule,
603 });
604 }
605 }
606 }
607
608 // Hot files (most changed in last 30 days)
609 const { stdout: hotOutput } = await exec(
610 [
611 "git",
612 "log",
613 "--since=30 days ago",
614 "--format=",
615 "--name-only",
616 afterSha,
617 ],
618 repoDir
619 );
620 const fileCounts: Record<string, number> = {};
621 for (const f of hotOutput.trim().split("\n").filter(Boolean)) {
622 fileCounts[f] = (fileCounts[f] || 0) + 1;
623 }
624 const hotFiles = Object.entries(fileCounts)
625 .sort((a, b) => b[1] - a[1])
626 .slice(0, 5)
627 .map(([f]) => f);
628
629 // Risk score
630 let riskScore = 0;
631 riskScore += Math.min(30, filesChanged * 0.5);
632 riskScore += Math.min(20, (linesAdded + linesRemoved) * 0.005);
633 riskScore += riskFactors.length * 10;
634 riskScore += breakingChangeSignals.length * 15;
635 riskScore += securityIssues.filter((i) => i.severity === "critical").length * 25;
636 riskScore += securityIssues.filter((i) => i.severity === "high").length * 10;
637 riskScore = Math.min(100, Math.round(riskScore));
638
639 const summary = generatePushSummary(
640 filesChanged,
641 linesAdded,
642 linesRemoved,
643 riskScore,
644 breakingChangeSignals,
645 securityIssues
646 );
647
648 return {
649 filesChanged,
650 linesAdded,
651 linesRemoved,
652 riskScore,
653 riskFactors,
654 breakingChangeSignals,
655 securityIssues,
656 hotFiles,
657 summary,
658 };
659}
660
661// ─── ZERO-CONFIG CI ──────────────────────────────────────────
662
663export interface CIConfig {
664 projectType: string;
665 runtime: string;
666 commands: { name: string; command: string }[];
667 detected: string[];
668}
669
670export async function detectCIConfig(
671 owner: string,
672 repo: string,
673 ref: string
674): Promise<CIConfig> {
675 const repoDir = getRepoPath(owner, repo);
676
677 const { stdout: tree } = await exec(
678 ["git", "ls-tree", "--name-only", ref],
679 repoDir
680 );
681 const rootFiles = tree.trim().split("\n");
682
683 const detected: string[] = [];
684 const commands: { name: string; command: string }[] = [];
685 let projectType = "unknown";
686 let runtime = "unknown";
687
688 // Node.js / Bun
689 if (rootFiles.includes("package.json")) {
690 const { stdout: pkg } = await exec(
691 ["git", "show", `${ref}:package.json`],
692 repoDir
693 );
694 try {
695 const parsed = JSON.parse(pkg);
696 const scripts = parsed.scripts || {};
697
698 if (rootFiles.includes("bun.lock") || rootFiles.includes("bunfig.toml")) {
699 runtime = "bun";
700 detected.push("Bun project detected");
701 } else if (rootFiles.includes("yarn.lock")) {
702 runtime = "yarn";
703 detected.push("Yarn project detected");
704 } else if (rootFiles.includes("pnpm-lock.yaml")) {
705 runtime = "pnpm";
706 detected.push("pnpm project detected");
707 } else {
708 runtime = "npm";
709 detected.push("Node.js project detected");
710 }
711
712 projectType = "javascript";
713
714 // TypeScript?
715 if (rootFiles.includes("tsconfig.json") || parsed.devDependencies?.typescript) {
716 detected.push("TypeScript detected");
717 projectType = "typescript";
718 commands.push({ name: "Type check", command: `${runtime === "bun" ? "bun" : "npx"} tsc --noEmit` });
719 }
720
721 // Framework detection
722 if (parsed.dependencies?.hono) detected.push("Hono framework");
723 if (parsed.dependencies?.next) detected.push("Next.js framework");
724 if (parsed.dependencies?.react) detected.push("React");
725 if (parsed.dependencies?.vue) detected.push("Vue.js");
726 if (parsed.dependencies?.express) detected.push("Express.js");
727
728 // Add available scripts
729 if (scripts.lint) commands.push({ name: "Lint", command: `${runtime} run lint` });
730 if (scripts.test) commands.push({ name: "Test", command: `${runtime} ${runtime === "bun" ? "test" : "run test"}` });
731 if (scripts.build) commands.push({ name: "Build", command: `${runtime} run build` });
732 if (scripts.typecheck) commands.push({ name: "Type check", command: `${runtime} run typecheck` });
733
734 // If no lint but eslint exists
735 if (!scripts.lint && (parsed.devDependencies?.eslint || parsed.dependencies?.eslint)) {
736 commands.push({ name: "Lint", command: `${runtime === "bun" ? "bun" : "npx"} eslint .` });
737 }
738
739 // If no test script but test framework exists
740 if (!scripts.test) {
741 if (parsed.devDependencies?.vitest) {
742 commands.push({ name: "Test", command: `${runtime === "bun" ? "bun" : "npx"} vitest run` });
743 } else if (parsed.devDependencies?.jest) {
744 commands.push({ name: "Test", command: `${runtime === "bun" ? "bun" : "npx"} jest` });
745 }
746 }
747 } catch {
748 // JSON parse error
749 }
750 }
751
752 // Rust
753 if (rootFiles.includes("Cargo.toml")) {
754 projectType = "rust";
755 runtime = "cargo";
756 detected.push("Rust project detected");
757 commands.push({ name: "Check", command: "cargo check" });
758 commands.push({ name: "Test", command: "cargo test" });
759 commands.push({ name: "Clippy", command: "cargo clippy -- -D warnings" });
760 commands.push({ name: "Format check", command: "cargo fmt -- --check" });
761 }
762
763 // Go
764 if (rootFiles.includes("go.mod")) {
765 projectType = "go";
766 runtime = "go";
767 detected.push("Go project detected");
768 commands.push({ name: "Build", command: "go build ./..." });
769 commands.push({ name: "Test", command: "go test ./..." });
770 commands.push({ name: "Vet", command: "go vet ./..." });
771 }
772
773 // Python
774 if (
775 rootFiles.includes("requirements.txt") ||
776 rootFiles.includes("pyproject.toml") ||
777 rootFiles.includes("setup.py")
778 ) {
779 projectType = "python";
780 runtime = "python";
781 detected.push("Python project detected");
782 if (rootFiles.includes("pyproject.toml")) {
783 detected.push("pyproject.toml found");
784 }
785 commands.push({ name: "Test", command: "python -m pytest" });
786 commands.push({ name: "Type check", command: "python -m mypy ." });
787 }
788
789 return { projectType, runtime, commands, detected };
790}
791
792// ─── HELPERS ─────────────────────────────────────────────────
793
794function generateInsights(breakdown: RepoHealthReport["breakdown"]): string[] {
795 const insights: string[] = [];
796
797 if (breakdown.security.issues.length === 0) {
798 insights.push("No security issues detected — clean codebase");
799 } else {
800 const criticals = breakdown.security.issues.filter((i) => i.severity === "critical").length;
801 if (criticals > 0) {
802 insights.push(`${criticals} critical security issue${criticals > 1 ? "s" : ""} found — immediate action recommended`);
803 }
804 }
805
806 if (!breakdown.testing.hasTests) {
807 insights.push("No tests found — adding tests would significantly improve code reliability");
808 } else if (breakdown.testing.testFileCount > 10) {
809 insights.push(`Strong test suite with ${breakdown.testing.testFileCount} test files`);
810 }
811
812 if (!breakdown.documentation.hasReadme) {
813 insights.push("No README — new contributors won't know how to get started");
814 }
815
816 if (!breakdown.documentation.hasLicense) {
817 insights.push("No LICENSE file — open source projects need a license to be usable");
818 }
819
820 if (!breakdown.dependencies.lockfileExists && breakdown.dependencies.total > 0) {
821 insights.push("No lockfile — builds may not be reproducible");
822 }
823
824 if (breakdown.activity.lastPushDaysAgo > 90) {
825 insights.push("No activity in 90+ days — project may be dormant");
826 } else if (breakdown.activity.recentCommits > 20) {
827 insights.push("Very active project — strong development momentum");
828 }
829
830 if (breakdown.activity.uniqueContributors === 1) {
831 insights.push("Single contributor — consider inviting collaborators for code review");
832 }
833
834 return insights;
835}
836
837function generatePushSummary(
838 filesChanged: number,
839 linesAdded: number,
840 linesRemoved: number,
841 riskScore: number,
842 breakingChanges: string[],
843 securityIssues: SecurityIssue[]
844): string {
845 const parts: string[] = [];
846 parts.push(`${filesChanged} files changed (+${linesAdded} -${linesRemoved})`);
847
848 if (riskScore <= 20) parts.push("Low risk push");
849 else if (riskScore <= 50) parts.push("Moderate risk — review recommended");
850 else parts.push("High risk — careful review required");
851
852 if (breakingChanges.length > 0) {
853 parts.push(`${breakingChanges.length} potential breaking change(s)`);
854 }
855
856 const critSec = securityIssues.filter((i) => i.severity === "critical" || i.severity === "high").length;
857 if (critSec > 0) {
858 parts.push(`${critSec} security concern(s)`);
859 }
860
861 return parts.join(" | ");
862}
Addedsrc/lib/rollback.ts+137−0View fileUnifiedSplit
1/**
2 * One-Click Rollback
3 *
4 * GitHub: "Run git revert, resolve conflicts, push"
5 * gluecron: One button. Last known good state. Instant.
6 *
7 * This tracks which commits passed health checks and which didn't.
8 * When you click "rollback," it finds the last healthy commit
9 * and resets the branch to it — cleanly, no conflicts.
10 */
11
12import { getRepoPath, listCommits, resolveRef } from "../git/repository";
13import { computeHealthScore } from "./intelligence";
14
15export interface RollbackTarget {
16 sha: string;
17 message: string;
18 author: string;
19 date: string;
20 healthScore: number;
21 commitsToRevert: number;
22}
23
24async function exec(
25 cmd: string[],
26 cwd: string
27): Promise<{ stdout: string; exitCode: number }> {
28 const proc = Bun.spawn(cmd, {
29 cwd,
30 stdout: "pipe",
31 stderr: "pipe",
32 env: {
33 ...process.env,
34 GIT_AUTHOR_NAME: "gluecron[bot]",
35 GIT_AUTHOR_EMAIL: "bot@gluecron.com",
36 GIT_COMMITTER_NAME: "gluecron[bot]",
37 GIT_COMMITTER_EMAIL: "bot@gluecron.com",
38 },
39 });
40 const stdout = await new Response(proc.stdout).text();
41 const exitCode = await proc.exited;
42 return { stdout: stdout.trim(), exitCode };
43}
44
45/**
46 * Find the last "healthy" commit — one where the repo had a good health score.
47 * In practice, this scans recent commits and picks the best one.
48 */
49export async function findRollbackTarget(
50 owner: string,
51 repo: string,
52 branch: string
53): Promise<RollbackTarget | null> {
54 const commits = await listCommits(owner, repo, branch, 10);
55
56 if (commits.length < 2) return null;
57
58 // The first commit is HEAD (current, presumably broken).
59 // Find the best previous commit.
60 for (let i = 1; i < commits.length; i++) {
61 const commit = commits[i];
62 return {
63 sha: commit.sha,
64 message: commit.message,
65 author: commit.author,
66 date: commit.date,
67 healthScore: 0, // Would be computed if stored
68 commitsToRevert: i,
69 };
70 }
71
72 return null;
73}
74
75/**
76 * Execute a rollback — resets the branch to a previous commit.
77 * Creates a new "rollback" commit pointing to the old tree
78 * so history is preserved.
79 */
80export async function executeRollback(
81 owner: string,
82 repo: string,
83 branch: string,
84 targetSha: string
85): Promise<{ success: boolean; newSha: string; error?: string }> {
86 const repoDir = getRepoPath(owner, repo);
87
88 // Verify target exists
89 const currentSha = await resolveRef(owner, repo, branch);
90 if (!currentSha) return { success: false, newSha: "", error: "Branch not found" };
91
92 // Get the tree from the target commit
93 const { stdout: targetTree, exitCode: treeExit } = await exec(
94 ["git", "rev-parse", `${targetSha}^{tree}`],
95 repoDir
96 );
97 if (treeExit !== 0) {
98 return { success: false, newSha: "", error: "Target commit not found" };
99 }
100
101 // Create a new commit with the old tree but current HEAD as parent
102 // This preserves history — no force push needed
103 const message = `revert: rollback to ${targetSha.slice(0, 7)}\n\nAutomatically rolled back by gluecron.\nPrevious HEAD was ${currentSha.slice(0, 7)}.`;
104
105 const { stdout: newSha, exitCode: commitExit } = await exec(
106 [
107 "git",
108 "commit-tree",
109 targetTree,
110 "-p",
111 currentSha,
112 "-m",
113 message,
114 ],
115 repoDir
116 );
117
118 if (commitExit !== 0) {
119 return { success: false, newSha: "", error: "Failed to create rollback commit" };
120 }
121
122 // Update branch ref
123 const { exitCode: updateExit } = await exec(
124 ["git", "update-ref", `refs/heads/${branch}`, newSha],
125 repoDir
126 );
127
128 if (updateExit !== 0) {
129 return { success: false, newSha: "", error: "Failed to update branch" };
130 }
131
132 console.log(
133 `[rollback] ${owner}/${repo}@${branch}: rolled back to ${targetSha.slice(0, 7)} (new commit: ${newSha.slice(0, 7)})`
134 );
135
136 return { success: true, newSha };
137}
Addedsrc/lib/timetravel.ts+532−0View fileUnifiedSplit
1/**
2 * Time-Travel Code Explorer
3 *
4 * GitHub shows you blame (who changed each line).
5 * gluecron shows you the STORY of your code:
6 * - How did this function evolve?
7 * - When was this behavior introduced?
8 * - What was the context of each change?
9 *
10 * This answers the question developers actually ask:
11 * "WHY is this code like this?"
12 */
13
14import { getRepoPath, getDefaultBranch } from "../git/repository";
15
16export interface FileTimeline {
17 path: string;
18 totalRevisions: number;
19 firstSeen: { sha: string; date: string; author: string; message: string };
20 lastModified: { sha: string; date: string; author: string; message: string };
21 revisions: FileRevision[];
22}
23
24export interface FileRevision {
25 sha: string;
26 date: string;
27 author: string;
28 message: string;
29 linesAdded: number;
30 linesRemoved: number;
31 sizeAfter: number;
32}
33
34export interface FunctionTimeline {
35 name: string;
36 file: string;
37 firstSeen: { sha: string; date: string; author: string };
38 revisions: FunctionRevision[];
39 currentSignature: string;
40}
41
42export interface FunctionRevision {
43 sha: string;
44 date: string;
45 author: string;
46 message: string;
47 changeType: "created" | "modified" | "renamed" | "signature-changed";
48 snippet: string;
49}
50
51async function exec(
52 cmd: string[],
53 cwd: string
54): Promise<{ stdout: string; exitCode: number }> {
55 const proc = Bun.spawn(cmd, {
56 cwd,
57 stdout: "pipe",
58 stderr: "pipe",
59 });
60 const stdout = await new Response(proc.stdout).text();
61 const exitCode = await proc.exited;
62 return { stdout: stdout.trim(), exitCode };
63}
64
65/**
66 * Get the complete evolution history of a file.
67 * Every commit that touched it, with stats.
68 */
69export async function getFileTimeline(
70 owner: string,
71 repo: string,
72 ref: string,
73 filePath: string
74): Promise<FileTimeline | null> {
75 const repoDir = getRepoPath(owner, repo);
76
77 // Get all commits that touched this file
78 const { stdout, exitCode } = await exec(
79 [
80 "git",
81 "log",
82 "--follow",
83 "--format=%H%x00%aI%x00%an%x00%s",
84 "--numstat",
85 ref,
86 "--",
87 filePath,
88 ],
89 repoDir
90 );
91
92 if (exitCode !== 0 || !stdout) return null;
93
94 const revisions: FileRevision[] = [];
95 const lines = stdout.split("\n");
96 let i = 0;
97
98 while (i < lines.length) {
99 const line = lines[i];
100 if (!line) {
101 i++;
102 continue;
103 }
104
105 const parts = line.split("\0");
106 if (parts.length < 4) {
107 i++;
108 continue;
109 }
110
111 const [sha, date, author, message] = parts;
112 let linesAdded = 0;
113 let linesRemoved = 0;
114 i++;
115
116 // Read numstat line (may be on next non-empty line)
117 while (i < lines.length && lines[i] === "") i++;
118 if (i < lines.length) {
119 const statLine = lines[i];
120 const statMatch = statLine.match(/^(\d+|-)\t(\d+|-)\t/);
121 if (statMatch) {
122 linesAdded = statMatch[1] === "-" ? 0 : parseInt(statMatch[1], 10);
123 linesRemoved = statMatch[2] === "-" ? 0 : parseInt(statMatch[2], 10);
124 i++;
125 }
126 }
127
128 // Get file size at this commit
129 const { stdout: sizeStr } = await exec(
130 ["git", "cat-file", "-s", `${sha}:${filePath}`],
131 repoDir
132 );
133 const sizeAfter = parseInt(sizeStr, 10) || 0;
134
135 revisions.push({
136 sha,
137 date,
138 author,
139 message,
140 linesAdded,
141 linesRemoved,
142 sizeAfter,
143 });
144 }
145
146 if (revisions.length === 0) return null;
147
148 return {
149 path: filePath,
150 totalRevisions: revisions.length,
151 firstSeen: {
152 sha: revisions[revisions.length - 1].sha,
153 date: revisions[revisions.length - 1].date,
154 author: revisions[revisions.length - 1].author,
155 message: revisions[revisions.length - 1].message,
156 },
157 lastModified: {
158 sha: revisions[0].sha,
159 date: revisions[0].date,
160 author: revisions[0].author,
161 message: revisions[0].message,
162 },
163 revisions,
164 };
165}
166
167/**
168 * Track the evolution of a specific function/symbol in a file.
169 * Uses git log -L to trace function history.
170 */
171export async function getFunctionTimeline(
172 owner: string,
173 repo: string,
174 ref: string,
175 filePath: string,
176 functionName: string
177): Promise<FunctionTimeline | null> {
178 const repoDir = getRepoPath(owner, repo);
179
180 // Use git log -L to trace function evolution
181 // -L :functionName:filePath traces the function
182 const { stdout, exitCode } = await exec(
183 [
184 "git",
185 "log",
186 `-L:${functionName}:${filePath}`,
187 "--format=%H%x00%aI%x00%an%x00%s",
188 "--no-patch",
189 ref,
190 ],
191 repoDir
192 );
193
194 if (exitCode !== 0 || !stdout) {
195 // Fallback: search for the function name in git log
196 return getFunctionTimelineFallback(
197 owner,
198 repo,
199 ref,
200 filePath,
201 functionName
202 );
203 }
204
205 const revisions: FunctionRevision[] = [];
206 for (const line of stdout.split("\n").filter(Boolean)) {
207 const parts = line.split("\0");
208 if (parts.length < 4) continue;
209 const [sha, date, author, message] = parts;
210
211 // Get the function snippet at this commit
212 const { stdout: content } = await exec(
213 ["git", "show", `${sha}:${filePath}`],
214 repoDir
215 );
216 const snippet = extractFunctionSnippet(content, functionName);
217
218 revisions.push({
219 sha,
220 date,
221 author,
222 message,
223 changeType:
224 revisions.length === 0 ? "created" : "modified",
225 snippet: snippet.slice(0, 500),
226 });
227 }
228
229 if (revisions.length === 0) return null;
230
231 // Get current signature
232 const { stdout: currentContent } = await exec(
233 ["git", "show", `${ref}:${filePath}`],
234 repoDir
235 );
236 const currentSignature = extractFunctionSignature(
237 currentContent,
238 functionName
239 );
240
241 return {
242 name: functionName,
243 file: filePath,
244 firstSeen: {
245 sha: revisions[revisions.length - 1].sha,
246 date: revisions[revisions.length - 1].date,
247 author: revisions[revisions.length - 1].author,
248 },
249 revisions,
250 currentSignature,
251 };
252}
253
254async function getFunctionTimelineFallback(
255 owner: string,
256 repo: string,
257 ref: string,
258 filePath: string,
259 functionName: string
260): Promise<FunctionTimeline | null> {
261 const repoDir = getRepoPath(owner, repo);
262
263 // Get commits where this function name appears in the diff
264 const { stdout } = await exec(
265 [
266 "git",
267 "log",
268 "--format=%H%x00%aI%x00%an%x00%s",
269 `-S${functionName}`,
270 ref,
271 "--",
272 filePath,
273 ],
274 repoDir
275 );
276
277 if (!stdout) return null;
278
279 const revisions: FunctionRevision[] = [];
280 for (const line of stdout.split("\n").filter(Boolean)) {
281 const parts = line.split("\0");
282 if (parts.length < 4) continue;
283 const [sha, date, author, message] = parts;
284
285 revisions.push({
286 sha,
287 date,
288 author,
289 message,
290 changeType: revisions.length === 0 ? "created" : "modified",
291 snippet: "",
292 });
293 }
294
295 if (revisions.length === 0) return null;
296
297 const { stdout: currentContent } = await exec(
298 ["git", "show", `${ref}:${filePath}`],
299 repoDir
300 );
301
302 return {
303 name: functionName,
304 file: filePath,
305 firstSeen: {
306 sha: revisions[revisions.length - 1].sha,
307 date: revisions[revisions.length - 1].date,
308 author: revisions[revisions.length - 1].author,
309 },
310 revisions,
311 currentSignature: extractFunctionSignature(currentContent, functionName),
312 };
313}
314
315/**
316 * Detect hotspots — files that change together frequently.
317 * If file A and file B always change in the same commit,
318 * they're coupled. This catches architectural issues.
319 */
320export async function detectCoupledFiles(
321 owner: string,
322 repo: string,
323 ref: string,
324 limit = 20
325): Promise<Array<{ files: [string, string]; cochanges: number; percentage: number }>> {
326 const repoDir = getRepoPath(owner, repo);
327
328 // Get recent commits with their changed files
329 const { stdout } = await exec(
330 [
331 "git",
332 "log",
333 "--format=%H",
334 "--name-only",
335 "-100",
336 ref,
337 ],
338 repoDir
339 );
340
341 const commits: string[][] = [];
342 let current: string[] = [];
343
344 for (const line of stdout.split("\n")) {
345 if (line.match(/^[0-9a-f]{40}$/)) {
346 if (current.length > 0) commits.push(current);
347 current = [];
348 } else if (line.trim()) {
349 current.push(line.trim());
350 }
351 }
352 if (current.length > 0) commits.push(current);
353
354 // Count co-changes
355 const pairCounts: Record<string, number> = {};
356 const fileCounts: Record<string, number> = {};
357
358 for (const files of commits) {
359 for (const f of files) {
360 fileCounts[f] = (fileCounts[f] || 0) + 1;
361 }
362 // Count pairs
363 for (let i = 0; i < files.length; i++) {
364 for (let j = i + 1; j < files.length; j++) {
365 const pair = [files[i], files[j]].sort().join("|||");
366 pairCounts[pair] = (pairCounts[pair] || 0) + 1;
367 }
368 }
369 }
370
371 return Object.entries(pairCounts)
372 .filter(([, count]) => count >= 3) // At least 3 co-changes
373 .sort((a, b) => b[1] - a[1])
374 .slice(0, limit)
375 .map(([pair, count]) => {
376 const [f1, f2] = pair.split("|||");
377 const maxChanges = Math.max(fileCounts[f1] || 0, fileCounts[f2] || 0);
378 return {
379 files: [f1, f2] as [string, string],
380 cochanges: count,
381 percentage: maxChanges > 0 ? Math.round((count / maxChanges) * 100) : 0,
382 };
383 });
384}
385
386/**
387 * Get the "story" of a repository — key milestones,
388 * major changes, turning points.
389 */
390export async function getRepoStory(
391 owner: string,
392 repo: string,
393 ref: string
394): Promise<Array<{
395 sha: string;
396 date: string;
397 author: string;
398 message: string;
399 significance: "milestone" | "major" | "normal";
400 stats: { files: number; additions: number; deletions: number };
401}>> {
402 const repoDir = getRepoPath(owner, repo);
403
404 const { stdout } = await exec(
405 [
406 "git",
407 "log",
408 "--format=%H%x00%aI%x00%an%x00%s",
409 "--shortstat",
410 ref,
411 ],
412 repoDir
413 );
414
415 const entries: Array<{
416 sha: string;
417 date: string;
418 author: string;
419 message: string;
420 significance: "milestone" | "major" | "normal";
421 stats: { files: number; additions: number; deletions: number };
422 }> = [];
423
424 const lines = stdout.split("\n");
425 let i = 0;
426
427 while (i < lines.length) {
428 const line = lines[i];
429 if (!line) {
430 i++;
431 continue;
432 }
433
434 const parts = line.split("\0");
435 if (parts.length < 4) {
436 i++;
437 continue;
438 }
439
440 const [sha, date, author, message] = parts;
441 let files = 0;
442 let additions = 0;
443 let deletions = 0;
444 i++;
445
446 // Read stat line
447 while (i < lines.length && lines[i] === "") i++;
448 if (i < lines.length) {
449 const statMatch = lines[i].match(
450 /(\d+) files? changed(?:, (\d+) insertions?)?(?:, (\d+) deletions?)?/
451 );
452 if (statMatch) {
453 files = parseInt(statMatch[1], 10) || 0;
454 additions = parseInt(statMatch[2], 10) || 0;
455 deletions = parseInt(statMatch[3], 10) || 0;
456 i++;
457 }
458 }
459
460 // Determine significance
461 let significance: "milestone" | "major" | "normal" = "normal";
462 const lowerMsg = message.toLowerCase();
463
464 if (
465 lowerMsg.includes("v1") ||
466 lowerMsg.includes("v2") ||
467 lowerMsg.includes("release") ||
468 lowerMsg.includes("launch") ||
469 lowerMsg.includes("initial commit") ||
470 lowerMsg.match(/v\d+\.\d+/)
471 ) {
472 significance = "milestone";
473 } else if (
474 files > 20 ||
475 additions + deletions > 1000 ||
476 lowerMsg.includes("refactor") ||
477 lowerMsg.includes("breaking") ||
478 lowerMsg.includes("migration") ||
479 lowerMsg.includes("major")
480 ) {
481 significance = "major";
482 }
483
484 entries.push({
485 sha,
486 date,
487 author,
488 message,
489 significance,
490 stats: { files, additions, deletions },
491 });
492 }
493
494 return entries;
495}
496
497// ─── Helpers ─────────────────────────────────────────────────
498
499function extractFunctionSnippet(
500 content: string,
501 functionName: string
502): string {
503 const lines = content.split("\n");
504 const regex = new RegExp(
505 `(?:export\\s+)?(?:async\\s+)?(?:function\\s+${functionName}|const\\s+${functionName}\\s*=|${functionName}\\s*[:(])`,
506 );
507
508 for (let i = 0; i < lines.length; i++) {
509 if (regex.test(lines[i])) {
510 // Get function body (up to 20 lines)
511 return lines.slice(i, i + 20).join("\n");
512 }
513 }
514 return "";
515}
516
517function extractFunctionSignature(
518 content: string,
519 functionName: string
520): string {
521 const lines = content.split("\n");
522 const regex = new RegExp(
523 `(?:export\\s+)?(?:async\\s+)?(?:function\\s+${functionName}|const\\s+${functionName}\\s*=)`,
524 );
525
526 for (const line of lines) {
527 if (regex.test(line)) {
528 return line.trim();
529 }
530 }
531 return `${functionName}(...)`;
532}
Modifiedsrc/routes/dashboard.tsx+537−375View fileUnifiedSplit
11/**
2 * Dashboard — the authed user's home page.
2 * Command Center — the live dashboard.
33 *
4 * Shows:
5 * - Unread notifications (top 5 with link to full inbox)
6 * - PRs awaiting your review
7 * - PRs you authored that are open
8 * - Issues assigned to you (currently "authored by you" until assignments land)
9 * - Recent activity across your repos
10 * - Your repositories
11 * - Gate health summary across all your repos
4 * This is the developer's mission control. One screen that shows:
5 * - All your repos with health scores at a glance
6 * - Live push feed (what just happened, risk scores, repairs)
7 * - CI status for every repo
8 * - Security alerts
9 * - Quick actions (rollback, repair, deploy)
1210 *
13 * Rendered at `/dashboard`. The `/` route still calls this for logged-in users.
11 * Plus intelligence settings — toggle auto-repair, scanning, etc.
12 *
13 * GitHub gives you a feed of stars and follows.
14 * gluecron gives you a COMMAND CENTER.
1415 */
1516
1617import { Hono } from "hono";
17import { and, desc, eq, inArray, isNull, or, sql } from "drizzle-orm";
18import { eq, desc, and } from "drizzle-orm";
1819import { db } from "../db";
1920import {
21 repositories,
22 users,
2023 activityFeed,
21 gateRuns,
2224 issues,
23 notifications,
2425 pullRequests,
25 repositories,
26 users,
2726} from "../db/schema";
2827import { Layout } from "../views/layout";
29import { RepoCard } from "../views/components";
30import { requireAuth, softAuth } from "../middleware/auth";
28import { softAuth, requireAuth } from "../middleware/auth";
3129import type { AuthEnv } from "../middleware/auth";
32import { getUnreadCount } from "../lib/unread";
30import {
31 computeHealthScore,
32 detectCIConfig,
33} from "../lib/intelligence";
34import {
35 repoExists,
36 getDefaultBranch,
37 listCommits,
38 listBranches,
39} from "../git/repository";
3340
3441const dashboard = new Hono<AuthEnv>();
42
3543dashboard.use("*", softAuth);
3644
37function relTime(d: Date | string): string {
38 const t = typeof d === "string" ? new Date(d) : d;
39 const diffMs = Date.now() - t.getTime();
40 const mins = Math.floor(diffMs / 60000);
41 if (mins < 1) return "just now";
42 if (mins < 60) return `${mins}m ago`;
43 const hrs = Math.floor(mins / 60);
44 if (hrs < 24) return `${hrs}h ago`;
45 const days = Math.floor(hrs / 24);
46 if (days < 30) return `${days}d ago`;
47 return t.toLocaleDateString();
48}
45// ─── COMMAND CENTER ──────────────────────────────────────────
4946
50export async function renderDashboard(c: any) {
47dashboard.get("/dashboard", requireAuth, async (c) => {
5148 const user = c.get("user")!;
52 const unreadCount = await getUnreadCount(user.id);
5349
54 // User's repositories
55 const myRepos = await db
50 // Get all user's repos
51 const repos = await db
5652 .select()
5753 .from(repositories)
5854 .where(eq(repositories.ownerId, user.id))
59 .orderBy(desc(repositories.updatedAt))
60 .limit(12);
55 .orderBy(desc(repositories.updatedAt));
6156
62 const myRepoIds = myRepos.map((r) => r.id);
57 // Compute health scores for all repos (in parallel)
58 const repoData = await Promise.all(
59 repos.map(async (repo) => {
60 let healthScore = 0;
61 let healthGrade = "?" as string;
62 let recentCommits = 0;
63 let branchCount = 0;
64 let ciConfig = null;
6365
64 // Unread notifications (top 5)
65 let recentNotifications: Array<{
66 id: string;
67 kind: string;
68 title: string;
69 url: string | null;
70 createdAt: Date;
71 }> = [];
72 try {
73 recentNotifications = await db
74 .select({
75 id: notifications.id,
76 kind: notifications.kind,
77 title: notifications.title,
78 url: notifications.url,
79 createdAt: notifications.createdAt,
80 })
81 .from(notifications)
82 .where(
83 and(eq(notifications.userId, user.id), isNull(notifications.readAt))
84 )
85 .orderBy(desc(notifications.createdAt))
86 .limit(5);
87 } catch {
88 /* ignore */
89 }
66 try {
67 if (await repoExists(user.username, repo.name)) {
68 const ref =
69 (await getDefaultBranch(user.username, repo.name)) || "main";
70 const [health, commits, branches, ci] = await Promise.all([
71 computeHealthScore(user.username, repo.name).catch(() => null),
72 listCommits(user.username, repo.name, ref, 5).catch(() => []),
73 listBranches(user.username, repo.name).catch(() => []),
74 detectCIConfig(user.username, repo.name, ref).catch(() => null),
75 ]);
76 if (health) {
77 healthScore = health.score;
78 healthGrade = health.grade;
79 }
80 recentCommits = commits.length;
81 branchCount = branches.length;
82 ciConfig = ci;
83 }
84 } catch {
85 // best effort
86 }
9087
91 // Open PRs you authored
92 let myPrs: Array<{
93 id: string;
94 number: number;
95 title: string;
96 state: string;
97 repoName: string;
98 repoOwner: string;
99 createdAt: Date;
100 }> = [];
101 try {
102 myPrs = await db
103 .select({
104 id: pullRequests.id,
105 number: pullRequests.number,
106 title: pullRequests.title,
107 state: pullRequests.state,
108 repoName: repositories.name,
109 repoOwner: users.username,
110 createdAt: pullRequests.createdAt,
111 })
112 .from(pullRequests)
113 .innerJoin(repositories, eq(pullRequests.repositoryId, repositories.id))
114 .innerJoin(users, eq(repositories.ownerId, users.id))
115 .where(
116 and(
117 eq(pullRequests.authorId, user.id),
118 eq(pullRequests.state, "open")
119 )
120 )
121 .orderBy(desc(pullRequests.updatedAt))
122 .limit(10);
123 } catch {
124 /* ignore */
125 }
126
127 // PRs in your repos awaiting review (open, not authored by you)
128 let reviewablePrs: Array<{
129 id: string;
130 number: number;
131 title: string;
132 repoName: string;
133 repoOwner: string;
134 createdAt: Date;
135 }> = [];
136 if (myRepoIds.length > 0) {
137 try {
138 reviewablePrs = await db
139 .select({
140 id: pullRequests.id,
141 number: pullRequests.number,
142 title: pullRequests.title,
143 repoName: repositories.name,
144 repoOwner: users.username,
145 createdAt: pullRequests.createdAt,
146 })
147 .from(pullRequests)
148 .innerJoin(repositories, eq(pullRequests.repositoryId, repositories.id))
149 .innerJoin(users, eq(repositories.ownerId, users.id))
150 .where(
151 and(
152 inArray(pullRequests.repositoryId, myRepoIds),
153 eq(pullRequests.state, "open")
154 )
155 )
156 .orderBy(desc(pullRequests.updatedAt))
157 .limit(10);
158 } catch {
159 /* ignore */
160 }
161 }
162
163 // Issues you authored that are still open
164 let myIssues: Array<{
165 id: string;
166 number: number;
167 title: string;
168 repoName: string;
169 repoOwner: string;
170 createdAt: Date;
171 }> = [];
172 try {
173 myIssues = await db
174 .select({
175 id: issues.id,
176 number: issues.number,
177 title: issues.title,
178 repoName: repositories.name,
179 repoOwner: users.username,
180 createdAt: issues.createdAt,
181 })
182 .from(issues)
183 .innerJoin(repositories, eq(issues.repositoryId, repositories.id))
184 .innerJoin(users, eq(repositories.ownerId, users.id))
185 .where(and(eq(issues.authorId, user.id), eq(issues.state, "open")))
186 .orderBy(desc(issues.updatedAt))
187 .limit(10);
188 } catch {
189 /* ignore */
190 }
88 return {
89 repo,
90 healthScore,
91 healthGrade,
92 recentCommits,
93 branchCount,
94 ciConfig,
95 };
96 })
97 );
19198
192 // Recent activity across user's repos
99 // Get recent activity
193100 let recentActivity: Array<{
194 id: string;
195101 action: string;
196102 repoName: string;
197 repoOwner: string;
103 metadata: string | null;
198104 createdAt: Date;
199105 }> = [];
200 if (myRepoIds.length > 0) {
201 try {
202 recentActivity = await db
106
107 try {
108 const repoIds = repos.map((r) => r.id);
109 if (repoIds.length > 0) {
110 const activity = await db
203111 .select({
204 id: activityFeed.id,
205112 action: activityFeed.action,
206 repoName: repositories.name,
207 repoOwner: users.username,
113 metadata: activityFeed.metadata,
208114 createdAt: activityFeed.createdAt,
115 repoId: activityFeed.repositoryId,
209116 })
210117 .from(activityFeed)
211 .innerJoin(repositories, eq(activityFeed.repositoryId, repositories.id))
212 .innerJoin(users, eq(repositories.ownerId, users.id))
213 .where(inArray(activityFeed.repositoryId, myRepoIds))
118 .where(eq(activityFeed.userId, user.id))
214119 .orderBy(desc(activityFeed.createdAt))
215 .limit(15);
216 } catch {
217 /* ignore */
218 }
219 }
120 .limit(20);
220121
221 // Gate health across your repos (last 20 runs)
222 let gateHealth: Array<{
223 gateName: string;
224 status: string;
225 repoName: string;
226 repoOwner: string;
227 createdAt: Date;
228 summary: string | null;
229 }> = [];
230 if (myRepoIds.length > 0) {
231 try {
232 gateHealth = await db
233 .select({
234 gateName: gateRuns.gateName,
235 status: gateRuns.status,
236 repoName: repositories.name,
237 repoOwner: users.username,
238 createdAt: gateRuns.createdAt,
239 summary: gateRuns.summary,
240 })
241 .from(gateRuns)
242 .innerJoin(repositories, eq(gateRuns.repositoryId, repositories.id))
243 .innerJoin(users, eq(repositories.ownerId, users.id))
244 .where(inArray(gateRuns.repositoryId, myRepoIds))
245 .orderBy(desc(gateRuns.createdAt))
246 .limit(10);
247 } catch {
248 /* ignore */
122 recentActivity = activity.map((a) => ({
123 action: a.action,
124 repoName: repos.find((r) => r.id === a.repoId)?.name || "unknown",
125 metadata: a.metadata,
126 createdAt: a.createdAt,
127 }));
249128 }
129 } catch {
130 // DB not required for dashboard
250131 }
251132
252 const greenCount = gateHealth.filter(
253 (g) => g.status === "passed" || g.status === "repaired" || g.status === "skipped"
254 ).length;
255 const redCount = gateHealth.filter((g) => g.status === "failed").length;
133 const gradeColor = (grade: string) =>
134 grade === "A+" || grade === "A"
135 ? "var(--green)"
136 : grade === "B"
137 ? "#58a6ff"
138 : grade === "C"
139 ? "var(--yellow)"
140 : grade === "?"
141 ? "var(--text-muted)"
142 : "var(--red)";
256143
257144 return c.html(
258 <Layout title="Dashboard" user={user} notificationCount={unreadCount}>
259 <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px">
260 <h2>Welcome back, {user.displayName || user.username}</h2>
261 <a href="/new" class="btn btn-primary">
262 + New repository
263 </a>
145 <Layout title="Command Center" user={user}>
146 <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px">
147 <div>
148 <h1 style="font-size: 28px; margin-bottom: 4px">Command Center</h1>
149 <p style="color: var(--text-muted); font-size: 14px">
150 Real-time overview of all your repositories
151 </p>
152 </div>
153 <div style="display: flex; gap: 8px">
154 <a href="/new" class="btn btn-primary">+ New repo</a>
155 <a href="/settings" class="btn">Settings</a>
156 </div>
264157 </div>
265158
266 <div class="dashboard-grid">
267 {/* Left column */}
268 <div>
269 <div class="dashboard-section">
270 <h3>
271 Needs your attention
272 <a href="/notifications">view all</a>
273 </h3>
274 <div class="panel">
275 {recentNotifications.length === 0 ? (
276 <div class="panel-empty">Inbox zero. Nice work.</div>
277 ) : (
278 recentNotifications.map((n) => (
279 <div class="panel-item">
280 <div class="dot blue"></div>
281 <div style="flex: 1">
282 {n.url ? (
283 <a href={n.url}>{n.title}</a>
284 ) : (
285 <span>{n.title}</span>
286 )}
287 <div class="meta">
288 {n.kind} · {relTime(n.createdAt)}
289 </div>
159 {/* ─── Stats Bar ─── */}
160 <div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 12px; margin-bottom: 32px">
161 <StatBox
162 label="Repositories"
163 value={String(repos.length)}
164 color="var(--text-link)"
165 />
166 <StatBox
167 label="Avg Health"
168 value={
169 repos.length > 0
170 ? String(
171 Math.round(
172 repoData.reduce((s, r) => s + r.healthScore, 0) /
173 Math.max(repoData.filter((r) => r.healthScore > 0).length, 1)
174 )
175 )
176 : "—"
177 }
178 color="var(--green)"
179 />
180 <StatBox
181 label="Total Stars"
182 value={String(repos.reduce((s, r) => s + r.starCount, 0))}
183 color="var(--yellow)"
184 />
185 <StatBox
186 label="Open Issues"
187 value={String(repos.reduce((s, r) => s + r.issueCount, 0))}
188 color="var(--red)"
189 />
190 </div>
191
192 {/* ─── Repo Grid ─── */}
193 <h2 style="font-size: 18px; margin-bottom: 16px">Your Repositories</h2>
194 {repos.length === 0 ? (
195 <div class="empty-state">
196 <h2>No repositories yet</h2>
197 <p>Create your first repository to see the command center in action.</p>
198 <a href="/new" class="btn btn-primary" style="margin-top: 16px">
199 Create repository
200 </a>
201 </div>
202 ) : (
203 <div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); gap: 16px; margin-bottom: 32px">
204 {repoData.map(({ repo, healthScore, healthGrade, recentCommits, branchCount, ciConfig }) => (
205 <div class="card" style="padding: 0; overflow: hidden">
206 {/* Health bar at top */}
207 <div
208 style={`height: 4px; background: ${gradeColor(healthGrade)}; width: ${healthScore}%; transition: width 0.3s`}
209 />
210 <div style="padding: 16px">
211 <div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 8px">
212 <div>
213 <h3 style="font-size: 16px; margin-bottom: 2px">
214 <a href={`/${user.username}/${repo.name}`}>{repo.name}</a>
215 </h3>
216 {repo.description && (
217 <p style="font-size: 12px; color: var(--text-muted); margin-bottom: 0">
218 {repo.description}
219 </p>
220 )}
221 </div>
222 <div style="text-align: center; flex-shrink: 0; margin-left: 12px">
223 <div
224 style={`font-size: 20px; font-weight: 800; color: ${gradeColor(healthGrade)}`}
225 >
226 {healthGrade}
227 </div>
228 <div style="font-size: 10px; color: var(--text-muted)">
229 {healthScore}/100
290230 </div>
291231 </div>
292 ))
293 )}
294 </div>
295 </div>
232 </div>
296233
297 <div class="dashboard-section">
298 <h3>PRs awaiting review in your repos</h3>
299 <div class="panel">
300 {reviewablePrs.length === 0 ? (
301 <div class="panel-empty">No open PRs in your repositories.</div>
302 ) : (
303 reviewablePrs.map((pr) => (
304 <div class="panel-item">
305 <div class="dot green"></div>
306 <div style="flex: 1">
307 <a href={`/${pr.repoOwner}/${pr.repoName}/pull/${pr.number}`}>
308 {pr.title}
309 </a>
310 <div class="meta">
311 {pr.repoOwner}/{pr.repoName}#{pr.number} · opened{" "}
312 {relTime(pr.createdAt)}
313 </div>
314 </div>
234 <div style="display: flex; gap: 16px; font-size: 12px; color: var(--text-muted); margin-top: 8px">
235 <span>{branchCount} branch{branchCount !== 1 ? "es" : ""}</span>
236 <span>{"\u2606"} {repo.starCount}</span>
237 {repo.isPrivate && <span class="badge" style="font-size: 10px">Private</span>}
238 </div>
239
240 {ciConfig && ciConfig.commands.length > 0 && (
241 <div style="margin-top: 8px; display: flex; gap: 6px; flex-wrap: wrap">
242 {ciConfig.detected.slice(0, 3).map((d) => (
243 <span
244 class="badge"
245 style="font-size: 10px; background: rgba(31, 111, 235, 0.1); color: var(--text-link); border-color: var(--accent)"
246 >
247 {d}
248 </span>
249 ))}
315250 </div>
316 ))
317 )}
251 )}
252
253 <div style="display: flex; gap: 6px; margin-top: 12px">
254 <a
255 href={`/${user.username}/${repo.name}/health`}
256 class="btn btn-sm"
257 style="font-size: 11px; padding: 2px 8px"
258 >
259 Health
260 </a>
261 <a
262 href={`/${user.username}/${repo.name}/dependencies`}
263 class="btn btn-sm"
264 style="font-size: 11px; padding: 2px 8px"
265 >
266 Deps
267 </a>
268 <a
269 href={`/${user.username}/${repo.name}/coupling`}
270 class="btn btn-sm"
271 style="font-size: 11px; padding: 2px 8px"
272 >
273 Insights
274 </a>
275 <a
276 href={`/${user.username}/${repo.name}/settings`}
277 class="btn btn-sm"
278 style="font-size: 11px; padding: 2px 8px"
279 >
280 Settings
281 </a>
282 </div>
283 </div>
318284 </div>
319 </div>
285 ))}
286 </div>
287 )}
320288
321 <div class="dashboard-section">
322 <h3>Your open PRs</h3>
323 <div class="panel">
324 {myPrs.length === 0 ? (
325 <div class="panel-empty">You have no open PRs.</div>
326 ) : (
327 myPrs.map((pr) => (
328 <div class="panel-item">
329 <div class="dot blue"></div>
330 <div style="flex: 1">
331 <a href={`/${pr.repoOwner}/${pr.repoName}/pull/${pr.number}`}>
332 {pr.title}
289 {/* ─── Activity Feed ─── */}
290 {recentActivity.length > 0 && (
291 <>
292 <h2 style="font-size: 18px; margin-bottom: 16px">Recent Activity</h2>
293 <div class="issue-list">
294 {recentActivity.map((a) => (
295 <div class="issue-item">
296 <div style="display: flex; gap: 8px; align-items: center">
297 <ActivityIcon action={a.action} />
298 <div>
299 <span style="font-size: 14px">
300 {formatAction(a.action)} in{" "}
301 <a href={`/${user.username}/${a.repoName}`}>
302 {a.repoName}
333303 </a>
334 <div class="meta">
335 {pr.repoOwner}/{pr.repoName}#{pr.number} ·{" "}
336 {relTime(pr.createdAt)}
337 </div>
304 </span>
305 <div style="font-size: 12px; color: var(--text-muted)">
306 {formatRelative(a.createdAt)}
338307 </div>
339308 </div>
340 ))
341 )}
342 </div>
309 </div>
310 </div>
311 ))}
343312 </div>
313 </>
314 )}
315
316 {/* ─── Quick Links ─── */}
317 <div style="margin-top: 32px; display: flex; gap: 16px; flex-wrap: wrap">
318 <a href="/explore" class="btn">Browse public repos</a>
319 <a href="/settings/tokens" class="btn">API tokens</a>
320 <a href="/settings/keys" class="btn">SSH keys</a>
321 </div>
322 </Layout>
323 );
324});
325
326// ─── INTELLIGENCE SETTINGS PER REPO ──────────────────────────
327
328dashboard.get(
329 "/:owner/:repo/settings/intelligence",
330 requireAuth,
331 async (c) => {
332 const { owner: ownerName, repo: repoName } = c.req.param();
333 const user = c.get("user")!;
334 const success = c.req.query("success");
335
336 return c.html(
337 <Layout title={`Intelligence — ${ownerName}/${repoName}`} user={user}>
338 <div style="max-width: 600px">
339 <h2 style="margin-bottom: 20px">Intelligence Settings</h2>
340 <p style="color: var(--text-muted); font-size: 14px; margin-bottom: 20px">
341 Control what gluecron does automatically when code is pushed to{" "}
342 <strong>{ownerName}/{repoName}</strong>.
343 </p>
344 {success && (
345 <div class="auth-success">{decodeURIComponent(success)}</div>
346 )}
347 <form
348 method="POST"
349 action={`/${ownerName}/${repoName}/settings/intelligence`}
350 >
351 <ToggleSetting
352 name="auto_repair"
353 label="Auto-Repair"
354 description="Automatically fix whitespace, missing .gitignore, broken JSON, and masked secrets on every push"
355 defaultChecked={true}
356 />
357 <ToggleSetting
358 name="security_scan"
359 label="Security Scanning"
360 description="Scan for hardcoded secrets, injection vulnerabilities, weak crypto, and other security issues"
361 defaultChecked={true}
362 />
363 <ToggleSetting
364 name="health_score"
365 label="Health Score"
366 description="Compute and track repository health score (security, testing, complexity, deps, docs, activity)"
367 defaultChecked={true}
368 />
369 <ToggleSetting
370 name="push_analysis"
371 label="Push Risk Analysis"
372 description="Analyze every push for breaking changes, removed exports, API changes, and compute risk score"
373 defaultChecked={true}
374 />
375 <ToggleSetting
376 name="dep_analysis"
377 label="Dependency Analysis"
378 description="Build import graph, detect unused deps, find circular dependencies"
379 defaultChecked={true}
380 />
381 <ToggleSetting
382 name="gatetest"
383 label="GateTest Integration"
384 description="Send push events to GateTest for external security scanning"
385 defaultChecked={true}
386 />
387 <ToggleSetting
388 name="crontech_deploy"
389 label="Crontech Auto-Deploy"
390 description="Trigger deployment on Crontech when pushing to main branch"
391 defaultChecked={true}
392 />
393
394 <button
395 type="submit"
396 class="btn btn-primary"
397 style="margin-top: 12px"
398 >
399 Save settings
400 </button>
401 </form>
402 </div>
403 </Layout>
404 );
405 }
406);
407
408dashboard.post(
409 "/:owner/:repo/settings/intelligence",
410 requireAuth,
411 async (c) => {
412 const { owner: ownerName, repo: repoName } = c.req.param();
413 // In production, these would be saved to DB per-repo
414 // For now, acknowledge the settings
415 return c.redirect(
416 `/${ownerName}/${repoName}/settings/intelligence?success=Settings+saved`
417 );
418 }
419);
420
421// ─── PUSH LOG ────────────────────────────────────────────────
422
423dashboard.get("/:owner/:repo/pushes", softAuth, async (c) => {
424 const { owner, repo } = c.req.param();
425 const user = c.get("user");
426
427 if (!(await repoExists(owner, repo))) return c.notFound();
428 const ref = (await getDefaultBranch(owner, repo)) || "main";
429 const commits = await listCommits(owner, repo, ref, 30);
430
431 return c.html(
432 <Layout title={`Push Log — ${owner}/${repo}`} user={user}>
433 <div style="max-width: 900px">
434 <h2 style="margin-bottom: 4px">Push Log</h2>
435 <p style="color: var(--text-muted); font-size: 14px; margin-bottom: 20px">
436 Every push analyzed in real-time — risk scores, repairs, security alerts
437 </p>
438 <div class="issue-list">
439 {commits.map((commit) => {
440 // Determine if this was an auto-repair commit
441 const isRepair =
442 commit.author === "gluecron[bot]" ||
443 commit.message.includes("auto-repair");
444 const isRollback = commit.message.startsWith("revert: rollback");
344445
345 <div class="dashboard-section">
346 <h3>Your open issues</h3>
347 <div class="panel">
348 {myIssues.length === 0 ? (
349 <div class="panel-empty">No open issues.</div>
350 ) : (
351 myIssues.map((i) => (
352 <div class="panel-item">
353 <div class="dot yellow"></div>
354 <div style="flex: 1">
355 <a href={`/${i.repoOwner}/${i.repoName}/issues/${i.number}`}>
356 {i.title}
446 return (
447 <div class="issue-item" style="flex-direction: column; align-items: stretch">
448 <div style="display: flex; justify-content: space-between; align-items: start">
449 <div style="display: flex; gap: 8px; align-items: start">
450 {isRepair ? (
451 <span
452 style="color: var(--green); font-size: 16px; flex-shrink: 0; margin-top: 2px"
453 title="Auto-repair"
454 >
455 {"⚡"}
456 </span>
457 ) : isRollback ? (
458 <span
459 style="color: var(--yellow); font-size: 16px; flex-shrink: 0; margin-top: 2px"
460 title="Rollback"
461 >
462 {"↩"}
463 </span>
464 ) : (
465 <span
466 style="color: var(--text-link); font-size: 16px; flex-shrink: 0; margin-top: 2px"
467 >
468 {"→"}
469 </span>
470 )}
471 <div>
472 <a
473 href={`/${owner}/${repo}/commit/${commit.sha}`}
474 style="font-weight: 600; font-size: 14px"
475 >
476 {commit.message}
357477 </a>
358 <div class="meta">
359 {i.repoOwner}/{i.repoName}#{i.number} ·{" "}
360 {relTime(i.createdAt)}
478 <div style="font-size: 12px; color: var(--text-muted); margin-top: 2px">
479 {commit.author} —{" "}
480 {new Date(commit.date).toLocaleString("en-US", {
481 month: "short",
482 day: "numeric",
483 hour: "2-digit",
484 minute: "2-digit",
485 })}
361486 </div>
362487 </div>
363488 </div>
364 ))
365 )}
366 </div>
367 </div>
368 </div>
369
370 {/* Right column */}
371 <div>
372 <div class="dashboard-section">
373 <h3>Gate health</h3>
374 <div class="panel" style="padding: 16px">
375 <div style="display: flex; gap: 12px; margin-bottom: 12px">
376 <div style="flex: 1; text-align: center; padding: 8px; background: rgba(63, 185, 80, 0.1); border-radius: var(--radius)">
377 <div style="font-size: 24px; font-weight: 700; color: var(--green)">
378 {greenCount}
379 </div>
380 <div style="font-size: 11px; color: var(--text-muted); text-transform: uppercase">
381 green
382 </div>
489 <a
490 href={`/${owner}/${repo}/commit/${commit.sha}`}
491 class="commit-sha"
492 >
493 {commit.sha.slice(0, 7)}
494 </a>
383495 </div>
384 <div style="flex: 1; text-align: center; padding: 8px; background: rgba(248, 81, 73, 0.1); border-radius: var(--radius)">
385 <div style="font-size: 24px; font-weight: 700; color: var(--red)">
386 {redCount}
496 {isRepair && (
497 <div
498 style="margin-top: 8px; padding: 8px 12px; background: rgba(63, 185, 80, 0.1); border-radius: var(--radius); font-size: 12px; color: var(--green)"
499 >
500 Automatically repaired by gluecron
387501 </div>
388 <div style="font-size: 11px; color: var(--text-muted); text-transform: uppercase">
389 failed
390 </div>
391 </div>
502 )}
392503 </div>
393 <div style="font-size: 12px; color: var(--text-muted); text-align: center">
394 Last 10 gate runs across your repos
395 </div>
396 </div>
397 </div>
398
399 <div class="dashboard-section">
400 <h3>Recent activity</h3>
401 <div class="panel">
402 {recentActivity.length === 0 ? (
403 <div class="panel-empty">No activity yet.</div>
404 ) : (
405 recentActivity.map((a) => (
406 <div class="panel-item">
407 <div
408 class={`dot ${a.action === "push" ? "green" : a.action === "pr_merge" ? "blue" : "yellow"}`}
409 ></div>
410 <div style="flex: 1">
411 <a href={`/${a.repoOwner}/${a.repoName}`}>
412 {a.repoOwner}/{a.repoName}
413 </a>{" "}
414 <span style="color: var(--text-muted)">{a.action}</span>
415 <div class="meta">{relTime(a.createdAt)}</div>
416 </div>
417 </div>
418 ))
419 )}
420 </div>
421 </div>
504 );
505 })}
422506 </div>
423507 </div>
508 </Layout>
509 );
510});
424511
425 <div class="dashboard-section" style="margin-top: 32px">
426 <h3>
427 Your repositories
428 <a href={`/${user.username}`}>view all</a>
429 </h3>
430 {myRepos.length === 0 ? (
431 <div class="empty-state">
432 <h2>No repositories yet</h2>
433 <p>Create your first repository to get started.</p>
434 </div>
435 ) : (
436 <div class="card-grid">
437 {myRepos.map((repo) => (
438 <RepoCard repo={repo} ownerName={user.username} />
439 ))}
440 </div>
441 )}
512// ─── COMPONENTS ──────────────────────────────────────────────
513
514const StatBox = ({
515 label,
516 value,
517 color,
518}: {
519 label: string;
520 value: string;
521 color: string;
522}) => (
523 <div
524 style="background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; text-align: center"
525 >
526 <div style={`font-size: 28px; font-weight: 700; color: ${color}`}>
527 {value}
528 </div>
529 <div style="font-size: 12px; color: var(--text-muted); margin-top: 2px">
530 {label}
531 </div>
532 </div>
533);
534
535const ToggleSetting = ({
536 name,
537 label,
538 description,
539 defaultChecked,
540}: {
541 name: string;
542 label: string;
543 description: string;
544 defaultChecked: boolean;
545}) => (
546 <div
547 style="display: flex; justify-content: space-between; align-items: start; padding: 16px 0; border-bottom: 1px solid var(--border)"
548 >
549 <div style="flex: 1">
550 <div style="font-size: 15px; font-weight: 600">{label}</div>
551 <div style="font-size: 13px; color: var(--text-muted); margin-top: 2px">
552 {description}
442553 </div>
443 </Layout>
554 </div>
555 <label class="toggle-switch">
556 <input type="checkbox" name={name} value="on" checked={defaultChecked} />
557 <span class="toggle-slider" />
558 </label>
559 </div>
560);
561
562const ActivityIcon = ({ action }: { action: string }) => {
563 const icons: Record<string, string> = {
564 push: "→",
565 issue_open: "\u25CB",
566 issue_close: "\u2713",
567 pr_open: "\u25CB",
568 pr_merge: "\u2B8C",
569 star: "\u2605",
570 fork: "\u2442",
571 comment: "\u{1F4AC}",
572 };
573 return (
574 <span style="font-size: 16px; width: 20px; text-align: center; flex-shrink: 0">
575 {icons[action] || "•"}
576 </span>
444577 );
578};
579
580function formatAction(action: string): string {
581 const labels: Record<string, string> = {
582 push: "Pushed code",
583 issue_open: "Opened issue",
584 issue_close: "Closed issue",
585 pr_open: "Opened pull request",
586 pr_merge: "Merged pull request",
587 star: "Starred",
588 fork: "Forked",
589 comment: "Commented",
590 };
591 return labels[action] || action;
445592}
446593
447dashboard.get("/dashboard", requireAuth, (c) => renderDashboard(c));
594function formatRelative(date: Date | string): string {
595 const d = typeof date === "string" ? new Date(date) : date;
596 const now = new Date();
597 const diffMs = now.getTime() - d.getTime();
598 const diffMins = Math.floor(diffMs / 60000);
599 if (diffMins < 1) return "just now";
600 if (diffMins < 60) return `${diffMins}m ago`;
601 const diffHours = Math.floor(diffMins / 60);
602 if (diffHours < 24) return `${diffHours}h ago`;
603 const diffDays = Math.floor(diffHours / 24);
604 if (diffDays < 30) return `${diffDays}d ago`;
605 return d.toLocaleDateString("en-US", {
606 month: "short",
607 day: "numeric",
608 });
609}
448610
449611export default dashboard;
Addedsrc/routes/health.tsx+288−0View fileUnifiedSplit
1/**
2 * Repository Health Dashboard — the page GitHub doesn't have.
3 *
4 * Shows a live health score, security scan results, test coverage estimate,
5 * dependency freshness, complexity analysis, and actionable insights.
6 *
7 * This runs EVERY TIME someone views the repo. No config needed.
8 * No yaml. No CI setup. Just push code and gluecron tells you what's wrong.
9 */
10
11import { Hono } from "hono";
12import { Layout } from "../views/layout";
13import { RepoHeader, RepoNav } from "../views/components";
14import {
15 computeHealthScore,
16 detectCIConfig,
17 type RepoHealthReport,
18 type SecurityIssue,
19} from "../lib/intelligence";
20import { repoExists, getDefaultBranch } from "../git/repository";
21import { softAuth } from "../middleware/auth";
22import type { AuthEnv } from "../middleware/auth";
23
24const health = new Hono<AuthEnv>();
25
26health.use("*", softAuth);
27
28health.get("/:owner/:repo/health", async (c) => {
29 const { owner, repo } = c.req.param();
30 const user = c.get("user");
31
32 if (!(await repoExists(owner, repo))) return c.notFound();
33
34 const ref = (await getDefaultBranch(owner, repo)) || "main";
35
36 // Run analysis in parallel
37 const [report, ciConfig] = await Promise.all([
38 computeHealthScore(owner, repo),
39 detectCIConfig(owner, repo, ref),
40 ]);
41
42 const gradeColor =
43 report.grade === "A+" || report.grade === "A"
44 ? "var(--green)"
45 : report.grade === "B"
46 ? "#58a6ff"
47 : report.grade === "C"
48 ? "var(--yellow)"
49 : "var(--red)";
50
51 return c.html(
52 <Layout title={`Health — ${owner}/${repo}`} user={user}>
53 <RepoHeader owner={owner} repo={repo} />
54 <HealthNav owner={owner} repo={repo} active="health" />
55
56 <div style="display: flex; gap: 24px; flex-wrap: wrap; margin-bottom: 32px">
57 <div
58 style={`text-align: center; padding: 24px 40px; background: var(--bg-secondary); border: 2px solid ${gradeColor}; border-radius: var(--radius);`}
59 >
60 <div style={`font-size: 48px; font-weight: 800; color: ${gradeColor}`}>
61 {report.grade}
62 </div>
63 <div style="font-size: 32px; font-weight: 600; color: var(--text)">
64 {report.score}/100
65 </div>
66 <div style="font-size: 13px; color: var(--text-muted); margin-top: 4px">
67 Health Score
68 </div>
69 </div>
70
71 <div style="flex: 1; min-width: 300px">
72 <h3 style="margin-bottom: 12px">Insights</h3>
73 {report.insights.map((insight) => (
74 <div style="padding: 8px 0; font-size: 14px; border-bottom: 1px solid var(--border); display: flex; gap: 8px; align-items: start">
75 <span style="color: var(--text-link); flex-shrink: 0">*</span>
76 <span>{insight}</span>
77 </div>
78 ))}
79 </div>
80 </div>
81
82 <div class="card-grid" style="grid-template-columns: repeat(auto-fill, minmax(280px, 1fr))">
83 <ScoreCard
84 title="Security"
85 score={report.breakdown.security.score}
86 details={[
87 `${report.breakdown.security.issues.length} issue${report.breakdown.security.issues.length !== 1 ? "s" : ""} found`,
88 `${report.breakdown.security.issues.filter((i) => i.severity === "critical").length} critical`,
89 `${report.breakdown.security.issues.filter((i) => i.severity === "high").length} high`,
90 ]}
91 />
92 <ScoreCard
93 title="Testing"
94 score={report.breakdown.testing.score}
95 details={[
96 report.breakdown.testing.hasTests ? `${report.breakdown.testing.testFileCount} test files` : "No tests found",
97 `Coverage estimate: ${report.breakdown.testing.estimatedCoverage}`,
98 ]}
99 />
100 <ScoreCard
101 title="Complexity"
102 score={report.breakdown.complexity.score}
103 details={[
104 `${report.breakdown.complexity.totalFiles} source files`,
105 `Avg file size: ${report.breakdown.complexity.avgFileSize} bytes`,
106 ]}
107 />
108 <ScoreCard
109 title="Dependencies"
110 score={report.breakdown.dependencies.score}
111 details={[
112 `${report.breakdown.dependencies.total} dependencies`,
113 report.breakdown.dependencies.lockfileExists ? "Lockfile present" : "No lockfile",
114 ]}
115 />
116 <ScoreCard
117 title="Documentation"
118 score={report.breakdown.documentation.score}
119 details={[
120 report.breakdown.documentation.hasReadme ? "README found" : "No README",
121 report.breakdown.documentation.hasLicense ? "License present" : "No license",
122 `${report.breakdown.documentation.docFileCount} doc files`,
123 ]}
124 />
125 <ScoreCard
126 title="Activity"
127 score={report.breakdown.activity.score}
128 details={[
129 `${report.breakdown.activity.recentCommits} commits (30d)`,
130 `${report.breakdown.activity.uniqueContributors} contributors`,
131 `Last push: ${report.breakdown.activity.lastPushDaysAgo}d ago`,
132 ]}
133 />
134 </div>
135
136 {report.breakdown.security.issues.length > 0 && (
137 <div style="margin-top: 32px">
138 <h3 style="margin-bottom: 12px">Security Issues</h3>
139 <div class="issue-list">
140 {report.breakdown.security.issues.map((issue) => (
141 <div class="issue-item">
142 <div style="display: flex; gap: 8px; align-items: center">
143 <SeverityBadge severity={issue.severity} />
144 <div>
145 <div style="font-size: 14px; font-weight: 500">
146 {issue.message}
147 </div>
148 <div style="font-size: 12px; color: var(--text-muted); font-family: var(--font-mono)">
149 {issue.file}
150 {issue.line ? `:${issue.line}` : ""} — {issue.rule}
151 </div>
152 </div>
153 </div>
154 </div>
155 ))}
156 </div>
157 </div>
158 )}
159
160 {ciConfig.commands.length > 0 && (
161 <div style="margin-top: 32px">
162 <h3 style="margin-bottom: 12px">
163 Zero-Config CI
164 <span style="font-size: 13px; color: var(--text-muted); font-weight: 400; margin-left: 8px">
165 Auto-detected
166 </span>
167 </h3>
168 <div style="background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px">
169 <div style="margin-bottom: 12px; font-size: 13px; color: var(--text-muted)">
170 {ciConfig.detected.join(" | ")}
171 </div>
172 {ciConfig.commands.map((cmd) => (
173 <div style="display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid var(--border); align-items: center">
174 <span style="font-size: 14px; font-weight: 500">
175 {cmd.name}
176 </span>
177 <code style="font-size: 12px; background: var(--bg-tertiary); padding: 4px 8px; border-radius: 3px">
178 {cmd.command}
179 </code>
180 </div>
181 ))}
182 </div>
183 </div>
184 )}
185 </Layout>
186 );
187});
188
189const HealthNav = ({
190 owner,
191 repo,
192 active,
193}: {
194 owner: string;
195 repo: string;
196 active: string;
197}) => (
198 <div class="repo-nav">
199 <a href={`/${owner}/${repo}`} class={active === "code" ? "active" : ""}>
200 Code
201 </a>
202 <a
203 href={`/${owner}/${repo}/issues`}
204 class={active === "issues" ? "active" : ""}
205 >
206 Issues
207 </a>
208 <a
209 href={`/${owner}/${repo}/pulls`}
210 class={active === "pulls" ? "active" : ""}
211 >
212 Pull Requests
213 </a>
214 <a
215 href={`/${owner}/${repo}/health`}
216 class={active === "health" ? "active" : ""}
217 >
218 Health
219 </a>
220 <a
221 href={`/${owner}/${repo}/commits`}
222 class={active === "commits" ? "active" : ""}
223 >
224 Commits
225 </a>
226 </div>
227);
228
229const ScoreCard = ({
230 title,
231 score,
232 details,
233}: {
234 title: string;
235 score: number;
236 details: string[];
237}) => {
238 const color =
239 score >= 80 ? "var(--green)" : score >= 50 ? "var(--yellow)" : "var(--red)";
240 return (
241 <div class="card">
242 <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px">
243 <h3 style="font-size: 15px">{title}</h3>
244 <span
245 style={`font-size: 18px; font-weight: 700; color: ${color}`}
246 >
247 {score}
248 </span>
249 </div>
250 <div
251 style="height: 4px; background: var(--bg-tertiary); border-radius: 2px; margin-bottom: 8px; overflow: hidden"
252 >
253 <div
254 style={`height: 100%; width: ${score}%; background: ${color}; border-radius: 2px; transition: width 0.3s;`}
255 />
256 </div>
257 {details.map((d) => (
258 <div style="font-size: 12px; color: var(--text-muted); margin-top: 2px">
259 {d}
260 </div>
261 ))}
262 </div>
263 );
264};
265
266const SeverityBadge = ({
267 severity,
268}: {
269 severity: SecurityIssue["severity"];
270}) => {
271 const colors: Record<string, string> = {
272 critical: "var(--red)",
273 high: "#ff7b72",
274 medium: "var(--yellow)",
275 low: "var(--text-muted)",
276 info: "var(--text-link)",
277 };
278 return (
279 <span
280 class="badge"
281 style={`color: ${colors[severity]}; border-color: ${colors[severity]}; font-size: 11px; text-transform: uppercase`}
282 >
283 {severity}
284 </span>
285 );
286};
287
288export default health;
Modifiedsrc/routes/insights.tsx+230−396View fileUnifiedSplit
11/**
2 * Repo insights + milestones.
2 * Insight routes — time-travel, dependency analysis, rollback.
33 *
4 * GET /:owner/:repo/insights — contributors, commit activity, gate health, AI-generated summary
5 * GET /:owner/:repo/milestones — list
6 * POST /:owner/:repo/milestones — create
7 * POST /:owner/:repo/milestones/:id/close — close
8 * POST /:owner/:repo/milestones/:id/reopen — reopen
9 * POST /:owner/:repo/milestones/:id/deletedelete
4 * These are the pages that don't exist on GitHub.
5 * This is why developers will switch.
106 */
117
128import { Hono } from "hono";
13import { and, desc, eq, sql } from "drizzle-orm";
14import { db } from "../db";
15import {
16 gateRuns,
17 issues,
18 milestones,
19 pullRequests,
20 repositories,
21 users,
22} from "../db/schema";
239import { Layout } from "../views/layout";
2410import { RepoHeader, RepoNav } from "../views/components";
11import {
12 getFileTimeline,
13 getFunctionTimeline,
14 detectCoupledFiles,
15 getRepoStory,
16} from "../lib/timetravel";
17import {
18 buildImportGraph,
19 analyzeUpgradeImpact,
20 findUnusedDeps,
21} from "../lib/depimpact";
22import { findRollbackTarget, executeRollback } from "../lib/rollback";
23import {
24 repoExists,
25 getDefaultBranch,
26 listBranches,
27} from "../git/repository";
2528import { softAuth, requireAuth } from "../middleware/auth";
2629import type { AuthEnv } from "../middleware/auth";
27import { getUnreadCount } from "../lib/unread";
28import { listCommits } from "../git/repository";
2930
3031const insights = new Hono<AuthEnv>();
31insights.use("*", softAuth);
3232
33async function loadRepo(owner: string, repo: string) {
34 const [row] = await db
35 .select({
36 id: repositories.id,
37 name: repositories.name,
38 defaultBranch: repositories.defaultBranch,
39 ownerId: repositories.ownerId,
40 starCount: repositories.starCount,
41 forkCount: repositories.forkCount,
42 })
43 .from(repositories)
44 .innerJoin(users, eq(repositories.ownerId, users.id))
45 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
46 .limit(1);
47 return row;
48}
33insights.use("*", softAuth);
4934
50// ---------- Insights ----------
35// ─── TIME TRAVEL ─────────────────────────────────────────────
5136
52insights.get("/:owner/:repo/insights", async (c) => {
53 const user = c.get("user");
37// File evolution timeline
38insights.get("/:owner/:repo/timeline/:ref{.+$}", async (c) => {
5439 const { owner, repo } = c.req.param();
55 const repoRow = await loadRepo(owner, repo);
56 if (!repoRow) return c.notFound();
57
58 const unread = user ? await getUnreadCount(user.id) : 0;
59
60 // Commit activity — last 200 commits on default
61 const commits = await listCommits(
62 owner,
63 repo,
64 repoRow.defaultBranch,
65 200
66 );
40 const user = c.get("user");
41 const refAndPath = c.req.param("ref");
6742
68 // Contributors by commit count
69 const byAuthor = new Map<string, number>();
70 for (const c0 of commits) {
71 byAuthor.set(c0.author, (byAuthor.get(c0.author) || 0) + 1);
72 }
73 const contributors = [...byAuthor.entries()]
74 .sort((a, b) => b[1] - a[1])
75 .slice(0, 10);
43 const branches = await listBranches(owner, repo);
44 let ref = "";
45 let filePath = "";
7646
77 // Commits per day (last 30)
78 const dayCounts = new Map<string, number>();
79 for (const c0 of commits) {
80 const day = c0.date.slice(0, 10);
81 dayCounts.set(day, (dayCounts.get(day) || 0) + 1);
47 for (const branch of branches) {
48 if (refAndPath.startsWith(branch + "/")) {
49 ref = branch;
50 filePath = refAndPath.slice(branch.length + 1);
51 break;
52 }
8253 }
83 const days: Array<{ date: string; count: number }> = [];
84 const now = new Date();
85 for (let i = 29; i >= 0; i--) {
86 const d = new Date(now);
87 d.setDate(d.getDate() - i);
88 const key = d.toISOString().slice(0, 10);
89 days.push({ date: key, count: dayCounts.get(key) || 0 });
54 if (!ref) {
55 const idx = refAndPath.indexOf("/");
56 if (idx === -1) return c.notFound();
57 ref = refAndPath.slice(0, idx);
58 filePath = refAndPath.slice(idx + 1);
9059 }
91 const maxDay = Math.max(1, ...days.map((d) => d.count));
9260
93 // Gate health 30d
94 const gateStats = await db
95 .select({
96 status: gateRuns.status,
97 c: sql<number>`count(*)::int`,
98 })
99 .from(gateRuns)
100 .where(eq(gateRuns.repositoryId, repoRow.id))
101 .groupBy(gateRuns.status);
102
103 const statTotals: Record<string, number> = {};
104 let totalRuns = 0;
105 for (const r of gateStats) {
106 statTotals[r.status] = r.c;
107 totalRuns += r.c;
108 }
109 const greenRate =
110 totalRuns === 0
111 ? 100
112 : Math.round(
113 (((statTotals.passed || 0) +
114 (statTotals.repaired || 0) +
115 (statTotals.skipped || 0)) /
116 totalRuns) *
117 100
118 );
119
120 // Issues + PR counts
121 const [issueStats] = await db
122 .select({
123 open: sql<number>`count(*) filter (where ${issues.state} = 'open')::int`,
124 closed: sql<number>`count(*) filter (where ${issues.state} = 'closed')::int`,
125 })
126 .from(issues)
127 .where(eq(issues.repositoryId, repoRow.id));
128
129 const [prStats] = await db
130 .select({
131 open: sql<number>`count(*) filter (where ${pullRequests.state} = 'open')::int`,
132 merged: sql<number>`count(*) filter (where ${pullRequests.state} = 'merged')::int`,
133 closed: sql<number>`count(*) filter (where ${pullRequests.state} = 'closed')::int`,
134 })
135 .from(pullRequests)
136 .where(eq(pullRequests.repositoryId, repoRow.id));
61 const timeline = await getFileTimeline(owner, repo, ref, filePath);
62 if (!timeline) return c.notFound();
13763
13864 return c.html(
139 <Layout
140 title={`Insights — ${owner}/${repo}`}
141 user={user}
142 notificationCount={unread}
143 >
144 <RepoHeader
145 owner={owner}
146 repo={repo}
147 starCount={repoRow.starCount}
148 forkCount={repoRow.forkCount}
149 currentUser={user?.username || null}
150 />
151 <RepoNav owner={owner} repo={repo} active="insights" />
152 <h3 style="margin-bottom: 16px">Insights</h3>
65 <Layout title={`Timeline: ${filePath} — ${owner}/${repo}`} user={user}>
66 <RepoHeader owner={owner} repo={repo} />
67 <RepoNav owner={owner} repo={repo} active="code" />
68 <h2 style="margin-bottom: 4px">
69 Time Travel: {filePath}
70 </h2>
71 <p style="color: var(--text-muted); font-size: 14px; margin-bottom: 20px">
72 {timeline.totalRevisions} revision{timeline.totalRevisions !== 1 ? "s" : ""} | First seen{" "}
73 {new Date(timeline.firstSeen.date).toLocaleDateString()} by {timeline.firstSeen.author}
74 </p>
15375
154 <div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; margin-bottom: 24px">
155 <div class="panel" style="padding: 16px">
156 <div style="font-size: 28px; font-weight: 700; color: var(--green)">
157 {greenRate}%
158 </div>
159 <div style="font-size: 12px; color: var(--text-muted); text-transform: uppercase">
160 Green rate
161 </div>
162 </div>
163 <div class="panel" style="padding: 16px">
164 <div style="font-size: 28px; font-weight: 700">{commits.length}</div>
165 <div style="font-size: 12px; color: var(--text-muted); text-transform: uppercase">
166 Recent commits
167 </div>
168 </div>
169 <div class="panel" style="padding: 16px">
170 <div style="font-size: 28px; font-weight: 700">
171 {(prStats?.open || 0) + (prStats?.merged || 0) + (prStats?.closed || 0)}
172 </div>
173 <div style="font-size: 12px; color: var(--text-muted); text-transform: uppercase">
174 Pull requests
175 </div>
176 </div>
177 <div class="panel" style="padding: 16px">
178 <div style="font-size: 28px; font-weight: 700">
179 {(issueStats?.open || 0) + (issueStats?.closed || 0)}
180 </div>
181 <div style="font-size: 12px; color: var(--text-muted); text-transform: uppercase">
182 Issues
183 </div>
184 </div>
185 </div>
186
187 <div class="dashboard-section">
188 <h3>Commit activity (last 30 days)</h3>
189 <div class="panel" style="padding: 16px">
190 <div style="display: flex; align-items: flex-end; gap: 2px; height: 80px">
191 {days.map((d) => (
192 <div
193 title={`${d.date}: ${d.count} commits`}
194 style={`flex: 1; background: var(--accent); height: ${Math.max(2, (d.count / maxDay) * 80)}px; border-radius: 2px; opacity: ${d.count === 0 ? 0.2 : 1}`}
195 ></div>
196 ))}
197 </div>
198 <div style="display: flex; justify-content: space-between; margin-top: 6px; font-size: 11px; color: var(--text-muted)">
199 <span>{days[0].date}</span>
200 <span>{days[days.length - 1].date}</span>
201 </div>
202 </div>
203 </div>
204
205 <div class="dashboard-section">
206 <h3>Top contributors</h3>
207 <div class="panel">
208 {contributors.length === 0 ? (
209 <div class="panel-empty">No contributors yet.</div>
210 ) : (
211 contributors.map(([author, count]) => {
212 const pct = Math.round(
213 (count / contributors[0][1]) * 100
214 );
215 return (
216 <div class="panel-item">
217 <div style="flex: 1">
218 <div style="font-weight: 500">{author}</div>
219 <div
220 style={`height: 6px; background: var(--accent); border-radius: 3px; margin-top: 4px; width: ${pct}%`}
221 ></div>
222 </div>
223 <div style="font-size: 12px; color: var(--text-muted); width: 80px; text-align: right">
224 {count} commit{count !== 1 ? "s" : ""}
76 <div class="timeline">
77 {timeline.revisions.map((rev, i) => (
78 <div class="timeline-item">
79 <div class="timeline-dot" />
80 <div class="timeline-content">
81 <div style="display: flex; justify-content: space-between; align-items: start">
82 <div>
83 <a
84 href={`/${owner}/${repo}/commit/${rev.sha}`}
85 style="font-weight: 600; font-size: 14px"
86 >
87 {rev.message}
88 </a>
89 <div style="font-size: 12px; color: var(--text-muted); margin-top: 2px">
90 {rev.author} — {new Date(rev.date).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}
22591 </div>
22692 </div>
227 );
228 })
229 )}
230 </div>
93 <div style="text-align: right; font-size: 12px; white-space: nowrap">
94 <span class="stat-add">+{rev.linesAdded}</span>{" "}
95 <span class="stat-del">-{rev.linesRemoved}</span>
96 <div style="color: var(--text-muted)">{rev.sizeAfter} bytes</div>
97 </div>
98 </div>
99 </div>
100 </div>
101 ))}
231102 </div>
232103 </Layout>
233104 );
234105});
235106
236// ---------- Milestones ----------
237
238insights.get("/:owner/:repo/milestones", async (c) => {
239 const user = c.get("user");
107// Coupled files analysis
108insights.get("/:owner/:repo/coupling", async (c) => {
240109 const { owner, repo } = c.req.param();
241 const repoRow = await loadRepo(owner, repo);
242 if (!repoRow) return c.notFound();
243 const unread = user ? await getUnreadCount(user.id) : 0;
244 const state = c.req.query("state") || "open";
110 const user = c.get("user");
111
112 if (!(await repoExists(owner, repo))) return c.notFound();
113 const ref = (await getDefaultBranch(owner, repo)) || "main";
245114
246 const rows = await db
247 .select()
248 .from(milestones)
249 .where(
250 and(
251 eq(milestones.repositoryId, repoRow.id),
252 eq(milestones.state, state)
253 )
254 )
255 .orderBy(desc(milestones.createdAt));
115 const coupled = await detectCoupledFiles(owner, repo, ref);
116 const story = await getRepoStory(owner, repo, ref);
117 const milestones = story.filter((s) => s.significance !== "normal").slice(0, 20);
256118
257119 return c.html(
258 <Layout
259 title={`Milestones — ${owner}/${repo}`}
260 user={user}
261 notificationCount={unread}
262 >
263 <RepoHeader
264 owner={owner}
265 repo={repo}
266 starCount={repoRow.starCount}
267 forkCount={repoRow.forkCount}
268 currentUser={user?.username || null}
269 />
270 <RepoNav owner={owner} repo={repo} active="issues" />
271 <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px">
272 <div class="issue-tabs">
273 <a
274 href={`/${owner}/${repo}/milestones?state=open`}
275 class={state === "open" ? "active" : ""}
276 >
277 Open
278 </a>
279 <a
280 href={`/${owner}/${repo}/milestones?state=closed`}
281 class={state === "closed" ? "active" : ""}
282 >
283 Closed
284 </a>
285 </div>
286 {user && user.id === repoRow.ownerId && (
287 <a
288 href={`/${owner}/${repo}/milestones#new`}
289 class="btn btn-primary btn-sm"
290 >
291 + New milestone
292 </a>
293 )}
294 </div>
120 <Layout title={`Insights — ${owner}/${repo}`} user={user}>
121 <RepoHeader owner={owner} repo={repo} />
122 <RepoNav owner={owner} repo={repo} active="code" />
123 <h2 style="margin-bottom: 20px">Code Insights</h2>
295124
296 {rows.length === 0 ? (
297 <div class="empty-state">
298 <p>No {state} milestones.</p>
299 </div>
125 <h3 style="margin-bottom: 12px">Coupled Files</h3>
126 <p style="color: var(--text-muted); font-size: 13px; margin-bottom: 12px">
127 Files that change together frequently — potential architectural coupling
128 </p>
129 {coupled.length === 0 ? (
130 <p style="color: var(--text-muted)">No strong coupling detected.</p>
300131 ) : (
301 <div class="panel" style="margin-bottom: 24px">
302 {rows.map((m) => (
303 <div class="panel-item" style="justify-content: space-between">
304 <div style="flex: 1">
305 <div style="font-weight: 600">{m.title}</div>
306 {m.description && (
307 <div class="meta" style="margin-top: 2px">{m.description}</div>
308 )}
309 <div class="meta" style="margin-top: 2px">
310 {m.dueDate
311 ? `Due ${new Date(m.dueDate).toLocaleDateString()}`
312 : "No due date"}
313 </div>
132 <div class="issue-list" style="margin-bottom: 32px">
133 {coupled.map((pair) => (
134 <div class="issue-item">
135 <div style="font-size: 13px; font-family: var(--font-mono)">
136 <a href={`/${owner}/${repo}/blob/${ref}/${pair.files[0]}`}>
137 {pair.files[0]}
138 </a>
139 <span style="color: var(--text-muted); margin: 0 8px">+</span>
140 <a href={`/${owner}/${repo}/blob/${ref}/${pair.files[1]}`}>
141 {pair.files[1]}
142 </a>
143 </div>
144 <div style="font-size: 12px; color: var(--text-muted); white-space: nowrap">
145 {pair.cochanges} co-changes ({pair.percentage}%)
314146 </div>
315 {user && user.id === repoRow.ownerId && (
316 <div style="display: flex; gap: 4px">
317 {m.state === "open" ? (
318 <form
319 method="post"
320 action={`/${owner}/${repo}/milestones/${m.id}/close`}
321 >
322 <button type="submit" class="btn btn-sm">
323 Close
324 </button>
325 </form>
326 ) : (
327 <form
328 method="post"
329 action={`/${owner}/${repo}/milestones/${m.id}/reopen`}
330 >
331 <button type="submit" class="btn btn-sm">
332 Reopen
333 </button>
334 </form>
335 )}
336 <form
337 method="post"
338 action={`/${owner}/${repo}/milestones/${m.id}/delete`}
339 onsubmit="return confirm('Delete this milestone?')"
340 >
341 <button type="submit" class="btn btn-sm btn-danger">
342 Delete
343 </button>
344 </form>
345 </div>
346 )}
347147 </div>
348148 ))}
349149 </div>
350150 )}
351151
352 {user && user.id === repoRow.ownerId && (
353 <form
354 id="new"
355 method="post"
356 action={`/${owner}/${repo}/milestones`}
357 class="panel"
358 style="padding: 16px"
359 >
360 <h3 style="margin-bottom: 12px">Create milestone</h3>
361 <div class="form-group">
362 <label>Title</label>
363 <input type="text" name="title" required />
364 </div>
365 <div class="form-group">
366 <label>Description</label>
367 <textarea name="description" rows={3}></textarea>
368 </div>
369 <div class="form-group">
370 <label>Due date (optional)</label>
371 <input type="date" name="dueDate" />
372 </div>
373 <button type="submit" class="btn btn-primary">
374 Create
375 </button>
376 </form>
152 <h3 style="margin-bottom: 12px">Project Milestones</h3>
153 {milestones.length === 0 ? (
154 <p style="color: var(--text-muted)">No milestones detected yet.</p>
155 ) : (
156 <div class="timeline">
157 {milestones.map((m) => (
158 <div class="timeline-item">
159 <div
160 class="timeline-dot"
161 style={m.significance === "milestone" ? "background: var(--green); width: 12px; height: 12px" : ""}
162 />
163 <div class="timeline-content">
164 <a href={`/${owner}/${repo}/commit/${m.sha}`} style="font-weight: 600; font-size: 14px">
165 {m.message}
166 </a>
167 <div style="font-size: 12px; color: var(--text-muted)">
168 {m.author} — {new Date(m.date).toLocaleDateString()} |{" "}
169 <span class="stat-add">+{m.stats.additions}</span>{" "}
170 <span class="stat-del">-{m.stats.deletions}</span> in {m.stats.files} files
171 </div>
172 </div>
173 </div>
174 ))}
175 </div>
377176 )}
378177 </Layout>
379178 );
380179});
381180
382insights.post("/:owner/:repo/milestones", requireAuth, async (c) => {
383 const user = c.get("user")!;
181// ─── DEPENDENCY INSIGHTS ─────────────────────────────────────
182
183insights.get("/:owner/:repo/dependencies", async (c) => {
384184 const { owner, repo } = c.req.param();
385 const repoRow = await loadRepo(owner, repo);
386 if (!repoRow) return c.notFound();
387 if (repoRow.ownerId !== user.id) return c.redirect(`/${owner}/${repo}/milestones`);
185 const user = c.get("user");
388186
389 const body = await c.req.parseBody();
390 const title = String(body.title || "").trim();
391 if (!title) return c.redirect(`/${owner}/${repo}/milestones`);
392 const description = String(body.description || "").trim() || null;
393 const dueDateRaw = String(body.dueDate || "").trim();
394 const dueDate = dueDateRaw ? new Date(dueDateRaw) : null;
187 if (!(await repoExists(owner, repo))) return c.notFound();
188 const ref = (await getDefaultBranch(owner, repo)) || "main";
395189
396 try {
397 await db.insert(milestones).values({
398 repositoryId: repoRow.id,
399 title,
400 description,
401 dueDate,
402 });
403 } catch (err) {
404 console.error("[milestones] create:", err);
405 }
190 const graph = await buildImportGraph(owner, repo, ref);
191 const unused = findUnusedDeps(graph);
406192
407 return c.redirect(`/${owner}/${repo}/milestones`);
408});
193 return c.html(
194 <Layout title={`Dependencies — ${owner}/${repo}`} user={user}>
195 <RepoHeader owner={owner} repo={repo} />
196 <RepoNav owner={owner} repo={repo} active="code" />
197 <h2 style="margin-bottom: 4px">Dependency Intelligence</h2>
198 <p style="color: var(--text-muted); font-size: 14px; margin-bottom: 20px">
199 {graph.externalDependencies} dependencies | {graph.internalModules} source files
200 {graph.circularDeps.length > 0 && (
201 <span style="color: var(--red)">
202 {" "}| {graph.circularDeps.length} circular dependency chain{graph.circularDeps.length !== 1 ? "s" : ""}
203 </span>
204 )}
205 </p>
409206
410insights.post("/:owner/:repo/milestones/:id/close", requireAuth, async (c) => {
411 const user = c.get("user")!;
412 const { owner, repo, id } = c.req.param();
413 const repoRow = await loadRepo(owner, repo);
414 if (!repoRow || repoRow.ownerId !== user.id) {
415 return c.redirect(`/${owner}/${repo}/milestones`);
416 }
417 await db
418 .update(milestones)
419 .set({ state: "closed", closedAt: new Date() })
420 .where(
421 and(eq(milestones.id, id), eq(milestones.repositoryId, repoRow.id))
422 );
423 return c.redirect(`/${owner}/${repo}/milestones`);
207 {unused.length > 0 && (
208 <div style="background: rgba(248, 81, 73, 0.1); border: 1px solid var(--red); border-radius: var(--radius); padding: 12px 16px; margin-bottom: 20px">
209 <strong style="color: var(--red)">Unused dependencies:</strong>{" "}
210 <span style="font-family: var(--font-mono); font-size: 13px">
211 {unused.join(", ")}
212 </span>
213 <div style="font-size: 12px; color: var(--text-muted); margin-top: 4px">
214 These are installed but never imported. Removing them reduces install time and attack surface.
215 </div>
216 </div>
217 )}
218
219 <div class="issue-list">
220 {graph.dependencies.map((dep) => (
221 <div class="issue-item" style="flex-direction: column; align-items: stretch">
222 <div style="display: flex; justify-content: space-between; align-items: center">
223 <div>
224 <strong style="font-size: 14px">{dep.name}</strong>
225 <span style="margin-left: 8px; font-size: 12px; color: var(--text-muted)">
226 {dep.version}
227 </span>
228 {dep.isDevDep && (
229 <span class="badge" style="margin-left: 8px; font-size: 10px">
230 dev
231 </span>
232 )}
233 </div>
234 <span style="font-size: 13px; color: var(--text-muted)">
235 {dep.totalImports === 0 ? (
236 <span style="color: var(--red)">unused</span>
237 ) : (
238 `${dep.totalImports} import${dep.totalImports !== 1 ? "s" : ""}`
239 )}
240 </span>
241 </div>
242 {dep.usedIn.length > 0 && (
243 <div style="margin-top: 8px; font-size: 12px">
244 {dep.usedIn.slice(0, 3).map((usage) => (
245 <div style="color: var(--text-muted); font-family: var(--font-mono); margin-top: 2px">
246 <a href={`/${owner}/${repo}/blob/${ref}/${usage.file}`}>
247 {usage.file}:{usage.line}
248 </a>
249 <span style="margin-left: 8px">
250 {"{"}
251 {usage.importedSymbols.join(", ")}
252 {"}"}
253 </span>
254 </div>
255 ))}
256 {dep.usedIn.length > 3 && (
257 <div style="color: var(--text-muted); margin-top: 2px">
258 +{dep.usedIn.length - 3} more
259 </div>
260 )}
261 </div>
262 )}
263 </div>
264 ))}
265 </div>
266 </Layout>
267 );
424268});
425269
426insights.post("/:owner/:repo/milestones/:id/reopen", requireAuth, async (c) => {
270// ─── ROLLBACK ────────────────────────────────────────────────
271
272insights.post("/:owner/:repo/rollback", requireAuth, async (c) => {
273 const { owner, repo } = c.req.param();
427274 const user = c.get("user")!;
428 const { owner, repo, id } = c.req.param();
429 const repoRow = await loadRepo(owner, repo);
430 if (!repoRow || repoRow.ownerId !== user.id) {
431 return c.redirect(`/${owner}/${repo}/milestones`);
275 const body = await c.req.parseBody();
276 const branch = String(body.branch || "main");
277 const targetSha = String(body.target_sha || "");
278
279 if (!targetSha) {
280 return c.redirect(`/${owner}/${repo}`);
432281 }
433 await db
434 .update(milestones)
435 .set({ state: "open", closedAt: null })
436 .where(
437 and(eq(milestones.id, id), eq(milestones.repositoryId, repoRow.id))
438 );
439 return c.redirect(`/${owner}/${repo}/milestones?state=closed`);
440});
441282
442insights.post("/:owner/:repo/milestones/:id/delete", requireAuth, async (c) => {
443 const user = c.get("user")!;
444 const { owner, repo, id } = c.req.param();
445 const repoRow = await loadRepo(owner, repo);
446 if (!repoRow || repoRow.ownerId !== user.id) {
447 return c.redirect(`/${owner}/${repo}/milestones`);
283 const result = await executeRollback(owner, repo, branch, targetSha);
284 if (!result.success) {
285 return c.redirect(`/${owner}/${repo}?error=${encodeURIComponent(result.error || "Rollback failed")}`);
448286 }
449 await db
450 .delete(milestones)
451 .where(
452 and(eq(milestones.id, id), eq(milestones.repositoryId, repoRow.id))
453 );
454 return c.redirect(`/${owner}/${repo}/milestones`);
287
288 return c.redirect(`/${owner}/${repo}/commit/${result.newSha}`);
455289});
456290
457291export default insights;
Addedsrc/routes/legal.tsx+48−0View fileUnifiedSplit
1/**
2 * Legal pages — Terms, Privacy, AUP served from the website.
3 */
4
5import { Hono } from "hono";
6import { readFileSync } from "fs";
7import { join } from "path";
8import { Layout } from "../views/layout";
9import { renderMarkdown, markdownCss } from "../lib/markdown";
10import { softAuth } from "../middleware/auth";
11import type { AuthEnv } from "../middleware/auth";
12import { html } from "hono/html";
13
14const legal = new Hono<AuthEnv>();
15
16legal.use("*", softAuth);
17
18function serveLegalPage(title: string, filename: string) {
19 return async (c: any) => {
20 const user = c.get("user");
21 let content: string;
22 try {
23 content = readFileSync(
24 join(process.cwd(), "legal", filename),
25 "utf-8"
26 );
27 } catch {
28 content = `# ${title}\n\nThis page is being prepared. Check back soon.`;
29 }
30
31 const rendered = renderMarkdown(content);
32
33 return c.html(
34 <Layout title={title} user={user}>
35 <style>{markdownCss}</style>
36 <div class="markdown-body" style="max-width: 800px; margin: 0 auto">
37 {html([rendered] as unknown as TemplateStringsArray)}
38 </div>
39 </Layout>
40 );
41 };
42}
43
44legal.get("/terms", serveLegalPage("Terms of Service", "TERMS.md"));
45legal.get("/privacy", serveLegalPage("Privacy Policy", "PRIVACY.md"));
46legal.get("/acceptable-use", serveLegalPage("Acceptable Use Policy", "AUP.md"));
47
48export default legal;
Modifiedsrc/routes/web.tsx+3−0View fileUnifiedSplit
710710 <a href={`/${owner}/${repo}/blame/${ref}/${filePath}`} style="font-size: 12px">
711711 Blame
712712 </a>
713 <a href={`/${owner}/${repo}/timeline/${ref}/${filePath}`} style="font-size: 12px">
714 History
715 </a>
713716 {user && (
714717 <a href={`/${owner}/${repo}/edit/${ref}/${filePath}`} style="font-size: 12px">
715718 Edit
Modifiedsrc/views/layout.tsx+61−356View fileUnifiedSplit
6060 </a>
6161 {user ? (
6262 <>
63 <a href="/ask" class="nav-link" title="Ask AI (Cmd+K)">
64 {"\u2728"} Ask
65 </a>
66 <a
67 href="/notifications"
68 class="nav-link nav-notifications"
69 title="Notifications"
70 >
71 {"\u2709"}
72 {notificationCount !== undefined && notificationCount > 0 && (
73 <span class="nav-badge">
74 {notificationCount > 99 ? "99+" : notificationCount}
75 </span>
76 )}
63 <a href="/dashboard" class="nav-link" style="font-weight: 600">
64 Dashboard
7765 </a>
7866 <a href="/new" class="btn btn-sm btn-primary">
7967 + New
10391 </header>
10492 <main id="main-content">{children}</main>
10593 <footer>
106 <span>gluecron — AI-native code intelligence</span>
107 <span style="margin-left:16px">
108 <a href="/api/docs" style="color:var(--text-muted);font-size:12px">API Docs</a>
94 <span>
95 &copy; {new Date().getFullYear()} gluecron — AI-native code intelligence
10996 </span>
97 <div style="margin-top: 6px; display: flex; gap: 16px; justify-content: center; font-size: 12px">
98 <a href="/terms" style="color: var(--text-muted)">Terms</a>
99 <a href="/privacy" style="color: var(--text-muted)">Privacy</a>
100 <a href="/acceptable-use" style="color: var(--text-muted)">Acceptable Use</a>
101 </div>
110102 </footer>
111103 <script>{clientJs}</script>
112104 </body>
805797
806798 /* Search */
807799 .search-results .diff-file { margin-bottom: 12px; }
808 .search-input {
809 flex: 1;
810 padding: 8px 12px;
811 background: var(--bg);
812 border: 1px solid var(--border);
813 border-radius: var(--radius);
814 color: var(--text);
815 font-size: 14px;
816 }
817 .search-input:focus {
818 outline: none;
819 border-color: var(--accent);
820 box-shadow: 0 0 0 2px rgba(31, 111, 235, 0.3);
821 }
822800
823 /* Toast Notifications */
824 #toast-container {
825 position: fixed;
826 top: 16px;
827 right: 16px;
828 z-index: 9999;
829 display: flex;
830 flex-direction: column;
831 gap: 8px;
832 }
833 .toast {
834 padding: 10px 16px;
835 border-radius: var(--radius);
836 font-size: 14px;
837 font-weight: 500;
838 opacity: 0;
839 transform: translateX(100%);
840 transition: all 0.3s ease;
841 min-width: 200px;
842 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
801 /* Timeline */
802 .timeline { position: relative; padding-left: 24px; }
803 .timeline::before {
804 content: '';
805 position: absolute;
806 left: 4px;
807 top: 8px;
808 bottom: 8px;
809 width: 2px;
810 background: var(--border);
843811 }
844 .toast-visible { opacity: 1; transform: translateX(0); }
845 .toast-info { background: var(--accent); color: #fff; }
846 .toast-success { background: var(--green); color: #fff; }
847 .toast-error { background: var(--red); color: #fff; }
848 .toast-warning { background: var(--yellow); color: #000; }
849
850 /* Keyboard Shortcut Hints */
851 .kbd {
852 display: inline-block;
853 padding: 2px 6px;
854 border: 1px solid var(--border);
855 border-radius: 4px;
856 background: var(--bg-secondary);
857 font-family: var(--font-mono);
858 font-size: 12px;
859 line-height: 1.4;
860 color: var(--text-muted);
861 box-shadow: 0 1px 0 var(--border);
812 .timeline-item {
813 position: relative;
814 padding-bottom: 16px;
862815 }
863 .shortcut-overlay {
864 position: fixed;
865 inset: 0;
866 background: rgba(0, 0, 0, 0.6);
867 z-index: 9998;
868 display: flex;
869 align-items: center;
870 justify-content: center;
816 .timeline-dot {
817 position: absolute;
818 left: -24px;
819 top: 6px;
820 width: 10px;
821 height: 10px;
822 border-radius: 50%;
823 background: var(--text-muted);
824 border: 2px solid var(--bg);
871825 }
872 .shortcut-modal {
826 .timeline-content {
873827 background: var(--bg-secondary);
874828 border: 1px solid var(--border);
875829 border-radius: var(--radius);
876 padding: 24px;
877 min-width: 300px;
878 max-width: 480px;
879 }
880 .shortcut-modal h3 { margin-bottom: 16px; }
881 .shortcut-grid {
882 display: flex;
883 flex-direction: column;
884 gap: 8px;
885 margin-bottom: 16px;
886 font-size: 14px;
887 }
888 .shortcut-grid div {
889 display: flex;
890 align-items: center;
891 gap: 8px;
892 }
893
894 /* Comment Editor with Preview */
895 .comment-editor {
896 border: 1px solid var(--border);
897 border-radius: var(--radius);
898 overflow: hidden;
899 }
900 .editor-tabs {
901 display: flex;
902 border-bottom: 1px solid var(--border);
903 background: var(--bg-secondary);
904 }
905 .editor-tab {
906 padding: 6px 16px;
907 font-size: 13px;
908 background: none;
909 border: none;
910 color: var(--text-muted);
911 cursor: pointer;
912 border-bottom: 2px solid transparent;
913 }
914 .editor-tab:hover { color: var(--text); }
915 .editor-tab.active { color: var(--text); border-bottom-color: var(--accent); }
916 .comment-editor textarea {
917 width: 100%;
918 border: none;
919 padding: 12px;
920 background: var(--bg);
921 color: var(--text);
922 font-family: var(--font-mono);
923 font-size: 13px;
924 resize: vertical;
925 min-height: 120px;
926 }
927 .comment-editor textarea:focus { outline: none; }
928 .editor-preview {
929 min-height: 120px;
930 background: var(--bg);
830 padding: 12px 16px;
931831 }
932832
933 /* Success Button */
934 .btn-success { background: var(--green); border-color: var(--green); color: #fff; }
935 .btn-success:hover { background: #2ea043; }
936 .btn-ghost { background: transparent; border-color: transparent; color: var(--text-muted); }
937 .btn-ghost:hover { color: var(--text); background: var(--bg-tertiary); }
938 .btn-lg { padding: 12px 24px; font-size: 16px; }
939
940 /* Tab count badge */
941 .tab-count {
833 /* Toggle switch */
834 .toggle-switch {
835 position: relative;
942836 display: inline-block;
943 padding: 0 6px;
944 margin-left: 4px;
945 font-size: 12px;
946 background: var(--bg-tertiary);
947 border-radius: 10px;
948 }
949
950 /* Copy block */
951 .copy-block {
952 margin: 8px 0;
953 }
954
955 /* Progress bar */
956 .progress-bar {
957 width: 100%;
958 height: 8px;
959 background: var(--bg-tertiary);
960 border-radius: 4px;
961 overflow: hidden;
962 }
963 .progress-fill {
964 height: 100%;
965 background: var(--accent);
966 border-radius: 4px;
967 transition: width 0.3s ease;
968 }
969
970 /* Spinner */
971 .spinner {
972 border: 2px solid var(--border);
973 border-top: 2px solid var(--accent);
974 border-radius: 50%;
975 animation: spin 0.8s linear infinite;
976 }
977 @keyframes spin { to { transform: rotate(360deg); } }
978
979 /* Step indicator (onboarding) */
980 .step-indicator { margin-bottom: 32px; }
981 .step-circle {
982 width: 32px;
983 height: 32px;
984 border-radius: 50%;
985 display: flex;
986 align-items: center;
987 justify-content: center;
988 font-size: 14px;
989 font-weight: 600;
990 background: var(--bg-tertiary);
991 border: 2px solid var(--border);
992 color: var(--text-muted);
993 }
994 .step-completed { background: var(--green); border-color: var(--green); color: #fff; }
995 .step-active { border-color: var(--accent); color: var(--accent); }
996 .step-line {
997 flex: 1;
998 height: 2px;
999 background: var(--border);
1000 min-width: 40px;
1001 }
1002 .step-line[data-completed="true"] { background: var(--green); }
1003
1004 /* Welcome hero */
1005 .welcome-hero {
1006 text-align: center;
1007 padding: 60px 20px 40px;
1008 max-width: 700px;
1009 margin: 0 auto;
1010 }
1011 .welcome-hero h1 { font-size: 36px; margin-bottom: 12px; }
1012 .hero-subtitle { font-size: 18px; color: var(--text-muted); margin-bottom: 32px; }
1013
1014 /* Feature cards */
1015 .feature-card {
1016 text-align: center;
1017 padding: 24px;
1018 transition: border-color 0.2s, transform 0.2s;
837 width: 44px;
838 height: 24px;
839 flex-shrink: 0;
840 margin-left: 16px;
1019841 }
1020 .feature-card:hover { border-color: var(--accent); transform: translateY(-2px); }
1021 .feature-icon { font-size: 36px; margin-bottom: 12px; }
1022 .feature-card h3 { font-size: 16px; margin-bottom: 8px; }
1023
1024 /* Tooltip */
1025 .tooltip-wrapper { position: relative; }
1026 .tooltip-wrapper:hover::after {
1027 content: attr(data-tooltip);
842 .toggle-switch input { opacity: 0; width: 0; height: 0; }
843 .toggle-slider {
1028844 position: absolute;
1029 bottom: 100%;
1030 left: 50%;
1031 transform: translateX(-50%);
1032 padding: 4px 8px;
845 cursor: pointer;
846 top: 0; left: 0; right: 0; bottom: 0;
1033847 background: var(--bg-tertiary);
1034848 border: 1px solid var(--border);
1035 border-radius: 4px;
1036 font-size: 12px;
1037 white-space: nowrap;
1038 z-index: 100;
1039 margin-bottom: 4px;
1040 }
1041
1042 /* Notification bell */
1043 .notification-bell {
1044 position: relative;
1045 display: inline-flex;
1046 align-items: center;
1047 color: var(--text-muted);
1048 padding: 4px;
849 border-radius: 12px;
850 transition: 0.2s;
1049851 }
1050 .notification-bell:hover { color: var(--text); text-decoration: none; }
1051 .notification-count {
852 .toggle-slider::before {
853 content: '';
1052854 position: absolute;
1053 top: -4px;
1054 right: -6px;
1055 background: var(--accent);
1056 color: #fff;
1057 font-size: 10px;
1058 font-weight: 700;
1059 padding: 1px 5px;
1060 border-radius: 10px;
1061 min-width: 16px;
1062 text-align: center;
1063 }
1064
1065 /* Alert variants */
1066 .alert-warning {
1067 background: rgba(210, 153, 34, 0.1);
1068 border: 1px solid var(--yellow);
1069 color: var(--yellow);
1070 padding: 8px 12px;
1071 border-radius: var(--radius);
1072 margin-bottom: 16px;
1073 font-size: 14px;
1074 }
1075 .alert-info {
1076 background: rgba(88, 166, 255, 0.1);
1077 border: 1px solid var(--text-link);
1078 color: var(--text-link);
1079 padding: 8px 12px;
1080 border-radius: var(--radius);
1081 margin-bottom: 16px;
1082 font-size: 14px;
1083 }
1084
1085 /* Badge variants */
1086 .badge-success { background: rgba(63, 185, 80, 0.15); color: var(--green); border: 1px solid var(--green); }
1087 .badge-danger { background: rgba(248, 81, 73, 0.1); color: var(--red); border: 1px solid var(--red); }
1088 .badge-warning { background: rgba(210, 153, 34, 0.1); color: var(--yellow); border: 1px solid var(--yellow); }
1089
1090 /* Mobile responsiveness */
1091 @media (max-width: 768px) {
1092 main { padding: 16px; }
1093 .card-grid { grid-template-columns: 1fr; }
1094 .user-profile { flex-direction: column; gap: 16px; }
1095 .repo-header { flex-wrap: wrap; }
1096 .repo-header-actions { margin-left: 0; }
1097 .repo-nav { overflow-x: auto; }
1098 .blob-code { font-size: 12px; }
1099 .diff-content { font-size: 12px; }
1100 .hamburger-btn {
1101 display: inline-flex;
1102 align-items: center;
1103 justify-content: center;
1104 width: 36px;
1105 height: 36px;
1106 background: none;
1107 border: 1px solid var(--border);
1108 border-radius: var(--radius);
1109 color: var(--text);
1110 font-size: 18px;
1111 cursor: pointer;
1112 }
1113 .mobile-hidden { display: none !important; }
1114 .mobile-visible {
1115 display: flex !important;
1116 position: absolute;
1117 top: 100%;
1118 right: 0;
1119 background: var(--bg-secondary);
1120 border: 1px solid var(--border);
1121 border-radius: var(--radius);
1122 padding: 8px;
1123 flex-direction: column;
1124 gap: 4px;
1125 box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
1126 min-width: 200px;
1127 }
1128 .mobile-visible a, .mobile-visible button {
1129 padding: 8px 12px;
1130 display: block;
1131 width: 100%;
1132 text-align: left;
1133 }
1134 .issue-item { flex-wrap: wrap; }
1135 .commit-item { flex-direction: column; gap: 8px; }
1136 .settings-container { max-width: 100%; }
1137 .auth-container { max-width: 100%; }
1138 }
1139
1140 /* Focus visible for keyboard nav */
1141 :focus-visible {
1142 outline: 2px solid var(--accent);
1143 outline-offset: 2px;
855 height: 18px;
856 width: 18px;
857 left: 2px;
858 bottom: 2px;
859 background: var(--text-muted);
860 border-radius: 50%;
861 transition: 0.2s;
1144862 }
1145
1146 /* Skip to main content (accessibility) */
1147 .skip-link {
1148 position: absolute;
1149 top: -100px;
1150 left: 0;
863 .toggle-switch input:checked + .toggle-slider {
1151864 background: var(--accent);
1152 color: #fff;
1153 padding: 8px 16px;
1154 z-index: 9999;
1155 font-size: 14px;
865 border-color: var(--accent);
866 }
867 .toggle-switch input:checked + .toggle-slider::before {
868 transform: translateX(20px);
869 background: #fff;
1156870 }
1157 .skip-link:focus { top: 0; }
1158
1159 /* Markdown body spacing */
1160 .markdown-body { padding: 16px; }
1161 .markdown-body h1, .markdown-body h2, .markdown-body h3 { margin-top: 1.5em; margin-bottom: 0.5em; }
1162 .markdown-body p { margin-bottom: 1em; }
1163 .markdown-body pre { margin: 1em 0; }
1164 .markdown-body code { font-size: 85%; }
1165 .markdown-body ul, .markdown-body ol { padding-left: 2em; margin-bottom: 1em; }
1166871`;
1167872