Integrators#

This chapter is for whoever writes something that talks to the tracker: a script, a dashboard, a CI job, a bot, or a language model.

The short version: the API is MantisBT’s. Same paths, same JSON, same status lines, same headers. A script written against MantisBT 2.28’s REST API runs against locust unchanged, which is the point – and that promise is checked, route by route, against a real MantisBT installation.

Two things are locust’s own and are marked where they appear: extra fields on the issue JSON for features MantisBT has not (estimates, deadlines, the compliance roles, work packages, meetings), and a separate namespace, /api/ai, for language models.

Getting in#

The API tokens page

Figure 31 API Tokens in the account pages: one token per client, shown once.#

Every request carries an API token in the Authorization header – the token itself, with no scheme word in front of it:

$ curl -H "Authorization: $TOKEN" https://bugs.example.org/api/rest/users/me

A user mints tokens on the API Tokens tab of their account pages. The token is shown once, at creation; after that only its name and the day it was last used. Revoking one is immediate.

A token acts as the user who made it, with that user’s access everywhere: there is no scope, no separate permission system, and nothing a token can do that its owner cannot. An installation that allows anonymous browsing also answers read requests with no token at all, under the anonymous user’s access.

Two base paths work, and are the same API:

  • https://host/api/rest/... – with MantisBT’s rewrite rule;

  • https://host/api/rest/index.php/... – without it.

Everything else of MantisBT’s URLs has moved: pages have lost their .php and old links answer 308. The API has not moved, on purpose.

Conventions#

JSON

Written as PHP’s json_encode() writes it, down to the escaping, because clients that compare bytes or hash bodies exist.

ETags

Read requests carry an ETag; sending it back in If-None-Match gets 304 Not Modified with no body. Use it for polling: it is far cheaper on both sides than fetching the list again.

Status lines

MantisBT’s, with its reason phrases.

Errors

{"message": "...", "code": N, "localized": "..."} – MantisBT’s shape, with MantisBT’s error numbers. /api/ai uses a different, slug-first shape (below), deliberately: the two namespaces are different promises.

Paging

page and page_size on the list routes.

Rate limits

An installation may limit requests per client address and per token. Over the limit, the answer is 429 with Retry-After; a client that respects that header will never notice anything else.

The routes#

Issues#

GET /api/rest/issues#

A page of issues. Query parameters: project_id, filter_id (a saved filter), page, page_size, and MantisBT’s select to narrow the fields returned.

GET /api/rest/issues/(int: id)#

One issue, with its notes, attachments, relationships, tags, monitors, custom fields and history as the viewer may see them.

POST /api/rest/issues#

File one. summary, description, project and category at least.

PATCH /api/rest/issues/(int: id)#

Change it. Only the fields you send.

DELETE /api/rest/issues/(int: id)#

Delete it, if your account may.

Notes, files, tags, monitors and relationships hang off an issue:

  • POST   /api/rest/issues/{id}/notes and DELETE /api/rest/issues/{id}/notes/{note_id}

  • GET/POST /api/rest/issues/{id}/files and GET /api/rest/issues/{id}/files/{file_id}

  • POST   /api/rest/issues/{id}/tags and DELETE /api/rest/issues/{id}/tags/{tag_id}

  • POST   /api/rest/issues/{id}/monitors

  • POST   /api/rest/issues/{id}/relationships and DELETE /api/rest/issues/{id}/relationships/{relationship_id}

Projects#

GET, POST, PATCH and DELETE on /api/rest/projects and /api/rest/projects/{id}, plus:

  • /projects/{id}/versions and /projects/{id}/versions/{version_id} (GET, POST, PATCH, DELETE)

  • /projects/{id}/subprojects and /projects/{id}/subprojects/{subproject_id}

  • /projects/{id}/users and /projects/{id}/users/{user_id}

  • /projects/{id}/handlers – who may be assigned issues here

