Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

import-helper.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

import-helper.tsBlame195 lines · 1 contributor
80bed05Claude1/**
2 * Small helpers for the GitHub import flow (/import).
3 *
14c3cc8Claude4 * Pure parsing/normalization helpers live at the top of the file.
5 * `importOneRepo` at the bottom wraps the clone + DB insert so that both
6 * the single-repo and bulk importers can share one code path.
80bed05Claude7 */
8
14c3cc8Claude9import { and, eq } from "drizzle-orm";
10import { mkdir } from "fs/promises";
11import { join } from "path";
12import { db } from "../db";
13import { repositories } from "../db/schema";
14import { config } from "../lib/config";
15
80bed05Claude16export interface ParsedGithubUrl {
17 owner: string;
18 repo: string;
19}
20
21/**
22 * Parse a GitHub URL into { owner, repo }. Accepts:
23 * - https://github.com/foo/bar
24 * - https://github.com/foo/bar.git
25 * - http://github.com/foo/bar/
26 * - git@github.com:foo/bar.git
27 * - github.com/foo/bar
28 * - foo/bar
29 *
30 * Returns null if the URL cannot be parsed.
31 */
32export function parseGithubUrl(raw: string): ParsedGithubUrl | null {
33 const input = (raw || "").trim();
34 if (!input) return null;
35
36 // SSH form: git@github.com:owner/repo(.git)?
37 const ssh = input.match(/^git@github\.com:([^/]+)\/([^/\s]+?)(?:\.git)?\/?$/i);
38 if (ssh) return { owner: ssh[1], repo: stripDotGit(ssh[2]) };
39
40 // HTTP(S) / bare host form
41 const http = input.match(
42 /^(?:https?:\/\/)?(?:www\.)?github\.com\/([^/\s]+)\/([^/\s?#]+?)(?:\.git)?\/?(?:[?#].*)?$/i
43 );
44 if (http) return { owner: http[1], repo: stripDotGit(http[2]) };
45
46 // owner/repo shorthand
47 const short = input.match(/^([^/\s]+)\/([^/\s]+?)(?:\.git)?$/);
48 if (short) return { owner: short[1], repo: stripDotGit(short[2]) };
49
50 return null;
51}
52
53function stripDotGit(name: string): string {
54 return name.replace(/\.git$/i, "");
55}
56
57/**
58 * Repository names on gluecron follow GitHub's rough rules: letters,
59 * digits, hyphens, underscores, dots. We normalize by replacing anything
60 * else with a hyphen so an imported repo is always addressable.
61 */
62export function sanitizeRepoName(name: string): string {
63 const cleaned = name.replace(/[^A-Za-z0-9._-]/g, "-").replace(/^-+|-+$/g, "");
64 return cleaned || "imported-repo";
65}
66
67/**
68 * Build the clone URL that `git clone --bare --mirror` will use. When a
69 * token is supplied we inject it so private repos are reachable.
70 */
71export function buildCloneUrl(cloneUrl: string, token: string | null): string {
72 if (!token) return cloneUrl;
73 return cloneUrl.replace("https://github.com/", `https://${token}@github.com/`);
74}
14c3cc8Claude75
76/**
77 * Strip any secret (token) from a string before it gets returned to the UI
78 * or written to logs. Defense in depth: we already avoid putting tokens into
79 * messages, but git/HTTP errors may echo the URL we passed in.
80 */
81export function scrubSecrets(input: string, token: string | null): string {
82 if (!input) return input;
83 let out = input;
84 if (token) out = out.split(token).join("***");
85 // Also redact any `https://<creds>@github.com/...` form the URL may leak.
86 out = out.replace(
87 /https:\/\/[^@\s]+@github\.com/gi,
88 "https://***@github.com"
89 );
90 return out;
91}
92
93export interface ImportOneRepoInput {
94 cloneUrl: string;
95 targetName: string;
96 ownerId: string;
97 ownerUsername: string;
98 token?: string | null;
99 description?: string | null;
100 isPrivate?: boolean;
101 defaultBranch?: string;
102}
103
104export type ImportOneRepoStatus = "success" | "skipped-exists" | "failed";
105
106export interface ImportOneRepoResult {
107 status: ImportOneRepoStatus;
108 name: string;
109 notes: string;
110}
111
112/**
113 * Clone one GitHub repo into this user's namespace and insert the DB row.
114 *
115 * Resilient: returns a result object instead of throwing, so bulk callers
116 * can continue past a failure. Never includes the token in the returned
117 * notes — all output is passed through `scrubSecrets`.
118 */
119export async function importOneRepo(
120 input: ImportOneRepoInput
121): Promise<ImportOneRepoResult> {
122 const {
123 cloneUrl,
124 targetName,
125 ownerId,
126 ownerUsername,
127 token = null,
128 description = null,
129 isPrivate = false,
130 defaultBranch = "main",
131 } = input;
132
133 const safeName = sanitizeRepoName(targetName);
134
135 try {
136 // Uniqueness in the caller's namespace (owner+name).
137 const [existing] = await db
138 .select()
139 .from(repositories)
140 .where(
141 and(eq(repositories.ownerId, ownerId), eq(repositories.name, safeName))
142 )
143 .limit(1);
144
145 if (existing) {
146 return {
147 status: "skipped-exists",
148 name: safeName,
149 notes: "Already exists in your namespace",
150 };
151 }
152
153 const destPath = join(config.gitReposPath, ownerUsername, `${safeName}.git`);
154 await mkdir(join(config.gitReposPath, ownerUsername), { recursive: true });
155
156 const authedCloneUrl = buildCloneUrl(cloneUrl, token);
157
158 const proc = Bun.spawn(
159 ["git", "clone", "--bare", "--mirror", authedCloneUrl, destPath],
160 {
161 stdout: "pipe",
162 stderr: "pipe",
163 env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
164 }
165 );
166 const stderr = await new Response(proc.stderr).text();
167 const exitCode = await proc.exited;
168
169 if (exitCode !== 0) {
170 return {
171 status: "failed",
172 name: safeName,
173 notes: `git clone failed: ${scrubSecrets(stderr, token).slice(0, 200)}`,
174 };
175 }
176
177 await db.insert(repositories).values({
178 name: safeName,
179 ownerId,
180 description,
181 isPrivate,
182 defaultBranch: defaultBranch || "main",
183 diskPath: destPath,
184 starCount: 0,
185 });
186
187 return { status: "success", name: safeName, notes: "Cloned + indexed" };
188 } catch (err) {
189 return {
190 status: "failed",
191 name: safeName,
192 notes: scrubSecrets(String(err), token).slice(0, 200),
193 };
194 }
195}