Guide

Pull requests

Merge requests and pull requests as one PullRequest, with branches, head SHA, mergeability, changed files, checks and reviews.

Calls

ts
const open = await forge.pullRequests.list("owner", "repo", { state: "open" });
const pr = await forge.pullRequests.get("owner", "repo", 1234);
const linked = await forge.pullRequests.get("owner", "repo", 1234, { closingIssues: true });
const found = await forge.pullRequests.search("owner", "repo", "renovate");
const files = await forge.pullRequests.listFiles("owner", "repo", 1234, { perPage: 100 });
const checks = await forge.pullRequests.listChecks("owner", "repo", 1234);
const reviews = await forge.pullRequests.listReviews("owner", "repo", 1234);
const comments = await forge.pullRequests.listComments("owner", "repo", 1234);
const posted = await forge.pullRequests.createComment("owner", "repo", 1234, { body: "Rebased." });
const reactions = await forge.pullRequests.listReactions("owner", "repo", 1234);

const created = await forge.pullRequests.create("owner", "repo", {
  title: "docs: fix the deploy link",
  body: "Closes #42.",
  sourceBranch: "fix/deploy-link",
  targetBranch: "main",
  draft: true,
});

const edited = await forge.pullRequests.update("owner", "repo", 1234, {
  body: "Closes #42 and #43.",
  addAssignees: ["octocat"],
  addLabels: ["docs"],
});

const merged = await forge.pullRequests.merge("owner", "repo", 1234, {
  method: "squash",
  headSha: edited.headSha,
});

The shape

ts
interface PullRequest extends Issue {
  merged: boolean;
  draft: boolean;
  sourceBranch: string;
  targetBranch: string;
  mergeCommitSha: string;
  mergedAt: string | null; // null until merged
  mergedBy: { login: string } | null;
  headSha: string;
  mergeable: boolean | null; // null while the platform is still computing it
  mergeStatus: string; // the platform's own word
  closingIssues?: ClosingIssue[] | null; // { number, title, state, url }, only when asked
}

mergedBy needs get on GitHub and Gitea: their list responses leave out who merged, so list gives the timestamp and null for the account. GitLab reports both either way.

Agents get a lighter row from forges_pull_requests_list. An open pull request has no merge commit, no merge time and nobody who merged it. So mergeCommitSha, mergedAt and mergedBy stay out until merged is true. mergeable and mergeStatus go too while the list doesn't know them, and on GitHub it never does. Five fields of nothing per row. Across five real pages that's about 8% fewer tokens, nearly 13% on a busy open queue. forges_pull_requests_get still says everything, and details in Pi and OMP keep the whole row.

{ closingIssues: true } answers the question every PR opener asks right after: did Fixes #42 actually link #42? It costs one extra request, sent alongside the main read, to GraphQL on GitHub and closes_issues on GitLab. Gitea has no such endpoint and GitBucket serves no GraphQL, so both give null, which means unknown, never "nothing linked". A linked issue can live in another repository, so check its url before trusting the number.

A GitLab merge request has an iid, that is number here. A GitHub pull request is also an issue, so id, labels, author and the dates come from the same place issues get them.

Changing one after it's open

update takes only what changes: title, body, state ("closed" closes, "open" reopens), and addAssignees, removeAssignees, addLabels, removeLabels. Everything you leave out stays, which is the whole point. Adding one assignee keeps the others, and fixing a typo in the body does not wipe the labels. The result is the pull request as it is after the write, so check that the body actually landed instead of trusting a 200.

Nothing goes out when the input changes nothing, or when the same login or label shows up in both an add and a remove list. That's a ForgesError with status 400, because I can't guess which one you meant.

The platforms disagree underneath. GitHub edits title, body and state on the pull request, and assignees and labels through its issue, one request per removed label. GitLab takes it all in one PUT, except assignees go out as the complete list of ids, so an assignee change reads the merge request first. Gitea also wants the complete assignee list, and it adds and removes labels by id. A label it doesn't know is a NotFoundError, not a new label. On both, someone assigned by hand between that read and the write drops off the list. GitLab has no narrower route, and the one Gitea is adding is too new for Forgejo and older hosts. Several requests in a row are not one transaction: if the third fails, the first two stay done, so read the pull request before you retry.