Users#

  • GET /api/rest/users/me – who this token belongs to, and what that account may do. The first call a client should make.

  • GET /api/rest/users/{id}, GET /api/rest/users/username/{name}

  • POST, PATCH, DELETE on users, and PUT /api/rest/users/{id}/reset, for accounts that may administer

  • POST/DELETE /api/rest/users/me/token and /api/rest/users/{user_id}/token/{token_id}

The rest#

  • GET /api/rest/config and PATCH /api/rest/config – the configuration options a client may read or set

  • GET /api/rest/filters, GET /api/rest/filters/{id}, DELETE /api/rest/filters/{id} – saved filters

  • GET /api/rest/lang – the tracker’s own strings, for a client that wants to show them

  • GET /api/rest/pages/issues/view/{id} – everything the issue page needs in one response

  • /api/rest/internal/autocomplete and /api/rest/internal/config_display – what MantisBT’s own pages use

locust’s own fields#

An issue’s JSON carries these in addition to MantisBT’s, each only where the project has the feature and the viewer may see it:

effort_estimate

Hours, where the project estimates.

due_date

The deadline, under MantisBT’s due_date_view_threshold – which locust, unlike MantisBT, does not set to “nobody” by default.

fast_track

Whether this issue takes the short way, where the project allows it.

work_package

{"id": N, "name": "..."}, the contract the hours are charged to. A PATCH may name one by id, by name or as a bare string, as it may name a version, and the empty string takes it off again. A package of another project, or one that has been switched off, is refused – except the one the issue already carries, which stays nameable after it has been switched off.

effort_actual

Hours, the sum of what the clock measured. Read-only.

meeting

Which meeting the issue came out of, where one did; notes likewise say which meeting they were written in. Read-only.

A read-only field is refused rather than ignored: a PATCH that names effort_actual or effective_due_date with a value other than the one the issue has answers 400 naming the field, and writes nothing else from that request either. Repeating the value the issue already has passes, so a client may read an issue, change one field and send the whole object back.

The four compliance roles – implementer, reviewer, verifier, communicator – the authority notification pairs and the notification list (notify) travel the same way in a project that runs the process, and can be set through POST and PATCH like any other field. Sending notify replaces the list: an addressee the request does not repeat is taken off.

One place where a compliance project is stricter than MantisBT: MantisBT’s issue update checks only that the caller may change a status at all, never which status, so set_status_threshold applies to its pages and not to its API. In a project that runs the process the thresholds are the process’s own and hold on every door, so a PATCH setting a status answers 403 where the page would refuse it. A project that does not run the process keeps MantisBT’s behaviour exactly.

Everything here is additive: a client that ignores unknown fields sees exactly MantisBT’s issue.

Other ways out#

Not everything needs the API:

  • Any filtered issue list exports as CSV, an Excel workbook or a Word document; the columns are the ones the account configured.

  • Every list is an RSS feed. The URL carries a per-user key – treat it as a credential, and note that it changes when that user changes their password.

  • GET /healthz answers 200/ok while the database answers. It needs no token and exposes nothing; poll it from a load balancer.

Agents#

A language model pointed at a bug in a tracker has to work for its answer: the bug is a page of markup, the API that would hand it over as data needs a token nobody can mint without a browser, and nothing on the site says any of this. locust’s answer is a door that is explicit about what is behind it – and not a second, laxer way into the data.

Reading#

Every GET page that shows data also answers JSON when asked, at the page’s own URL: Accept: application/json, or ?format=json.

$ curl -H 'Accept: application/json' https://bugs.example.org/view?id=42

The shape is locust’s own and deliberately slim: scalars where MantisBT’s REST API has objects ("status": "assigned" with "status_code": 50 beside it, a user as their username). Reading needs no token wherever reading the page needs none, and the visibility rules are the same ones the HTML obeys – an agent sees exactly what an anonymous visitor sees, no more.

