Guide

Commits, CI and releases

Commit history, ref comparisons, bounded patch reads, CI runs with one status and one conclusion, job logs with the failing step first, and releases keyed by tag across GitHub, GitLab and Gitea.

Commits

ts
const page = await forge.commits.list("owner", "repo", {
  ref: "main",
  path: "src/",
  since: "2026-08-01T00:00:00Z",
  until: "2026-09-01T00:00:00Z",
  perPage: 50,
});

const commit = await forge.commits.get("owner", "repo", page.items[0]!.sha);

list returns CommitSummary rows: sha, message, author and committer as { name, email, date }, parents as SHAs, and url. No files.

get adds the changed files:

ts
interface Commit extends CommitSummary {
  files: {
    path: string;
    status: ChangedFileStatus;
    additions: number | null;
    deletions: number | null;
  }[];
  filesComplete: boolean | null;
}

get still omits 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.

Read patch content separately and keep the resolved SHA for continuation:

ts
const first = await forge.commits.readPatch("owner", "repo", "main", {
  path: "src/provider.ts",
  maxChars: 20_000,
});

const next =
  first.nextOffset === null
    ? null
    : await forge.commits.readPatch("owner", "repo", first.sha, {
        path: first.path ?? undefined,
        offset: first.nextOffset,
        maxChars: 20_000,
      });

content is a stable stream with file headers. truncated and nextOffset describe the UTF-16 code-unit budget, while filesComplete describes provider pagination. Continue with the returned sha, nextOffset and the same non-null path; filtered offsets do not apply to the whole commit stream. Every slice repeats provider pagination, so prefer the largest practical maxChars instead of many tiny reads. states counts included, binary and unavailable patches before slicing. GitLab binary markers stay distinct, empty metadata-only diffs remain included, and oversized diffs are unavailable. Gitea reads its raw commit diff, keeps binary markers distinct and rejects raw diffs above 2 000 000 characters before materializing an unbounded response. GitHub labels a missing patch field unavailable because its commit response does not say why it was omitted. A continuation with a branch or tag is rejected after the first slice; use the returned sha so a moving ref cannot splice two commits together.

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.

Compare two refs

ts
const range = await forge.commits.compare("owner", "repo", "v1.2.0", "main");

items holds the commits main has and v1.2.0 doesn't, the same set git log v1.2.0..main prints. Oldest first, so release notes read in order. aheadBy and totalCount count the whole range, items is one page of it: 30 by default, perPage up to 100.

The first page also carries files, the net change from the merge base to head, with the same rows get returns. Later pages have files: null, it would be the same list again.

What about the commits base has and head doesn't? Only GitHub says. There you get behindBy, mergeBaseSha and status, one of identical, ahead, behind or diverged. GitLab and Gitea answer with the head side only, so all three are null. Swap base and head to count the other side.

Files have limits of their own. GitHub lists at most 300, and filesComplete turns null at that cap. GitLab's answer has no flag for a cut list, so filesComplete is always null there, and collapsed diffs have null counts. Gitea has no file list for a range at all, files is null.

GitLab and Gitea send the whole range in one answer, and the page is cut locally. Page 20 of a range between two old tags costs as much as page 1.

Search across repositories

ts
const matches = await forge.commits.search("author:octocat committer-date:2026-08-21..2026-09-20", {
  perPage: 10,
});

GitHub and GitLab. CommitSearchItem adds repository (the full owner/repo name) to the summary, so contributions to somebody else's project aren't lost. author-date: and committer-date: remain native query qualifiers; they don't mean the same thing. Optional owner and repo narrow the scope, and repo requires owner.

On GitHub the CommitSearchResult carries totalCount, incomplete and resultLimit: 1000. A timeout, more than 1000 matches, or filtering out a result outside the requested scope makes incomplete true. Pagination stops at 1000, not at the total GitHub reports: narrow the query to inspect more matches. Pages start at 1 and perPage accepts 1 through 100, defaulting to 30. Search isn't a complete activity feed; GitHub's index determines which commits can be found.

GitLab has no qualifiers. It matches the words against commit messages on the default branch, and a commit SHA finds that commit too. Every call needs a token. With owner and repo it searches one project, and that works on any GitLab. A group owner alone, or no scope at all, needs advanced search. Without it GitLab refuses the scope, and you get a 501 that says to pass owner and repo. There is no totalCount, because GitLab stops counting 100 hits past the earlier pages, and resultLimit is null.

forges_commits_search exposes the same result over MCP, Pi and OMP. Gitea returns 501, as do GitHub-compatible hosts without this endpoint.

Agents get a shorter row than the library does. forges_commits_search, forges_commits_list and forges_commits_compare cut each message to its subject line, at most 200 characters, and mark the cut ones with messageTruncated: true. A list is for picking commits, and one busy author's page of full messages can run past 100 000 characters. forges_commits_get still returns the message whole. A row also drops committer when it matches author in name, email and date, which is what a direct push looks like. A month of one author's direct pushes came out 19% shorter. A repository that uses squash merge barely changes, because GitHub is the committer there. details in Pi and OMP still carry both.

forges_commits_compare also leaves files out unless you pass files: true. Most calls only ask how far apart two branches are, and 300 file rows are a lot of tokens for that answer.

CI runs

ts
const runs = await forge.ciRuns.list("owner", "repo", { branch: "main", perPage: 10 });
ts
interface CiRun {
  id: string;
  branch: string;
  revision: string;
  status: "queued" | "in_progress" | "waiting" | "completed";
  conclusion:
    | "success"
    | "failure"
    | "cancelled"
    | "skipped"
    | "neutral"
    | "timed_out"
    | "action_required"
    | "stale"
    | "startup_failure"
    | null; // no outcome yet
  url: string;
}

