fix(mcp): totals that only counted the page, and an XSS rule that could not read escaping #5610
5 changed files+414−41
Modifiedsrc/__tests__/health-scanner-accuracy.test.ts+88−0View fileUnifiedSplit
@@ -135,3 +135,91 @@ describe("complexity: the mega-file penalty is bounded and size-relative", () =>
135135 expect(megaFilePenalty(10, 100)).toBe(megaFilePenalty(100, 1000));
136136 });
137137});
138
139describe("no-inner-html: concatenation and multi-line statements", () => {
140 it("clears a literal concatenated with an escaped value", () => {
141 // The real shape that survived the first fix:
142 // out.innerHTML = '<div style="...">' + escHtml(e.message) + '</div>';
143 expect(
144 innerHtmlAssignmentIsSafe(
145 `out.innerHTML = '<div style="color:var(--red)">' + escHtml(e.message) + '</div>';`
146 )
147 ).toBe(true);
148 });
149
150 it("still reports a literal concatenated with a RAW value", () => {
151 expect(
152 innerHtmlAssignmentIsSafe("out.innerHTML = '<div>' + e.message + '</div>';")
153 ).toBe(false);
154 });
155
156 it("still reports when only some operands of a concatenation are escaped", () => {
157 expect(
158 innerHtmlAssignmentIsSafe("el.innerHTML = escHtml(a) + b + escHtml(c);")
159 ).toBe(false);
160 });
161
162 it("clears a multi-line template whose every interpolation is escaped", () => {
163 const stmt = [
164 "card.innerHTML = `",
165 ' <div class="box-header">',
166 " <span>${escHtml(b.boxName)}</span>",
167 " <span>${escHtml(b.label)}</span>",
168 " </div>",
169 "`;",
170 ].join("\n");
171 expect(innerHtmlAssignmentIsSafe(stmt)).toBe(true);
172 });
173
174 it("reports a multi-line template with one raw interpolation in its body", () => {
175 // The suppression must not be fooled by a body that is mostly escaped —
176 // one unescaped value anywhere is the whole vulnerability.
177 const stmt = [
178 "card.innerHTML = `",
179 " <span>${escHtml(b.boxName)}</span>",
180 " <span>${b.rawLabel}</span>",
181 "`;",
182 ].join("\n");
183 expect(innerHtmlAssignmentIsSafe(stmt)).toBe(false);
184 });
185
186 it("treats a template that never closes within the window as unsafe", () => {
187 expect(
188 innerHtmlAssignmentIsSafe("card.innerHTML = `\n <div>${escHtml(x)}</div>")
189 ).toBe(false);
190 });
191
192 it("handles a nested literal inside an interpolation", () => {
193 expect(
194 innerHtmlAssignmentIsSafe(
195 "el.innerHTML = `<b>${escHtml(a ? '}' : '{')}</b>`;"
196 )
197 ).toBe(true);
198 });
199});
200
201describe("no-inner-html: adversarial cases from the GateTest engine", () => {
202 // Contributed by the GateTest session, which hit the same problem from the
203 // other end and had its own predicate fail two of these. Kept verbatim as
204 // the contract: a second engine's failures are cheaper than our own.
205
206 it("an escaper whose ARGUMENT contains + is safe", () => {
207 // A naive split('+') cuts through the escaper's own argument list.
208 expect(innerHtmlAssignmentIsSafe("el.innerHTML = escapeHtml(a + b);")).toBe(true);
209 });
210
211 it("an UNBALANCED expression is never cleared", () => {
212 // The dangerous direction: a false negative silences a real finding on
213 // input the rule could not parse, and unlike a false positive nothing
214 // shows up to say so.
215 expect(innerHtmlAssignmentIsSafe("el.innerHTML = escapeHtml(name;")).toBe(false);
216 });
217
218 it("partial escaping still fires", () => {
219 expect(innerHtmlAssignmentIsSafe("el.innerHTML = escapeHtml(a) + b;")).toBe(false);
220 });
221
222 it("a + inside a string literal does not split the expression", () => {
223 expect(innerHtmlAssignmentIsSafe('el.innerHTML = "a + b" + userInput;')).toBe(false);
224 });
225});
Modifiedsrc/lib/intelligence.ts+158−29View fileUnifiedSplit
@@ -600,62 +600,178 @@ function isEscapedInterpolation(expr: string): boolean {
600600 * Exported so the rule can be asserted directly, for the same reason
601601 * `isCommittedEnvSecret` is: these defects are invisible from the call site.
602602 */
603export function innerHtmlAssignmentIsSafe(line: string): boolean {
604 const assign = line.match(/innerHTML\s*=\s*/);
603export function innerHtmlAssignmentIsSafe(statement: string): boolean {
604 const assign = statement.match(/innerHTML\s*=\s*/);
605605 if (!assign || assign.index === undefined) return false;
606 const rhs = line.slice(assign.index + assign[0].length).trim();
606 let rhs = statement.slice(assign.index + assign[0].length);
607
608 // Inline scripts inside a server-rendered page are themselves written in a
609 // template literal, so their backticks and interpolations arrive escaped:
610 // the source text reads ``card.innerHTML = \`…\${escHtml(x)}\`;``. Read
611 // literally, that opens with a backslash and the literal never parses, so
612 // a fully escaped template read as unverifiable. Unescaped only when the
613 // right-hand side actually opens with an escaped backtick, so this cannot
614 // change how any ordinary assignment is read.
615 if (/^\\`/.test(rhs.trimStart())) {
616 rhs = rhs.replace(/\\`/g, "`").replace(/\\\$\{/g, "${");
617 }
607618
608 const quote = rhs[0];
609 if (quote !== '"' && quote !== "'" && quote !== "`") return false;
619 // The right-hand side is a `+` chain of operands. Every operand must be
620 // independently safe, so `'<b>' + escHtml(x) + '</b>'` clears while
621 // `'<b>' + x` does not. Concatenation is the form real code uses at least
622 // as often as interpolation and was previously unhandled entirely.
623 let i = 0;
624 const skipWs = () => {
625 while (i < rhs.length && /\s/.test(rhs[i])) i++;
626 };
627
628 for (;;) {
629 skipWs();
630 if (i >= rhs.length) return false; // nothing where an operand belongs
610631
611 const interpolations: string[] = [];
612 let i = 1;
613 let closed = false;
614 while (i < rhs.length) {
615632 const ch = rhs[i];
633 if (ch === '"' || ch === "'" || ch === "`") {
634 const lit = readLiteral(rhs, i);
635 if (!lit) return false; // literal never terminates — unverifiable
636 if (!lit.interpolations.every(isEscapedInterpolation)) return false;
637 i = lit.end;
638 } else {
639 const call = readCall(rhs, i);
640 if (!call || !isEscaperName(call.callee)) return false;
641 i = call.end;
642 }
643
644 skipWs();
645 if (i >= rhs.length) return true;
646 if (rhs[i] === "+") {
647 i++;
648 continue;
649 }
650 // Anything else terminates the statement. A trailing `;`, or a comment,
651 // is fine; an operator we do not model is not, because we cannot say what
652 // it does to the value.
653 const rest = rhs.slice(i).replace(/^;/, "").trim();
654 return rest === "" || rest.startsWith("//") || rest.startsWith("/*");
655 }
656}
657
658/** Is this callee name a recognised escaper/sanitiser? */
659function isEscaperName(callee: string): boolean {
660 return /esc|saniti[sz]|purif/i.test(callee);
661}
662
663/**
664 * Read a complete string or template literal starting at `s[start]`.
665 *
666 * Returns null when the literal does not terminate — an unterminated literal
667 * is a statement we cannot see the end of, and unverifiable must never read
668 * as clean.
669 */
670function readLiteral(
671 s: string,
672 start: number
673): { end: number; interpolations: string[] } | null {
674 const quote = s[start];
675 const interpolations: string[] = [];
676 let i = start + 1;
677 while (i < s.length) {
678 const ch = s[i];
616679 if (ch === "\\") {
617680 i += 2;
618681 continue;
619682 }
620 if (ch === quote) {
621 i++;
622 closed = true;
623 break;
624 }
625 if (quote === "`" && ch === "$" && rhs[i + 1] === "{") {
683 if (ch === quote) return { end: i + 1, interpolations };
684 if (quote === "`" && ch === "$" && s[i + 1] === "{") {
626685 let depth = 1;
627686 let j = i + 2;
628 while (j < rhs.length && depth > 0) {
629 if (rhs[j] === "{") depth++;
630 else if (rhs[j] === "}") {
687 while (j < s.length && depth > 0) {
688 // A nested literal inside the interpolation can contain braces that
689 // are not block delimiters, so it is consumed whole.
690 if (s[j] === '"' || s[j] === "'" || s[j] === "`") {
691 const nested = readLiteral(s, j);
692 if (!nested) return null;
693 for (const n of nested.interpolations) interpolations.push(n);
694 j = nested.end;
695 continue;
696 }
697 if (s[j] === "{") depth++;
698 else if (s[j] === "}") {
631699 depth--;
632700 if (depth === 0) break;
633701 }
634702 j++;
635703 }
636 if (depth !== 0) return false; // interpolation continues past this line
637 interpolations.push(rhs.slice(i + 2, j));
704 if (depth !== 0) return null;
705 interpolations.push(s.slice(i + 2, j));
638706 i = j + 1;
639707 continue;
640708 }
641709 i++;
642710 }
643 if (!closed) return false; // literal continues onto the next line
711 return null;
712}
644713
645 // Nothing may follow the literal — a trailing `+ userInput` would make the
646 // assignment unsafe while the literal itself looked fine.
647 const rest = rhs.slice(i).replace(/^;/, "").trim();
648 if (rest !== "" && !rest.startsWith("//")) return false;
714/** Read a complete `name(...)` / `a.b(...)` call starting at `s[start]`. */
715function readCall(
716 s: string,
717 start: number
718): { callee: string; end: number } | null {
719 const head = s.slice(start).match(/^([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)\s*\(/);
720 if (!head) return null;
721 let depth = 0;
722 for (let i = start + head[0].length - 1; i < s.length; i++) {
723 const ch = s[i];
724 if (ch === '"' || ch === "'" || ch === "`") {
725 const nested = readLiteral(s, i);
726 if (!nested) return null;
727 i = nested.end - 1;
728 continue;
729 }
730 if (ch === "(") depth++;
731 else if (ch === ")") {
732 depth--;
733 if (depth === 0) return { callee: head[1], end: i + 1 };
734 }
735 }
736 return null;
737}
649738
650 return interpolations.every(isEscapedInterpolation);
739/**
740 * Join `lines[start]` with the lines that continue it, up to a cap.
741 *
742 * Only used for innerHTML assignments. A dashboard renders a card with a
743 * multi-line template literal, and judging that from its first line alone
744 * means judging `card.innerHTML = \`` — a literal with no visible end, which
745 * the predicate must treat as unsafe. Reading forward lets a genuinely
746 * escaped multi-line template be recognised as safe, while one containing a
747 * single raw interpolation anywhere in its body is still reported.
748 *
749 * The cap is a bound on cost, not a judgement: if the statement has not
750 * closed within it, the predicate sees an unterminated literal and reports —
751 * which is the same answer it gave before, so the cap can only ever leave a
752 * false positive in place, never create a false negative.
753 */
754function joinStatement(lines: string[], start: number, maxLines = 60): string {
755 const end = Math.min(lines.length, start + maxLines);
756 return lines.slice(start, end).join("\n");
651757}
652758
653759export function detectSecurityIssuesInLine(args: {
654760 filePath: string;
655761 line: string;
656762 lineNumber: number;
763 /**
764 * The full statement this line begins, when the caller can supply it.
765 *
766 * Every rule matches on `line`; only the innerHTML suppression consults
767 * this, because a template literal assigned to innerHTML routinely spans
768 * many lines and a line-based view cannot see where it ends — which the
769 * predicate correctly treats as unverifiable, i.e. a finding. Giving it the
770 * whole statement turns "cannot tell" into an actual answer.
771 */
772 statement?: string;
657773}): SecurityIssue[] {
658 const { filePath, line, lineNumber } = args;
774 const { filePath, line, lineNumber, statement } = args;
659775 const found: SecurityIssue[] = [];
660776 if (line.includes("secrets-ok")) return found;
661777
@@ -673,7 +789,13 @@ export function detectSecurityIssuesInLine(args: {
673789 if (!candidate || !looksLikeRealSecret(candidate)) continue;
674790 }
675791
676 if (rule.rule === "no-inner-html" && innerHtmlAssignmentIsSafe(line)) continue;
792 if (
793 rule.rule === "no-inner-html" &&
794 (innerHtmlAssignmentIsSafe(line) ||
795 (statement !== undefined && innerHtmlAssignmentIsSafe(statement)))
796 ) {
797 continue;
798 }
677799
678800 found.push({
679801 severity: rule.severity,
@@ -742,7 +864,14 @@ async function analyzeSecurityScore(
742864 if (isFixture) continue;
743865
744866 issues.push(
745 ...detectSecurityIssuesInLine({ filePath, line, lineNumber: i + 1 })
867 ...detectSecurityIssuesInLine({
868 filePath,
869 line,
870 lineNumber: i + 1,
871 statement: /innerHTML\s*=/.test(line)
872 ? joinStatement(lines, i)
873 : undefined,
874 })
746875 );
747876 }
748877 }
Addedsrc/lib/mcp-paging.ts+107−0View fileUnifiedSplit
@@ -0,0 +1,107 @@
1/**
2 * One definition of paging for every MCP tool that returns a list.
3 *
4 * WHY THIS FILE EXISTS. Every listing tool returned `total: rows.length` — the
5 * number of rows it had just decided to return, not the number that matched.
6 * With a hard cap of 50 and no offset, cursor or limit on `gluecron_list_prs`,
7 * a repository with thousands of PRs reported `total: 50` and everything past
8 * the fiftieth was unreachable. The Vapron session proved it the only way that
9 * is provable: it listed PRs with `state:"all"`, got `total: 50`, then fetched
10 * PR #74 directly and got a real merged PR that was not in the listing.
11 *
12 * `total` tracking `limit` is the dangerous part. An agent that asks for
13 * everything, is told it received everything, and acts on a truncated set has
14 * no signal that anything is missing — the same "confident answer to a
15 * question it did not understand" shape as the state filter and the ref
16 * fallback. A cap is fine. A cap that reports itself as the total is not.
17 *
18 * Shared rather than reimplemented per tool, for the reason state-filters.ts
19 * gives: four copies of a contract is how one of them drifts.
20 */
21import { McpError, ERR_INVALID_PARAMS } from "./mcp";
22
23export type Paging = { limit: number; offset: number };
24
25/**
26 * Resolve `limit` / `offset` arguments, refusing values that cannot be
27 * honoured rather than silently reinterpreting them.
28 *
29 * The old clamp was `Math.max(1, Math.min(max, n))`, which gave `limit: 0` one
30 * row (not zero) and `limit: -5` zero rows (not one) — three behaviours for
31 * values that are all equally invalid. A caller passing 0 means something, and
32 * whatever it means, one row is not it.
33 */
34export function resolvePaging(
35 rawLimit: unknown,
36 rawOffset: unknown,
37 opts: { defaultLimit: number; maxLimit: number }
38): Paging {
39 const num = (v: unknown): number | undefined => {
40 if (typeof v === "number" && Number.isFinite(v)) return v;
41 if (typeof v === "string" && /^-?\d+$/.test(v.trim())) {
42 return Number.parseInt(v.trim(), 10);
43 }
44 return undefined;
45 };
46
47 const l = num(rawLimit);
48 let limit = opts.defaultLimit;
49 if (l !== undefined) {
50 if (!Number.isInteger(l) || l < 1) {
51 throw new McpError(
52 ERR_INVALID_PARAMS,
53 `limit must be a positive integer (got ${JSON.stringify(rawLimit)})`
54 );
55 }
56 // Over the cap is clamped, not refused: asking for more than we serve is
57 // a reasonable thing to do, and `total` + `hasMore` tell the caller what
58 // actually came back. Documented on each tool as the real maximum.
59 limit = Math.min(l, opts.maxLimit);
60 }
61
62 const o = num(rawOffset);
63 let offset = 0;
64 if (o !== undefined) {
65 if (!Number.isInteger(o) || o < 0) {
66 throw new McpError(
67 ERR_INVALID_PARAMS,
68 `offset must be a non-negative integer (got ${JSON.stringify(rawOffset)})`
69 );
70 }
71 offset = o;
72 }
73
74 return { limit, offset };
75}
76
77/**
78 * Build the paging envelope.
79 *
80 * `total` is the count of rows MATCHING THE FILTER, independent of the page.
81 * `returned` is how many are in this response — the number the old `total`
82 * actually held. Both are present because a caller needs to compare them.
83 */
84export function pagedEnvelope(args: {
85 total: number;
86 returned: number;
87 paging: Paging;
88}): {
89 total: number;
90 returned: number;
91 offset: number;
92 limit: number;
93 hasMore: boolean;
94 nextOffset: number | null;
95} {
96 const { total, returned, paging } = args;
97 const consumed = paging.offset + returned;
98 const hasMore = consumed < total;
99 return {
100 total,
101 returned,
102 offset: paging.offset,
103 limit: paging.limit,
104 hasMore,
105 nextOffset: hasMore ? consumed : null,
106 };
107}
Modifiedsrc/lib/mcp-tools-expanded.ts+30−6View fileUnifiedSplit
@@ -74,6 +74,7 @@ import {
7474} from "../git/repository";
7575import { resolveReadRefOrThrow } from "./mcp-refs";
7676import { stateFilter, ISSUE_STATES, PR_STATES } from "./state-filters";
77import { resolvePaging, pagedEnvelope } from "./mcp-paging";
7778import { join } from "path";
7879import { mkdir, unlink, rm } from "fs/promises";
7980import { config } from "./config";
@@ -631,7 +632,10 @@ const searchIssues: McpToolHandler = {
631632 const repo = mcpArgString(args, "repo");
632633 const q = mcpArgString(args, "query");
633634 const state = mcpArgString(args, "state", "open");
634 const limit = Math.max(1, Math.min(100, mcpArgNumber(args, "limit", 25)));
635 const paging = resolvePaging(args.limit, args.offset, {
636 defaultLimit: 25,
637 maxLimit: 100,
638 });
635639 const info = await mcpResolveAccessibleRepo(owner, repo, ctx.userId);
636640 const pattern = `%${q.replace(/[%_]/g, (m) => "\\" + m)}%`;
637641 // Routed through the shared resolver rather than re-inlined. This handler
@@ -660,9 +664,15 @@ const searchIssues: McpToolHandler = {
660664 )
661665 )
662666 .orderBy(desc(issues.createdAt))
663 .limit(limit);
667 .limit(paging.limit)
668 .offset(paging.offset);
669 // Count of every matching row, not of the page — see mcp-paging.ts.
670 const [{ n: matched }] = await db
671 .select({ n: sql<number>`count(*)::int` })
672 .from(issues)
673 .where(and(stateClause, or(ilike(issues.title, pattern), ilike(issues.body, pattern))));
664674 return {
665 total: rows.length,
675 ...pagedEnvelope({ total: matched, returned: rows.length, paging }),
666676 issues: rows.map((r) => ({
667677 number: r.number,
668678 title: r.title,
@@ -743,7 +753,10 @@ const searchPrs: McpToolHandler = {
743753 const repo = mcpArgString(args, "repo");
744754 const q = mcpArgString(args, "query");
745755 const state = mcpArgString(args, "state", "open");
746 const limit = Math.max(1, Math.min(100, mcpArgNumber(args, "limit", 25)));
756 const paging = resolvePaging(args.limit, args.offset, {
757 defaultLimit: 25,
758 maxLimit: 100,
759 });
747760 const info = await mcpResolveAccessibleRepo(owner, repo, ctx.userId);
748761 const pattern = `%${q.replace(/[%_]/g, (m) => "\\" + m)}%`;
749762 // Same shared resolver as gluecron_search_issues — see the note there.
@@ -769,9 +782,20 @@ const searchPrs: McpToolHandler = {
769782 )
770783 )
771784 .orderBy(desc(pullRequests.createdAt))
772 .limit(limit);
785 .limit(paging.limit)
786 .offset(paging.offset);
787 // Count of every matching row, not of the page — see mcp-paging.ts.
788 const [{ n: matched }] = await db
789 .select({ n: sql<number>`count(*)::int` })
790 .from(pullRequests)
791 .where(
792 and(
793 stateClause,
794 or(ilike(pullRequests.title, pattern), ilike(pullRequests.body, pattern))
795 )
796 );
773797 return {
774 total: rows.length,
798 ...pagedEnvelope({ total: matched, returned: rows.length, paging }),
775799 prs: rows.map((p) => ({
776800 number: p.number,
777801 title: p.title,
Modifiedsrc/lib/mcp-tools.ts+31−6View fileUnifiedSplit
@@ -40,6 +40,7 @@ import { performGatedMerge } from "./pr-merge-gated";
4040import { expandedTools } from "./mcp-tools-expanded";
4141import { stateFilter, PR_STATES } from "./state-filters";
4242import { resolveReadRefOrThrow } from "./mcp-refs";
43import { resolvePaging, pagedEnvelope } from "./mcp-paging";
4344
4445/**
4546 * MCP spec ToolAnnotations — behavioural hints surfaced to clients via
@@ -398,7 +399,8 @@ const repoListIssues: McpToolHandler = {
398399 properties: {
399400 owner: { type: "string", description: "Repo owner username" },
400401 repo: { type: "string", description: "Repo name" },
401 limit: { type: "number", description: "Max results, default 25" },
402 limit: { type: "number", description: "Max rows per page (default 25, max 50)" },
403 offset: { type: "number", description: "Rows to skip, for paging. Use `nextOffset` from the previous response." },
402404 },
403405 required: ["owner", "repo"],
404406 },
@@ -406,7 +408,10 @@ const repoListIssues: McpToolHandler = {
406408 async run(args, ctx) {
407409 const owner = argString(args, "owner");
408410 const repo = argString(args, "repo");
409 const limit = Math.max(1, Math.min(50, argNumber(args, "limit", 25)));
411 const paging = resolvePaging(args.limit, args.offset, {
412 defaultLimit: 25,
413 maxLimit: 50,
414 });
410415
411416 const r = await resolveReadableRepo(owner, repo, ctx);
412417
@@ -421,9 +426,14 @@ const repoListIssues: McpToolHandler = {
421426 .from(issues)
422427 .where(and(eq(issues.repositoryId, r.id), eq(issues.state, "open")))
423428 .orderBy(desc(issues.createdAt))
424 .limit(limit);
429 .limit(paging.limit)
430 .offset(paging.offset);
431 const [{ n: matched }] = await db
432 .select({ n: drizzleSql<number>`count(*)::int` })
433 .from(issues)
434 .where(and(eq(issues.repositoryId, r.id), eq(issues.state, "open")));
425435 return {
426 total: rows.length,
436 ...pagedEnvelope({ total: matched, returned: rows.length, paging }),
427437 issues: rows.map((i) => ({
428438 number: i.number,
429439 title: i.title,
@@ -1157,6 +1167,8 @@ const listPrs: McpToolHandler = {
11571167 type: "string",
11581168 description: "open | closed | merged | all (default: open)",
11591169 },
1170 limit: { type: "number", description: "Max rows per page (default 50, max 100)" },
1171 offset: { type: "number", description: "Rows to skip, for paging. Use `nextOffset` from the previous response." },
11601172 },
11611173 required: ["owner", "repo"],
11621174 },
@@ -1165,6 +1177,10 @@ const listPrs: McpToolHandler = {
11651177 const owner = argString(args, "owner");
11661178 const repo = argString(args, "repo");
11671179 const state = argString(args, "state", "open");
1180 const paging = resolvePaging(args.limit, args.offset, {
1181 defaultLimit: 50,
1182 maxLimit: 100,
1183 });
11681184 // Shared with the REST list endpoints (src/lib/state-filters.ts). This
11691185 // tool had the contract right while GET /api/v2/.../pulls did not, and
11701186 // nothing compared them — see that file's header.
@@ -1200,10 +1216,19 @@ const listPrs: McpToolHandler = {
12001216 .innerJoin(users, eq(pullRequests.authorId, users.id))
12011217 .where(whereClause)
12021218 .orderBy(desc(pullRequests.createdAt))
1203 .limit(50);
1219 .limit(paging.limit)
1220 .offset(paging.offset);
1221
1222 // The COUNT is of every row matching the filter, not of the page. This
1223 // used to be `rows.length`, so a repo with thousands of PRs reported
1224 // `total: 50` and the caller had no way to learn otherwise.
1225 const [{ n: matched }] = await db
1226 .select({ n: drizzleSql<number>`count(*)::int` })
1227 .from(pullRequests)
1228 .where(whereClause);
12041229
12051230 return {
1206 total: rows.length,
1231 ...pagedEnvelope({ total: matched, returned: rows.length, paging }),
12071232 prs: rows.map((p) => ({
12081233 number: p.number,
12091234 title: p.title,
12101235
c comment · e edit title · m merge · a approve · r request changes · ? shortcuts