GET /api/ai/issues and GET /api/ai/issues/{id} are the same data under a stable path, and full=1 widens the shape to the REST v1 issue for a client that wants everything.

Discovery#

/llms.txt

Generated Markdown: what this tracker is, which projects are readable without a token, the policies (below), how to get a token, what can be read and what can be written, and every error slug.

/.well-known/ai

The same thing as JSON, in the shape of the draft-aiendpoint-ai-discovery-00 Internet-Draft. The draft is an unadopted individual draft and the document says which version it follows, so a client can tell what it is reading.

Every page also carries one comment in its <head> pointing at /llms.txt and saying that the page answers JSON.

Writing#

Writing needs an account, and an agent’s account is marked as one. The four rules, which the guide states in the second person and the code enforces:

  1. An agent may register its own account, but its username begins with bot- and its contact address is the human who runs it.

  2. That human confirms it. Until they do, the token is inert.

  3. An agent never deletes anything, and never rewrites somebody else’s words – only its own notes.

  4. Everything it does carries its name, and its name is marked as an agent’s wherever it appears.

POST /api/ai/signup#

username, email (the human’s), real_name and purpose. The human is mailed who asked, under what name, for what purpose and from which address, with a link to allow it and a link to reject it; a third link, later, switches it off again. Rejecting removes the account and its token; switching off leaves what it wrote where it is.

GET /api/ai/me#

What this agent’s account is and what it may do.

POST /api/ai/issues/tree#

File a whole tree in one statement, all or nothing: {"request_id": "...", "issue": {...}} where every node may carry children, nested as deep as the installation allows. A specification in one project with three implementations in three others, linked as children, is one request.

A node may set what the update page can set, at the agent’s level in each project. The status may not be set: it is always new, and a request that says otherwise is refused rather than quietly corrected – an agent that got a 201 after asking for assigned would believe it had assigned the issue.

POST /api/ai/issues/(int: id)/notes#

Add a note.

POST /api/ai/issues/(int: id)/files#

Attach a file.

PATCH /api/ai/notes/(int: id)#

Correct a note the agent itself wrote.

Every write takes a ``request_id``. A timed-out agent retries, and a tree of twenty issues filed twice is the expensive mistake; the tracker remembers the id with a hash of the request and answers the retry with the first result instead of filing again.

Errors on /api/ai are slug-first, so a program can branch on them without parsing prose:

{"error": "tree-too-large", "message": "...", "ref": "<node>", "field": "..."}

Every slug is listed in /llms.txt.

Limits#

An installation sets how big one tree may be (issues, depth, bytes), how long a confirmation link lives and how long request ids are remembered, and can switch the whole door off ([locust-ai] ENABLED = NO), which removes the discovery documents, the JSON negotiation and /api/ai and changes nothing else. Signup for agents can be refused separately.

What is deliberately not there: an agent cannot create tags – it attaches the tracker’s vocabulary, it does not invent it – and there is no webhook or feed yet, so an agent that wants to know about changes polls (with If-None-Match, which is what ETags are for here).

A worked example#

Three issues, assigned to you, oldest first, as JSON:

$ curl -s -H "Authorization: $TOKEN" \
    'https://bugs.example.org/api/rest/issues?page_size=3&filter_id=assigned'

Add a note to issue 42:

$ curl -s -X POST -H "Authorization: $TOKEN" \
    -H 'Content-Type: application/json' \
    -d '{"text": "Reproduced on 1.2.3, log attached."}' \
    https://bugs.example.org/api/rest/issues/42/notes

Poll a list without downloading it twice:

import requests

session = requests.Session()
session.headers["Authorization"] = TOKEN
etag = None
while True:
    headers = {"If-None-Match": etag} if etag else {}
    r = session.get(f"{BASE}/api/rest/issues?project_id=1", headers=headers)
    if r.status_code == 304:
        ...            # nothing changed
    else:
        etag = r.headers.get("ETag")
        handle(r.json()["issues"])
    time.sleep(60)