draft isn't part of it. Neither GitHub's REST edit nor GitLab's has the field, and Gitea tells a draft by a WIP: prefix on the title. On GitLab and Gitea a Draft: or WIP: title does the job, if you really need it.

Merging it

merge lands an open pull request and gives it back as it is afterwards, with merged, mergedAt, mergedBy and the mergeCommitSha you'd go looking for next. Every field is optional. method is "merge", "squash" or "rebase", title and message make up the commit, and headSha is the one I'd always pass.

headSha says which commit you actually looked at. The checks ran on it, the review was about it. If someone pushes after that, the platform refuses with a 409 instead of merging code nobody has seen. A 405 means it can't merge right now: closed, a draft, a conflict, a required check or approval still missing, or the method is switched off for the repository. Both come back as a ForgesError that says what to check before the next try, because Gitea's 405 has an empty body and the status alone tells you nothing.

A 5xx is worse. The merge may have landed and the answer didn't. GitHub can send that 500 with an empty body, so the message spells it out: read merged and mergeCommitSha before you merge again. And if the merge went fine but reading the pull request back didn't? The error says the merge went through. Nobody merges twice.

The platforms disagree here too. Leave method out and GitHub and Gitea make a merge commit, while GitLab follows the project and the merge request, squash setting included. GitLab can't rebase on request either: the project's merge method decides, so "rebase" there is a 501, not a merge commit you didn't ask for. It also takes the commit message as one string, so a message without a title is a 400 on GitLab.

Search returns less

search gives you PullRequestSearchItem: the Issue fields plus merged and draft. No branches, no head SHA, no mergeability. GitHub and Gitea search responses simply do not carry them, and fetching them per row would make one search page cost a whole page of requests. Call get for the row you actually care about. Like issue search, it looks at every state unless you pass state.

Search across repositories

searchGlobal finds an author's PRs without knowing the repositories first. The existing search(owner, repo, query) stays scoped to one repository.

ts
const recent = await forge.pullRequests.searchGlobal("", {
  author: "octocat",
  sort: "created",
  order: "desc",
  perPage: 20,
});

author is a login and means the same thing on every platform. Each provider turns it into its own filter: author: on GitHub, author_username on GitLab, created_by on Gitea. With author or owner set the query can stay empty, so an org's PRs start coming on the first call, page by page. Otherwise it holds keywords, plus GitHub's qualifiers there.

Each hit adds repository (for example octocat/Hello-World) to PullRequestSearchItem. Optional owner and repo narrow the scope; repo needs an owner. sort accepts created, updated or comments, and order accepts asc or desc. Omit sort for GitHub's best match ordering; order defaults to desc.

Pass nextCursor back as cursor while there is one. totalCount is the provider's match count, not the number available to download: GitHub exposes at most resultLimit: 1000 hits. incomplete is true when GitHub marks the response partial, the count exceeds that cap, or rows are dropped because they aren't PRs with a usable repository identity in the requested scope. Narrow the query instead of treating a capped search as a complete history.

Gitea takes plain keywords instead of GitHub's qualifiers and matches them against titles and bodies. It always lists the newest first, so any other sort or order: "asc" is a 501, and since it caps nothing, resultLimit is null. Its search route can't filter by repository, so owner with repo reads that repository's pull requests instead. incomplete only flags rows dropped for lacking a repository. Forgejo ignores created_by on the search route, so a page holding someone else's pull request is a 501, not an unfiltered list passed off as filtered.

GitLab matches keywords too. It sorts by created or updated either way round, comments is a 501, and resultLimit is null. owner has to be a group: GitLab can't search the merge requests of one user's projects, so pass author for that. On gitlab.com a search with neither an owner nor an author can time out, and the 408 says how to narrow it.

forges_pull_requests_search_global exposes this contract in MCP (including forges mcp), Pi and OMP, without the bodies. GitHub-compatible hosts without the endpoint return 501. This doesn't change their existing repository searches.

Changed files

listFiles turns each changed file into a path, a status and two counts:

ts
interface PullRequestFile {
  path: string;
  status: "added" | "modified" | "removed" | "renamed" | "copied" | "unknown";
  additions: number | null;
  deletions: number | null;
}

No patches. GitLab withholds counts for a collapsed or oversized diff, and those come back as null. Never as zero, zero would be a lie.

Checks

