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 AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ src/
├── cache.ts # unstorage LRU cache — GET-only, lazy-initialized
├── errors.ts # ForgesError hierarchy + normalizeError()
├── ci-run.ts # Cross-platform CI/check status and conclusion normalization
├── pull-request-file.ts # Changed-file status normalization + GitLab diff line counts
├── changed-file.ts # Changed-file status normalization + GitLab diff line counts
├── pagination.ts # Link header + x-next-page async generator
├── version.ts # Package version — the one source for it in src/
├── tool-operations.ts # Executors behind every agent surface (MCP, Pi, OMP)
Expand Down
12 changes: 7 additions & 5 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-five tools - repositories, CI runs, 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-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:

| Platform | Environment variable |
| ------------------ | ------------------------ |
Expand Down Expand Up @@ -138,12 +138,14 @@ The extensions add the details the harnesses render; MCP drops them and keeps th

## API

Every provider gives you six resources with the same method shapes. Thread semantics still follow the platform: GitHub and GitLab return real multi-comment conversations, while Gitea has no parent id on review comments, so each one comes back as its own single-comment thread.
Every provider gives you seven resources with the same method shapes. Thread semantics still follow the platform: GitHub and GitLab return real multi-comment conversations, while Gitea has no parent id on review comments, so each one comes back as its own single-comment thread.

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

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

**commits** - `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?)`

**pullRequests** - `list(owner, repo, opts?)`, `listFiles(owner, repo, number, opts?)`, `listChecks(owner, repo, number, opts?)`, `search(owner, repo, query, opts?)`, `get(owner, repo, number)`, `create(owner, repo, input)`, `listComments(owner, repo, number, opts?)`
Expand All @@ -152,7 +154,7 @@ Every provider gives you six resources with the same method shapes. Thread seman

**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. 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 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 Expand Up @@ -231,9 +233,9 @@ import { fetchAllPages, paginate } from "@agntn/forges";

## What this doesn't do

This is an MVP. It covers repos, CI runs, issues, PRs, users, and review threads. It does not handle:
This is an MVP. It covers repos, CI runs, commits, issues, PRs, users, and review threads. It does not handle:

