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.
| eae38d1 | 1 | /** |
| 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 | ||
| 30 | import { and, desc, eq, ilike, or, sql } from "drizzle-orm"; | |
| 31 | import { db } from "../db"; | |
| 32 | import { | |
| 33 | issues, | |
| 34 | pullRequests, | |
| 35 | repositories, | |
| 36 | users, | |
| 37 | } from "../db/schema"; | |
| 38 | ||
| 39 | // ---------- Types ---------- | |
| 40 | ||
| 41 | export interface GqlError { | |
| 42 | message: string; | |
| 43 | path?: string[]; | |
| 44 | } | |
| 45 | ||
| 46 | export interface GqlResponse { | |
| 47 | data?: Record<string, any> | null; | |
| 48 | errors?: GqlError[]; | |
| 49 | } | |
| 50 | ||
| 51 | type ArgValue = string | number | boolean | null; | |
| 52 | ||
| 53 | interface 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. */ | |
| 63 | export 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 | ||
| 85 | class 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 | ||
| 232 | type Resolver = ( | |
| 233 | args: Record<string, ArgValue>, | |
| 234 | sel: Field[], | |
| 235 | ctx: ExecCtx | |
| 236 | ) => Promise<any>; | |
| 237 | ||
| 238 | export interface ExecCtx { | |
| 239 | user: { id: string; username?: string } | null; | |
| 240 | } | |
| 241 | ||
| 242 | async 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 | ||
| 277 | const 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, | |
| 0316dbb | 313 | isPrivate: repositories.isPrivate, |
| eae38d1 | 314 | starCount: repositories.starCount, |
| 315 | createdAt: repositories.createdAt, | |
| 316 | }) | |
| 317 | .from(repositories) | |
| 318 | .where( | |
| 319 | and( | |
| 320 | eq(repositories.ownerId, u.id), | |
| 0316dbb | 321 | eq(repositories.isPrivate, false) |
| eae38d1 | 322 | ) |
| 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, | |
| 0316dbb | 339 | isPrivate: repositories.isPrivate, |
| eae38d1 | 340 | 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); | |
| 0316dbb | 350 | if (!r || r.isPrivate) return null; |
| eae38d1 | 351 | |
| 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( | |
| 0316dbb | 417 | eq(repositories.isPrivate, false), |
| eae38d1 | 418 | 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 | ||
| 435 | export 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 | } |