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
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ const gt = createProvider("gitea", {

## Agent tools

The same twenty-six tools - repositories, CI runs, commits, issues, pull requests and their changed files and checks, users, authentication reload, discussion comments, and review threads - are exposed over MCP and through the Pi and OMP extensions. Read tools use the normal token detection chain, then fall back to anonymous access when no credential exists. Writes, `forges_users_authenticated`, and `forges_auth_reload` still require a credential. For a trusted self-hosted endpoint, set the matching local environment variable to the full API base URL:
The same twenty-seven tools - repositories, CI runs, commits, issues, pull requests and their changed files and checks, users, authentication reload, discussion comments, and review threads - are exposed over MCP and through the Pi and OMP extensions. Read tools use the normal token detection chain, then fall back to anonymous access when no credential exists. Writes, `forges_users_authenticated`, and `forges_auth_reload` still require a credential. For a trusted self-hosted endpoint, set the matching local environment variable to the full API base URL:

| Platform | Environment variable |
| ------------------ | ------------------------ |
Expand Down Expand Up @@ -144,7 +144,7 @@ Every provider gives you seven resources with the same method shapes. Thread sem

**ciRuns** - `list(owner, repo, opts?)`

**commits** - `get(owner, repo, sha)`
**commits** - `list(owner, repo, opts?)`, `get(owner, repo, sha)`

**issues** - `list(owner, repo, opts?)`, `search(owner, repo, query, opts?)`, `get(owner, repo, number)`, `create(owner, repo, input)`, `listComments(owner, repo, number, opts?)`

Expand All @@ -154,7 +154,7 @@ Every provider gives you seven resources with the same method shapes. Thread sem

**threads** - `list(owner, repo, number, opts?)`, `get(owner, repo, number, threadId)`, `reply(owner, repo, number, threadId, input)`, `resolve(owner, repo, number, threadId)`, `unresolve(owner, repo, number, threadId)`

CI-run lists accept `ListCiRunsOptions`: `page`, `perPage`, and an optional `branch` filter. They normalize GitHub Actions runs, GitLab pipelines, and Gitea Actions runs to branch, revision SHA, lifecycle status, terminal conclusion, and URL. Commit reads return the SHA, message, author, committer, parent SHAs, URL, and changed-file rows without patches. GitHub pages are collected through its 3,000-file API cap; GitLab reads at most 10,000 rows per call. Gitea per-file counts and withheld GitLab diff counts are `null`. `filesComplete` is `true` when GitHub confirms a complete result and `null` when provider or safety limits make completeness unknowable. Pull-request file lists accept `ListPullRequestFilesOptions`: `page` and `perPage`. They normalize each changed file to path, status, additions, and deletions without returning patches; GitLab counts are `null` when its API withholds a collapsed or oversized diff. Pull-request check lists accept `ListPullRequestChecksOptions`: `page` and `perPage`. They read GitHub check runs for the head SHA, GitLab merge-request pipelines for the current head SHA, and Gitea commit statuses, normalized to name, lifecycle status, terminal conclusion, and URL. Issue and pull request lists accept `ListOptions`: `page`, `perPage`, and `state` (`'open' | 'closed' | 'all'`); repository lists use its pagination fields. Lists return `PageResult<T>` with `items`, `hasNextPage`, `nextPage`, and an optional `totalCount`. Issue and pull-request searches accept the same options and return those fields plus `incomplete`, which is true when the result is known to be partial. Queries keep the selected platform's syntax: GitHub qualifiers work on GitHub, while GitLab and Gitea treat them as text. Pull-request search returns `PullRequestSearchItem`; call `get` for branches, revisions, and mergeability.
CI-run lists accept `ListCiRunsOptions`: `page`, `perPage`, and an optional `branch` filter. They normalize GitHub Actions runs, GitLab pipelines, and Gitea Actions runs to branch, revision SHA, lifecycle status, terminal conclusion, and URL. Commit lists accept `ListCommitOptions`: `page`, `perPage`, `ref`, `path`, `since`, and `until`. They return metadata-only `CommitSummary` rows. Gitea rejects `path` because its API ignores pagination limits for that filter; the other filters remain supported. Commit reads return the SHA, message, author, committer, parent SHAs, URL, and changed-file rows without patches. GitHub pages are collected through its 3,000-file API cap; GitLab reads at most 10,000 rows per call. Gitea per-file counts and withheld GitLab diff counts are `null`. `filesComplete` is `true` when GitHub confirms a complete result and `null` when provider or safety limits make completeness unknowable. Pull-request file lists accept `ListPullRequestFilesOptions`: `page` and `perPage`. They normalize each changed file to path, status, additions, and deletions without returning patches; GitLab counts are `null` when its API withholds a collapsed or oversized diff. Pull-request check lists accept `ListPullRequestChecksOptions`: `page` and `perPage`. They read GitHub check runs for the head SHA, GitLab merge-request pipelines for the current head SHA, and Gitea commit statuses, normalized to name, lifecycle status, terminal conclusion, and URL. Issue and pull request lists accept `ListOptions`: `page`, `perPage`, and `state` (`'open' | 'closed' | 'all'`); repository lists use its pagination fields. Lists return `PageResult<T>` with `items`, `hasNextPage`, `nextPage`, and an optional `totalCount`. Issue and pull-request searches accept the same options and return those fields plus `incomplete`, which is true when the result is known to be partial. Queries keep the selected platform's syntax: GitHub qualifiers work on GitHub, while GitLab and Gitea treat them as text. Pull-request search returns `PullRequestSearchItem`; call `get` for branches, revisions, and mergeability.

`listComments` reads the discussion under an issue or pull request oldest first and accepts `ListCommentOptions`: `page` and `perPage`. On GitHub and Gitea the two variants read the same endpoint, because both platforms index pull requests as issues. GitLab notes are fetched with an explicit ascending sort, and both its system notes about label and state churn and its inline DiffNotes, which belong to the thread surface, are dropped, so a short page whose `hasNextPage` is true means keep paging. Gitea answers with the whole discussion in one response, so the requested page is cut locally.

Expand Down
35 changes: 35 additions & 0 deletions packages/omp/extensions/forges.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,18 @@ export default function forgesOmpExtension(pi: ExtensionAPI): void {
const repo = Type.String({ description: "Repository name", minLength: 1 });
const sha = Type.String({ description: "Commit SHA", minLength: 1 });
const branch = Type.Optional(Type.String({ description: "Filter by branch", minLength: 1 }));
const ref = Type.Optional(
Type.String({ description: "Branch, tag, or commit reference", minLength: 1 }),
);
const path = Type.Optional(
Type.String({ description: "Filter by repository path", minLength: 1 }),
);
const since = Type.Optional(
Type.String({ description: "Only commits at or after this ISO-8601 date", minLength: 1 }),
);
const until = Type.Optional(
Type.String({ description: "Only commits at or before this ISO-8601 date", minLength: 1 }),
);
const page = Type.Optional(Type.Integer({ description: "Page number", minimum: 1 }));
const perPage = Type.Optional(
Type.Integer({ description: "Results per page", minimum: 1, maximum: 100 }),
Expand All @@ -57,6 +69,17 @@ export default function forgesOmpExtension(pi: ExtensionAPI): void {
const listRepositoriesParameters = Type.Object({ platform, owner, page, perPage });
const repositoryParameters = Type.Object({ platform, owner, repo });
const commitParameters = Type.Object({ platform, owner, repo, sha });
const listCommitsParameters = Type.Object({
platform,
owner,
repo,
ref,
path,
since,
until,
page,
perPage,
});
const listCiRunsParameters = Type.Object({ platform, owner, repo, branch, page, perPage });
const listRepositoryItemsParameters = Type.Object({
platform,
Expand Down Expand Up @@ -168,6 +191,18 @@ export default function forgesOmpExtension(pi: ExtensionAPI): void {
},
});

pi.registerTool({
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",
parameters: listCommitsParameters,
approval: "read",
async execute(_toolCallId, params) {
return (await loadToolOperations()).listCommits(params);
},
});

pi.registerTool({
name: "forges_commits_get",
label: "Forges Commit",
Expand Down
16 changes: 16 additions & 0 deletions packages/pi/extensions/forges.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
createPullRequestParameters,
listCiRunsParameters,
listCommentsParameters,
listCommitsParameters,
listPullRequestChecksParameters,
listPullRequestFilesParameters,
listRepositoriesParameters,
Expand Down Expand Up @@ -80,6 +81,21 @@ export default function forgesExtension(pi: ExtensionAPI): void {
},
});

pi.registerTool({
name: "forges_commits_list",
label: "Forges Commits",
description: "List paged commits, optionally filtered by ref, path, or date range",
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.",
],
parameters: listCommitsParameters,
async execute(_toolCallId, params) {
return (await loadToolOperations()).listCommits(params);
},
});

pi.registerTool({
name: "forges_commits_get",
label: "Forges Commit",
Expand Down
21 changes: 21 additions & 0 deletions packages/shared/forges-tool-schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,16 @@ const owner = Type.String({ description: "Repository owner or organization", min
const repo = Type.String({ description: "Repository name", minLength: 1 });
const sha = Type.String({ description: "Commit SHA", minLength: 1 });
const branch = Type.Optional(Type.String({ description: "Filter by branch", minLength: 1 }));
const ref = Type.Optional(
Type.String({ description: "Branch, tag, or commit reference", minLength: 1 }),
);
const path = Type.Optional(Type.String({ description: "Filter by repository path", minLength: 1 }));
const since = Type.Optional(
Type.String({ description: "Only commits at or after this ISO-8601 date", minLength: 1 }),
);
const until = Type.Optional(
Type.String({ description: "Only commits at or before this ISO-8601 date", minLength: 1 }),
);
const page = Type.Optional(Type.Integer({ description: "Page number", minimum: 1 }));
const perPage = Type.Optional(
Type.Integer({ description: "Results per page", minimum: 1, maximum: 100 }),
Expand Down Expand Up @@ -60,6 +70,17 @@ const assignees = Type.Optional(
export const listRepositoriesParameters = Type.Object({ platform, owner, page, perPage });
export const repositoryParameters = Type.Object({ platform, owner, repo });
export const commitParameters = Type.Object({ platform, owner, repo, sha });
export const listCommitsParameters = Type.Object({
platform,
owner,
repo,
ref,
path,
since,
until,
page,
perPage,
});
export const listCiRunsParameters = Type.Object({ platform, owner, repo, branch, page, perPage });
export const listRepositoryItemsParameters = Type.Object({
platform,
Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export type {
ChangedFileStatus,
ChangedFile,
CommitIdentity,
CommitSummary,
Commit,
IssueState,
Issue,
Expand All @@ -41,6 +42,7 @@ export type {
ListOptions,
ListCiRunsOptions,
ListCommentOptions,
ListCommitOptions,
ListPullRequestFilesOptions,
ListPullRequestChecksOptions,
ListThreadOptions,
Expand Down
11 changes: 11 additions & 0 deletions src/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
createPullRequestParameters,
listCiRunsParameters,
listCommentsParameters,
listCommitsParameters,
listPullRequestChecksParameters,
listPullRequestFilesParameters,
listRepositoriesParameters,
Expand All @@ -41,6 +42,7 @@ import {
getThread,
getUser,
listCiRuns,
listCommits,
listIssueComments,
listIssues,
listPullRequestChecks,
Expand Down Expand Up @@ -142,6 +144,15 @@ const tools: ToolDefinition[] = [
annotations: readAnnotations,
execute: listCiRuns,
}),
defineTool({
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.",
inputSchema: listCommitsParameters,
annotations: readAnnotations,
execute: listCommits,
}),
defineTool({
name: "forges_commits_get",
title: "Get Commit",
Expand Down
10 changes: 10 additions & 0 deletions src/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,14 @@ import type {
Comment,
Commit,
CommitResource,
CommitSummary,
CreateIssueInput,
CreatePullRequestInput,
Issue,
IssueResource,
ListCiRunsOptions,
ListCommentOptions,
ListCommitOptions,
ListOptions,
ListPullRequestChecksOptions,
ListPullRequestFilesOptions,
Expand Down Expand Up @@ -79,6 +81,7 @@ export abstract class Provider<Raw extends ProviderRawTypes = ProviderRawTypes>
list: (owner, repo, options) => this.listCiRuns(owner, repo, options),
};
this.commits = {
list: (owner, repo, options) => this.listCommits(owner, repo, options),
get: (owner, repo, sha) => this.getCommit(owner, repo, sha),
};
this.issues = {
Expand Down Expand Up @@ -156,6 +159,13 @@ export abstract class Provider<Raw extends ProviderRawTypes = ProviderRawTypes>
): Promise<PageResult<CiRun>> {
return Promise.reject(new ForgesError("CI-run listing is not supported by this provider", 501));
}
protected listCommits(
_owner: string,
_repo: string,
_options?: ListCommitOptions,
): Promise<PageResult<CommitSummary>> {
return Promise.reject(new ForgesError("Commit listing is not supported by this provider", 501));
}
protected abstract getCommit(owner: string, repo: string, sha: string): Promise<Commit>;
protected abstract listIssues(
owner: string,
Expand Down
68 changes: 61 additions & 7 deletions src/providers/gitea.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

import { createHttpClient, rawFetch, type HttpClient } from "../http.ts";
import { parseLinkHeader } from "../pagination.ts";
import { normalizeError, NotFoundError } from "../errors.ts";
import { ForgesError, normalizeError, NotFoundError } from "../errors.ts";
import { encodePathSegment, normalizeApiBaseURL } from "./base-url.ts";
import { Provider, type ProviderRawTypes } from "../provider.ts";
import { mapBooleanRepositoryPermission } from "../repository-access.ts";
Expand All @@ -17,6 +17,7 @@ import type {
Repository,
CiRun,
Commit,
CommitSummary,
Issue,
PullRequest,
PullRequestCheck,
Expand All @@ -29,6 +30,7 @@ import type {
ListOptions,
ListCiRunsOptions,
ListCommentOptions,
ListCommitOptions,
ListPullRequestChecksOptions,
ListPullRequestFilesOptions,
ListThreadOptions,
Expand Down Expand Up @@ -362,6 +364,17 @@ export class GiteaProvider extends Provider<GiteaRawTypes> {
};
}

private mapCommitSummary(raw: GiteaCommit): CommitSummary {
return {
sha: raw.sha,
message: raw.commit.message,
author: raw.commit.author,
committer: raw.commit.committer,
parents: (raw.parents ?? []).map((parent) => parent.sha),
url: raw.html_url ?? "",
};
}

private mapPullRequestCheck(raw: GiteaCommitStatus): PullRequestCheck {
let url = raw.target_url ?? raw.url ?? "";
if (url.startsWith("/")) {
Expand Down Expand Up @@ -554,18 +567,59 @@ export class GiteaProvider extends Provider<GiteaRawTypes> {
}
}

protected override async listCommits(
owner: string,
repo: string,
options?: ListCommitOptions,
): Promise<PageResult<CommitSummary>> {
try {
if (options?.path) {
throw new ForgesError(
"Path-filtered commit listing is not supported by Gitea because its API ignores pagination limits",
501,
PLATFORM,
);
}

const page = options?.page ?? 1;
const query: Record<string, string> = {
stat: "false",
verification: "false",
files: "false",
page: String(page),
limit: String(options?.perPage ?? 30),
};
if (options?.ref) query.sha = options.ref;
if (options?.since) query.since = options.since;
if (options?.until) query.until = options.until;

const { data, headers } = await rawFetch<GiteaCommit[]>(
this.client,
`/repos/${encodePathSegment(owner)}/${encodePathSegment(repo)}/commits`,
{ query },
);
const result = buildPageResult(data ?? [], headers, (raw) => this.mapCommitSummary(raw));
const totalHeader = headers.get("x-total-count");
const totalCount = totalHeader === null ? undefined : Number.parseInt(totalHeader, 10);
const hasNextPage = result.hasNextPage || headers.get("x-hasmore") === "true";
return {
...result,
totalCount: Number.isInteger(totalCount) ? totalCount : undefined,
hasNextPage,
nextPage: hasNextPage ? (result.nextPage ?? page + 1) : undefined,
};
} catch (error) {
throw normalizeError(error, PLATFORM);
}
}

protected override async getCommit(owner: string, repo: string, sha: string): Promise<Commit> {
try {
const commit = await this.client<GiteaCommit>(
`/repos/${encodePathSegment(owner)}/${encodePathSegment(repo)}/git/commits/${encodePathSegment(sha)}`,
);
return {
sha: commit.sha,
message: commit.commit.message,
author: commit.commit.author,
committer: commit.commit.committer,
parents: (commit.parents ?? []).map((parent) => parent.sha),
url: commit.html_url ?? "",
...this.mapCommitSummary(commit),
files: (commit.files ?? []).map((file) => this.mapPullRequestFile(file)),
filesComplete: null,
};
Expand Down
Loading
Loading