One row is a GitHub Actions run, a GitLab pipeline or a Gitea Actions run. Each platform's vocabulary lands on the two enums: a GitLab running pipeline is in_progress with a null conclusion, a GitHub completed run carries its conclusion, a Gitea waiting run is waiting. Three vocabularies, two fields, done.

Pull request checks use the same two enums for whatever the platform pins to a head SHA.

Checks on one commit

Pull request checks need a pull request. What about the branch you pushed a minute ago and haven't opened one for?

ts
const checks = await forge.commits.listChecks("owner", "repo", "feat/commit-checks");

ref takes a SHA, a branch or a tag. A branch means the commit it points at right now. The rows are the same PullRequestCheck a pull request gives you, paged the same way. No pull request gets read. So it also answers for an old head, one the pull request already left behind.

Each platform answers with what it has. GitHub sends the commit statuses, then the check runs. Gitea sends the combined status, and Gitea Actions report there too. GitLab filters pipelines by an exact SHA. A branch costs one extra read to resolve, then you get every pipeline on that commit, newest first. A merged results pipeline isn't among them. It ran on a merge commit GitLab built.

A ref the repository doesn't have is a NotFoundError. An empty list would be a polite lie: nothing ran there because there's no there.

Jobs and logs

A run tells you it failed. Its jobs tell you which one, and the log tells you why.

ts
const jobs = await forge.ciRuns.listJobs("owner", "repo", run.id);
const failed = jobs.items.find((job) => job.conclusion === "failure");

if (failed) {
  const log = await forge.ciRuns.readJobLog("owner", "repo", failed.id, { maxChars: 20_000 });
}
ts
interface CiJob {
  id: string;
  runId: string; // the pipeline on GitLab
  name: string;
  status: CiRunStatus;
  conclusion: CiRunConclusion;
  startedAt: string | null;
  completedAt: string | null;
  url: string;
  steps: CiJobStep[]; // number, name, status, conclusion and times; empty on GitLab and Forgejo
}

An empty first page means the run never started a job. On GitHub a check id from listChecks is already a job id, so a failing check goes straight to readJobLog.

The log comes back without timestamps and ANSI colors. On GitHub and Gitea every step that ran is a --- step 10 Test: failure section, and failing steps go first. So the first slice usually holds the failing assertion or lint rule, and the other steps follow in the order they ran. The API gives step times in whole seconds, so a step starts at the first line of its window that opens a step, a Run group or a post step, and at the window's first line when none does. Continue with nextOffset like a patch read.

GitLab has no steps. Its trace reads top to bottom with the collapsible section markers stripped, and the failure is usually at the end, so start at length - maxChars. Forgejo 16 reads in order too. It has no route for one job. A job without a log is a NotFoundError there. Elsewhere a job no runner ever took returns started: false and an empty content without asking for a log. A log longer than 8 000 000 characters keeps its end and says logComplete: false.

Releases

ts
const page = await forge.releases.list("owner", "repo", { perPage: 10 });
const release = await forge.releases.get("owner", "repo", "v1.2.0");

await forge.releases.update("owner", "repo", "v1.2.0", { body: "## What changed\n..." });

await forge.releases.create("owner", "repo", {
  tag: "v1.3.0",
  ref: "main", // tags main when v1.3.0 does not exist yet
  name: "v1.3.0",
  body: "Draft notes, edit before publishing",
  draft: true,
});
ts
interface Release {
  id: string; // platform id, the tag on GitLab
  tag: string;
  name: string;
  body: string;
  draft: boolean;
  prerelease: boolean;
  author: { login: string };
  createdAt: string;
  publishedAt: string; // empty for a GitHub draft
  url: string;
}

The tag is the key everywhere, because a GitLab release has no id of its own, and a tag with a slash in it, release/1.2, is sent percent-encoded rather than refused; a name git check-ref-format would reject fails locally instead. get and update take the tag; on GitHub and Gitea the update reads the release first and edits it by the id that read hands back, on GitLab it is one PUT. An update sends only the fields you pass, the rest stays.

GitLab has no drafts and no pre-releases. Both flags read false there, and draft: true or prerelease: true on a create or update fails with a 501 instead of quietly publishing. GitHub lists drafts only to a token with push access, so an anonymous list is shorter than the maintainer's. A GitHub draft also has no tag in git yet and the tag route serves published releases only, so with a token get and update look for a draft carrying that tag among the 500 newest releases before answering NotFoundError; without one they answer straight away, since drafts are never listed anonymously. Gitea serves a draft by tag to anyone who may write releases. GitBucket has no releases API at all, releases.* is a 501 there.

releases.get hands your code the whole body. An agent gets it in slices. Why? Big release notes are a lousy way to find a context limit, and Nuxt v4.6.0 carries 76 042 characters of them. forges_releases_get returns 20 000 by default, up to 200 000 with maxChars. It reads like a job log. Follow nextOffset until it's null, and length tells you up front how far there is to go. Each continuation hands back notesHash. Someone edits the notes halfway through? You get a 409, not a changelog stitched from two versions. Nuxt takes four bites, or one if you crank maxChars.

What is left out

Repository content, trees, branches, release assets, webhooks and admin. On purpose. The scope is the review workflow: what is proposed, what was said about it, whether it passed, and what shipped.