listChecks takes the cursor of the page before and an integer perPage from 1 to 100, defaulting to 30. A cursor it never handed out, a perPage outside that range or a page range beyond JavaScript's safe integers rejects with a ForgesError (status 400) before any HTTP request. A bad cursor isn't an empty set of checks.

listChecks reads whatever the platform pins to the head revision: GitHub commit statuses and then check runs of the head SHA, GitLab merge request pipelines for the current head, Gitea commit statuses. Each one is a PullRequestCheck with a name, a lifecycle status and a terminal conclusion, the same two enums CI runs use. No pull request yet? Commit checks take a branch.

A GitLab project with merged results pipelines or merge trains runs the merge request on a merge commit it builds. That pipeline carries the merge commit's SHA, not the head's. It's listed anyway, as the head pipeline GraphQL names for the merge request. Why GraphQL? REST has a head_pipeline too, but after a push it can still point at the previous head. Old results dressed up as new ones, no thanks. Earlier pipelines on the same merge ref never show up, since the API doesn't say which head they were built from.

A GitLab check is named after its pipeline, the workflow:name of .gitlab-ci.yml, so a project that never set one lists every check as pipeline. The merge request pipeline rows don't carry that name, so each pipeline on the page is read once more, from the project it ran in: for a merge request from a fork that's the fork. Five of those reads go out at a time.

The GitHub statuses matter because branch protection can require them. A CLA bot, Jenkins or Buildkite reports through the Status API, not as a check run, so a page of green check runs isn't a green pull request. The statuses come first, since there are few of them and a page of check runs can be long. totalCount counts both, and a status with no target page has an empty url. GitBucket has the status route and no check runs. A 404 there, on a host that isn't GitHub or Enterprise, is an empty list, so the statuses still come back. On GitHub that same 404 stays not found.

Reviews

listReviews is who said what about the pull request as a whole: the approvals, the changes requested, the review comments that took no side.

ts
interface PullRequestReview {
  id: string;
  state: "approved" | "changes_requested" | "commented" | "dismissed" | "pending";
  body: string;
  author: { login: string };
  revision: string; // the commit reviewed, empty when the platform keeps none
  submittedAt: string; // empty when the platform keeps no time
  url: string;
}

A request for review that nobody has answered isn't a review and stays out. GitHub and Gitea have real review objects, and a Gitea review dismissed later keeps its verdict in the API, so the dismissed flag wins here. GitLab has none: an approval is a row on the approvals endpoint with its time, a reviewer's stance is a state on the reviewers endpoint without one, and the two merge per user, so id there is the user's id. A reviewer who withdrew an approval reads as dismissed, a review started and not sent as pending. GitBucket has no reviews route at all and says so with an unsupported error. Compare revision with headSha to see whether a verdict predates the current push. The inline comments a review carries live under threads.

getReview(owner, repo, number, reviewId) reads one complete GitHub or Gitea review, including its body and verdict. Use the id from listReviews, not an inline comment id. GitLab has no individual review bodies, so this read returns a 501 rather than treating a user id as a review.

Agent lists keep bodies short. Follow forges_pull_requests_reviews with forges_pull_requests_reviews_get when a finding falls beyond that excerpt. Nothing is cut from the single-review response.

ts
const review = await forge.pullRequests.getReview("owner", "repo", 1234, "5678");
console.log(review.body);

Comments

listComments on a pull request is the discussion, not the review threads. On GitHub and Gitea it is the same endpoint as issue comments. Inline review comments live under threads.

createComment posts to that discussion too, never into a review thread. GitLab keeps it on the merge request, as a note, so an issue with the same number doesn't get it. To answer a reviewer next to their line, use threads.reply.

Reactions

Some review bots never write a word. Codex drops eyes on a pull request while it reads, then swaps it for +1 when it has nothing to say. That's the whole review. Easy to miss, and listReactions won't miss it.

ts
const { items } = await forge.pullRequests.listReactions("owner", "repo", 1234);
const afterPush = items.filter((reaction) => Date.parse(reaction.createdAt) > pushedAt);

Is that +1 about your last push or the one before? Only createdAt knows. Rows come oldest first, so the newest verdict sits on the last page.

GitHub and Gitea read it through the issue route, GitLab through the merge request's award emoji. An issue number is a NotFoundError, so a thumbs up on the issue never passes for a review. commentId picks a conversation comment instead, with the same names and rules as issue reactions.