Skip to content
Gitea Adapter

Gitea Adapter

The Gitea adapter connects Sortie to a self-hosted Gitea instance over the Gitea REST API v1. It is registered under kind "gitea", fetches issues from the repository issue-list route, derives Sortie states from repository labels, follows Link header pagination, and normalizes responses to the same issue object fields. Two facts shape the rest of this page. Gitea is self-hosted, so tracker.endpoint is required and there is no default host. Gitea exposes no GraphQL API, so the REST surface under /api/v1 is the whole contract. The canonical API documentation is at docs.gitea.com, and each instance also serves its own OpenAPI description at {endpoint}/api/swagger.

See also: WORKFLOW.md configuration for the full tracker schema, how to connect Sortie to Gitea for setup instructions, error reference for all tracker error kinds, environment variables for $VAR expansion behavior.


Configuration

The adapter reads its configuration from the tracker section of the WORKFLOW.md front matter. Five fields are required; the rest have defaults.

FieldTypeRequiredDefaultDescription
kindstringYes-Must be "gitea".
endpointstringYes-Instance base URL, for example https://gitea.example.com. No default host. See endpoint.
api_keystringYes-Gitea access token. Sent verbatim as Authorization: token <key>. See authentication.
projectstringYes-Repository in owner/repo form. See identifiers and project scoping.
active_stateslist of stringsYes-Repository label names whose open issues are eligible for dispatch. An open issue carrying none of them is left alone. Stored lowercased. See state lists.
terminal_stateslist of stringsYes-Repository label names that mark completed issues. Must name at least one. Stored lowercased. See state lists.
handoff_statestringNo(absent)Repository label name set after a successful agent run. Must appear in neither active_states nor terminal_states. Absent disables handoff.
in_progress_statestringNo(absent)Repository label set at dispatch, before the agent runs. Must appear in active_states. Absent disables the dispatch-time transition.
query_filterstringNo""URL query fragment merged into the repository issue-list request. See query filter.
user_agentstringNosortie/<version>User-Agent header sent on all requests. Sortie sets the tracker role’s value to its own version string, so only the SCM and CI roles honor an override, set in a top-level gitea: block.

in_progress_state is a generic tracker field, not a Gitea-specific one. When set, the orchestrator transitions the issue into that label at dispatch through the same label swap a transition performs. Its collision rules (must appear in active_states, must not collide with terminal_states or handoff_state) are enforced by the generic config validation, so the Gitea validate hook carries no in_progress_state arm of its own.

tracker:
  kind: gitea
  endpoint: $SORTIE_GITEA_ENDPOINT
  api_key: $SORTIE_GITEA_TOKEN
  project: sortie-ai/sortie
  active_states:
    - backlog
    - in-progress
  handoff_state: review
  terminal_states:
    - done
    - wontfix
  query_filter: "assigned_by=hermes-bot"

endpoint, api_key, and project accept $VAR indirection.

endpoint

The instance base URL, for example https://gitea.example.com. Required: Gitea is self-hosted, so there is no default host, and an empty value is rejected when the adapter is built. Surrounding whitespace and trailing slashes are trimmed. The adapter appends /api/v1, and tolerates a value that already ends in /api/v1 without appending it twice. Plain-http endpoints send the token in cleartext; sortie validate warns on an http endpoint and on a value already ending in /api/v1.

A non-empty value must also parse as an absolute http or https URL carrying a hostname, with neither a query nor a fragment; this is rejected when the adapter is built, rather than surfacing later as a network error. An IPv6 literal must be bracketed: http://[fd00::1]:3000, not http://fd00::1:3000. The unbracketed form is exactly how such an address appears in ip addr output on a self-hosted instance. The same rule applies wherever an endpoint reaches this adapter family: the tracker, the SCM adapter, and the CI status provider (see SCM and CI surface for where those two read theirs).

project

Repository in owner/repo form, for example sortie-ai/sortie. The adapter splits the value on its single slash when it is built and rejects anything that is not exactly one slash with a non-empty owner and repository.

State lists

