diff --git a/docs/content/1.guide/07.commits.md b/docs/content/1.guide/07.commits.md index fb0805a..7522dd5 100644 --- a/docs/content/1.guide/07.commits.md +++ b/docs/content/1.guide/07.commits.md @@ -36,7 +36,7 @@ interface Commit extends CommitSummary { Still no patches. GitHub pages are collected up to its cap of 3 000 files and `filesComplete` is `true` when GitHub itself confirms the list is complete. GitLab reads at most 10 000 rows per call. When a provider or a safety limit makes completeness unknowable, `filesComplete` is `null`, not a hopeful `true`. Gitea does not report counts per file and GitLab withholds them for collapsed diffs, both come back as `null`. -Gitea rejects the `path` filter. Its API ignores pagination limits for it and would answer with the entire history, so the call fails early instead of downloading a repo. The other filters work. +Gitea rejects the `path` filter. Its API ignores `limit` when `path` is set and would answer with the entire history, so the call fails early instead of downloading a repo. Forgejo paginates that filter, so the same call works there. The other filters work on both. ## CI runs diff --git a/docs/content/2.platforms/03.gitea.md b/docs/content/2.platforms/03.gitea.md index c18c652..6a7c12e 100644 --- a/docs/content/2.platforms/03.gitea.md +++ b/docs/content/2.platforms/03.gitea.md @@ -41,7 +41,7 @@ Templates are repository scope only. There is no owner inheritance to claim, so ## Gotchas -- `commits.list` rejects the `path` filter. The API ignores pagination limits for it and would hand you the whole history. `ref`, `since` and `until` work. +- `commits.list` rejects the `path` filter on Gitea. That API ignores `limit` when `path` is set and would hand you the whole history. Forgejo paginates the filter. `ref`, `since` and `until` work on both. - Code search does not exist. `code.search` is a `ForgesError` with status 501 and a sentence. - Discussion comments arrive as one response, so the page you asked for is cut locally. - Older Forgejo responses keep an Actions run's branch only inside the webhook payload. It is read from there when the top level field is missing. diff --git a/packages/omp/extensions/forges.ts b/packages/omp/extensions/forges.ts index 5972338..7c1acf2 100644 --- a/packages/omp/extensions/forges.ts +++ b/packages/omp/extensions/forges.ts @@ -292,7 +292,7 @@ export default function forgesOmpExtension(pi: ExtensionAPI): void { name: "forges_commits_list", label: "Forges Commits", description: - "List paged commits, optionally filtered by ref, path, or date range; Gitea rejects path because its API ignores pagination limits", + "List paged commits, optionally filtered by ref, path, or date range; Gitea rejects path because its API ignores pagination limits; Forgejo paginates it", parameters: listCommitsParameters, ...statusRenderers("forges_commits_list", "Forges Commits"), approval: "read", diff --git a/packages/pi/extensions/forges.ts b/packages/pi/extensions/forges.ts index df70a03..a7c5aa8 100644 --- a/packages/pi/extensions/forges.ts +++ b/packages/pi/extensions/forges.ts @@ -214,7 +214,7 @@ export default function forgesExtension(pi: ExtensionAPI): void { promptSnippet: "Read repository commit history from GitHub, GitLab, or Gitea.", promptGuidelines: [ "Use forges_commits_list for repository history; use forges_commits_get only when one commit's changed files are needed.", - "forges_commits_list rejects path on Gitea because that API ignores pagination limits for the filter.", + "forges_commits_list rejects path on Gitea because that API ignores pagination limits for the filter; Forgejo paginates it.", ], parameters: listCommitsParameters, ...statusRenderers("forges_commits_list", "Forges Commits"), diff --git a/src/mcp.ts b/src/mcp.ts index 3f14e39..a9a82d3 100644 --- a/src/mcp.ts +++ b/src/mcp.ts @@ -182,7 +182,7 @@ const tools: ToolDefinition[] = [ name: "forges_commits_list", title: "List Commits", description: - "List paged commit summaries for one repository, optionally filtered by ref, path, and ISO-8601 since/until dates. Summaries omit changed-file rows; use forges_commits_get for one commit's files. Gitea rejects path because its API ignores pagination limits for that filter.", + "List paged commit summaries for one repository, optionally filtered by ref, path, and ISO-8601 since/until dates. Summaries omit changed-file rows; use forges_commits_get for one commit's files. Gitea rejects path because that API ignores pagination limits for the filter; Forgejo paginates it.", inputSchema: listCommitsParameters, annotations: readAnnotations, execute: listCommits, diff --git a/src/providers/gitea.ts b/src/providers/gitea.ts index 1714ba6..66d3304 100644 --- a/src/providers/gitea.ts +++ b/src/providers/gitea.ts @@ -338,12 +338,20 @@ const GITEA_PULL_REQUEST_TEMPLATE_CANDIDATES = [ ".github/pull_request_template.yml", ] as const; +/** Gitea ignores `limit` when `path` is set. Forgejo paginates that filter. */ +function versionPagesCommitPathFilter(version: string | undefined): boolean { + if (!version) return false; + const normalized = version.toLowerCase(); + return normalized.includes("forgejo") || normalized.includes("+gitea-"); +} + /** * Gitea/Forgejo provider implementation. */ export class GiteaProvider extends Provider { private client: HttpClient; private readonly apiBaseURL: string; + private pagedCommitPathFilter: boolean | undefined; /** * Create a Gitea/Forgejo provider. @@ -712,13 +720,26 @@ export class GiteaProvider extends Provider { } } + private async commitPathFilterIsPaged(): Promise { + if (this.pagedCommitPathFilter !== undefined) { + return this.pagedCommitPathFilter; + } + try { + const info = await this.client<{ version?: string }>("/version"); + this.pagedCommitPathFilter = versionPagesCommitPathFilter(info?.version); + return this.pagedCommitPathFilter; + } catch { + return false; + } + } + protected override async listCommits( owner: string, repo: string, options?: ListCommitOptions, ): Promise> { try { - if (options?.path) { + if (options?.path && !(await this.commitPathFilterIsPaged())) { throw new ForgesError( "Path-filtered commit listing is not supported by Gitea because its API ignores pagination limits", 501, @@ -735,6 +756,7 @@ export class GiteaProvider extends Provider { limit: String(options?.perPage ?? 30), }; if (options?.ref) query.sha = options.ref; + if (options?.path) query.path = options.path; if (options?.since) query.since = options.since; if (options?.until) query.until = options.until; diff --git a/test/gitea.test.ts b/test/gitea.test.ts index 575d263..1909189 100644 --- a/test/gitea.test.ts +++ b/test/gitea.test.ts @@ -641,6 +641,8 @@ describe("Gitea Provider", () => { describe("commits.list", () => { it("rejects Gitea path filters because that API ignores pagination limits", async () => { + mockClient.mockResolvedValueOnce({ version: "1.27.0+dev-954-g1f3981a301" }); + await expect( provider.commits.list("testowner", "test-repo", { path: "src/provider.ts", @@ -653,9 +655,77 @@ describe("Gitea Provider", () => { status: 501, platform: "gitea", }); + expect(mockClient).toHaveBeenCalledWith("/version"); expect(mockedRawFetch).not.toHaveBeenCalled(); }); + it("rejects path filters when the version endpoint is unavailable", async () => { + mockClient.mockRejectedValueOnce(new Error("version unavailable")); + + await expect( + provider.commits.list("testowner", "test-repo", { path: "README.md" }), + ).rejects.toMatchObject({ + status: 501, + platform: "gitea", + }); + expect(mockedRawFetch).not.toHaveBeenCalled(); + }); + + it("forwards path on Forgejo, which paginates that filter", async () => { + const sha = "cb9d4e5dc0f07fd9504b74e6ef58c37e9a32af38"; + mockClient.mockResolvedValueOnce({ + version: "16.0.0-dev-741-6f391573+gitea-1.22.0", + }); + mockedRawFetch.mockResolvedValueOnce({ + data: [ + { + sha, + html_url: `https://codeberg.org/forgejo/forgejo/commit/${sha}`, + commit: { + message: "docs: refresh readme", + author: { name: "Ori", email: "ori@example.com", date: "2026-08-29T10:00:00Z" }, + committer: { + name: "Ori", + email: "ori@example.com", + date: "2026-08-29T10:01:00Z", + }, + }, + parents: [{ sha: "parent-sha" }], + }, + ], + headers: new Headers({ "x-hasmore": "true", "x-total-count": "546" }), + status: 200, + }); + + const result = await provider.commits.list("forgejo", "forgejo", { + path: "README.md", + page: 1, + perPage: 2, + }); + + expect(mockClient).toHaveBeenCalledWith("/version"); + expect(mockedRawFetch).toHaveBeenCalledWith( + expect.anything(), + "/repos/forgejo/forgejo/commits", + { + query: { + path: "README.md", + stat: "false", + verification: "false", + files: "false", + page: "1", + limit: "2", + }, + }, + ); + expect(result).toMatchObject({ + totalCount: 546, + hasNextPage: true, + nextPage: 2, + items: [{ sha, message: "docs: refresh readme" }], + }); + }); + it("uses Gitea pagination headers when path is omitted", async () => { const sha = "cb9d4e5dc0f07fd9504b74e6ef58c37e9a32af38"; const commit = { @@ -703,6 +773,7 @@ describe("Gitea Provider", () => { hasNextPage: true, nextPage: 3, }); + expect(mockClient).not.toHaveBeenCalled(); }); });