- Repository content operations (reading file contents, commits, trees)
- Repository content operations (reading file contents and trees)
- Webhooks
- Branch/tag management
- GraphQL outside GitHub review threads
Expand Down
13 changes: 13 additions & 0 deletions packages/omp/extensions/forges.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export default function forgesOmpExtension(pi: ExtensionAPI): void {
);
const owner = Type.String({ description: "Repository owner or organization", minLength: 1 });
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 page = Type.Optional(Type.Integer({ description: "Page number", minimum: 1 }));
const perPage = Type.Optional(
Expand All @@ -55,6 +56,7 @@ 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 listCiRunsParameters = Type.Object({ platform, owner, repo, branch, page, perPage });
const listRepositoryItemsParameters = Type.Object({
platform,
Expand Down Expand Up @@ -166,6 +168,17 @@ export default function forgesOmpExtension(pi: ExtensionAPI): void {
},
});

pi.registerTool({
name: "forges_commits_get",
label: "Forges Commit",
description: "Get one commit with metadata and changed-file rows",
parameters: commitParameters,
approval: "read",
async execute(_toolCallId, params) {
return (await loadToolOperations()).getCommit(params);
},
});

pi.registerTool({
name: "forges_issues_list",
label: "Forges Issues",
Expand Down
15 changes: 15 additions & 0 deletions packages/pi/extensions/forges.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type * as ForgesTools from "../../../dist/tool-operations.d.mts";
import {
authenticatedUserParameters,
commentParameters,
commitParameters,
createIssueParameters,
createPullRequestParameters,
listCiRunsParameters,
Expand Down Expand Up @@ -79,6 +80,20 @@ export default function forgesExtension(pi: ExtensionAPI): void {
},
});

pi.registerTool({
name: "forges_commits_get",
label: "Forges Commit",
description: "Get one commit with metadata and changed-file rows",
promptSnippet: "Read one commit and the files it changed from GitHub, GitLab, or Gitea.",
promptGuidelines: [
"Use forges_commits_get when a known commit SHA needs exact metadata or changed paths.",
],
parameters: commitParameters,
async execute(_toolCallId, params) {
return (await loadToolOperations()).getCommit(params);
},
});

pi.registerTool({
name: "forges_issues_list",
label: "Forges Issues",
Expand Down
2 changes: 2 additions & 0 deletions packages/shared/forges-tool-schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ const platform = Type.Unsafe<ForgesPlatform>({
});
const owner = Type.String({ description: "Repository owner or organization", minLength: 1 });
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 page = Type.Optional(Type.Integer({ description: "Page number", minimum: 1 }));
const perPage = Type.Optional(
Expand Down Expand Up @@ -58,6 +59,7 @@ 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 listCiRunsParameters = Type.Object({ platform, owner, repo, branch, page, perPage });
export const listRepositoryItemsParameters = Type.Object({
platform,
Expand Down
4 changes: 2 additions & 2 deletions src/pull-request-file.ts → src/changed-file.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { PullRequestFileStatus } from "./types.ts";
import type { ChangedFileStatus } from "./types.ts";

/** Normalize provider-specific changed-file states. */
export function normalizePullRequestFileStatus(status: string): PullRequestFileStatus {
export function normalizeChangedFileStatus(status: string): ChangedFileStatus {
switch (status) {
case "added":
return "added";
Expand Down
5 changes: 5 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ export type {
CiRunStatus,
CiRunConclusion,
CiRun,
ChangedFileStatus,
ChangedFile,
CommitIdentity,
Commit,
IssueState,
Issue,
PullRequestSearchItem,
Expand All @@ -46,6 +50,7 @@ export type {
ProviderConfig,
RepositoryResource,
CiRunResource,
CommitResource,
IssueResource,
PullRequestResource,
UserResource,
Expand Down
11 changes: 11 additions & 0 deletions src/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { ForgesError, RateLimitError } from "./errors.ts";
import {
authenticatedUserParameters,
commentParameters,
commitParameters,
createIssueParameters,
createPullRequestParameters,
listCiRunsParameters,
Expand All @@ -31,6 +32,7 @@ import {
createIssue,
createPullRequest,
getAuthenticatedUser,
getCommit,
getIssue,
getIssueComment,
getPullRequest,
Expand Down Expand Up @@ -140,6 +142,15 @@ const tools: ToolDefinition[] = [
annotations: readAnnotations,
execute: listCiRuns,
}),
defineTool({
name: "forges_commits_get",
title: "Get Commit",
description:
"Get one commit by SHA with normalized author, committer, parent revisions, message, URL, and changed-file rows. Patches are omitted; per-file counts are null when the provider does not report them, and filesComplete is null when provider or safety limits make completeness unknowable.",
inputSchema: commitParameters,
annotations: readAnnotations,
execute: getCommit,
}),
defineTool({
name: "forges_issues_list",
title: "List Issues",
Expand Down
7 changes: 7 additions & 0 deletions src/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import type {
CiRun,
CiRunResource,
Comment,
Commit,
CommitResource,
CreateIssueInput,
CreatePullRequestInput,
Issue,
Expand Down Expand Up @@ -62,6 +64,7 @@ export interface ProviderRawTypes {
export abstract class Provider<Raw extends ProviderRawTypes = ProviderRawTypes> {
public readonly repos: RepositoryResource;
public readonly ciRuns: CiRunResource;
public readonly commits: CommitResource;
public readonly issues: IssueResource;
public readonly pullRequests: PullRequestResource;
public readonly users: UserResource;
Expand All @@ -75,6 +78,9 @@ export abstract class Provider<Raw extends ProviderRawTypes = ProviderRawTypes>
this.ciRuns = {
list: (owner, repo, options) => this.listCiRuns(owner, repo, options),
};
this.commits = {
get: (owner, repo, sha) => this.getCommit(owner, repo, sha),
};
this.issues = {
list: (owner, repo, options) => this.listIssues(owner, repo, options),
search: async (owner, repo, query, options) => {
Expand Down Expand Up @@ -150,6 +156,7 @@ 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 abstract getCommit(owner: string, repo: string, sha: string): Promise<Commit>;
protected abstract listIssues(
owner: string,
repo: string,
Expand Down
43 changes: 41 additions & 2 deletions src/providers/gitea.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type {
ProviderConfig,
Repository,
CiRun,
Commit,
Issue,
PullRequest,
PullRequestCheck,
Expand All @@ -39,7 +40,7 @@ import type {
ThreadComment,
} from "../types.ts";
import { normalizeCiRunState } from "../ci-run.ts";
import { normalizePullRequestFileStatus } from "../pull-request-file.ts";
import { normalizeChangedFileStatus } from "../changed-file.ts";

// -- Raw Gitea API response types --

Expand Down Expand Up @@ -169,6 +170,24 @@ interface GiteaPullRequestFile {
deletions?: number;
}

interface GiteaCommitIdentity {
name: string;
email: string;
date: string;
}

interface GiteaCommit {
sha: string;
html_url?: string | null;
commit: {
message: string;
author: GiteaCommitIdentity;
committer: GiteaCommitIdentity;
};
parents?: Array<{ sha: string }>;
files?: GiteaPullRequestFile[];
}

interface GiteaComment {
id: number;
body?: string | null;
Expand Down Expand Up @@ -363,7 +382,7 @@ export class GiteaProvider extends Provider<GiteaRawTypes> {
private mapPullRequestFile(raw: GiteaPullRequestFile): PullRequestFile {
return {
path: raw.filename,
status: normalizePullRequestFileStatus(raw.status),
status: normalizeChangedFileStatus(raw.status),
additions: raw.additions ?? null,
deletions: raw.deletions ?? null,
};
Expand Down Expand Up @@ -535,6 +554,26 @@ export class GiteaProvider extends Provider<GiteaRawTypes> {
}
}

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 ?? "",
files: (commit.files ?? []).map((file) => this.mapPullRequestFile(file)),
filesComplete: null,
};
} catch (error) {
throw normalizeError(error, PLATFORM);
}
}

protected override async listIssues(
owner: string,
repo: string,
Expand Down
Loading
Loading