The adapter carries no default labels. Both lists are written in the workflow, and an entry cannot be blank.

An open issue is dispatched only when it carries a label from active_states, so labeling an issue is how you hand it to Sortie. An empty active_states dispatches nothing, and validation rejects a workflow with both lists empty. terminal_states must name at least one state, because a closed issue that carries no terminal label reads as the first entry; an empty list fails the workflow load.

At the first poll, and again after a reload changes active_states, the adapter reads the repository’s labels and its organization’s labels, and logs a warning, configured active label missing from tracker labels, for each active label that neither has. in_progress_state is not checked, because a transition creates that label itself. See create-on-missing labels.

sortie validate checks these fields without making network calls, reusing the rules enforced when the adapter is built. A padded state-list element and an overlap between active_states and terminal_states are warnings here rather than errors, since a padded label only fails to match at dispatch time rather than blocking startup. An empty or whitespace-only element fails the workflow load on every tracker, and an empty active_states draws the generic warning tracker.active_states.empty; see sortie validate.


Authentication

The adapter authenticates with a Gitea access token. The token is sent in the Authorization header with the lowercase token scheme, the canonical Gitea scheme:

Authorization: token <api_key>

Gitea also accepts the Bearer scheme, but the adapter sends token. The token is sent verbatim, so the configured value must be the bare token with no surrounding whitespace; a leading or trailing space becomes part of the credential and fails authentication. sortie validate warns when the resolved key carries surrounding whitespace, and when api_key is empty, suggesting api_key: $SORTIE_GITEA_TOKEN if that variable is set.

A Gitea token is a 40-character hex string with no identifying prefix, unlike a GitHub ghp_ or a Linear lin_api_ key. Secret scanners cannot recognize a leaked Gitea token by shape.

Gitea also accepts an ?access_token=<key> query parameter, but the adapter never uses it: a query parameter leaks the secret into URLs and server logs. The token travels only in the Authorization header.

Scopes

The minimal verified scope set is write:issue, read:user, and read:repository. A write scope implies its read, so write:issue covers every issue, comment, and label operation; read:user covers the credential and identity check; read:repository covers the project check. Auto-merge and branch cleanup additionally require the write:repository scope, and the token’s user needs repository write access; see token scope for merge and branch operations.

Fixed headers

HeaderValue
Authorizationtoken <api_key>, verbatim.
Acceptapplication/json
User-Agentsortie/<version> on tracker requests; the configured user_agent value on SCM and CI requests, defaulting to sortie/dev.
Content-Typeapplication/json, on requests with a body.

The HTTP client has a 30-second per-request timeout. Cancelling the operation in progress aborts the in-flight request immediately.

Startup preflight

Two calls run before the first poll, and a failure blocks Sortie from starting:

CallPurpose
GET /userValidates the token and reads the automation identity.
GET /repos/{owner}/{repo}Confirms the configured repository.

An invalid token fails the first call (tracker_auth_error); a mistyped repository fails the second (tracker_not_found). Transient 5xx or transport failures are retried with a bounded backoff before the adapter fails to build.


State model

Gitea issues natively carry only open and closed. There is no workflow engine, no transition graph, and no state_reason field. The adapter derives Sortie state from repository labels. active_states, terminal_states, and handoff_state name labels, lowercased when the adapter is built.

Derivation

The adapter scans an issue’s labels against the configured lists and returns the first match.

  1. terminal_states, in configuration order.
  2. handoff_state, if set.
  3. active_states, in configuration order.

When more than one configured label is present on an open issue, the adapter logs a WARN, issue carries more than one state label, naming the issue and the matched labels, and keeps the first. When no configured label is present, a closed issue maps to the first terminal_states entry. An open issue maps to no state: it is not dispatched, and the adapter logs skipped open issues without a state label at INFO with the number it skipped, and logs again only when that number changes. sortie --dry-run logs it too. All comparisons are case-insensitive.

A running issue whose state label is removed keeps running through one poll that reads no state, because editing labels by hand can pass through a moment with none. At the second consecutive poll that reads none, Sortie stops the worker and keeps its workspace.

