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
2 changes: 1 addition & 1 deletion docs/content/1.guide/07.commits.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion docs/content/2.platforms/03.gitea.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion packages/omp/extensions/forges.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion packages/pi/extensions/forges.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
2 changes: 1 addition & 1 deletion src/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
24 changes: 23 additions & 1 deletion src/providers/gitea.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<GiteaRawTypes> {
private client: HttpClient;
private readonly apiBaseURL: string;
private pagedCommitPathFilter: boolean | undefined;

/**
* Create a Gitea/Forgejo provider.
Expand Down Expand Up @@ -712,13 +720,26 @@ export class GiteaProvider extends Provider<GiteaRawTypes> {
}
}

private async commitPathFilterIsPaged(): Promise<boolean> {
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<PageResult<CommitSummary>> {
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,
Expand All @@ -735,6 +756,7 @@ export class GiteaProvider extends Provider<GiteaRawTypes> {
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;

Expand Down
71 changes: 71 additions & 0 deletions test/gitea.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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 = {
Expand Down Expand Up @@ -703,6 +773,7 @@ describe("Gitea Provider", () => {
hasNextPage: true,
nextPage: 3,
});
expect(mockClient).not.toHaveBeenCalled();
});
});

Expand Down
Loading