Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 19 additions & 2 deletions src/providers/github/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -757,7 +757,8 @@ export const githubActions: ActionDefinition[] = [
}),
action({
name: "list_repository_issues",
description: "List issues for a GitHub repository. Pull requests are filtered out from the response.",
description:
"List issues for a GitHub repository. Pull requests are filtered out of the response; pageInfo.fetched reports the raw page length before filtering, so paginating callers must continue while fetched equals perPage (30 by default) even when the issues array comes back short or empty.",
requiredScopes: githubRepoScopes,
inputSchema: s.object({
owner: nonEmptyString,
Expand All @@ -767,10 +768,26 @@ export const githubActions: ActionDefinition[] = [
sort: s.stringEnum(["created", "updated", "comments"]),
direction: s.stringEnum(["asc", "desc"]),
since: s.string(),
...optionalPaginationFields,
perPage: s.integer({
minimum: 1,
maximum: 100,
default: 30,
description: "Number of results requested per page. Defaults to 30.",
}),
page: optionalPaginationFields.page,
}),
outputSchema: s.object({
issues: s.array(githubIssueSchema),
pageInfo: s.requiredObject(
"Pagination signals from the raw GitHub page, before pull requests are filtered out.",
{
fetched: s.integer({
minimum: 0,
description:
"Number of items GitHub returned on this page before filtering. Continue paginating while this equals perPage, which defaults to 30.",
}),
},
),
}),
}),
action({
Expand Down
82 changes: 82 additions & 0 deletions src/providers/github/runtime-issue.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import type { JsonSchema } from "../../core/types.ts";

import { describe, expect, it } from "vitest";
import { provider } from "./definition.ts";
import { issueActionHandlers } from "./runtime-issue.ts";

function pageFetcher(items: unknown[], onRequest?: (url: string) => void): typeof fetch {
return async (url) => {
onRequest?.(String(url));
return new Response(JSON.stringify(items), {
status: 200,
headers: { "content-type": "application/json" },
});
};
}

describe("list_repository_issues pagination signal", () => {
it("reports the raw page length before pull requests are filtered out", async () => {
// A raw GitHub page of 3 items where one is a pull request. The
// filtered `issues` array alone would read as a short page and stop
// page-number pagination early; `pageInfo.fetched` preserves the raw
// length so callers can keep paginating correctly.
const result = (await issueActionHandlers.list_repository_issues(
{ owner: "acme", repo: "widgets", perPage: 3 },
{
accessToken: "token",
fetcher: pageFetcher([
{ id: 1, number: 10, title: "real issue" },
{ id: 2, number: 11, title: "a pull request", pull_request: { url: "https://example.test" } },
{ id: 3, number: 12, title: "another issue" },
]),
},
)) as { issues: Array<{ id: number }>; pageInfo: { fetched: number } };

expect(result.issues.map((issue) => issue.id)).toEqual([1, 3]);
expect(result.pageInfo.fetched).toBe(3);
});

it("reports fetched on an all-pull-request page whose issues array is empty", async () => {
const result = (await issueActionHandlers.list_repository_issues(
{ owner: "acme", repo: "widgets", perPage: 2 },
{
accessToken: "token",
fetcher: pageFetcher([
{ id: 1, pull_request: {} },
{ id: 2, pull_request: {} },
]),
},
)) as { issues: unknown[]; pageInfo: { fetched: number } };

expect(result.issues).toEqual([]);
expect(result.pageInfo.fetched).toBe(2);
});

it("requests the documented default page size when perPage is omitted", async () => {
let requestedUrl = "";
await issueActionHandlers.list_repository_issues(
{ owner: "acme", repo: "widgets" },
{
accessToken: "token",
fetcher: pageFetcher([], (url) => {
requestedUrl = url;
}),
},
);

expect(new URL(requestedUrl).searchParams.get("per_page")).toBe("30");
});

it("declares the pagination contract in the action schemas", () => {
const action = provider.actions.find((entry) => entry.name === "list_repository_issues");
const inputProperties = action?.inputSchema.properties as Record<string, JsonSchema> | undefined;
const outputProperties = action?.outputSchema.properties as Record<string, JsonSchema> | undefined;
const perPage = inputProperties?.perPage;
const pageInfo = outputProperties?.pageInfo;
const pageInfoProperties = pageInfo?.properties as Record<string, JsonSchema> | undefined;

expect(perPage).toMatchObject({ type: "integer", minimum: 1, maximum: 100, default: 30 });
expect(pageInfoProperties?.fetched?.type).toBe("integer");
expect(pageInfo?.required as string[] | undefined).toContain("fetched");
});
});
9 changes: 8 additions & 1 deletion src/providers/github/runtime-issue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,7 @@ export const issueActionHandlers: Record<string, GitHubActionHandler> = {
};

async function listRepositoryIssues(input: Record<string, unknown>, accessToken: string, fetcher: typeof fetch) {
const perPage = optionalInteger(input.perPage) ?? 30;
const issues = await githubRequestJson<Record<string, unknown>[]>({
path: `/repos/${encodeURIComponent(String(input.owner))}/${encodeURIComponent(String(input.repo))}/issues`,
query: compactObject({
Expand All @@ -281,15 +282,21 @@ async function listRepositoryIssues(input: Record<string, unknown>, accessToken:
sort: optionalString(input.sort),
direction: optionalString(input.direction),
since: optionalString(input.since),
per_page: optionalInteger(input.perPage),
per_page: perPage,
page: optionalInteger(input.page),
}),
accessToken,
fetcher,
});

return {
// The raw GitHub page mixes issues and pull requests; filtering PRs out
// destroys the only pagination signal page-number callers have (the raw
// page length). `pageInfo.fetched` preserves it: a caller must continue
// paginating while `fetched` equals `perPage`, even when `issues` comes
// back short or empty.
issues: issues.filter((issue) => issue.pull_request == null),
pageInfo: { fetched: issues.length },
};
}

Expand Down
Loading