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

graphql.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.

graphql.tsBlame488 lines · 1 contributor
eae38d1Claude1/**
2 * Block G2 — GraphQL mirror of REST.
3 *
4 * A deliberately-tiny, dependency-free GraphQL-over-HTTP handler. Supports
5 * a fixed set of query fields that mirror the existing REST endpoints:
6 *
7 * query {
8 * viewer { id username email }
9 * user(username:"...") { id username createdAt repos { id name visibility } }
10 * repository(owner:"...", name:"...") {
11 * id name description visibility starCount forkCount createdAt
12 * owner { id username }
13 * issues(state:"open", limit:20) { id number title state createdAt }
14 * pullRequests(state:"open", limit:20) { id number title state createdAt }
15 * }
16 * search(q:"...", limit:20) { id name ownerUsername }
17 * rateLimit { remaining reset }
18 * }
19 *
20 * No mutations — callers should use the REST + /api endpoints for writes.
21 * This keeps the attack surface small and sidesteps the need for an
22 * auth/authz layer beyond softAuth.
23 *
24 * The parser is a hand-rolled recursive descent over the subset of
25 * GraphQL syntax we actually support (selection sets, named fields,
26 * string/number/boolean/enum args). It's not a spec-complete parser —
27 * it rejects anything it doesn't understand with a friendly error.
28 */
29
30import { and, desc, eq, ilike, or, sql } from "drizzle-orm";
31import { db } from "../db";
32import {
33 issues,
34 pullRequests,
35 repositories,
36 users,
37} from "../db/schema";
38
39// ---------- Types ----------
40
41export interface GqlError {
42 message: string;
43 path?: string[];
44}
45
46export interface GqlResponse {
47 data?: Record<string, any> | null;
48 errors?: GqlError[];
49}
50
51type ArgValue = string | number | boolean | null;
52
53interface Field {
54 name: string;
55 alias?: string;
56 args: Record<string, ArgValue>;
57 selections: Field[];
58}
59
60// ---------- Parser ----------
61
62/** Parses a query. Returns either the top-level selection set or an error. */
63export function parseQuery(
64 src: string
65): { ok: true; fields: Field[] } | { ok: false; error: string } {
66 const s = src.trim();
67 try {
68 const p = new Parser(s);
69 p.skipWs();
70 // Optional "query" or "query Name" prefix.
71 if (p.peekWord() === "query") {
72 p.consumeWord("query");
73 p.skipWs();
74 // optional operation name
75 if (p.peek() && /[A-Za-z_]/.test(p.peek()!)) p.readName();
76 p.skipWs();
77 }
78 const fields = p.parseSelectionSet();
79 return { ok: true, fields };
80 } catch (err) {
81 return { ok: false, error: (err as Error).message };
82 }
83}
84
85class Parser {
86 private i = 0;
87 constructor(private src: string) {}
88
89 peek(): string | null {
90 return this.i < this.src.length ? this.src[this.i] : null;
91 }
92
93 peekWord(): string {
94 this.skipWs();
95 let j = this.i;
96 let out = "";
97 while (j < this.src.length && /[A-Za-z_]/.test(this.src[j])) {
98 out += this.src[j];
99 j++;
100 }
101 return out;
102 }
103
104 consumeWord(expected: string) {
105 this.skipWs();
106 const w = this.readName();
107 if (w !== expected) {
108 throw new Error(`expected '${expected}', got '${w}'`);
109 }
110 }
111
112 readName(): string {
113 this.skipWs();
114 let out = "";
115 while (this.i < this.src.length && /[A-Za-z0-9_]/.test(this.src[this.i])) {
116 out += this.src[this.i];
117 this.i++;
118 }
119 if (!out) throw new Error(`expected identifier at ${this.i}`);
120 return out;
121 }
122
123 expect(ch: string) {
124 this.skipWs();
125 if (this.src[this.i] !== ch) {
126 throw new Error(`expected '${ch}' at ${this.i}, got '${this.src[this.i] || "EOF"}'`);
127 }
128 this.i++;
129 }
130
131 skipWs() {
132 while (
133 this.i < this.src.length &&
134 /[\s,]/.test(this.src[this.i])
135 )
136 this.i++;
137 // Line comments (#)
138 if (this.src[this.i] === "#") {
139 while (this.i < this.src.length && this.src[this.i] !== "\n") this.i++;
140 this.skipWs();
141 }
142 }
143
144 parseSelectionSet(): Field[] {
145 this.skipWs();
146 this.expect("{");
147 const out: Field[] = [];
148 while (true) {
149 this.skipWs();
150 if (this.peek() === "}") {
151 this.i++;
152 return out;
153 }
154 out.push(this.parseField());
155 }
156 }
157
158 parseField(): Field {
159 this.skipWs();
160 const first = this.readName();
161 let alias: string | undefined;
162 let name = first;
163 this.skipWs();
164 if (this.peek() === ":") {
165 this.i++;
166 alias = first;
167 name = this.readName();
168 }
169 this.skipWs();
170 const args: Record<string, ArgValue> = {};
171 if (this.peek() === "(") {
172 this.i++;
173 while (true) {
174 this.skipWs();
175 if (this.peek() === ")") {
176 this.i++;
177 break;
178 }
179 const argName = this.readName();
180 this.skipWs();
181 this.expect(":");
182 const val = this.parseValue();
183 args[argName] = val;
184 }
185 }
186 this.skipWs();
187 let selections: Field[] = [];
188 if (this.peek() === "{") {
189 selections = this.parseSelectionSet();
190 }
191 return { name, alias, args, selections };
192 }
193
194 parseValue(): ArgValue {
195 this.skipWs();
196 const ch = this.peek();
197 if (ch === '"') {
198 this.i++;
199 let out = "";
200 while (this.i < this.src.length && this.src[this.i] !== '"') {
201 if (this.src[this.i] === "\\") {
202 this.i++;
203 out += this.src[this.i];
204 } else {
205 out += this.src[this.i];
206 }
207 this.i++;
208 }
209 this.expect('"');
210 return out;
211 }
212 // number
213 if (ch && /[0-9-]/.test(ch)) {
214 let out = "";
215 while (this.i < this.src.length && /[0-9.-]/.test(this.src[this.i])) {
216 out += this.src[this.i];
217 this.i++;
218 }
219 return Number(out);
220 }
221 // boolean / null / enum
222 const w = this.readName();
223 if (w === "true") return true;
224 if (w === "false") return false;
225 if (w === "null") return null;
226 return w; // enum-ish
227 }
228}
229
230// ---------- Executor ----------
231
232type Resolver = (
233 args: Record<string, ArgValue>,
234 sel: Field[],
235 ctx: ExecCtx
236) => Promise<any>;
237
238export interface ExecCtx {
239 user: { id: string; username?: string } | null;
240}
241
242async function resolveSelections(
243 obj: Record<string, any>,
244 selections: Field[]
245): Promise<Record<string, any>> {
246 if (!obj || selections.length === 0) return obj;
247 const out: Record<string, any> = {};
248 for (const s of selections) {
249 const key = s.alias || s.name;
250 const val = obj[s.name];
251 if (val == null) {
252 out[key] = null;
253 continue;
254 }
255 if (Array.isArray(val)) {
256 out[key] = val.map((v) =>
257 typeof v === "object" ? resolveSelections(v, s.selections) : v
258 );
259 // Awaits inside map are wrapped individually above (resolveSelections
260 // returns a promise only if we call it async). Normalise here:
261 out[key] = await Promise.all(
262 val.map((v) =>
263 typeof v === "object"
264 ? resolveSelections(v, s.selections)
265 : Promise.resolve(v)
266 )
267 );
268 } else if (typeof val === "object") {
269 out[key] = await resolveSelections(val, s.selections);
270 } else {
271 out[key] = val;
272 }
273 }
274 return out;
275}
276
277const ROOT: Record<string, Resolver> = {
278 viewer: async (_args, sel, ctx) => {
279 if (!ctx.user) return null;
280 const [u] = await db
281 .select({
282 id: users.id,
283 username: users.username,
284 email: users.email,
285 })
286 .from(users)
287 .where(eq(users.id, ctx.user.id))
288 .limit(1);
289 return u ? resolveSelections(u, sel) : null;
290 },
291
292 user: async (args, sel, _ctx) => {
293 const username = String(args.username || "");
294 if (!username) return null;
295 const [u] = await db
296 .select({
297 id: users.id,
298 username: users.username,
299 email: users.email,
300 createdAt: users.createdAt,
301 })
302 .from(users)
303 .where(eq(users.username, username))
304 .limit(1);
305 if (!u) return null;
306 const needsRepos = sel.some((s) => s.name === "repos");
307 let repos: any[] = [];
308 if (needsRepos) {
309 repos = await db
310 .select({
311 id: repositories.id,
312 name: repositories.name,
0316dbbClaude313 isPrivate: repositories.isPrivate,
eae38d1Claude314 starCount: repositories.starCount,
315 createdAt: repositories.createdAt,
316 })
317 .from(repositories)
318 .where(
319 and(
320 eq(repositories.ownerId, u.id),
0316dbbClaude321 eq(repositories.isPrivate, false)
eae38d1Claude322 )
323 )
324 .orderBy(desc(repositories.createdAt))
325 .limit(100);
326 }
327 return resolveSelections({ ...u, repos }, sel);
328 },
329
330 repository: async (args, sel, _ctx) => {
331 const owner = String(args.owner || "");
332 const name = String(args.name || "");
333 if (!owner || !name) return null;
334 const [r] = await db
335 .select({
336 id: repositories.id,
337 name: repositories.name,
338 description: repositories.description,
0316dbbClaude339 isPrivate: repositories.isPrivate,
eae38d1Claude340 starCount: repositories.starCount,
341 forkCount: repositories.forkCount,
342 createdAt: repositories.createdAt,
343 ownerId: repositories.ownerId,
344 ownerUsername: users.username,
345 })
346 .from(repositories)
347 .innerJoin(users, eq(repositories.ownerId, users.id))
348 .where(and(eq(users.username, owner), eq(repositories.name, name)))
349 .limit(1);
0316dbbClaude350 if (!r || r.isPrivate) return null;
eae38d1Claude351
352 const payload: Record<string, any> = {
353 ...r,
354 owner: { id: r.ownerId, username: r.ownerUsername },
355 };
356
357 const issuesSel = sel.find((s) => s.name === "issues");
358 if (issuesSel) {
359 const state = String(issuesSel.args.state || "open");
360 const limit = Math.min(100, Number(issuesSel.args.limit || 20));
361 payload.issues = await db
362 .select({
363 id: issues.id,
364 number: issues.number,
365 title: issues.title,
366 state: issues.state,
367 createdAt: issues.createdAt,
368 })
369 .from(issues)
370 .where(
371 and(eq(issues.repositoryId, r.id), eq(issues.state, state))
372 )
373 .orderBy(desc(issues.createdAt))
374 .limit(limit);
375 }
376
377 const prSel = sel.find((s) => s.name === "pullRequests");
378 if (prSel) {
379 const state = String(prSel.args.state || "open");
380 const limit = Math.min(100, Number(prSel.args.limit || 20));
381 payload.pullRequests = await db
382 .select({
383 id: pullRequests.id,
384 number: pullRequests.number,
385 title: pullRequests.title,
386 state: pullRequests.state,
387 createdAt: pullRequests.createdAt,
388 })
389 .from(pullRequests)
390 .where(
391 and(
392 eq(pullRequests.repositoryId, r.id),
393 eq(pullRequests.state, state)
394 )
395 )
396 .orderBy(desc(pullRequests.createdAt))
397 .limit(limit);
398 }
399
400 return resolveSelections(payload, sel);
401 },
402
403 search: async (args, sel, _ctx) => {
404 const q = String(args.q || "").trim();
405 if (!q) return [];
406 const limit = Math.min(50, Number(args.limit || 20));
407 const rows = await db
408 .select({
409 id: repositories.id,
410 name: repositories.name,
411 ownerUsername: users.username,
412 })
413 .from(repositories)
414 .innerJoin(users, eq(repositories.ownerId, users.id))
415 .where(
416 and(
0316dbbClaude417 eq(repositories.isPrivate, false),
eae38d1Claude418 or(
419 ilike(repositories.name, `%${q}%`),
420 ilike(repositories.description, `%${q}%`)
421 )!
422 )
423 )
424 .limit(limit);
425 return Promise.all(rows.map((r) => resolveSelections(r, sel)));
426 },
427
428 rateLimit: async (_args, _sel, _ctx) => {
429 // GraphQL doesn't share the REST rate-limit state directly. Surface a
430 // permissive synthetic window so clients can consume the shape.
431 return { remaining: 1000, reset: Math.floor(Date.now() / 1000) + 3600 };
432 },
433};
434
435export async function execute(
436 src: string,
437 ctx: ExecCtx
438): Promise<GqlResponse> {
439 const parsed = parseQuery(src);
440 if (!parsed.ok) {
441 return { errors: [{ message: parsed.error }] };
442 }
443 const out: Record<string, any> = {};
444 const errors: GqlError[] = [];
445 for (const field of parsed.fields) {
446 const resolver = ROOT[field.name];
447 const key = field.alias || field.name;
448 if (!resolver) {
449 errors.push({
450 message: `Unknown root field '${field.name}'`,
451 path: [key],
452 });
453 out[key] = null;
454 continue;
455 }
456 try {
457 const raw = await resolver(field.args, field.selections, ctx);
458 if (raw == null) {
459 out[key] = null;
460 continue;
461 }
462 if (Array.isArray(raw)) {
463 out[key] = await Promise.all(
464 raw.map((v) =>
465 typeof v === "object"
466 ? resolveSelections(v, field.selections)
467 : Promise.resolve(v)
468 )
469 );
470 } else if (typeof raw === "object") {
471 // resolver may already have expanded object to selection set; if not,
472 // do it now.
473 out[key] = await resolveSelections(raw, field.selections);
474 } else {
475 out[key] = raw;
476 }
477 } catch (err) {
478 errors.push({
479 message: (err as Error).message || "execution error",
480 path: [key],
481 });
482 out[key] = null;
483 }
484 }
485 const response: GqlResponse = { data: out };
486 if (errors.length > 0) response.errors = errors;
487 return response;
488}