Transitions

A transition composes the move from label and state edits, because Gitea has no transition API. It attaches the target state label by id first, then removes every spelling of the other configured state labels that the issue carries, by the id the issue itself reports, so a previous state label that is an organization label is removed too. The issue never reads as being in no state between the two writes. It then reconciles the native state: a terminal-state target closes an open issue, and an active-state target reopens a closed one. Labels outside the configured states are never touched. A target that is not a configured active, terminal, or handoff state is rejected with tracker_payload_error before any write, and a move to the handoff state is refused for a closed issue.

Create-on-missing labels

A configured state label absent from the repository is created on demand with a fixed default color (#cccccc) the first time an issue transitions into it. Gitea silently ignores an attach of an unknown label name and returns HTTP 200, which removes the fail-loudly option that pre-created labels give on GitHub. The adapter resolves every label name to an id and attaches by id, creating the label first when the name does not resolve, so a transition to a not-yet-existing state label lands instead of no-oping.


Identifiers and project scoping

A Gitea issue carries two numbers. The number (the index) is repo-scoped, human-visible, and the value every per-issue route consumes (/repos/{owner}/{repo}/issues/{index}). The id is an instance-global integer that no issue route accepts as input.

The adapter maps both .issue.id and .issue.identifier to the index as a string and never uses the global id. Because the two are the same value, looking up issue states by ID and by identifier share one implementation.

tracker.project is owner/repo, split once when the adapter is built into the owner and repository parts. Every route the adapter builds derives from those parts.


API operations

The adapter implements every method of the tracker contract against Gitea’s issues, comments, labels, and dependencies surfaces, addressing each issue by its per-repository index rather than its global ID. Which route serves which call is Gitea’s to document; see external references.

Pull requests are excluded server-side by the type constraint on every list query, and the adapter keeps a client-side guard on the pull-request marker as a second line of defence. A per-issue route that resolves to a pull request is reported as tracker_not_found rather than normalized into an issue.

Gitea has no transition API, so a transition is composed from label and state edits rather than being a single call: the target label is resolved or created and attached, the labels of the other configured states are removed, and the native open or closed status is reconciled. Every step is idempotent, so a partial failure converges on retry rather than stranding the issue, and a transition to the state an issue already holds does no label work at all.

A comment that carries the agent’s stop statement is the stop text, a blank line, and the statement in a fenced Markdown code block. The fence is backticks, at least three and one more than the longest run of backticks in the statement, so nothing inside the statement can close it.

Label writes

Adding a label, such as an escalation label or a stage label, lowercases the name and resolves it against the repository’s labels. A name with no match is created as a repository label, as described in create-on-missing labels, and the label is attached by id with POST /repos/{owner}/{repo}/issues/{index}/labels. The repository label list does not include organization labels, so adding a name that exists only as an organization label creates a repository label of that name. Gitea answers the attach with the issue’s labels, and the adapter checks the new label there.

Removing a label reads the issue’s own labels with their ids, keeps every one whose name equals the argument ignoring letter case, and deletes each with DELETE /repos/{owner}/{repo}/issues/{index}/labels/{id}. Because the ids come from the issue, an organization label is removed as reliably as a repository label. An issue that carries no match gets no request, so removing an absent label succeeds without a change. A delete that Gitea answers as not found or invalid does not stop the others. A delete returns no label list, so every removal ends with a fresh read of the issue’s labels.

An add after which the issue lacks the label, or a removal after which it still carries it, fails with tracker_payload_error. Both writes are covered by the write:issue scope.


Field mapping

The adapter normalizes Gitea issue responses to issue object fields.

Template fieldGitea sourceNormalization
.issue.idnumberIndex as a string. Same value as .issue.identifier.
.issue.identifiernumberIndex as a string (for example, "42").
.issue.titletitleString, as-is.
.issue.descriptionbodyMarkdown pass-through. Empty string when null.
.issue.priority(not available)Always nil. Gitea issues have no priority field.
.issue.statelabels + native stateDerived via the state model.
.issue.branch_namerefOpaque string, as-is. Empty maps to null. Never parsed.
.issue.urlhtml_urlString, as-is.
.issue.labelslabels[].nameEach label lowercased. Non-nil empty list when no labels.
.issue.assigneeassignees[0].loginFirst assignee’s login. Empty string when no assignees.
.issue.issue_type(not available)Always empty. Gitea has no native issue-type field.
.issue.parent(not available)Always nil. Gitea has no parent or sub-issue concept.
.issue.commentsseparate routenil on candidate fetch. Populated when the issue is read individually or comments are fetched on demand. Markdown.
.issue.blocked_by.../issues/{index}/dependenciesEach blocker to a blocker entry with .id and .identifier set to its index and .state label-derived. See blocker extraction.
.issue.created_atcreated_atRFC 3339 string, as-is.
.issue.updated_atupdated_atString, as-is.

Comment normalization

Template fieldGitea sourceNormalization
.ididInteger formatted as a string.
.authoruser.loginString, as-is.
.bodybodyMarkdown pass-through.
.created_atcreated_atRFC 3339 string, as-is.

Comments arrive oldest-first from Gitea and need no client-side re-sort.

Blocker extraction

Candidate polling does not call the dependencies route: the candidate response carries no dependency field, so every candidate is marked unresolved unconditionally, with no cheap zero-dependency shortcut like the GitHub adapter’s dependency summary. Each candidate’s blockers are resolved once the cheaper dispatch checks pass, bounded by a per-poll budget shared across every candidate that needs a read. Reading the issue individually still reads the route directly and resolves the candidate’s list immediately.

GET /repos/{owner}/{repo}/issues/{index}/dependencies returns a JSON array of full issue objects blocking the queried one. Each becomes a blocker entry with .id and .identifier set to the blocker’s index, .display_id set to the qualified owner/repo#N form, and .state derived from the blocker’s own labels the same way the adapter derives any issue’s state.

A 404, or any other non-2xx response, is a failure rather than an empty list: the route is expected to answer a genuinely empty blocker list with 200 and [], not 404. A candidate whose read fails this way is held out of dispatch and retried on a later poll. See candidate eligibility for the dispatch-side effect and the Prometheus metrics reference for the sortie_candidate_holds_total counter this produces.


Query filter

tracker.query_filter is a URL query fragment merged into the repository issue-list request. A value that is not a valid URL query is rejected when the adapter is built, with tracker_payload_error.

# Issues assigned to the automation account
query_filter: "assigned_by=hermes-bot"

# Issues carrying a label named "agent-ready"
query_filter: "labels=agent-ready"

# Combined
query_filter: "assigned_by=hermes-bot&labels=agent-ready"

The adapter owns four keys and rejects a fragment that names any of them when it is built, with tracker_payload_error. They are checked in this order: state, type, page, limit. Every other key passes through. Gitea silently ignores an unrecognized parameter and returns every open issue, so a key outside Gitea’s known issue-list parameters (labels, q, milestones, since, before, created_by, assigned_by, mentioned_by) widens rather than narrows the result; the adapter warns when it is built on such a key.

The labels parameter carries three edges. It is server-side AND across comma-separated names, so an issue must carry every name listed. It is case-sensitive. An unresolvable name silently drops the whole filter and returns every open issue. The adapter warns, when it is built, that a query_filter label does not resolve against the repository’s labels.

The filter merges into candidate polling and the open-state half of state-based lookups. It does not merge into the closed-state half of state-based lookups, nor into the per-id and per-identifier reconciliation lookups, which fetch each issue directly.


Pagination

List routes take page (1-based) and limit. The page-size parameter is limit, not per_page. The adapter sends limit=50 and follows the RFC 8288 Link header (rel="next", rel="last") the same way on every list route, up to a 200-page guard.

The server clamps limit to the instance’s MAX_RESPONSE_ITEMS (default 50), so the adapter iterates by the Link header rather than assuming a page size; an operator who lowers the cap in app.ini does not break pagination. An absent Link header is the normal end-of-results signal.

The per-issue comments route is the exception: it is unpaginated and returns the complete comment list in one response. There are no cursors, so the missing-end-cursor guard does not apply.


Rate limiting

Gitea ships no built-in API rate limiting. There is no /rate_limit endpoint, no x-ratelimit-* response headers, and no ETag header, so there is no conditional-request cache. The budget is the self-hosted instance’s capacity, and poll cadence is the only pressure control.

A reverse proxy in front of the instance may inject HTTP 429. The adapter maps 429 to tracker_api_error and honors a Retry-After header when present, but expects never to see one from Gitea itself.


Error model

Every Gitea API error carries one uniform JSON body:

{"message": "<diagnostic>", "url": "https://<instance>/api/swagger"}

The adapter maps the HTTP status to a normalized error category.

HTTP statusConditionError kind
200, 201, 204Success(none)
400Bad requesttracker_payload_error
401Invalid credentialstracker_auth_error
403Insufficient permissions or missing scopetracker_auth_error
404Missing issue, repository, or labeltracker_not_found
405Method not allowedtracker_api_error
409Conflicttracker_api_error
412Precondition failed, including an unknown state value on an edittracker_payload_error
422Validation failed, including a missing required fieldtracker_payload_error
423Locked, including a write to an archived repositorytracker_api_error
429Rate limited by a fronting proxy; honors Retry-Aftertracker_api_error
5xxServer errortracker_transport_error
-Network, DNS, TCP, or TLS failuretracker_transport_error
-JSON decode failure on a 2xx responsetracker_payload_error

Silent success traps

Two Gitea behaviors return HTTP 200 with a wrong-shaped success, so no status mapping catches them. Attaching an unknown label name no-ops. A labels filter with an unresolvable name drops the filter and returns every open issue. The adapter’s own resolve-before-write steps are the mitigation: it attaches labels by id after resolving or creating them, and it warns on an unresolved query_filter label rather than trusting the server to reject it. A label add or removal is also checked against the issue’s labels afterwards; see label writes.

For the full error taxonomy and operator guidance, see the error reference.


SCM and CI surface

The gitea kind also provides an SCM adapter and a CI status provider, so a Gitea-backed deployment drives the same pull-request reactions as a GitHub-backed one: review-comment feedback, CI-failure escalation, auto-merge, and branch cleanup. The reaction kinds and their lifecycle are provider-agnostic and documented in the reactions reference; provider: gitea on a reaction block activates this adapter, and how to set up PR reactions covers the operator procedure. This section documents only the Gitea-specific behavior. Gitea exposes no GraphQL API and no aggregate review-decision or check-runs endpoint, so every read below is composed from REST routes under /api/v1.

Both surfaces read endpoint from a top-level gitea: block first, the same adapter pass-through configuration mechanism the user_agent field uses, and fall back to tracker.endpoint when the block omits it and tracker.kind is also gitea. Whichever value they resolve is validated exactly like tracker.endpoint: a value that is not an absolute http(s) URL with a hostname is rejected when the adapter is built, before either one makes a request. sortie validate only inspects tracker.endpoint, so a gitea: block endpoint that would fail this check is not caught offline. It surfaces the first time Sortie starts.

SCM read operations

The adapter implements the SCM contract’s six read operations. Every route uses the PR index; pull requests share the issue index sequence, so the timeline route lives under /issues/.

OperationGitea route(s)
Review decisionGET /repos/{owner}/{repo}/pulls/{index}/reviews, GET .../pulls/{index}
MergeabilityGET .../pulls/{index}
CI statusGET .../pulls/{index}, GET .../commits/{sha}/status
Pending reviewsGET .../pulls/{index}/reviews, GET .../pulls/{index}/reviews/{id}/comments
Bot review commentsSame routes as pending reviews, filtered by the bot-username allowlist
Label eventsGET .../issues/{index}/timeline

These routes paginate by page number, not by the Link header the tracker routes follow. The adapter accumulates fixed-size pages of 50 until a short page arrives, capped at 50 pages with a logged warning.

Reviews carry a state enum of APPROVED, PENDING, COMMENT, REQUEST_CHANGES, and REQUEST_REVIEW. Gitea spells the changes-requested state REQUEST_CHANGES, not GitHub’s CHANGES_REQUESTED; a state filter copied from the GitHub adapter matches nothing. Reviews an operator dismissed are skipped by every read.

The review-decision read folds the review list, since Gitea has no aggregate field to read. Reviews are ordered by submitted_at then id, and the latest APPROVED or REQUEST_CHANGES per reviewer supersedes that reviewer’s earlier reviews; COMMENT, PENDING, and REQUEST_REVIEW are not decisions. The ordering is load-bearing, so a submitted_at that is not a valid RFC 3339 value fails the read rather than sorting the review to the epoch, where a superseded approval could outrank the changes-requested review that supersedes it. Only reviews that can change the verdict are parsed, so a dismissed or non-decision review cannot fail the read. Any standing REQUEST_CHANGES yields the changes-requested decision; otherwise any APPROVED yields approved; otherwise a non-empty requested_reviewers list on the PR yields review-required; otherwise not-required.

Review comments are single-line: the comment object carries position but no end-line field. A comment whose anchor a later push removed reports position: 0; its line falls back to original_position and the comment is marked outdated. A retained review’s own body is returned as a PR-level comment alongside its inline comments.

Bot classification

Gitea users carry no platform bot marker, so bot classification is the bot_usernames allowlist alone: the bot-review-comment read retains a review or inline comment only when its author’s login matches an allowlist entry case-insensitively, and it applies no review-state filter. An empty allowlist selects nothing, so the bot_review reaction routes no comments on Gitea until bot_usernames names each bot account.

The pending-review read passes no allowlist, so unlike the GitHub adapter it cannot exclude a bot-authored REQUEST_CHANGES review.

Mergeability

The pull request object carries a plain mergeable bool: there is no mergeable_state string and no tri-state computing field. The mapping to the normalized mergeability state is lossy. A draft maps to blocked, a mergeable non-draft to clean, and every other state to unknown. Gitea never yields dirty or unstable; a merge conflict and an in-progress recheck both collapse to unknown, which the auto-merge state machine re-enqueues rather than treating as a hard conflict. The same read supplies head.sha (the CI ref), head.ref (the head branch), and base.ref (the base branch).

Combined commit status

GET .../commits/{sha}/status returns {state, sha, statuses, total_count}. The adapter computes the aggregate from the per-status entries and never trusts the top-level state: a commit with no CI reports a spurious top-level state: "pending". The route paginates, so the adapter walks every page and a commit with more statuses than one page is read in full.

Per-status status valueClassification
successNon-failing
warningNon-failing
pendingPending
failureFailing
errorFailing

The CI status read reports failing when any entry is failing, pending when no entry is failing but one is pending, and success otherwise. A head commit with no statuses reports the empty conclusion, meaning no checks exist. Values are compared case-insensitively.

CI status provider

The CI status provider drives the ci_failure reaction. Reading a ref’s CI status reads the combined commit status directly by ref (a branch name or SHA, percent-encoded into the route), with no PR fetch or SHA resolution, and normalizes it to the CI result.

Each per-status entry becomes a check run: context is the check name, status maps to the run status and conclusion, and target_url is the details URL. success, failure, error, and warning count as completed runs; any other value is in progress. The conclusion is success for success, failure for failure and error, neutral for warning, and pending otherwise. The aggregate is failing when any run concludes failure, passing when every run has completed and none failed, and pending otherwise; a ref with no statuses yields a pending result with an empty, non-nil check-run list.

The failing-run log excerpt is assembled from the first failing entry’s description and target_url, both already present in the authenticated combined-status response; the provider never fetches target_url, so a third-party run URL cannot expand the request surface beyond the Gitea API. The excerpt keeps the last max_log_lines lines of that text after the line cleaning described in the excerpt format, and has no [sortie] line or omission lines. A max_log_lines of zero, or a failing entry carrying neither field, omits the excerpt.

SCM write operations

The write surface covers merging a pull request, deleting a branch, and removing a label. The supported merge strategies are merge, squash, and rebase, the same set the auto-merge strategy field accepts; any other value is rejected before a request is issued.

Merging a pull request posts to .../pulls/{index}/merge with a body carrying Do (the strategy) and head_commit_id (the expected head SHA, sent as a stale-merge precondition).

Merge outcomeGitea responseMapping
MergedHTTP 200, empty bodySuccess. No merge-commit SHA is returned on this route.
Already mergedHTTP 405Conflict error carrying the “already merged” marker; Sortie dispatches it as a success.
Stale head_commit_idHTTP 409Conflict error; the adapter re-reads the merge state and reattempts.
Missing scopeHTTP 403 naming a scopeAuth error rewritten to name write:repository.

The already-merged marker is gated on a PR re-read, not on Gitea’s message text: after any 405 or 409 the adapter re-reads the PR and attaches the marker only when the PR is in fact merged, so a stale-head rejection never carries it.

Deleting a branch calls DELETE .../branches/{branch}; success is HTTP 204. An already-gone branch returns HTTP 404, which is a no-op. The branch name is percent-encoded, so feature/x reaches Gitea as feature%2Fx.

Removing a label resolves the label name to its numeric id against the PR’s own labels (GET .../issues/{index}/labels), then calls DELETE .../issues/{index}/labels/{id}; Gitea’s label routes are id-based, and a name in the id position returns 404. A name that does not resolve is a no-op, and no request is issued. A delete that races an external removal (HTTP 404) is likewise treated as success.

Token scope for merge and branch operations

One coarse write:repository scope covers both merging a pull request and deleting a branch; Gitea has no separate pull-request and contents scope split. Gitea also exposes no scope-introspection surface: there is no /rate_limit endpoint, no X-OAuth-Scopes response header, and a token’s own scopes appear only inside the body of a 403 rejection. permissions.push from GET /repos/{owner}/{repo} reflects the token owner’s repository role, not the token’s scope; a read-only token owned by a repository admin still reports push: true.

The startup auto-merge preflight therefore cannot verify the token’s scope. It fails open, reporting the scope as unverifiable so auto-merge proceeds, and adds the one gate it can check: when permissions.push is false, the token’s user lacks repository write access, and the failed preflight disables auto-merge for the process lifetime. A missing scope on a token whose user has write access surfaces only at runtime, as a 403 on the first merge or branch delete that the adapter rewrites to name write:repository.

Grant the token’s user write access to the repository, and grant the token the write:repository scope alongside the tracker scopes. How to connect Sortie to Gitea covers token creation.


Forgejo and Codeberg

Forgejo is the 2024 hard fork of Gitea; Codeberg is the flagship hosted Forgejo instance. Both are expected to work behind the same kind: gitea configuration, because the adapter targets the portable subset the two forges share: the issue and comment routes, id-based label operations, and Link header pagination. This compatibility is claimed by design, not tested. Sortie does not run its gated integration suite against a Forgejo instance or against Codeberg, so treat a Forgejo deployment as unverified until it does.

The adapter removes labels by id rather than by name, which keeps it inside the portable subset it targets. Operators pointing Sortie at codeberg.org must respect Codeberg’s terms of service for automation; self-hosted instances are the primary target.


Key differences from the GitHub adapter

Most of what separates the two is their own API surface, which each vendor documents. Three differences change what you configure or what you can rely on:

DifferenceConsequence for a Sortie configuration
endpoint is requiredThere is no default host; the same value is reused by the SCM and CI roles unless overridden.
There is no bot marker on a reviewbot_usernames is the only signal, so the bot_review reaction routes nothing until you name each bot account.
Mergeability is a single booleanA merge conflict collapses to an unknown state that the auto-merge state machine re-enqueues, rather than reporting as a conflict.

External references

  • Gitea API reference: the generated reference for every route this adapter uses
  • API usage: base path, authentication, and pagination conventions
  • Swagger explorer: the live schema, useful for confirming a payload against your own version

Related pages

Was this page helpful?