Multi-forge, policy-enforcing MCP server for AI coding agents.
Supported forges:
- Forgejo / Gitea — full support
- GitHub — native REST/GraphQL support with managed GitHub App installation authentication
- GitLab — read + write support. The GitLab adapter was autonomously implemented by cockpit-orchestrated agents overnight while I slept — which is kind of the whole point of this project.
MCP tools:
read_repository_file— read a single UTF-8 text file from a repositorycommit_patch— apply a git-format patch to a new branch and push itopen_change_request— open a pull request on the forge
Write safety:
- Diff validation rejects binary files, symlinks, submodules, path traversal, oversized patches
commit_patchonly accepts git diff format; generate patches withgit diff --no-ext-diff --binaryorgit show, not hand-written traditional unified diffs- The server validates and applies patches in a clean clone of the base branch
- Policy engine enforces branch prefix (
agent/) and protected path rejection - Audit-before-action on all write operations
- Git auth via
http.extraHeader— token never in argv or URLs
get_change_request_comments remains the mixed chronological discussion/review
feed. Use get_change_request_discussion_comments for general discussion alone,
or get_change_request_reviews for submitted top-level formal reviews alone.
These narrow reads fetch only their selected provider resource; an error reading
the other resource cannot break them. Empty streams return []. Existing comment
and submit-review writes are unchanged. Inline review comments are not included.
For example, pass forge="adlevio", owner="tokarix", repo="forge-mcp", index=137
to these tools (substitute the pull request index for the task):
- A fresh review starts with
get_change_requestandget_change_request_diff. Read historical feedback only when deliberately needed. - Rework calls
get_change_request_reviewsand selects the triggering review by itsidandcommit_id, where supplied by the provider. - Optional conversation inspection uses
get_change_request_discussion_comments; optional combined history inspection usesget_change_request_comments.
The HTTP reads are GET /api/v1/repos/{forge}/{owner}/{repo}/pulls/{index}/reviews
and GET /api/v1/repos/{forge}/{owner}/{repo}/pulls/{index}/discussion-comments.
GET/POST .../comments and POST .../reviews remain available.
Forgejo and GitHub retain review IDs, available commit references, submitted
empty-body reviews, and dismissed reviews. Drafts without submitted_at are
omitted. State spellings remain provider-specific: Forgejo uses REQUEST_CHANGES
and COMMENT; GitHub uses CHANGES_REQUESTED and COMMENTED. Both also expose
APPROVED and DISMISSED. Results use stable chronological timestamp ordering;
the mixed feed places discussion before reviews for equal timestamps.
GitLab discussion consists of non-system merge request notes. Its reviews read
exposes current approvals only, not a historical formal-review stream:
kind="review", review_state="APPROVED", id=0, empty body and timestamp, and
commit_id=null. It cannot identify a REQUEST_CHANGES event. Discussion text
is never parsed to synthesize review feedback.
GitHub retains bounded pagination and fails the read if a later selected page fails. Forgejo comments/reviews and GitLab notes currently fetch one upstream page; these APIs do not promise exhaustive historical results. Future pagination work must preserve isolation between discussion and review resources.
Limitations:
- This thing is not efficient.
commit_patchandrebase_branchdo a full clone every time. For small to medium repos, that's fine. For large repos, you'll feel it. - Repos with many submodules will be painful — but then again, submodules are a vile antipattern and nobody should be using them.
- Scaling to very large monorepos is not a goal right now.
Workspace layout:
audit/: audit sink interfaces and in-memory implementationdomain/: canonical types, service traits, diff validation, policy engineforge/: forge adapter trait and Forgejo, GitHub, and GitLab implementationsgit-exec/: CLI-based git operations for the write pathorchestrator/: read and write workflow compositionserver/: binary wiring and MCP entry pointtransport/: stdio MCP transport usingrmcp
How to run:
- Copy
forge-mcp.example.tomltoforge-mcp.tomland configure the forges and agents. - Start the server with
cargo run -p server -- forge-mcp.toml.
Operators can make one Git identity authoritative for every forge and every
agent by configuring both fields under [server]:
[server]
listen = "0.0.0.0:8443"
commit_author_name = "forge-mcp"
commit_author_email = "forge-mcp@example.com"These fields belong only under [server], not under [[agents]]. For
commit_patch, the configured identity becomes both author and committer and
overrides request fields, including identity discovered from a transport's
local Git configuration. For rebase_branch, original authors are preserved
while the configured identity becomes the committer of every rewritten
commit; forge-user and agent fallback lookup is skipped.
Both fields are required together. Omitting both preserves the existing behavior: patch commits use the request author, while rebases use the authenticated forge user when available and otherwise fall back to the agent identity.
For distinct GitHub actors, register one GitHub App per agent and install each
App on the repositories it may access. Configure that App installation under
[agents.github_app.<forge-alias>]. For example, Apps whose slugs are
stintel-codex and stintel-qwen act on GitHub as the separate bot accounts
stintel-codex[bot] and stintel-qwen[bot]. A pull request created by one bot
can therefore be reviewed by the other; GitHub does not allow a pull-request
author to approve its own pull request. forge-mcp rejects assigning the same
App ID to different agent IDs on one forge because that would collapse them
back to a single GitHub actor.
[[agents]]
token = "bearer-token-for-codex"
agent_id = "codex"
session_id = "default"
[agents.github_app.github]
app_id = 123456
installation_id = 78901234
private_key_path = "/run/secrets/stintel-codex.pem"
[agents.policy]
allowed_repos = ["github/org/repo"]forge-mcp signs a short-lived App JWT for every configured identity, exchanges it for an installation access token at startup, and refreshes it automatically. The caller's managed token is used for API requests, patch pushes, and Git smart-HTTP clone/fetch operations. Tokens are never placed in command arguments or repository URLs.
[forges.github_app] is an optional fallback/system App. It is useful for
unattended operations that are not initiated by an authenticated agent, such
as webhook-driven auto-merge. A per-agent App always takes precedence. Static
[agents.forge_identity.<alias>] tokens remain available, but an agent cannot
configure both identity modes for the same forge.
The GitHub App needs repository access to every repository agents may use and these repository permissions:
- Metadata: read
- Contents: read and write
- Issues: read and write
- Pull requests: read and write
- Commit statuses: read and write
- Checks: read (discovers check runs)
- Actions: read (resolves workflow runs, attempts, jobs, and logs)
Existing GitHub App installations must approve the added Actions: read permission before failed-job resolution works for private repositories.
If webhooks are enabled, subscribe each App to Issues, Issue comments,
Pull requests, Pull request reviews, Pull request review comments,
and Pull request review threads, and configure the same webhook
secret on the Apps and in forge-mcp. Webhook-driven auto-merge additionally
requires a forge-level token or [forges.github_app]; auto-merge must also be
enabled in the target repository.
Authenticated issue deliveries publish one repository-scoped issue event.
Subscribe Forgejo to Issues (issues), GitHub Apps to Issues with
Issues read permission, and GitLab to Issue events (Issue Hook). Subscribe
to the normalized issue channel (or use poll_events with channels disabled) and call
get_issue for authoritative state, content, and labels before acting.
| Normalized action | Forgejo 16.0.3 | GitHub issues | GitLab Issue Hook |
|---|---|---|---|
opened |
opened |
opened |
open |
closed |
closed |
closed |
close |
reopened |
reopened |
reopened |
reopen |
edited |
edited (title/body) |
edited |
update with title/description delta |
labels_changed |
label_updated, label_cleared |
labeled, unlabeled |
update with label membership delta |
The existing Forgejo provider CI lane records actual Event and Event-Type headers for reopen, separate title/body edits, and label operations. That lane supplies versioned service evidence; deterministic fixtures alone do not establish wire compatibility.
Dedicated label changes carry metadata such as:
{"event_kind":"issue","action":"labels_changed","labels_changed":true,"forge_alias":"internal","owner":"org","repo":"repo","issue":42,"delivery_id":"delivery-1"}A GitLab content edit plus label change produces a single envelope with:
{"event_kind":"issue","action":"edited","labels_changed":true,"forge_alias":"internal","owner":"org","repo":"repo","issue":42,"delivery_id":"delivery-2"}These examples show the relevant metadata within the normal channel envelope. Open/close/reopen remains the primary action when a valid label delta accompanies it. The marker defaults to false in old events and is omitted when false. New metadata is limited to finite action strings and a boolean; no body snapshots, before/after values, label arrays, raw changes, or fingerprints enter the envelope.
GitLab requires object_kind=issue for reopen/update. Equal or malformed deltas,
label reordering, snapshot-only and unrelated updates produce no update hint.
Explicit PR/MR-shaped payloads are excluded. Repository label-definition events
and invented replace/clear actions are unsupported on GitHub; bulk membership
operations arrive as individual labeled/unlabeled deliveries.
Nonempty provider delivery IDs retain their existing dedupe and SSE identities. Without an ID, reopen/edit/label-bearing hints use an internal SHA-256 payload fingerprint: identical bytes coalesce within the existing dedupe TTL, while distinct no-ID label bodies now survive. Different serialization can escape this best-effort dedupe. Ordinary opened/closed fallback keys remain unchanged. Authorized live/replay delivery and periodic polling remain necessary.
Reopened/edited hints do not recover tasks, invalidate plans automatically, or authorize workflow mutations. No workflow trigger labels are introduced.
Subscribe Forgejo to pull request events (including closure), GitHub Apps to Pull requests, and GitLab to Merge request events. Configure the shared secret/token in the provider and forge-mcp; registration is not changed automatically.
| Provider delivery | Published action |
|---|---|
Forgejo/GitHub closed, pull_request.merged: false |
closed |
Forgejo/GitHub closed, pull_request.merged: true |
merged |
GitLab close with state closed |
closed |
GitLab merge with state merged |
merged |
These use the existing change_request envelope: both kind and
meta.event_kind remain change_request. Repository identity and PR number/MR
iid identify the target. Available source-head SHA is retained; terminal
meta.head_sha is omitted when unavailable, never replaced by a merge or target
commit. Terminal deliveries only publish hints, even with webhook auto-merge
enabled; they do not schedule, cancel, or mutate consumer workflows.
Closure requires explicit boolean merge evidence on Forgejo/GitHub, and
GitLab terminal actions require matching state. Ambiguous or malformed payloads
are rejected. Guessed standalone merge/merged actions on Forgejo/GitHub,
state-only updates, and branch deletion do not create terminal hints.
Existing opening, reopening and head-change actions remain unchanged.
Delivery deduplication is in memory with a five-minute TTL, scoped by forge alias and delivery ID. Without a delivery ID, the existing repository, index, head and action fallback key is used. Replay retains the latest 32 events and uses the same repository authorization as live delivery. These are best-effort hints: consumers must refetch authoritative state. Polling remains the fallback for missed, duplicate, out-of-order, or unsupported deliveries and restarts.
The deterministic provider fixtures follow
GitHub's closure discriminator
and GitLab's MR event contract.
Forgejo provenance is versioned source, not a live delivery observation:
v15.0.0 notifier
emits HookIssueClosed on merge;
PR conversion
copies pr.HasMerged, and the
API PR type
serializes that boolean as merged.
Consumers select meta.event_kind == "change_request" && meta.labels_changed == true.
Do not require meta.action == "labels_changed": GitLab retains its existing
lifecycle action, including update → synchronize, when a delivery also changes
labels or code. Open, reopen, close and merge deliveries can carry the marker too.
The additive boolean defaults to false when reading old envelopes and is omitted
when false. It survives SSE, replay, poll_events (including channels disabled)
and channel notifications. Channels retain their existing forge_alias → forge
name mapping. These example metadata objects show a dedicated delivery and a
combined GitLab update:
{"event_kind":"change_request","action":"labels_changed","labels_changed":true,"change_request":42,"issue":null,"issue_comment":null,"forge_alias":"forgejo","owner":"org","repo":"repo","delivery_id":"label-1","head_sha":null,"review_state":null}{"event_kind":"change_request","action":"synchronize","labels_changed":true,"change_request":42,"issue":null,"issue_comment":null,"forge_alias":"gitlab","owner":"org/subgroup","repo":"repo","delivery_id":"label-2","head_sha":"source-head","review_state":null}| Provider | Subscription and HTTP headers | Supported label signal |
|---|---|---|
| Forgejo 16.0.3 | Select pull_request_label; X-Forgejo-Event: pull_request, X-Forgejo-Event-Type: pull_request_label. Existing X-Gitea-* aliases are accepted. |
label_updated for add/remove/replace; label_cleared for clear. Both normalize to labels_changed. |
| Forgejo/Gitea adapter compatibility | pull_request dispatch also accepts those two actions without Event-Type; a dedicated pull_request_label Event value is accepted only for those actions. |
This compatibility input is not a claim that Forgejo 16.0.3 emits a dedicated Event value. Other provider versions/actions are unverified. |
| GitHub | Pull requests, X-GitHub-Event: pull_request; Apps need Pull requests read permission. |
labeled / unlabeled → labels_changed. Bulk operations are individual membership deliveries, not invented replace/clear actions. Repository label definition events are excluded. |
| GitLab | Merge request events, X-Gitlab-Event: Merge Request Hook. |
Different changes.labels.previous/current sets of label IDs/titles add the marker without replacing the lifecycle action. Current-label snapshots alone do not. Reordering and cosmetic fields are ignored. Absent/null/malformed deltas preserve the original lifecycle event; malformed projections produce a fixed diagnostic. |
Forgejo evidence is pinned to the v16.0.3 notifier
(IssueChangeLabels, IssueClearLabels), event grouping,
HTTP header construction
and payload types.
The ignored forgejo_issue_label_webhooks test in the existing Woodpecker
forgejo-integration lane verifies real PR add/remove/multiple/replace/clear
mutations through authenticated delivery and MCP polling, including actual Event
and Event-Type headers, distinct delivery IDs and genuine issue-label identity.
CI owns the provider process and credentials; no local service is required.
The GitHub contract
and GitLab changes contract
provide the other deterministic fixture provenance.
Label hints require a positive PR number/MR iid and nonempty target repository
owner/name. Conflicting supplied top-level and nested PR numbers are rejected.
Forgejo accepts either PR number location; GitHub requires its top-level number.
GitLab label deltas also require object_kind: merge_request.
Issue-shaped bodies are never converted into PR hints. Explicit PR discriminators
on issue label webhooks suppress source-issue hints. Source heads are retained
when available; label hints need no head/ref or merge discriminator, and missing
heads serialize as null. Target and merge commits never fill a missing source head.
Existing lifecycle and terminal state validation remains in place.
No label lists, names, deltas or provider bodies reach the output. The extra label-specific output stays constant-size for arbitrarily large label snapshots; existing webhook body limits remain unchanged. Any label can trigger the hint: the boolean says only refetch this PR/MR, not that labels remain present or that review is eligible. Cockpit must refetch authoritative PR state and labels before any workflow mutation. Removal does not authorize review cancellation. Workflow selection, tasks, claims, re-review and polling changes belong to Cockpit; these hints do not trigger approvals or auto-merge.
Delivery-ID dedupe remains forge alias + delivery ID (including the existing GitLab event UUID selection). Without an ID, label-bearing events use a namespaced repository/PR/action key plus SHA-256 of the original authenticated bytes. Distinct mutations at an unchanged head survive; identical bodies inside the five-minute TTL coalesce as best-effort retries. Different serialization can escape dedupe, and identical separate operations can coalesce. Non-label fallback keys are unchanged. Delivery may be delayed, missing or out of order; retain periodic polling and refetch as the fallback.
Authenticated GitHub and GitLab CI notifications publish one repository and
exact-commit wake hint through SSE (event: ci), channel notifications and
poll_events. No PR/MR association is required or inferred. For example:
{
"kind": "ci",
"content": "ci changed at aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"meta": {
"action": "changed",
"event_kind": "ci",
"forge_alias": "github",
"owner": "org",
"repo": "repo",
"head_sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"delivery_id": "delivery-123",
"change_request": null,
"issue": null,
"issue_comment": null,
"review_state": null,
"provider_action": "completed",
"ci": {
"source": "check_run",
"provider_event": "check_run",
"provider_delivery_id": "delivery-123",
"delivery_id_source": "X-GitHub-Delivery",
"id": 123,
"parent_id": 100,
"name": "unit tests",
"status": "completed",
"conclusion": "success"
}
}
}ci.source is commit_status, check_run, check_suite, pipeline, or
job. Native id and parent_id are optional positive integers; parent means
check-suite ID for a run and pipeline ID for a job. Optional name, context,
status, conclusion, started_at, completed_at and updated_at preserve
source values. Absent/null lifecycle values stay absent; unknown values remain
opaque. provider_action records a native check action separately from status.
Status, pipeline and job notifications have no synthesized provider action.
provider_event records the native event header. provider_event_id preserves
GitLab's event UUID; provider_delivery_id preserves its webhook UUID (or
GitHub's delivery header), even when a different message ID is selected.
| Provider | Subscription and event header | Identity and supported lifecycle |
|---|---|---|
| GitHub | Statuses, X-GitHub-Event: status |
Top-level sha, id, context, state |
| GitHub | Check runs, X-GitHub-Event: check_run |
Run head_sha; actions created, completed, rerequested, requested_action |
| GitHub | Check suites, X-GitHub-Event: check_suite |
Suite head_sha; actions completed, requested, rerequested |
| GitLab | Pipeline events, X-Gitlab-Event: Pipeline Hook |
object_kind=pipeline; object_attributes.sha/id/status |
| GitLab | Job events, X-Gitlab-Event: Job Hook |
object_kind=build; top-level sha/build_id/build_status, optional pipeline_id |
| Forgejo v16.0.3 | Generic status/check ingress unsupported | Keep polling external commit statuses; native Actions completion notifications are separate and unnormalized |
See the GitHub event contract
(and its check_suite/status sections). Apps need Commit statuses read access
for statuses and Checks read access for the read-level check subset. Receiving
requested/rerequested/requested_action notifications can require Checks
write access. Repository and organization hooks receive only run created/
completed and suite completed actions; Apps can receive the other actions
with the required permissions. These subscriptions do not promise every intermediate transition. in_progress
is a payload status, never an invented action. Existing App permissions,
credentials and webhook registration are unchanged.
GitLab requires authoritative project.path_with_namespace, split at the last
slash to retain nested groups. Older jobs without that path are acknowledged as
unsupported; display names and clone URLs cannot supply authorization identity.
Present malformed paths, conflicting project IDs or conflicting commit SHAs
are rejected. Job commit.id is a pipeline ID and never supplies the commit SHA.
MR and source_pipeline coordinates do not replace the event project.
See GitLab pipeline and job payloads.
Original-byte signature verification and GitLab secret-token authentication
remain unchanged. Invalid authentication returns 401, malformed supported
payloads 400, and supported or ignored authenticated deliveries 202.
The new projection limits UTF-8 bytes: identifiers/headers 256, repository path 1024, names/context 1024, lifecycle/action tokens 128, timestamps 64, and the serialized normalized envelope 16 KiB. Oversized retained fields are rejected, not truncated. Commit IDs must be nonzero full 40- or 64-digit hexadecimal object IDs, copied exactly from the CI source. Repository components must be nonempty, without dot, empty or control components. Typed subset parsing omits logs, arrays, annotations, variables, users, URLs and other raw payload data. The router body-size limit remains in force. Fingerprints never serialize.
GitHub uses X-GitHub-Delivery. New GitLab CI hints select the first nonempty
webhook-id, Idempotency-Key, then X-Gitlab-Webhook-UUID, recorded in
delivery_id_source. GitLab event UUIDs can be shared by recursive events and
are not delivery IDs; see delivery headers.
MR hints carrying base/draft deltas also use this selection, including compound
synchronize and lifecycle hints. MR events without these deltas and issue/note
handling retain their legacy event-UUID identity. Dedupe retains forge plus
selected delivery ID, with best-effort legacy webhook-UUID retries. Without an ID, delivery_id
stays empty, SSE uses its synthetic transport ID, and a namespaced key includes
repository, source, exact SHA, native event/action and verified-body SHA-256.
Byte-identical retries collapse; changed bodies and other SHAs survive.
Different encodings and genuinely repeated identical no-ID actions are
ambiguous. The five-minute in-memory dedupe, 32-event replay and bounded
subscriber channels provide neither persistence, ordering nor lossless delivery.
Live delivery and replay apply the same repository authorization.
A successful job, suite or pipeline is only a source-scoped hint. Consumers
must resolve their repository/exact-SHA bindings, refetch authoritative forge
checks and revalidate the current binding/head before dispatch, failure handling
or merge-readiness changes. Old-SHA and out-of-order hints remain historical.
Periodic polling is mandatory. CI hints only publish; they never schedule,
cancel or modify auto-merge, regardless of webhook.auto_merge.
Upgrade the shim to recognize ci; older shims ignore the new kind. Polling
works with channels disabled. Channel metadata has identical semantics, with
forge_alias renamed to forge and no outer kind. Old envelopes omit ci.
Forgejo capability evidence is source-verified at v16.0.3, matching the
pinned integration image, not a claim about a running deployment:
webhook types
have no generic status/check family. The
status API
calls CreateCommitStatus,
which updates status/summary data, caches and native scheduled-merge checking
without publishing a commit-status webhook. The
Actions notifier
does expose action_run_success, action_run_failure and action_run_recover.
Those terminal native Actions events do not cover external statuses or a full
pending/running lifecycle and remain ignored by this adapter. Signed negative
fixtures prove that adapter boundary only. Native Actions normalization or a
separate CI connector is follow-up scope; no direct Woodpecker ingress or new
CI credentials are introduced. Dedicated Woodpecker provider lanes own live
service-backed proof; deterministic fixtures do not claim live delivery.
Inline feedback uses distinct kind, SSE event name and meta.event_kind:
pull_request_review_comment (created, edited, deleted) and
pull_request_review_thread (resolved, unresolved). Unresolved means a
conversation was reopened, not the PR. The original action remains in
provider_action. Both families carry repository/PR identity and delivery ID
through authorized SSE, replay, MCP channels and poll_events.
| Provider / evidence | Subscription | Enabled mapping | Gaps |
|---|---|---|---|
| GitHub documented contract and Octokit schemas, read 2026-09-07 | Pull request review comments and Pull request review threads; Apps need Pull requests: read | Comment created/edited/deleted; thread resolved/unresolved | Absent optional metadata stays absent; thread comments may be empty |
| GitLab v18.3.0-ee source | Comments, Note Hook |
MR notes with explicit object_attributes.type: DiffNote: create → created, update → edited |
No delete or individual thread transition mapping; general/ambiguous and LegacyDiffNote notes keep the legacy path |
| Forgejo v16.0.3 source (CI-pinned) | Existing review/general-comment subscriptions | No distinct inline mapping established | Code-comment notifier is a no-op; edit/delete webhook payloads lack an inline discriminator; no resolution event in notifier interface |
The versioned inventory beside the deterministic fixtures
records emission paths, payload conversion and links. This is source evidence,
not live-provider delivery proof. Only GitLab's proven DiffNote create/update
payloads migrate from the legacy pull_request_review stream; each delivery
still emits one event. An MR aggregate blocking_discussions_resolved change,
approval, arbitrary note update or Forgejo verdict-header lookalike never
becomes an individual thread transition.
meta.inline_review contains an optional opaque thread_id, optional changed
comment, and supplied thread comments. Each comment independently retains
its actual comment_id, node_id, review_id, in_reply_to_id, current and
original commit/path/line metadata, multiline sides, and legacy diff positions
when supplied. A reply ID is not a thread ID; a diff position is not a file line.
GitLab retains native old/new paths, lines, commit coordinates and multiline
endpoints under gitlab_position / gitlab_original_position; image pixel
coordinates are omitted. Missing/null/empty strings are absent. There is no
representative first comment or synthesized ID. PR head_sha is independent of
comment commits and is null unless explicitly supplied by GitHub; GitLab MR
last_commit is not used. Formal review_state, review_id and
reviewed_commit_id remain unset at the common metadata level.
Projections omit bodies, diff hunks and raw changes. Parser limits are UTF-8 bytes: 256 for IDs/commits/deliveries/alias, 4096 per path, 1024 combined repository coordinates and 1024 thread comments; GitLab position types use 64 bytes. The existing HTTP body limit also applies. Recognized malformed identity or optional field types return HTTP 400 after verification; unknown actions are ignored. Existing HMAC/token verification and repository policy still apply.
These are best-effort refresh hints. Refetch authoritative feedback and unresolved-thread state before mutations, retaining periodic polling for missed hints or unsupported providers. The inline read API is not expanded here. Resolving a thread does not approve a PR, dismiss a review or remove an independent REQUEST_CHANGES verdict. These events never schedule/cancel webhook auto-merge or write reviews or labels.
Nonempty delivery IDs keep existing forge-alias retry deduplication. Without one, a length-prefixed repository/PR/kind/resource/action key includes SHA-256 of the verified body, which is never serialized. Byte-identical repeated same-action bodies inside the five-minute TTL are indistinguishable from retries; changed bodies/timestamps survive, and differently encoded retries may also survive. Polling remains necessary.
Review submissions, edits and dismissals share kind, SSE event name and
meta.event_kind = "pull_request_review". The normalized meta.action is
submitted, edited or dismissed; meta.provider_action retains the
provider's original action independently. No second event stream is required.
Subscribe to GET /api/v1/agent/events with an authorized agent token, or use
the shim's poll_events (also available with channels disabled). Channel
notifications carry the same optional identity fields and use forge instead
of forge_alias.
| Provider and provenance | Webhook subscription / header | Supported review actions | Identity and commit availability |
|---|---|---|---|
| GitHub, documented webhook contract | Pull request reviews, X-GitHub-Event: pull_request_review; Apps need Pull requests read permission |
submitted, edited, dismissed |
Formal review.id; review.commit_id when supplied. Edits/dismissals require positive integer review ID and PR number, and nonempty repository owner/name. |
| Forgejo v15.0.0, source inventory below | Pull request reviewed verdict subscriptions; X-Forgejo-Event: pull_request_approved, pull_request_rejected, pull_request_comment |
reviewed → submitted; existing submitted and pull_request_review header compatibility retained |
v15 ReviewPayload has only type/content, no review ID or reviewed commit. Existing submitted payloads with a positive formal review ID expose it; ID-less/zero-ID payloads omit meta.review_id. No formal lifecycle mapping. |
| GitLab, documented MR/note contract | Comments, X-Gitlab-Event: Note Hook for legacy MR notes |
Ordinary/ambiguous notes retain submitted/comment, including updates; explicit MR DiffNote create/update now use the inline family below; never normalized as formal edited or dismissed |
Note IDs and MR last_commit are not formal review identity. New review_id and reviewed_commit_id stay absent. MR approval/unapproval and reviewer-state updates do not establish formal review edit/dismiss equivalence. |
GitHub's review webhook contract distinguishes formal reviews from inline review comments and ordinary issue comments. GitLab's MR and note contracts do not demonstrate a stable formal-review mapping for this feature.
The Forgejo inventory is source-verified at v15.0.0, not a claim about unobserved deployments or live-delivery testing:
- Review services
call
PullReviewDismissfor explicit and stale-approval dismissal. The webhook notifier inherits the no-op implementation and does not override it: no dismissal webhook is emitted by that notifier. - Comment updates
notify
UpdateCommentfor published review text. The webhook notifier emitsIssueCommentPayloadwithaction: edited; the header mapping maps this toissue_comment. Its Comment type and conversion expose a comment ID, not a formal review ID or reviewed commit. The separate timeline comment type is not the webhook payload. - Submitted verdict payloads use
action: reviewedand the ReviewPayload type. Guessing GitHub edit/dismiss actions or using comment IDs would invent identity; these unsupported payload families remain unnormalized.
reviewed_commit_id is the authoritative review commit field. It is never
filled from a PR head, MR last commit, merge commit or target commit. For new
lifecycle actions, missing/null/empty review commits leave both this field and
head_sha absent/null even when the live PR head is available. Present malformed
commit types are rejected. Historical submitted head_sha retains its
provider-dependent meaning: GitHub review commit, Forgejo PR head, GitLab MR
last commit. Do not use that historical field as proof of formal review binding.
Lifecycle and verdict are independent. Edited reviews keep an explicitly
recognized verdict (approved, request_changes or comment) or null;
dismissed reviews always carry null. Missing display text, verdict or commit
does not prevent an identity-valid lifecycle hint. Unknown/pending submissions
remain ignored. After signature verification, GitHub and Forgejo review
payloads with an unsupported action are ignored (HTTP 202) even if other
fields do not match a supported review schema. Invalid JSON or malformed
supported-action payloads return HTTP 400; invalid signatures still return
HTTP 401. An example identity-only dismissal envelope:
{
"kind": "pull_request_review",
"content": "pull_request_review dismissed on github/org/repo#42",
"meta": {
"action": "dismissed",
"provider_action": "dismissed",
"review_id": 71,
"event_kind": "pull_request_review",
"forge_alias": "github",
"owner": "org",
"repo": "repo",
"change_request": 42,
"delivery_id": "review-dismissal-delivery",
"head_sha": null,
"review_state": null,
"issue": null,
"issue_comment": null
}
}New optional fields are omitted when unknown and old envelopes still deserialize. Delivery-ID deduplication remains scoped to forge alias. Without a delivery ID, submissions retain their existing fallback; edits/dismissals use repository/PR/review identity, action and SHA-256 of the verified body. The fingerprint stays internal. This fallback is best effort: differently encoded retries can produce extra hints, and byte-identical no-ID actions within the five-minute TTL cannot be distinguished from retries.
Events can be missed, duplicated or delivered out of order. Consumers must refetch current formal reviews before invalidating approval/blocker evidence or mutating workflow state; periodic polling remains the fallback. Edit and dismiss hints neither schedule nor cancel auto-merge, add workflow-trigger labels, nor initiate rework/re-review. Cockpit's submitted-only decoder needs a separate consumer adjustment. Formal review/discussion read separation (#137) and trade PR #31's marker-comment selection are outside this feature.
Signed/token-verified deterministic fixtures cover normalization, HTTP publication, authorization/replay, deduplication and transport metadata. Provider processes and any live-delivery proof remain owned by dedicated CI.
DELETE /api/v1/repos/{forge}/{owner}/{repo}/pulls/{index}/automerge
and MCP cancel_auto_merge(forge, owner, repo, index) cancel a scheduled merge
on Forgejo. DELETE has no body, head SHA, merge style, or branch-deletion flag.
Gateway bearer authentication and repository authorization are required.
The caller's forge credential takes precedence over the configured default;
only callers without an override use that default. Rejected caller credentials
are never retried with the default. Read access alone need not grant cancellation
rights. GitHub and GitLab currently return unsupported without provider requests.
| Condition | Gateway response |
|---|---|
| Exactly upstream 204 | 200 with JSON {} |
| Missing/invalid gateway bearer, or upstream 401 | 401 |
| Repository authorization denial, or upstream 403 | 403 |
| Authorized unknown forge alias | 400 |
| Unsupported backend | 501 |
| Audit failure before the provider write | 500 |
| Any upstream 404, unexpected status (including 200/202/501), redirect, timeout or transport failure | 502 |
Only upstream 204 confirms cancellation. Forgejo 16.0.3 returns the same generic 404 for an absent schedule and a missing PR; every such response remains 502, even after a successful same-credential PR read. Positive absence proof and successful never-scheduled/repeated cancellation are deferred. A successful DELETE whose response is lost can leave subsequent retries conservatively blocked indefinitely. No receipt or cache converts those retries to success.
Cancellation audits the attempt before one bodyless provider DELETE. It performs no PR/head/settings reads, scheduling, status updates, branch deletion, or other PR mutations. Closed/merged state does not prove absence. Cancellation cannot undo an already completed merge or prevent an independently authorized future schedule. It is PR-scoped and does not promise head-specific cancellation or uniform success on repetition.
Cockpit's revocation barrier remains intact: 200 permits the cancellation stage
to proceed to its existing fresh-PR and binding/task-state checks; 401/403 retains
an authorization diagnostic; 400/500/501/502 retains upstream uncertainty and
RevokePending. This route removes the missing-method 405 but does not guarantee
recovery of memory-server PR #65. It does not provide authoritative schedule
status, serialized scheduling ownership, or every F2 remote-disarm guarantee.
Provider-backed cancellation tests are ignored locally and run in the existing
Forgejo CI lane; source findings and mock tests are not executed provider proof.
Enqueued submitted approval webhooks schedule auto-merge by default for compatibility. Operators can disable only that scheduling side effect while continuing to verify, normalize, and deliver webhook events:
[forges.webhook]
secret = "webhook-secret"
auto_merge = falseWebhook-triggered scheduling is an unattended system action and therefore uses the forge-level token or system GitHub App. Authenticated HTTP and MCP callers instead retain their resolved per-forge identity (including a managed GitHub App), so the upstream scheduling action is attributed to the caller.
The schedule_auto_merge merge style is optional. An explicit value must use a
canonical name: merge, rebase, rebase-merge, squash, or
fast-forward-only. When omitted, forge-mcp uses the repository default if it
is allowed, then prefers rebase, squash, and merge in that order, and
finally uses the first allowed canonical style. The expected full head SHA
remains required.
For GitHub.com, base_url = "https://github.com" automatically selects
https://api.github.com. For GitHub Enterprise Server, forge-mcp derives
<base_url>/api/v3; set api_url explicitly for a nonstandard API endpoint.
Use rebase_branch with an exclusive list of reword operations to change
messages while preserving every commit's tree, patch order and author metadata:
{
"forge": "adlevio",
"owner": "tokarix",
"repo": "example",
"base_branch": "main",
"branch": "agent/codex/example",
"operations": [
{"type": "reword", "commit": "2222222222222222222222222222222222222222", "message": "Explain the change\n\nInclude the reason for this change.\n"}
]
}For REST, POST the same body without forge, owner and repo to
/api/v1/repos/{forge}/{owner}/{repo}/rebase. The IDs below are illustrative:
{
"branch": "agent/codex/example",
"commit_sha": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"old_commit_sha": "3333333333333333333333333333333333333333",
"commit_mapping": [
{"old_commit_sha": "1111111111111111111111111111111111111111", "new_commit_sha": "1111111111111111111111111111111111111111"},
{"old_commit_sha": "2222222222222222222222222222222222222222", "new_commit_sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},
{"old_commit_sha": "3333333333333333333333333333333333333333", "new_commit_sha": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}
]
}Targets must be distinct exact full object IDs in merge-base..original-head.
Abbreviations, refs, revision expressions, the merge base and out-of-range IDs
are rejected. Both SHA-1 and SHA-256 repositories are supported. Request order
selects replacement messages and never reorders commits. Do not mix reword with
fixup, drop or rebase_onto; merges and unsupported commit metadata are
rejected before replay. Empty commits remain in the series.
Each submitted message must be nonblank, contain no NUL, and be at most 65,536 UTF-8 bytes. The limit applies before normalization: supplied trailing newlines are retained; exactly one LF is appended if the message has none. All other whitespace, multiline bodies, trailers, comment-looking lines and shell characters are data, preserved without trimming or shell evaluation.
The complete mapping follows original commit order, including unchanged entries
before the first target. Targets and descendants may receive new IDs. Their
original signatures are removed because the signed payload changes; no new
signing policy is introduced. Author names, emails, dates and timezones remain
unchanged. Recreated commits use the existing global committer configuration
or legacy authenticated-user/agent fallback, with current committer dates.
Legacy rebase modes omit old_commit_sha and commit_mapping.
Repository authorization, the nonempty agent branch prefix, provider branch protections and the MCP read-only guard still apply. The gateway verifies the complete series and final tree, records an intended transition audit without replacement messages, then publishes once using the original head as a lease. Validation, replay, verification or audit failures prevent publication. A lease failure preserves the concurrent remote tip. The branch and existing PR remain attached; success and mapping are returned only after publication succeeds.
Real-Forgejo tests use an all-or-nothing disposable-provider contract:
FORGEJO_TEST_BASE_URL=http://localhost:3000 \
FORGEJO_TEST_USERNAME=forge-mcp-ci \
FORGEJO_TEST_PASSWORD=disposable-password \
cargo test -p forge --test forgejo_ci_smoke -- --ignored --nocapture
FORGEJO_TEST_BASE_URL=http://localhost:3000 \
FORGEJO_TEST_USERNAME=forge-mcp-ci \
FORGEJO_TEST_PASSWORD=disposable-password \
cargo test -p forge --test forgejo_issue_dependencies -- --ignored --nocapture
FORGEJO_TEST_BASE_URL=http://forgejo:3000 \
FORGEJO_TEST_USERNAME=forge-mcp-ci \
FORGEJO_TEST_PASSWORD=disposable-password \
CARGO_TARGET_DIR=/tmp/target \
cargo test -p server --test forgejo_reword -- --ignored --nocapture
FORGEJO_TEST_BASE_URL=http://localhost:3000 \
FORGEJO_TEST_USERNAME=forge-mcp-ci \
FORGEJO_TEST_PASSWORD=disposable-password \
FORGEJO_TEST_WEBHOOK_CALLBACK_BASE_URL=http://host-reachable-from-forgejo:38080 \
FORGEJO_TEST_WEBHOOK_LISTEN_ADDR=0.0.0.0:38080 \
cargo test -p server --test forgejo_issue_label_webhooks -- --ignored --nocapture
Ordinary local test runs intentionally leave all provider tests ignored. Missing local Forgejo access or credentials is expected and is not a blocker. Tests consume the configured service but never start Forgejo or a container runtime themselves. The label-webhook test additionally needs a listener address and a callback base URL that the CI-owned Forgejo service can route back to; the ordinary local Rust gates do not require either value. The reword test runs in the dedicated CI lane, which installs Git and supplies the disposable service and credentials. It checks PR continuity, ordered trees, messages and author metadata, and protected-branch push rejection. Explicitly running an ignored provider test with missing configuration or an unreachable service fails; agents do not start a local provider to run it.
Woodpecker is the service-backed integration authority. The separate
checks workflow runs the normal Rust gates and lints all workflow files with
Woodpecker CLI 2.8.3. After it succeeds, forgejo-integration starts the pinned
codeberg.org/forgejo/forgejo:16.0.3-rootless image as a detached forgejo
step. This workflow places the checkout directly at the shared /woodpecker
volume root, avoiding the nested default checkout path that failed with
Permission denied in the rootless service's generated startup wrapper.
A foreground forgejo-preflight step uses the same rootless image to check
checkout access, script executability, /tmp writability, and the Forgejo binary
before the detached service starts. A workspace failure therefore fails a normal
step instead of being hidden behind the integration readiness timeout. It does
not run Forgejo as root or change host permissions.
Later steps reach the service at http://forgejo:3000. The fixture uses bounded
readiness polling, authenticates the throwaway user, creates a short-lived API
token, and reports redacted, bounded diagnostics. Repositories and tokens are
logically cleaned up, while the user, SQLite database, repositories, and all
other state disappear with the workflow container. No production credential,
persistent volume, privileged mode, or container socket is used.
The independent cargo-audit workflow runs only for the named cargo-audit
cron on main. It installs cargo-audit 0.22.2 with its locked tool dependencies
and scans the committed Cargo.lock against freshly fetched RustSec data.
It does not update application dependencies, build the application, or start
provider services, and is outside the required PR checks.
After merge, an operator with repository push access should open the repository
in Woodpecker, go to Settings → Cron, and create a cron named cargo-audit
with schedule @daily and branch main (see the
Woodpecker cron documentation).
Schedule activation and the first real scheduled run are operator follow-up.
Inspect the resulting cron pipeline in the repository's Woodpecker pipeline
list, then open the cargo-audit workflow and step logs. A completed scan with
vulnerabilities prints advisory details and a vulnerability count and fails;
scanner or advisory-fetch errors print their native error diagnostics and also
fail. A failed fetch is not a clean scan. Successful scans end with
Cargo audit completed successfully. Existing PR CI must pass before merge.
Issues & PRs disabled. Development happens on an internal Forgejo instance.
branch_push has action pushed and repository-scoped meta.branch_push:
{
"ref": "refs/heads/release/next",
"before_sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"after_sha": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"deleted": false,
"provider_event": "push"
}forced and deleted are optional booleans; omission means unknown. Pushes
have no PR, issue, comment or source-head identity (those metadata values are
null). Full 40/64-digit hexadecimal IDs retain their original spelling; all-zero
before/after IDs signal creation/deletion and must not be treated as commits.
Tags and other ref namespaces produce no hint. Any valid branch, including an
unrelated feature branch, emits only its own hint; ingress never enumerates PRs.
PR envelopes optionally contain typed meta.change_request_changes, for example:
{
"base": {"previous": "main", "current": "release/next"},
"draft": {"previous": true, "current": false}
}Either delta can occur alone. previous is optional for explicit action signals
without an old snapshot. Branch values are provider branch names; consumers add
refs/heads/ when comparing them to a push ref. State-only events use updated;
compound source changes retain synchronize, and supported lifecycle actions
remain unchanged. These envelopes also retain provider_action, simultaneous
labels_changed, and the actual source head when supplied. Missing source heads
on state-only hints remain null; no base or merge SHA substitutes for them.
Current snapshots alone never establish a transition.
| Provider contract | Push subscription | Base retarget | Draft transition | Push status |
|---|---|---|---|---|
| GitHub documented webhooks | push |
pull_request: edited, changes.base.ref.from |
ready_for_review / converted_to_draft |
Explicit deleted/forced fields |
| GitLab documented webhooks | Push events (Push Hook, object_kind push) |
Merge request events: unequal changes.target_branch |
Unequal changes.draft |
Deleted from zero after ID; forced unknown |
| Forgejo 16.0.3 | push |
pull_request: edited, changes.ref.from |
Unsupported: draft is derived from configured title prefixes | Deleted from zero after ID; forced unknown |
Forgejo's Gitea header aliases accept the same source-verified payload contract;
arbitrary Gitea versions are not asserted equivalent. Title/body-only edits,
base SHA-only changes, equal deltas and unknown actions add no state metadata.
GitLab update with state deltas and absent/null oldrev is updated; valid
nonzero code-change oldrev retains synchronize. Unrelated legacy updates
retain their previous mapping. See fixture provenance and exact provider
sources.
For every GitLab push and base/draft-bearing MR hint, the first nonblank of
webhook-id, Idempotency-Key, X-Gitlab-Webhook-UUID is the delivery identity.
Header names are case insensitive; chosen values retain their bytes, bounded
at 256 bytes. X-Gitlab-Event-UUID is correlation only for these hints. With no
actual ID, delivery_id is empty even when a correlation UUID is present.
Retry-stable modern IDs win over a changing legacy webhook UUID; that last
fallback remains best effort. CI selection is unchanged. MR events without
base/draft metadata, issues and notes retain legacy identity behavior.
Nonempty delivery IDs dedupe by forge alias plus selected ID. Otherwise these new hints use an internal authenticated-body SHA-256 with namespaced forge, repository and resource coordinates (full ref for pushes; index and action for PRs). Compound label/state changes take this path before label/head fallbacks. Identical bytes collapse; distinct bodies at one head/ref survive. Equivalent encodings and genuinely repeated identical no-ID actions remain ambiguous. Fingerprints never appear in envelopes or log bodies. Existing five-minute dedupe, bounded subscriber channels and 32-event replay remain in memory; replay and live delivery apply the same repository authorization.
The new projections validate identities, branch syntax, typed deltas and snapshot consistency, retaining at most a 16 KiB normalized envelope. Repository paths and branch names are bounded at 1024 bytes; titles/URLs at 4096. Errors are bounded diagnostics without payload contents. Authentication precedes all dispatch, including ignored refs, and the existing ingress body limit stays in force. Commit arrays and unrelated raw data never enter the hint.
Consumers coalesce hints, match repository/target bindings and refetch authoritative PR/check/review state before acting. Keep periodic polling: provider push limits can suppress events, and this is neither ordered nor lossless delivery. Ingress makes no provider reads, git calls, rebase, merge, label or scheduling requests for these hints. Auto-merge remains restricted to newly enqueued submitted approvals. No webhook registration or CI branch-filter change is made by this feature.
GET /api/v1/repos/{forge}/{owner}/{repo}/contents/{path} preserves upstream
404 as gateway 404 with the fixed explanation "repository resource unavailable
at the requested path/ref". This does not distinguish an absent path, unknown
ref, missing repository, or privacy-masked access. No existence probes or
credential retries are performed. Explicit upstream 401/403 remain 401/403;
other upstream failures, transport failures and malformed file responses return
502. Successful UTF-8 content and omitted/explicit ref behavior are unchanged.
MCP read_repository_file reports gateway 404 as invalid-params (-32602) and
502 as internal-error (-32603); failures never become empty successful content.
Request spans distinguish route (the matched route template, or unmatched)
from path (a safe resolved path). The existing operation field remains a
compatibility alias for the route template. Resolved paths are diagnostic text
only and must not be used as metrics labels. Request IDs, methods, failure
statuses and structured forge/owner/repo/target fields remain available, including
on authentication failures.
Only forge, owner and repository identifiers (ASCII letters, digits, -, _,
.) and numeric issue/pull/dependency IDs are resolved. Identifier components
are limited to 128 bytes; empty, dot-only (. or ..), encoded, control-bearing
or otherwise unsafe values become [redacted]. Values are never percent-decoded.
File wildcards, labels and unreviewed parameters are also [redacted].
Structured repository fields use the same safe components. The complete resolved
path is limited to 1024 bytes, with [redacted] as the fallback for unsafe path
shapes or excessive length; unmatched requests use unmatched. Route/operation
fields use the existing 128-character diagnostic bound. Query strings, full URLs,
headers, credentials and bodies are never included in these fields.
For GET contents, request middleware is the sole primary failure reporter:
expected typed upstream not-found produces one INFO event; other failures
produce one WARN event. Adapter and handler warnings are suppressed only for
this operation. The event includes error_kind, gateway_status, and numeric
upstream_status when a response was received (including 200 for invalid file
payloads). An absent upstream status means unknown, not an inferred 404.
credential_source="unselected" distinguishes early failures from a selected
credential or a known lack of credentials. Successful request logging is unchanged.
Dedicated requested_path, requested_ref, ref_source, path_visibility, and
ref_visibility fields supplement the existing wildcard/query redaction;
target remains a numeric issue/PR identifier or empty. Detailed file context
is considered only after successful gateway authentication and repository
authorization. Missing ref means ref_source="default"; explicit HEAD or
main means explicit. The actual default branch is never inferred. Before
extraction/authorization, the source is unavailable and visibility is
unauthorized_or_unparsed.
Disclosure is disabled by default, including for ordinary filenames. Configure
[[server.file_read_diagnostics.repositories]] with exact forge, owner,
repo, paths, and refs approvals (see forge-mcp.example.toml). Each field
is independently allowlisted; there are no wildcard or caller-controlled
approvals. Operators must approve only known non-sensitive values and protect
this configuration and log access. This is not automatic detection of arbitrary
secrets. Unapproved values stay [redacted] with not_approved; invalid values
use invalid, known credential matches use credential, and an omitted ref
uses default. Approved values use approved.
Startup rejects invalid or duplicate repository entries and excessive lists:
at most 64 repositories and 128 values per field per repository. Each path/ref
must be 1–256 UTF-8 bytes and use only ASCII letters, digits, -, _, ., /.
Absolute paths, empty or dot-traversal components, controls, Unicode and percent
escapes are ineligible. Values are compared against the extractor's canonical
once-decoded text, never recursively decoded or truncated. Known configured
gateway credentials and the effective upstream credential are blocked even if
accidentally approved. Invalid or unmatched fields are entirely redacted;
no unkeyed hashes, raw URIs, provider bodies, redirects or file contents are logged.
Older configuration files require no new options.
Change-request REST and MCP list/get responses, and create/update/close responses,
include draft: true means authoritative provider draft, false means
explicitly ready, and null means unavailable, absent or unsupported metadata.
Missing fields in older JSON remain accepted. No title/body inference or GitLab
work_in_progress fallback is used. Wrong-type metadata fails deserialization.
Sparse list results stay sparse; the gateway does not fetch each list item.
Consumers must re-read authoritative get before readiness decisions.
mergeability retains mergeable, not_mergeable, conflicting, and unknown.
has_conflicts=null means unknown, including generic blockers; consumers must
never translate not_mergeable back into a conflict. conflicting always has
positive evidence and has_conflicts=true. mergeable is evidence, not permission
to merge or advance ownership. Draft is independent and can coexist with conflict.
| Provider evidence | Normalized mergeability / has_conflicts |
|---|---|
Forgejo mergeable=true, draft not true |
mergeable / false |
Forgejo mergeable=true, draft true (contradictory pinned contract) |
unknown / null |
Forgejo mergeable=false, any draft state |
not_mergeable / null |
| Forgejo absent/null mergeable | unknown / null |
GitHub false + dirty |
conflicting / true |
GitHub true, except contradictory dirty |
mergeable / false |
GitHub true + dirty, or absent/null mergeable |
unknown / null |
| GitHub other false | not_mergeable / null |
GitLab conflict or explicit true without contradiction |
conflicting / true |
GitLab mergeable, or legacy can_be_merged without detailed status |
mergeable / false |
| GitLab conflict + explicit false, clean + explicit true, or pending + true | unknown / null |
GitLab generic blockers, including draft_status and legacy cannot_be_merged |
not_mergeable / null unless independent positive conflict evidence |
| GitLab checking/unchecked/recheck, error, unrecognized or missing status | unknown / null absent independent positive evidence; pending computation contradicts positive evidence |
GitLab detailed status takes precedence over legacy status. Conditional explicit false during pending, unknown or blocked states is not clean-content evidence. GitHub draft plus clean evidence is valid. Contradictions preserve draft metadata.
Forgejo 16.0.3's false boolean conflates WIP, pending/error checking and content
conflicts. This correction intentionally disables automatic Forgejo conflict
classification from that boolean, even for known ready PRs. Surface ambiguous
non-mergeability visibly. The CI-owned forgejo_mergeability test retains the
ready/WIP/ready regression from closed PR #236, diagnostic commit
408437b7b5e4363166593b491bd7759a6f1e79b4, and verifies a genuine conflicting
single-line replacement from a common ancestor, both ready and draft. Exact
ancestry/content establish the control's conflict; raw false does not establish
checker completion. No provider processes are started by local tests.
Follow-up boundaries (not implemented here):
- F1: opt-in bounded read-only conflict proof using documented exact source/target identities and supported Git merge-tree capabilities, outside normal conversion. It needs authorized fetches, resource/process limits, identity rechecks and permission-partitioned commit-pair caching. Errors remain uncertainty. No clone per list item or real merge/rebase probe. F1 is optional for this correction.
- F2: beyond the confirmed Forgejo cancellation endpoint above, provider-aware auto-merge status/cancellation with proven remote disarm, authorization/audit, exact heads and serialized ownership generations. Assess GitLab scheduling's currently ignored head argument in that separate scope.
- C1 (Cockpit): known drafts defer new owned review, conflict repair and scheduling; retain active workers and completion evidence, terminal/cancellation/integrity and needs-input reconciliation. ReviewOnly may review drafts without ownership. Unknown draft requires get reconciliation and a visible bounded hold/escalation. Same-head readiness must invalidate eligibility and claim each action exactly once across concurrent/replayed events. Worker exit with incomplete draft needs a visible operator state, not completion or endless automatic continuation.
Safe activation of C1's automatic merge lifecycle depends on F2: eligibility loss must persist a revocation barrier and advance authority before new actions; coordinate in-flight schedules and cancel late obsolete successes. Only confirmed remote disarm clears that barrier, including across restarts or same-head readiness. Unsupported/failed/unknown cancellation escalates visibly and blocks new authority. Independent remote schedules and unobserved transitions remain provider race risks; validate enforcement before activation. Reconcile a merge/close race truthfully. Required downstream tests include active-worker preservation, ReviewOnly isolation, all draft gates, duplicate dispatch, stale metadata, incomplete worker visibility, late scheduling, cancellation failure/restart and terminal/provider races. Cockpit #366 SQL repair and the broader completion protocol remain separate work.
Deploy this producer contract before consumer activation. JSON is additive, but strict decoders need inventory and Rust constructors need the new field. Existing Forgejo draft hints are unsupported, so polling is required; GitHub/GitLab refresh hints only expedite authoritative reads. This correction alone does not remediate already-armed schedules or the incomplete-worker incident.
New auto-merge schedules re-read the PR and validate the expected head, then reject
draft=true with pull request is draft; auto-merge deferred before repository
settings, success audit, provider scheduling or synthetic status writes. Explicit
REST/MCP callers receive validation failure; submitted-approval scheduling treats
this exact validation as expected deferral without AutoMergeFailed. Other
failures remain visible. Unknown draft retains existing gateway scheduling
behavior for compatibility and is not readiness; C1's owned policy is stricter.
This guard does not cancel already-armed remote schedules and does not eliminate
the race between reading draft state and scheduling. Those require F2/C1.
GET /api/v1/repos/{forge}/{owner}/{repo}/pulls and the MCP
list_change_requests tool return the existing JSON array. The optional state
accepts exactly open, closed, merged, or all; invalid values are rejected.
open selects domain Open, closed selects Closed (excluding merged PRs),
and merged selects Merged. all includes all three states. An omitted filter
preserves provider defaults (Forgejo open, GitHub all, GitLab upstream default).
Forgejo filters the upstream closed set to distinguish closed from merged.
GitHub supports all through its existing pagination; GitLab rejects explicit
all because its listing does not yet guarantee exhaustive results.
Forgejo success means pagination was exhausted according to provider continuation metadata. Results are deduplicated by repository PR number in first-occurrence order. Every page uses the caller's scoped credential. Continuation URLs must retain the origin, repository path and query filters; only the validated page number is used to construct the next request. Redirects, invalid or inconsistent continuations, failed pages and exhausted safety budgets reject the entire request, never return a successful prefix. The shared issue-list budgets apply: 100 requested items per page, at most 1,000 pages and 100,000 raw entries (including duplicates), 8 MiB per page, 64 MiB total response bodies, and a 60-second listing deadline. Short pages with a next link are followed; a full page without continuation is terminal according to provider metadata.
This is not an atomic snapshot of a concurrently changing forge. Consumers, including Cockpit recovery, still need fresh PR detail/head checks and admission fences. Exhaustive listing alone does not prevent concurrent PR creation.
For issue #255 rollout, merge/issue closure does not prove deployment. The
operator must deploy the fixed server and rebuild/deploy the shim for the tool
schema, verify deployed exhaustive listing and all, and only then remove
needs-input from tokarix/cockpit#410.