From 1626452bd870981cb680ea011ac5e4b9a61da053 Mon Sep 17 00:00:00 2001 From: Aei <256851514+aeitwoen@users.noreply.github.com> Date: Mon, 31 Aug 2026 11:38:30 +0200 Subject: [PATCH 1/2] feat: list repository commits --- README.md | 6 +-- packages/omp/extensions/forges.ts | 35 ++++++++++++ packages/pi/extensions/forges.ts | 16 ++++++ packages/shared/forges-tool-schemas.ts | 21 ++++++++ src/index.ts | 2 + src/mcp.ts | 27 ++++++---- src/provider.ts | 10 ++++ src/providers/gitea.ts | 68 ++++++++++++++++++++--- src/providers/github.ts | 45 +++++++++++++--- src/providers/gitlab.ts | 66 ++++++++++++++++++----- src/tool-operations.ts | 18 +++++++ src/types.ts | 23 +++++++- test/eval-packed-extensions.mjs | 1 + test/extensions.test.ts | 35 +++++++++++- test/gitea.test.ts | 74 +++++++++++++++++++++++++- test/github.test.ts | 59 ++++++++++++++++++++ test/gitlab.test.ts | 66 +++++++++++++++++++++++ test/integration.test.ts | 2 + test/mcp.test.ts | 46 +++++++++++++++- 19 files changed, 575 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index a741450..eb439ad 100644 --- a/README.md +++ b/README.md @@ -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 | | ------------------ | ------------------------ | @@ -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?)` @@ -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` 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` 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. diff --git a/packages/omp/extensions/forges.ts b/packages/omp/extensions/forges.ts index 43b4cb6..6ece661 100644 --- a/packages/omp/extensions/forges.ts +++ b/packages/omp/extensions/forges.ts @@ -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 }), @@ -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, @@ -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", diff --git a/packages/pi/extensions/forges.ts b/packages/pi/extensions/forges.ts index 0870688..2417002 100644 --- a/packages/pi/extensions/forges.ts +++ b/packages/pi/extensions/forges.ts @@ -12,6 +12,7 @@ import { createPullRequestParameters, listCiRunsParameters, listCommentsParameters, + listCommitsParameters, listPullRequestChecksParameters, listPullRequestFilesParameters, listRepositoriesParameters, @@ -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", diff --git a/packages/shared/forges-tool-schemas.ts b/packages/shared/forges-tool-schemas.ts index 0c7520e..89974cb 100644 --- a/packages/shared/forges-tool-schemas.ts +++ b/packages/shared/forges-tool-schemas.ts @@ -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 }), @@ -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, diff --git a/src/index.ts b/src/index.ts index fb9ab86..ccf5ae7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -25,6 +25,7 @@ export type { ChangedFileStatus, ChangedFile, CommitIdentity, + CommitSummary, Commit, IssueState, Issue, @@ -41,6 +42,7 @@ export type { ListOptions, ListCiRunsOptions, ListCommentOptions, + ListCommitOptions, ListPullRequestFilesOptions, ListPullRequestChecksOptions, ListThreadOptions, diff --git a/src/mcp.ts b/src/mcp.ts index a2c464d..405c9a1 100644 --- a/src/mcp.ts +++ b/src/mcp.ts @@ -16,6 +16,7 @@ import { createPullRequestParameters, listCiRunsParameters, listCommentsParameters, + listCommitsParameters, listPullRequestChecksParameters, listPullRequestFilesParameters, listRepositoriesParameters, @@ -41,6 +42,7 @@ import { getThread, getUser, listCiRuns, + listCommits, listIssueComments, listIssues, listPullRequestChecks, @@ -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", @@ -425,15 +436,13 @@ export function createMcpServer(): Server { const server = new Server({ name: "forges", version }, { capabilities: { tools: {} } }); server.setRequestHandler(ListToolsRequestSchema, () => ({ - tools: tools.map( - (tool): Tool => ({ - name: tool.name, - title: tool.title, - description: tool.description, - inputSchema: tool.inputSchema as Tool["inputSchema"], - annotations: tool.annotations, - }), - ), + tools: tools.map((tool): Tool => ({ + name: tool.name, + title: tool.title, + description: tool.description, + inputSchema: tool.inputSchema as Tool["inputSchema"], + annotations: tool.annotations, + })), })); server.setRequestHandler(CallToolRequestSchema, async (request) => { diff --git a/src/provider.ts b/src/provider.ts index 14b7c53..9ba838b 100644 --- a/src/provider.ts +++ b/src/provider.ts @@ -10,12 +10,14 @@ import type { Comment, Commit, CommitResource, + CommitSummary, CreateIssueInput, CreatePullRequestInput, Issue, IssueResource, ListCiRunsOptions, ListCommentOptions, + ListCommitOptions, ListOptions, ListPullRequestChecksOptions, ListPullRequestFilesOptions, @@ -79,6 +81,7 @@ export abstract class Provider 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 = { @@ -156,6 +159,13 @@ export abstract class Provider ): Promise> { return Promise.reject(new ForgesError("CI-run listing is not supported by this provider", 501)); } + protected listCommits( + _owner: string, + _repo: string, + _options?: ListCommitOptions, + ): Promise> { + return Promise.reject(new ForgesError("Commit listing is not supported by this provider", 501)); + } protected abstract getCommit(owner: string, repo: string, sha: string): Promise; protected abstract listIssues( owner: string, diff --git a/src/providers/gitea.ts b/src/providers/gitea.ts index ca2de38..a9ff46a 100644 --- a/src/providers/gitea.ts +++ b/src/providers/gitea.ts @@ -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"; @@ -17,6 +17,7 @@ import type { Repository, CiRun, Commit, + CommitSummary, Issue, PullRequest, PullRequestCheck, @@ -29,6 +30,7 @@ import type { ListOptions, ListCiRunsOptions, ListCommentOptions, + ListCommitOptions, ListPullRequestChecksOptions, ListPullRequestFilesOptions, ListThreadOptions, @@ -362,6 +364,17 @@ export class GiteaProvider extends Provider { }; } + 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("/")) { @@ -554,18 +567,59 @@ export class GiteaProvider extends Provider { } } + protected override async listCommits( + owner: string, + repo: string, + options?: ListCommitOptions, + ): Promise> { + 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 = { + 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( + 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 { try { const commit = await this.client( `/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, }; diff --git a/src/providers/github.ts b/src/providers/github.ts index 807069a..f0b1309 100644 --- a/src/providers/github.ts +++ b/src/providers/github.ts @@ -9,6 +9,7 @@ import type { Repository, CiRun, Commit, + CommitSummary, Issue, PullRequest, PullRequestCheck, @@ -21,6 +22,7 @@ import type { ListOptions, ListCiRunsOptions, ListCommentOptions, + ListCommitOptions, ListPullRequestChecksOptions, ListPullRequestFilesOptions, ListThreadOptions, @@ -478,6 +480,17 @@ export class GitHubProvider extends Provider { }; } + private mapCommitSummary(raw: GitHubCommit): 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: GitHubCheckRun): PullRequestCheck { return { id: String(raw.id), @@ -663,6 +676,31 @@ export class GitHubProvider extends Provider { } } + protected override async listCommits( + owner: string, + repo: string, + options?: ListCommitOptions, + ): Promise> { + try { + const query: Record = {}; + 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; + if (options?.page) query.page = String(options.page); + if (options?.perPage) query.per_page = String(options.perPage); + + const { data, headers } = await rawFetch( + this.client, + `/repos/${encodePathSegment(owner)}/${encodePathSegment(repo)}/commits`, + { query }, + ); + return buildPageResult(data ?? [], headers, (raw) => this.mapCommitSummary(raw)); + } catch (error) { + throw normalizeError(error, "github"); + } + } + protected override async getCommit(owner: string, repo: string, sha: string): Promise { try { const path = `/repos/${encodePathSegment(owner)}/${encodePathSegment(repo)}/commits/${encodePathSegment(sha)}`; @@ -695,12 +733,7 @@ export class GitHubProvider extends Provider { } 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, filesComplete: files.length < 3000 ? filesComplete : null, }; diff --git a/src/providers/gitlab.ts b/src/providers/gitlab.ts index 3d6b158..aa35d41 100644 --- a/src/providers/gitlab.ts +++ b/src/providers/gitlab.ts @@ -18,6 +18,7 @@ import type { RepositoryPermission, CiRun, Commit, + CommitSummary, Issue, PullRequest, PullRequestCheck, @@ -30,6 +31,7 @@ import type { ListOptions, ListCiRunsOptions, ListCommentOptions, + ListCommitOptions, ListPullRequestChecksOptions, ListPullRequestFilesOptions, ListThreadOptions, @@ -358,6 +360,25 @@ export class GitLabProvider extends Provider { }; } + private mapCommitSummary(raw: GitLabCommit): CommitSummary { + return { + sha: raw.id, + message: raw.message, + author: { + name: raw.author_name, + email: raw.author_email, + date: raw.authored_date, + }, + committer: { + name: raw.committer_name, + email: raw.committer_email, + date: raw.committed_date, + }, + parents: raw.parent_ids, + url: raw.web_url, + }; + } + private mapPullRequestCheck(raw: GitLabPipeline): PullRequestCheck { return { id: String(raw.id), @@ -672,6 +693,36 @@ export class GitLabProvider extends Provider { } } + protected override async listCommits( + owner: string, + repo: string, + options?: ListCommitOptions, + ): Promise> { + try { + const projectId = await this.resolveProjectId(owner, repo); + const query: Record = { + page: options?.page ?? 1, + per_page: options?.perPage ?? 30, + }; + if (options?.ref) query.ref_name = options.ref; + if (options?.path) query.path = options.path; + if (options?.since) query.since = options.since; + if (options?.until) query.until = options.until; + + const { data, headers } = await rawFetch( + this.client, + `/projects/${projectId}/repository/commits`, + { query }, + ); + return this.parsePagination( + (data ?? []).map((raw) => this.mapCommitSummary(raw)), + headers, + ); + } catch (error: unknown) { + throw normalizeError(error, "gitlab"); + } + } + protected override async getCommit(owner: string, repo: string, sha: string): Promise { try { const projectId = await this.resolveProjectId(owner, repo); @@ -700,20 +751,7 @@ export class GitLabProvider extends Provider { } return { - sha: commit.id, - message: commit.message, - author: { - name: commit.author_name, - email: commit.author_email, - date: commit.authored_date, - }, - committer: { - name: commit.committer_name, - email: commit.committer_email, - date: commit.committed_date, - }, - parents: commit.parent_ids, - url: commit.web_url, + ...this.mapCommitSummary(commit), files, filesComplete: null, }; diff --git a/src/tool-operations.ts b/src/tool-operations.ts index 1373ff9..6ad6616 100644 --- a/src/tool-operations.ts +++ b/src/tool-operations.ts @@ -7,12 +7,14 @@ import type { CiRun, Comment, Commit, + CommitSummary, CreateIssueInput, CreatePullRequestInput, Issue, IssueState, ListCiRunsOptions, ListCommentOptions, + ListCommitOptions, ListOptions, ListPullRequestChecksOptions, ListPullRequestFilesOptions, @@ -167,6 +169,8 @@ export interface GetCommitParams extends RepositoryParams { sha: string; } +export type ListCommitsParams = RepositoryParams & ListCommitOptions; + export interface ListCiRunsParams extends RepositoryParams { branch?: string; page?: number; @@ -316,6 +320,20 @@ export async function listCiRuns( return result(params.platform, runs); } +export async function listCommits( + params: ListCommitsParams, +): Promise>> { + const commits = await readProvider(params.platform).commits.list(params.owner, params.repo, { + ref: params.ref, + path: params.path, + since: params.since, + until: params.until, + page: params.page, + perPage: params.perPage, + }); + return result(params.platform, commits); +} + export async function getCommit(params: GetCommitParams): Promise> { const commit = await readProvider(params.platform).commits.get( params.owner, diff --git a/src/types.ts b/src/types.ts index 1bb6cda..f91816b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -159,14 +159,18 @@ export interface CommitIdentity { date: string; } -/** One commit with normalized metadata and changed-file rows. filesComplete is null when provider or safety limits prevent certainty. */ -export interface Commit { +/** One commit summary without changed-file rows. */ +export interface CommitSummary { sha: string; message: string; author: CommitIdentity; committer: CommitIdentity; parents: string[]; url: string; +} + +/** One commit with normalized metadata and changed-file rows. filesComplete is null when provider or safety limits prevent certainty. */ +export interface Commit extends CommitSummary { files: ChangedFile[]; filesComplete: boolean | null; } @@ -202,6 +206,16 @@ export interface ListCiRunsOptions { branch?: string; } +/** List options for repository commits. */ +export interface ListCommitOptions { + page?: number; + perPage?: number; + ref?: string; + path?: string; + since?: string; + until?: string; +} + /** * Input for creating an issue */ @@ -339,6 +353,11 @@ export interface CiRunResource { /** Resource accessor for commits. */ export interface CommitResource { + list( + owner: string, + repo: string, + options?: ListCommitOptions, + ): Promise>; get(owner: string, repo: string, sha: string): Promise; } diff --git a/test/eval-packed-extensions.mjs b/test/eval-packed-extensions.mjs index cdbf41b..8349600 100644 --- a/test/eval-packed-extensions.mjs +++ b/test/eval-packed-extensions.mjs @@ -16,6 +16,7 @@ const expectedToolNames = [ "forges_repos_list", "forges_repos_get", "forges_ci_runs_list", + "forges_commits_list", "forges_commits_get", "forges_issues_list", "forges_issues_search", diff --git a/test/extensions.test.ts b/test/extensions.test.ts index 7579094..b2b26e1 100644 --- a/test/extensions.test.ts +++ b/test/extensions.test.ts @@ -19,7 +19,7 @@ import { resetPinnedProviders } from "../src/tool-operations.ts"; const mocks = vi.hoisted(() => { const repos = { list: vi.fn(), get: vi.fn() }; const ciRuns = { list: vi.fn() }; - const commits = { get: vi.fn() }; + const commits = { list: vi.fn(), get: vi.fn() }; const issues = { list: vi.fn(), search: vi.fn(), @@ -68,6 +68,7 @@ const toolNames = [ "forges_repos_list", "forges_repos_get", "forges_ci_runs_list", + "forges_commits_list", "forges_commits_get", "forges_issues_list", "forges_issues_search", @@ -149,6 +150,7 @@ beforeEach(() => { vi.stubEnv("FORGES_GITEA_BASE_URL", undefined); mocks.repos.list.mockResolvedValue({ items: [], hasNextPage: false }); mocks.ciRuns.list.mockResolvedValue({ items: [], hasNextPage: false }); + mocks.commits.list.mockResolvedValue({ items: [], hasNextPage: false }); mocks.commits.get.mockResolvedValue({ sha: "abc", files: [], filesComplete: true }); mocks.issues.search.mockResolvedValue({ items: [], incomplete: false, hasNextPage: false }); mocks.pullRequests.search.mockResolvedValue({ @@ -242,6 +244,37 @@ describe("Forges Pi extension", () => { expect(result.details.result).toEqual({ items: [], hasNextPage: false }); }); + it("executes commit listing through the shared provider operation", async () => { + const tool = requirePiTool(registerPiTools(), "forges_commits_list"); + const result = await tool.execute( + "test", + { + platform: "github", + owner: "agntn", + repo: "forges", + ref: "main", + path: "src/provider.ts", + since: "2026-08-01T00:00:00Z", + until: "2026-08-29T23:59:59Z", + page: 2, + perPage: 10, + }, + undefined, + undefined, + unusedPiContext, + ); + + expect(mocks.commits.list).toHaveBeenCalledWith("agntn", "forges", { + ref: "main", + path: "src/provider.ts", + since: "2026-08-01T00:00:00Z", + until: "2026-08-29T23:59:59Z", + page: 2, + perPage: 10, + }); + expect(result.details.result).toEqual({ items: [], hasNextPage: false }); + }); + it("executes commit reads through the shared provider operation", async () => { const tool = requirePiTool(registerPiTools(), "forges_commits_get"); const result = await tool.execute( diff --git a/test/gitea.test.ts b/test/gitea.test.ts index 100e6e7..84057da 100644 --- a/test/gitea.test.ts +++ b/test/gitea.test.ts @@ -26,8 +26,9 @@ vi.mock("../src/http.ts", () => ({ })); vi.mock("../src/cache.ts", () => ({ - cachedFetch: vi.fn(async (client: (...args: unknown[]) => unknown, url: string, opts?: unknown) => - opts ? client(url, opts) : client(url), + cachedFetch: vi.fn( + async (client: (...args: unknown[]) => unknown, url: string, opts?: unknown) => + opts ? client(url, opts) : client(url), ), })); @@ -192,6 +193,8 @@ describe("Gitea Provider", () => { beforeEach(() => { vi.clearAllMocks(); + mockedRawFetch.mockReset(); + mockedRawFetch.mockImplementation(async () => ({ ...mockRawResponse })); mockClient.mockReset(); mockClient.mockResolvedValue({}); mockedCachedFetch.mockReset(); @@ -494,6 +497,73 @@ describe("Gitea Provider", () => { }); }); + describe("commits.list", () => { + it("rejects Gitea path filters because that API ignores pagination limits", async () => { + await expect( + provider.commits.list("testowner", "test-repo", { + path: "src/provider.ts", + page: 2, + perPage: 1, + }), + ).rejects.toMatchObject({ + message: + "Path-filtered commit listing is not supported by Gitea because its API ignores pagination limits", + status: 501, + platform: "gitea", + }); + expect(mockedRawFetch).not.toHaveBeenCalled(); + }); + + it("uses Gitea pagination headers when path is omitted", async () => { + const sha = "cb9d4e5dc0f07fd9504b74e6ef58c37e9a32af38"; + const commit = { + sha, + html_url: `https://gitea.com/testowner/test-repo/commit/${sha}`, + commit: { + message: "feat: list commit history", + 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" }], + }; + mockedRawFetch.mockResolvedValueOnce({ + data: [commit], + headers: new Headers({ "x-hasmore": "true", "x-total-count": "31" }), + status: 200, + }); + + const result = await provider.commits.list("testowner", "test-repo", { + ref: "main", + since: "2026-08-01T00:00:00Z", + until: "2026-08-29T23:59:59Z", + page: 2, + perPage: 10, + }); + + expect(mockedRawFetch).toHaveBeenCalledWith( + expect.anything(), + "/repos/testowner/test-repo/commits", + { + query: { + sha: "main", + since: "2026-08-01T00:00:00Z", + until: "2026-08-29T23:59:59Z", + stat: "false", + verification: "false", + files: "false", + page: "2", + limit: "10", + }, + }, + ); + expect(result).toMatchObject({ + totalCount: 31, + hasNextPage: true, + nextPage: 3, + }); + }); + }); + describe("commits.get", () => { it("returns commit metadata with null counts when Gitea omits them", async () => { const sha = "cb9d4e5dc0f07fd9504b74e6ef58c37e9a32af38"; diff --git a/test/github.test.ts b/test/github.test.ts index dfd74a8..397fab8 100644 --- a/test/github.test.ts +++ b/test/github.test.ts @@ -447,6 +447,65 @@ describe("GitHubProvider", () => { }); }); + describe("commits.list", () => { + it("returns paged commit summaries and forwards every history filter", async () => { + const commit = { + sha: "cb9d4e5dc0f07fd9504b74e6ef58c37e9a32af38", + commit: { + message: "feat: list commit history", + 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" }, + }, + html_url: "https://github.com/octocat/hello-world/commit/cb9d4e5", + parents: [{ sha: "parent-sha" }], + }; + mocks.rawFetch.mockResolvedValueOnce({ + data: [commit], + headers: makeHeaders( + '; rel="next"', + ), + }); + + const result = await gh.commits.list("octocat", "hello-world", { + ref: "main", + path: "src/provider.ts", + since: "2026-08-01T00:00:00Z", + until: "2026-08-29T23:59:59Z", + page: 2, + perPage: 10, + }); + + expect(mocks.rawFetch).toHaveBeenCalledWith( + mocks.client, + "/repos/octocat/hello-world/commits", + { + query: { + sha: "main", + path: "src/provider.ts", + since: "2026-08-01T00:00:00Z", + until: "2026-08-29T23:59:59Z", + page: "2", + per_page: "10", + }, + }, + ); + expect(result).toEqual({ + items: [ + { + sha: commit.sha, + message: "feat: list commit history", + author: commit.commit.author, + committer: commit.commit.committer, + parents: ["parent-sha"], + url: commit.html_url, + }, + ], + hasNextPage: true, + nextPage: 3, + }); + }); + }); + describe("commits.get", () => { it("returns commit metadata and drains changed-file pages without patches", async () => { const commit = { diff --git a/test/gitlab.test.ts b/test/gitlab.test.ts index bc10d5d..21edaf1 100644 --- a/test/gitlab.test.ts +++ b/test/gitlab.test.ts @@ -571,6 +571,72 @@ describe("GitLabProvider", () => { }); }); + describe("commits.list", () => { + it("returns paged commit summaries and maps portable filters", async () => { + const sha = "cb9d4e5dc0f07fd9504b74e6ef58c37e9a32af38"; + const commit = { + id: sha, + message: "feat: list commit history", + author_name: "Ori", + author_email: "ori@example.com", + authored_date: "2026-08-29T10:00:00Z", + committer_name: "Ori", + committer_email: "ori@example.com", + committed_date: "2026-08-29T10:01:00Z", + parent_ids: ["parent-sha"], + web_url: `https://gitlab.com/gitlab-org/gitlab-foss/-/commit/${sha}`, + }; + mockProjectResolve(); + mocks.rawFetch.mockResolvedValueOnce({ + data: [commit], + headers: glHeaders({ nextPage: "3" }), + }); + + const result = await gl.commits.list("gitlab-org", "gitlab-foss", { + ref: "main", + path: "src/provider.ts", + since: "2026-08-01T00:00:00Z", + until: "2026-08-29T23:59:59Z", + page: 2, + perPage: 10, + }); + + expect(mocks.rawFetch).toHaveBeenCalledWith( + mocks.client, + "/projects/278964/repository/commits", + { + query: { + ref_name: "main", + path: "src/provider.ts", + since: "2026-08-01T00:00:00Z", + until: "2026-08-29T23:59:59Z", + page: 2, + per_page: 10, + }, + }, + ); + expect(result).toEqual({ + items: [ + { + sha, + message: "feat: list commit history", + 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: ["parent-sha"], + url: commit.web_url, + }, + ], + totalCount: undefined, + hasNextPage: true, + nextPage: 3, + }); + }); + }); + describe("commits.get", () => { it("returns commit metadata and drains diff pages without returning diffs", async () => { const sha = "cb9d4e5dc0f07fd9504b74e6ef58c37e9a32af38"; diff --git a/test/integration.test.ts b/test/integration.test.ts index a36a006..5d3f96a 100644 --- a/test/integration.test.ts +++ b/test/integration.test.ts @@ -215,6 +215,7 @@ describe("cross-provider class consistency", () => { for (const platform of platforms) { const p = providers[platform]; expect(p.commits).toBeDefined(); + expect(typeof p.commits.list).toBe("function"); expect(typeof p.commits.get).toBe("function"); } }); @@ -249,6 +250,7 @@ describe("cross-provider class consistency", () => { expect(p.repos.get.length).toBeGreaterThanOrEqual(2); expect(p.ciRuns.list.length).toBeGreaterThanOrEqual(2); + expect(p.commits.list.length).toBeGreaterThanOrEqual(2); expect(p.commits.get.length).toBeGreaterThanOrEqual(3); expect(p.issues.list.length).toBeGreaterThanOrEqual(2); diff --git a/test/mcp.test.ts b/test/mcp.test.ts index a1faea7..86d84d2 100644 --- a/test/mcp.test.ts +++ b/test/mcp.test.ts @@ -9,7 +9,7 @@ import { resetPinnedProviders } from "../src/tool-operations.ts"; const mocks = vi.hoisted(() => { const repos = { list: vi.fn(), get: vi.fn() }; const ciRuns = { list: vi.fn() }; - const commits = { get: vi.fn() }; + const commits = { list: vi.fn(), get: vi.fn() }; const issues = { list: vi.fn(), search: vi.fn(), get: vi.fn(), create: vi.fn() }; const pullRequests = { list: vi.fn(), @@ -51,6 +51,7 @@ const toolNames = [ "forges_repos_list", "forges_repos_get", "forges_ci_runs_list", + "forges_commits_list", "forges_commits_get", "forges_issues_list", "forges_issues_search", @@ -225,6 +226,49 @@ describe("forges MCP server", () => { expect(JSON.parse(text(response.content))).toEqual({ platform: "github", result: runs }); }); + it("lists commit summaries through the shared operation", async () => { + const commits = { + items: [ + { + sha: "cb9d4e5dc0f07fd9504b74e6ef58c37e9a32af38", + message: "feat: list commit history", + author: { name: "Ori", email: "ori@example.com", date: "2026-08-29T10:00:00Z" }, + committer: { name: "Ori", email: "ori@example.com", date: "2026-08-29T10:00:00Z" }, + parents: ["parent"], + url: "https://github.com/agntn/forges/commit/cb9d4e5", + }, + ], + hasNextPage: false, + }; + mocks.commits.list.mockResolvedValue(commits); + const client = await connectTestClient(); + + const response = await client.callTool({ + name: "forges_commits_list", + arguments: { + platform: "github", + owner: "agntn", + repo: "forges", + ref: "main", + path: "src/provider.ts", + since: "2026-08-01T00:00:00Z", + until: "2026-08-29T23:59:59Z", + page: 2, + perPage: 10, + }, + }); + + expect(mocks.commits.list).toHaveBeenCalledWith("agntn", "forges", { + ref: "main", + path: "src/provider.ts", + since: "2026-08-01T00:00:00Z", + until: "2026-08-29T23:59:59Z", + page: 2, + perPage: 10, + }); + expect(JSON.parse(text(response.content))).toEqual({ platform: "github", result: commits }); + }); + it("gets one commit through the shared operation", async () => { const commit = { sha: "cb9d4e5dc0f07fd9504b74e6ef58c37e9a32af38", From c7c12a372efe5e47708f46272b82f1ef7409146c Mon Sep 17 00:00:00 2001 From: Aei <256851514+aeitwoen@users.noreply.github.com> Date: Mon, 31 Aug 2026 11:47:41 +0200 Subject: [PATCH 2/2] fix: match locked formatter output --- src/mcp.ts | 16 +++++++++------- test/gitea.test.ts | 5 ++--- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/src/mcp.ts b/src/mcp.ts index 405c9a1..c802f85 100644 --- a/src/mcp.ts +++ b/src/mcp.ts @@ -436,13 +436,15 @@ export function createMcpServer(): Server { const server = new Server({ name: "forges", version }, { capabilities: { tools: {} } }); server.setRequestHandler(ListToolsRequestSchema, () => ({ - tools: tools.map((tool): Tool => ({ - name: tool.name, - title: tool.title, - description: tool.description, - inputSchema: tool.inputSchema as Tool["inputSchema"], - annotations: tool.annotations, - })), + tools: tools.map( + (tool): Tool => ({ + name: tool.name, + title: tool.title, + description: tool.description, + inputSchema: tool.inputSchema as Tool["inputSchema"], + annotations: tool.annotations, + }), + ), })); server.setRequestHandler(CallToolRequestSchema, async (request) => { diff --git a/test/gitea.test.ts b/test/gitea.test.ts index 84057da..bad5949 100644 --- a/test/gitea.test.ts +++ b/test/gitea.test.ts @@ -26,9 +26,8 @@ vi.mock("../src/http.ts", () => ({ })); vi.mock("../src/cache.ts", () => ({ - cachedFetch: vi.fn( - async (client: (...args: unknown[]) => unknown, url: string, opts?: unknown) => - opts ? client(url, opts) : client(url), + cachedFetch: vi.fn(async (client: (...args: unknown[]) => unknown, url: string, opts?: unknown) => + opts ? client(url, opts) : client(url), ), }));