fix(health,mcp): a security score from 3% of the repo, and the last of the fake totals #5611
7 changed files+370−42
Modifiedsrc/__tests__/git-arg-injection.test.ts+13−0View fileUnifiedSplit
@@ -103,11 +103,24 @@ describe("guard is wired in", () => {
103103 // passed as an argument still has to be allowlisted.
104104 const STRING_PREDICATE =
105105 /\.(startsWith|endsWith|includes|indexOf|lastIndexOf|split|replace|replaceAll|match|search|trimStart|trimEnd)\(\s*$/;
106 // Prose is not an argument either. A comment explaining WHY a flag is not
107 // on the allowlist quotes that flag, and the collector cannot tell
108 // `` `-i` `` in a sentence from "-i" in an argv array — so documenting a
109 // guard would fail the guard's own test. Detected by the line the match
110 // sits on rather than by stripping comments, which would have to parse
111 // strings containing "//" correctly to avoid dropping a real flag.
112 const isCommentLine = (index: number) => {
113 const lineStart = SRC.lastIndexOf("\n", index) + 1;
114 const trimmed = SRC.slice(lineStart, index).trimStart();
115 return trimmed.startsWith("//") || trimmed.startsWith("*");
116 };
106117 const found = new Set<string>();
107118 const collect = (re: RegExp) => {
108119 for (const m of SRC.matchAll(re)) {
120 if (m.index === undefined) continue;
109121 const before = SRC.slice(0, m.index);
110122 if (STRING_PREDICATE.test(before)) continue;
123 if (isCommentLine(m.index)) continue;
111124 found.add(m[1]);
112125 }
113126 };
Addedsrc/__tests__/mcp-paging.test.ts+103−0View fileUnifiedSplit
@@ -0,0 +1,103 @@
1/**
2 * The paging contract, asserted directly.
3 *
4 * Every MCP listing tool returned `total: rows.length` — the size of the page
5 * it had just chosen, not the number of rows that matched. Two independent
6 * sessions proved it from opposite ends on the same day: one listed PRs with
7 * state "all", got `total: 50`, then fetched a real merged PR that was not in
8 * the listing; the other got `total: 5` for `limit=5` and `total: 20` for
9 * `limit=20` against a corpus of 36.
10 *
11 * `total` tracking `limit` is the dangerous half. `total: 50` at least looks
12 * like a round number worth questioning; `total: 5` when you asked for 5 reads
13 * as a complete and precise answer, and nothing signals the 31 rows you cannot
14 * see.
15 */
16import { describe, it, expect } from "bun:test";
17import { resolvePaging, pagedEnvelope } from "../lib/mcp-paging";
18
19const OPTS = { defaultLimit: 25, maxLimit: 100 };
20
21describe("resolvePaging — limit", () => {
22 it("defaults when absent", () => {
23 expect(resolvePaging(undefined, undefined, OPTS)).toEqual({ limit: 25, offset: 0 });
24 });
25
26 it("accepts a positive integer", () => {
27 expect(resolvePaging(10, undefined, OPTS).limit).toBe(10);
28 });
29
30 it("accepts the string form clients sometimes send", () => {
31 expect(resolvePaging("10", "5", OPTS)).toEqual({ limit: 10, offset: 5 });
32 });
33
34 it("clamps over the cap rather than refusing", () => {
35 // Asking for more than we serve is reasonable; `hasMore` says what came back.
36 expect(resolvePaging(1000, undefined, OPTS).limit).toBe(100);
37 });
38
39 it("refuses 0 instead of quietly returning one row", () => {
40 // The old clamp gave `limit: 0` one row and `limit: -5` zero rows: three
41 // behaviours for values that are all equally invalid.
42 expect(() => resolvePaging(0, undefined, OPTS)).toThrow();
43 });
44
45 it("refuses a negative limit", () => {
46 expect(() => resolvePaging(-5, undefined, OPTS)).toThrow();
47 });
48
49 it("refuses a fractional limit", () => {
50 expect(() => resolvePaging(2.5, undefined, OPTS)).toThrow();
51 });
52
53 it("ignores an unparseable limit rather than coercing it to a number", () => {
54 expect(resolvePaging("banana", undefined, OPTS).limit).toBe(25);
55 });
56});
57
58describe("resolvePaging — offset", () => {
59 it("defaults to 0", () => {
60 expect(resolvePaging(undefined, undefined, OPTS).offset).toBe(0);
61 });
62
63 it("accepts 0 explicitly — unlike limit, it is a meaningful value", () => {
64 expect(resolvePaging(undefined, 0, OPTS).offset).toBe(0);
65 });
66
67 it("refuses a negative offset", () => {
68 expect(() => resolvePaging(undefined, -1, OPTS)).toThrow();
69 });
70});
71
72describe("pagedEnvelope", () => {
73 const paging = { limit: 25, offset: 0 };
74
75 it("reports the MATCH count, not the page size", () => {
76 const e = pagedEnvelope({ total: 36, returned: 5, paging: { limit: 5, offset: 0 } });
77 expect(e.total).toBe(36);
78 expect(e.returned).toBe(5);
79 });
80
81 it("signals more pages and where to resume", () => {
82 const e = pagedEnvelope({ total: 36, returned: 25, paging });
83 expect(e.hasMore).toBe(true);
84 expect(e.nextOffset).toBe(25);
85 });
86
87 it("ends cleanly on the last page", () => {
88 const e = pagedEnvelope({ total: 36, returned: 11, paging: { limit: 25, offset: 25 } });
89 expect(e.hasMore).toBe(false);
90 expect(e.nextOffset).toBeNull();
91 });
92
93 it("a full page that exactly exhausts the matches is not 'more'", () => {
94 const e = pagedEnvelope({ total: 25, returned: 25, paging });
95 expect(e.hasMore).toBe(false);
96 expect(e.nextOffset).toBeNull();
97 });
98
99 it("an empty result is honest rather than looking like a full page", () => {
100 const e = pagedEnvelope({ total: 0, returned: 0, paging });
101 expect(e).toMatchObject({ total: 0, returned: 0, hasMore: false, nextOffset: null });
102 });
103});
Modifiedsrc/git/repository.ts+78−2View fileUnifiedSplit
@@ -1165,6 +1165,70 @@ export async function getBlame(
11651165 return lines;
11661166}
11671167
1168
1169/**
1170 * Every line at `ref` containing a token any security rule could match.
1171 *
1172 * The health scanner ran one `git show` per file, so covering a 3,090-file
1173 * repository meant 3,090 subprocesses. It was capped at 100 files instead —
1174 * and then scored the result as though it had read the repository. A security
1175 * category reporting 88/100 from 3.2% of the source is not measuring that
1176 * repository, however honestly it labels its coverage.
1177 *
1178 * One `git grep -n` returns the candidate LINES for the whole tree in a
1179 * single subprocess (measured: 12,574 lines, 1.2MB, 0.9s on this repo), so
1180 * the per-file read disappears entirely and coverage becomes complete.
1181 *
1182 * The token list is deliberately a SUPERSET of every rule in
1183 * SECURITY_PATTERNS: a line it fails to return is a finding silently lost, so
1184 * each entry is the shortest fragment common to a rule. Leading letters are
1185 * dropped where that is how case-insensitivity is bought — `assword` matches
1186 * `password` and `Password` alike, and `-i` is not on the git flag allowlist.
1187 * Broad costs a few thousand lines of output; narrow costs a missed
1188 * vulnerability.
1189 *
1190 * Returns null when git grep cannot answer, so the caller falls back to its
1191 * bounded scan rather than reading "no candidates" as "nothing to find".
1192 */
1193export async function securityCandidateLines(
1194 repoDir: string,
1195 ref: string
1196): Promise<{ path: string; line: string; lineNumber: number }[] | null> {
1197 const tokens = [
1198 "assword", "asswd", "pwd", "ecret", "oken", "redential",
1199 "pi_key", "piKey", "pi-key", "PI_KEY",
1200 "AKIA", "AGPA", "AIDA", "AROA", "AIPA", "ANPA", "ANVA", "ASIA",
1201 "PRIVATE KEY",
1202 "eval", "innerHTML", "ocument.write", "exec", "query", "raw",
1203 "createHash", "TODO", "slint", "ecurity",
1204 ];
1205 const cmd = ["git", "grep", "-I", "-n"];
1206 for (const t of tokens) cmd.push("-e", t);
1207 cmd.push(ref);
1208
1209 const { stdout, exitCode } = await exec(cmd, { cwd: repoDir });
1210 // git grep exits 1 for "no matches", which is an answer, not a failure.
1211 if (exitCode !== 0 && exitCode !== 1) return null;
1212 if (exitCode === 1) return [];
1213
1214 const prefix = `${ref}:`;
1215 const out: { path: string; line: string; lineNumber: number }[] = [];
1216 for (const raw of stdout.split("\n")) {
1217 if (!raw.startsWith(prefix)) continue;
1218 const rest = raw.slice(prefix.length);
1219 // `<path>:<lineno>:<content>` — split on the first ":<digits>:", since a
1220 // path may itself contain a colon.
1221 const m = rest.match(/^(.+?):(\d+):/);
1222 if (!m) continue;
1223 out.push({
1224 path: m[1],
1225 lineNumber: Number.parseInt(m[2], 10),
1226 line: rest.slice(m[0].length),
1227 });
1228 }
1229 return out;
1230}
1231
11681232export async function getRawBlob(
11691233 owner: string,
11701234 name: string,
@@ -1318,12 +1382,24 @@ export async function getTreeRecursive(
13181382 // hand it all back, so asking for one directory of a 4,226-entry repo
13191383 // returned 4,226 entries (~800KB) of which 9 were the ones requested.
13201384 // Filtering after the fact would fix the answer and keep the cost.
1385 const scope = subPath.replace(/^\/+|\/+$/g, "");
13211386 const cmd = ["git", "ls-tree", "-r", "-t", "-l", "--full-tree", ref];
1322 if (subPath) cmd.push(subPath.replace(/^\/+|\/+$/g, ""));
1387 if (scope) cmd.push(scope);
13231388 const { stdout, exitCode } = await exec(cmd, { cwd: path });
13241389 if (exitCode !== 0) return null;
13251390
1326 const lines = stdout.split("\n").filter(Boolean);
1391 // With `-t` and a pathspec, git also emits the ancestor trees leading to
1392 // that path, so a listing scoped to `packages/queue` carried `packages` and
1393 // `packages/queue` themselves among its "contents". They are the container,
1394 // not what is in it — and counting them reported `totalCount: 10` for 8
1395 // actual entries, the same class of wrong number as a `total` that counts
1396 // the page rather than the matches.
1397 const isAncestorOfScope = (entryPath: string) =>
1398 !!scope && (entryPath === scope || scope.startsWith(entryPath + "/"));
1399 const lines = stdout
1400 .split("\n")
1401 .filter(Boolean)
1402 .filter((l) => !isAncestorOfScope(l.split("\t")[1] ?? ""));
13271403 const totalCount = lines.length;
13281404 const truncated = totalCount > maxEntries;
13291405 const sliced = truncated ? lines.slice(0, maxEntries) : lines;
Modifiedsrc/lib/ai-commit-message.ts+40−6View fileUnifiedSplit
@@ -31,6 +31,22 @@ export type CommitStyle = "conventional" | "plain";
3131export interface CommitMessage {
3232 subject: string;
3333 body: string;
34 /**
35 * Which engine actually produced this message.
36 *
37 * `"heuristic"` is a deterministic fallback derived from the diff's file
38 * paths — useful, but it is not a model reading the change. Three separate
39 * paths returned it with nothing to say so: no API key configured, the
40 * model returning empty text, and any network or API failure. The result
41 * shape was identical to a real generation, so a caller could not tell
42 * `chore(src): update 1 file` from an AI's considered summary, and an agent
43 * chaining this into a commit had no way to know it should not.
44 *
45 * Optional so existing consumers keep compiling; always populated here.
46 */
47 source?: "ai" | "heuristic";
48 /** Why the heuristic was used. Present only when `source` is "heuristic". */
49 degradedReason?: string;
3450}
3551
3652export interface GenerateOptions {
@@ -261,11 +277,16 @@ export async function generateCommitMessage(
261277 return {
262278 subject: style === "conventional" ? "chore: empty commit" : "Empty commit",
263279 body: "",
280 source: "heuristic",
281 degradedReason: "the diff was empty, so there was nothing to summarise",
264282 };
265283 }
266284
267285 if (!isAiAvailable()) {
268 return heuristicMessage(trimmed, style);
286 return degraded(
287 heuristicMessage(trimmed, style),
288 "no Anthropic API key is configured on this host, so no model was called"
289 );
269290 }
270291
271292 const truncated = truncateDiff(trimmed);
@@ -300,15 +321,28 @@ export async function generateCommitMessage(
300321 }
301322 const text = extractText(message);
302323 if (!text.trim()) {
303 return heuristicMessage(trimmed, style);
324 return degraded(
325 heuristicMessage(trimmed, style),
326 "the model returned an empty response"
327 );
304328 }
305 return parseModelOutput(text, style);
306 } catch {
307 // Network/API failure → never block the developer; degrade.
308 return heuristicMessage(trimmed, style);
329 return { ...parseModelOutput(text, style), source: "ai" };
330 } catch (err) {
331 // Network/API failure → never block the developer; degrade. But SAY so:
332 // degrading silently is what made this indistinguishable from success.
333 console.error("[ai-commit-message] generation failed:", err);
334 return degraded(
335 heuristicMessage(trimmed, style),
336 `the model call failed: ${err instanceof Error ? err.message : String(err)}`
337 );
309338 }
310339}
311340
341/** Tag a heuristic result with why the model was not used. */
342function degraded(msg: CommitMessage, reason: string): CommitMessage {
343 return { ...msg, source: "heuristic", degradedReason: reason };
344}
345
312346/** Test-only exports — not part of the public API. */
313347export const __test = {
314348 truncateDiff,
Modifiedsrc/lib/intelligence.ts+82−24View fileUnifiedSplit
@@ -14,7 +14,7 @@
1414 * GitHub shows you code. Gluecron UNDERSTANDS your code.
1515 */
1616
17import { getRepoPath, getDefaultBranch } from "../git/repository";
17import { getRepoPath, getDefaultBranch, securityCandidateLines } from "../git/repository";
1818
1919export interface RepoHealthReport {
2020 score: number; // 0-100
@@ -33,6 +33,19 @@ export interface RepoHealthReport {
3333 sourceFilesEligible: number;
3434 sourceFilesScanned: number;
3535 truncated: boolean;
36 /**
37 * How the file set was chosen.
38 *
39 * "grep" — one `git grep -n` returned the candidate lines for the
40 * whole tree, so coverage is complete. "capped" — git grep could not
41 * answer, so the first N files were read and `truncated` says the
42 * rest were never looked at.
43 *
44 * Without this the two cases are indistinguishable from the numbers,
45 * and a score computed from 3% of a repository reads exactly like one
46 * computed from all of it.
47 */
48 method?: "grep" | "capped";
3649 };
3750 };
3851 testing: { score: number; hasTests: boolean; testFileCount: number; estimatedCoverage: string };
@@ -840,49 +853,94 @@ async function analyzeSecurityScore(
840853 // prove a repository is clean — it can only report what it looked at. That
841854 // distinction is now carried in the return value instead of being lost, so
842855 // generateInsights cannot turn "found nothing" into "there is nothing".
856 // Only reached when the git-grep prefilter cannot answer. Reading one file
857 // per subprocess is why this number is small; see candidateSecurityFiles.
843858 const SCAN_FILE_LIMIT = 100;
844859 const eligibleFiles = filePaths.filter((f) =>
845860 /\.(ts|tsx|js|jsx|py|rb|go|rs|java|php|sh|yaml|yml|json)$/.test(f)
846861 );
847 const sourceFiles = eligibleFiles.slice(0, SCAN_FILE_LIMIT);
862 // ONE `git grep -n` returns every candidate line in the whole tree, so the
863 // per-file read disappears and coverage becomes complete. The scanner used
864 // to read the first 100 of a repo's source files and score the result as
865 // though it had read the repository.
866 //
867 // The token list is a superset of every rule, so a line it does not return
868 // cannot contain a finding. When git grep cannot answer at all it returns
869 // null and we fall back to the bounded per-file scan and say so — "no
870 // candidates" must never be arrived at by way of "the tool failed".
871 const candidateLines = await securityCandidateLines(repoDir, ref);
872 const eligibleSet = new Set(eligibleFiles);
873
874 if (candidateLines !== null) {
875 // innerHTML assignments are judged as whole STATEMENTS, and a template
876 // literal routinely spans many lines, so those few files are read in
877 // full. Only files that actually contain an innerHTML assignment pay for
878 // it, which on a real repo is a handful rather than thousands.
879 const innerHtmlFiles = [
880 ...new Set(
881 candidateLines
882 .filter((c) => eligibleSet.has(c.path) && /innerHTML\s*=/.test(c.line))
883 .map((c) => c.path)
884 ),
885 ].slice(0, 100);
848886
849 for (const filePath of sourceFiles) {
850 const { stdout: content, exitCode } = await exec(
851 ["git", "show", `${ref}:${filePath}`],
852 repoDir
853 );
854 if (exitCode !== 0) continue;
855
856 const isFixture = isTestOrFixtureFile(filePath);
857 const lines = content.split("\n");
858 for (let i = 0; i < lines.length; i++) {
859 const line = lines[i];
860 if (line.includes("secrets-ok")) continue;
861 // Test/fixture data (e.g. a hardcoded TEST_PASSWORD for e2e login)
862 // isn't a real leak at any severity — this is a health heuristic,
863 // not the push-time secret gate.
864 if (isFixture) continue;
887 const fileLines = new Map<string, string[]>();
888 for (const filePath of innerHtmlFiles) {
889 const { stdout: content, exitCode } = await exec(
890 ["git", "show", `${ref}:${filePath}`],
891 repoDir
892 );
893 if (exitCode === 0) fileLines.set(filePath, content.split("\n"));
894 }
895
896 for (const c of candidateLines) {
897 if (!eligibleSet.has(c.path)) continue;
898 if (c.line.includes("secrets-ok")) continue;
899 if (isTestOrFixtureFile(c.path)) continue;
865900
901 const all = fileLines.get(c.path);
866902 issues.push(
867903 ...detectSecurityIssuesInLine({
868 filePath,
869 line,
870 lineNumber: i + 1,
871 statement: /innerHTML\s*=/.test(line)
872 ? joinStatement(lines, i)
873 : undefined,
904 filePath: c.path,
905 line: c.line,
906 lineNumber: c.lineNumber,
907 statement:
908 all && /innerHTML\s*=/.test(c.line)
909 ? joinStatement(all, c.lineNumber - 1)
910 : undefined,
874911 })
875912 );
876913 }
914
915 return {
916 score: scoreSecurityIssues(issues),
917 issues,
918 coverage: {
919 sourceFilesEligible: eligibleFiles.length,
920 // Every eligible file was covered by the grep. Files absent from the
921 // output were not skipped — they provably contain nothing any rule
922 // matches, which is why this is the eligible count and not the number
923 // of files that happened to produce a candidate line.
924 sourceFilesScanned: eligibleFiles.length,
925 truncated: false,
926 method: "grep",
927 },
928 };
877929 }
878930
931 const sourceFiles = eligibleFiles.slice(0, SCAN_FILE_LIMIT);
879932 return {
880933 score: scoreSecurityIssues(issues),
881934 issues,
882935 coverage: {
883936 sourceFilesEligible: eligibleFiles.length,
884937 sourceFilesScanned: sourceFiles.length,
938 // With the prefilter, every file that could match WAS read, so coverage
939 // is complete even though most of the tree was never opened — the
940 // skipped files provably contain nothing any rule matches. Truncation
941 // is only real on the fallback path.
885942 truncated: eligibleFiles.length > sourceFiles.length,
943 method: "capped",
886944 },
887945 };
888946}
Modifiedsrc/lib/mcp-tools-expanded.ts+26−5View fileUnifiedSplit
@@ -378,14 +378,15 @@ const searchRepos: McpToolHandler = {
378378 tool: {
379379 name: "gluecron_search_repos",
380380 description:
381 "Full-text search of public repositories by name/description. Mirrors GET /api/v2/search/repos. Returns ranked rows.",
381 "Full-text search of public repositories by name/description, case-insensitively. Mirrors GET /api/v2/search/repos. `total` is the full match count (not the page size) and `nextOffset` pages through it.",
382382 annotations: { title: "Search repositories (ranked)", readOnlyHint: true, destructiveHint: false },
383383 inputSchema: {
384384 type: "object",
385385 properties: {
386386 query: { type: "string", description: "Search keyword" },
387387 sort: { type: "string", description: "stars | updated | name (default: stars)" },
388 limit: { type: "number", description: "Max results (1-100, default 30)" },
388 limit: { type: "number", description: "Max rows per page (default 30, max 100)" },
389 offset: { type: "number", description: "Rows to skip. Use `nextOffset` from the previous response." },
389390 },
390391 required: ["query"],
391392 },
@@ -393,7 +394,10 @@ const searchRepos: McpToolHandler = {
393394 async run(args) {
394395 const q = mcpArgString(args, "query");
395396 const sort = mcpArgString(args, "sort", "stars");
396 const limit = Math.max(1, Math.min(100, mcpArgNumber(args, "limit", 30)));
397 const paging = resolvePaging(args.limit, args.offset, {
398 defaultLimit: 30,
399 maxLimit: 100,
400 });
397401 const orderBy =
398402 sort === "updated"
399403 ? desc(repositories.updatedAt)
@@ -422,9 +426,26 @@ const searchRepos: McpToolHandler = {
422426 )
423427 )
424428 .orderBy(orderBy)
425 .limit(limit);
429 .limit(paging.limit)
430 .offset(paging.offset);
431 // True match count — `rows.length` tracked `limit` exactly, so this tool
432 // answered "5" and "20" for the same corpus of 36 depending only on what
433 // the caller asked for.
434 const [{ n: matched }] = await db
435 .select({ n: sql<number>`count(*)::int` })
436 .from(repositories)
437 .innerJoin(users, eq(repositories.ownerId, users.id))
438 .where(
439 and(
440 eq(repositories.isPrivate, false),
441 or(
442 ilike(repositories.name, pattern),
443 ilike(repositories.description, pattern)
444 )
445 )
446 );
426447 return {
427 total: rows.length,
448 ...pagedEnvelope({ total: matched, returned: rows.length, paging }),
428449 repos: rows.map((r) => ({
429450 fullName: `${r.ownerName}/${r.name}`,
430451 description: r.description || "",
Modifiedsrc/lib/mcp-tools.ts+28−5View fileUnifiedSplit
@@ -274,13 +274,14 @@ const repoSearch: McpToolHandler = {
274274 tool: {
275275 name: "gluecron_repo_search",
276276 description:
277 "Search Gluecron repositories by keyword (name + description). Returns public repos plus any private repos owned by the authenticated caller. Up to 20 results.",
277 "Search Gluecron repositories by keyword (name + description), case-insensitively. Returns public repos plus any private repos owned by the authenticated caller. Default 20 per page, max 50; `total` is the full match count and `nextOffset` pages through it.",
278278 annotations: { title: "Search repositories", readOnlyHint: true, destructiveHint: false },
279279 inputSchema: {
280280 type: "object",
281281 properties: {
282282 query: { type: "string", description: "Search keyword (1-100 chars)" },
283 limit: { type: "number", description: "Max results, default 20" },
283 limit: { type: "number", description: "Max rows per page (default 20, max 50)" },
284 offset: { type: "number", description: "Rows to skip. Use `nextOffset` from the previous response." },
284285 },
285286 required: ["query"],
286287 },
@@ -290,7 +291,10 @@ const repoSearch: McpToolHandler = {
290291 if (q.length > 100) {
291292 throw new McpError(ERR_INVALID_PARAMS, "query too long (max 100 chars)");
292293 }
293 const limit = Math.max(1, Math.min(50, argNumber(args, "limit", 20)));
294 const paging = resolvePaging(args.limit, args.offset, {
295 defaultLimit: 20,
296 maxLimit: 50,
297 });
294298 const pattern = `%${q.replace(/[%_]/g, (m) => "\\" + m)}%`;
295299 // Visible = public, OR private-but-owned-by-the-authenticated-caller.
296300 const visibility = ctx.userId
@@ -316,9 +320,28 @@ const repoSearch: McpToolHandler = {
316320 )
317321 )
318322 .orderBy(desc(repositories.starCount))
319 .limit(limit);
323 .limit(paging.limit)
324 .offset(paging.offset);
325 // Count of every matching repo, not of the page. This was `rows.length`,
326 // which tracks `limit` exactly: asking for 5 reported `total: 5` and
327 // asking for 20 reported `total: 20`, on a corpus of 36. A round 50 at
328 // least invites suspicion; `total: 5` when you asked for 5 reads as a
329 // complete and precise answer, and nothing signals the other 31.
330 const [{ n: matched }] = await db
331 .select({ n: drizzleSql<number>`count(*)::int` })
332 .from(repositories)
333 .innerJoin(users, eq(repositories.ownerId, users.id))
334 .where(
335 and(
336 visibility,
337 or(
338 ilike(repositories.name, pattern),
339 ilike(repositories.description, pattern)
340 )
341 )
342 );
320343 return {
321 total: rows.length,
344 ...pagedEnvelope({ total: matched, returned: rows.length, paging }),
322345 repos: rows.map((r) => ({
323346 fullName: `${r.ownerName}/${r.name}`,
324347 description: r.description || "",
325348
c comment · e edit title · m merge · a approve · r request changes · ? shortcuts