import type { CreateRepositoryInput, Repository } from "@vtit-agent-coding/shared"; import { HTTPException } from "hono/http-exception"; import { type D1, newId } from "./db"; /** * Normalize git URL to canonical HTTPS form without .git suffix or trailing slash. * Accepts: https://host/owner/repo, http://host/owner/repo, git@host:owner/repo. * Rejects everything else (file://, local paths, single-segment paths, unknown schemes) * with 400 — agents cannot invent URLs, users cannot paste local paths, and the daemon * must never see an un-cloneable URL. */ export function normalizeGitUrl(url: string): string { const sshMatch = url.match(/^git@([^:]+):(.+?)(?:\.git)?$/); if (sshMatch) return `https://${sshMatch[1]}/${sshMatch[2]}`; if (/^https?:\/\/[^/]+\/[^/]+\/.+/.test(url)) { return url.replace(/(?:\.git|\/)+$/, ""); } throw new HTTPException(400, { message: `Invalid repository URL "${url}": only https://host/owner/repo, http://host/owner/repo, or git@host:owner/repo are accepted`, }); } /** Extract owner/repo from a canonicalized URL. Invariant: the URL has already passed normalizeGitUrl. */ function extractFullName(canonicalUrl: string): string { const match = canonicalUrl.match(/^https?:\/\/[^/]+\/(.+)$/); if (!match) { throw new HTTPException(500, { message: `Repository URL "${canonicalUrl}" has no owner/repo — data invariant broken (bypassed normalizeGitUrl)`, }); } return match[1]; } function withFullName(repo: T): T & { full_name: string } { return { ...repo, full_name: extractFullName(repo.url) }; } export async function createRepository(db: D1, ownerId: string, input: CreateRepositoryInput): Promise { const id = newId(); const now = new Date().toISOString(); const url = normalizeGitUrl(input.url); const provider = input.provider || "github"; const gitlabHost = input.gitlab_host || null; const accessToken = input.access_token || null; await db .prepare("INSERT INTO repositories (id, owner_id, name, url, provider, gitlab_host, access_token, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)") .bind(id, ownerId, input.name, url, provider, gitlabHost, accessToken, now) .run(); return withFullName({ id, owner_id: ownerId, name: input.name, url, provider, gitlab_host: gitlabHost, access_token: accessToken, created_at: now, }); } export async function findOrCreateRepository(db: D1, ownerId: string, input: CreateRepositoryInput): Promise { const url = normalizeGitUrl(input.url); try { return await createRepository(db, ownerId, input); } catch (err) { const isUniqueViolation = err instanceof Error && err.message.includes("UNIQUE constraint failed"); if (!isUniqueViolation) throw err; } const existing = await db.prepare("SELECT * FROM repositories WHERE owner_id = ? AND url = ?").bind(ownerId, url).first(); if (existing) return withFullName(existing); throw new Error("Failed to create or find repository"); } export async function listRepositories(db: D1, ownerId: string, filters?: { url?: string }): Promise { let query = ` SELECT r.*, COUNT(t.id) as task_count FROM repositories r LEFT JOIN tasks t ON t.repository_id = r.id WHERE r.owner_id = ?`; const binds: string[] = [ownerId]; if (filters?.url) { query += " AND r.url = ?"; binds.push(normalizeGitUrl(filters.url)); } query += " GROUP BY r.id ORDER BY r.created_at DESC"; const result = await db .prepare(query) .bind(...binds) .all(); return result.results.map(withFullName); } export async function getRepository(db: D1, id: string, ownerId: string): Promise { const row = await db.prepare("SELECT * FROM repositories WHERE id = ? AND owner_id = ?").bind(id, ownerId).first(); return row ? withFullName(row) : null; } export async function deleteRepository(db: D1, id: string): Promise { const result = await db.prepare("DELETE FROM repositories WHERE id = ?").bind(id).run(); return result.meta.changes > 0; } export function githubRepoRef(url: string) { const match = url.match(/github\.com[:/]([^/]+)\/([^/.]+)(?:\.git)?$/); if (!match) return null; return { type: "github_repository", owner: match[1], repo: match[2] }; }