-
Notifications
You must be signed in to change notification settings - Fork 15
External Issue Trackers Developer Guide
How a FreeITSM ticket gets linked to an issue in someone else's tracker, and why the machinery underneath is deliberately not about Jira. Shipped as #945β#947.
Jira is the first connector and the only one currently written, but almost nothing here is about Jira. This page is the contract; everything Jira-specific lives on Jira Connector β Developer Guide, which doubles as the worked example for anyone adding GitHub, GitLab or Azure DevOps.
β οΈ Status: engine only. There is no user interface yet β no settings screen, no button on a ticket, no workflow action. What exists is the layer all of that will sit on. The roadmap is in Β§9.
Colour key: π§ shared rule Β· π provider Β· ποΈ schema Β· π§ͺ tests Β· π docs
| π¨ | File | What it does |
|---|---|---|
| π§ | includes/integrations/IssueDoc.php |
One description or comment, built once, rendered four ways (Β§4) |
| π§ | includes/integrations/IssueTrackerProvider.php |
The abstract contract every tracker implements (Β§3) |
| π§ | includes/integrations/integrations.php |
The service: load a connection, escalate, refresh status β and the company guard (Β§6, Β§7) |
| π | includes/integrations/JiraProvider.php |
The first concrete connector β see its own page |
| ποΈ |
database/freeitsm.sql, includes/db_verify_schema.php, includes/db_verify_indexes.php
|
integration_connections + integration_links, their keys and indexes |
| π§ͺ | tests/integrations/run.php |
151 assertions, none needing a live tracker (Β§8) |
| π |
CHANGELOG.local.md, this wiki |
logged as #945, #946, #947 |
Note what is not there: no new module folder, no cron yet, no changes to the tickets module at all.
Three layers. The middle one is new; the outer two already existed.
WORKFLOW ENGINE (exists β workflow/includes/engine.php)
triggers Β· conditions Β· action registry Β· vars Β· dry run Β· step log
β escalate_to_tracker (not built yet)
β β² tracker.* triggers (not built yet)
βββββββββββββββββββββ΄βββββββββββββββββββββββββββββββββββββββ
β INTEGRATION ENGINE (this page) β
β link registry Β· IssueDoc Β· status categories Β· β
β company guard Β· escalate service Β· status refresh β
βββββββββββββββββββββββββ¬βββββββββββββββββββββββββββββββββββ
β IssueTrackerProvider (abstract)
βββββββββββββββββββββΌβββββββββββββββββββ¬βββββββββββββββββ
JiraProvider GitHubProvider GitLabProvider DevOpsProvider
(built) (later) (later) (later)
The bottom split is deliberately the same one as includes/messaging/MessagingProvider.php, which already puts Twilio and Meta Cloud behind a single contract while ingestion, ticket creation and the reply path stay ignorant of which is live. That pattern is proven in this codebase, so it was copied rather than reinvented.
The top connection is the important design decision. Escalation will be a workflow action, not code in the tickets module β because Workflows already has triggers, conditions, {{ticket.subject}} substitution, dry run and an audit trail. "When a ticket becomes type Bug, raise it in Jira" is then a rule a user writes, not a feature we build. The consequence is that V1 adds no hook to the tickets module whatsoever.
This is the table to read before writing a connector. Generic means the engine relies on it and you must comply. Provider-specific means it is only how one tracker happens to work, and copying Jira's shape into a different tracker would be a mistake.
| Concept | Generic (the contract) | Provider-specific (illustrative) |
|---|---|---|
| Issue identity | every tracker has a stable id, and usually a human key | Jira's id is numeric, its key is OPS-412 and changes on project rename |
| Status | must map onto four categories: todo / in_progress / done / cancelled
|
Jira exposes statusCategory.key with three values and no cancelled |
| Body | build an IssueDoc, never a string |
Jira Cloud wants ADF; Data Center wants wiki markup |
| Auth | credentials are a JSON blob, encrypted at rest | Jira Cloud is Basic email:token; Data Center is a Bearer PAT |
| Identity of us |
testConnection() must return account_identity
|
Cloud gives accountId, Data Center gives a username |
| Target | opaque array passed to createIssue()
|
Jira needs project + issue type; GitHub needs only a repo |
| Optional features | declare via supports(); unsupported methods throw |
Jira supports everything, GitHub has no issue types at all |
Four abstract methods, because every tracker genuinely has them:
abstract public function createIssue(array $target, string $summary, IssueDoc $body, array $fields = []): array;
abstract public function fetchIssue(string $externalId): array;
abstract public function testConnection(): array;
abstract public function renderDoc(IssueDoc $doc);Everything else β addComment(), addAttachment(), verifyWebhook(), pollChanges(), listProjects(), listIssueTypes() β has a default that throws "not supported for this provider". That is copied from MessagingProvider and it matters: if optional methods were abstract, the contract would collapse to whatever the weakest tracker can do.
supports() answers the same question for the UI, which needs to know before it renders a field. A thrown exception is the wrong way for a settings screen to discover that GitHub has no issue types.
The default loops fetchIssue(), which is always correct. Providers that can read many issues in one call should override it, because the status-refresh cron is the hot path. One issue failing must not lose the rest.
Status names are per-project, per-workflow and renamed at will. Every decision in the system keys off status_category; status_name is stored for display only. This is the same lesson as tickets.merged_into_id β a merged ticket is identified by a column, never by a status called "Merged", because a user can rename it.
The problem: every tracker wants a different body format.
| Tracker | Format |
|---|---|
| Jira Cloud | ADF β Atlassian Document Format, a nested JSON document |
| Jira Data Center | wiki markup |
| GitHub / GitLab | Markdown |
| Azure DevOps | HTML |
If core built strings, there would be four sets of string-building that drift. So core builds a document:
$doc = (new IssueDoc)
->heading('Raised in FreeITSM')
->para('Ticket ', IssueDoc::link($url, 'SD-1042'), ' Β· Jane Doe (Acme Ltd)')
->rule()
->para($ticket['description']);β¦and asks for the shape it needs: toAdf(), toWikiMarkup(), toMarkdown(), toHtml(), toPlainText().
The renderers live in IssueDoc, not in the connectors. That is what makes a new tracker cheap β renderDoc() is usually one line returning an existing renderer, so adding GitHub means writing no renderer at all.
Deliberately small: heading, paragraph, link, bullet list, code block, rule. It is not a rich-text format. Anything bigger becomes a second thing that has to be kept safe against untrusted ticket text, and the house rule is that there is only ever one sanitiser (assets/js/safe-html.js) β so this stays small enough not to become a rival to it.
Text is always treated as plain text and escaped for the target format. A requester writing "use the * wildcard" must not produce italics; <script> must not survive into an Azure DevOps description. Never pass markup in and expect it to survive.
ADF rejects empty text nodes and empty paragraphs, and a malformed document 400s the whole request with an unhelpful error. So the builders refuse to create empty blocks at all: ->para('') adds nothing, a leading ->rule() is swallowed, and an entirely empty document still renders as one valid paragraph rather than an invalid empty one.
One configured tracker instance β a Jira site, a GitHub org. credentials is a JSON blob encrypted at rest with the same helper as messaging_channels.credentials; webhook_secret likewise.
Two columns deserve a note:
-
account_identityβ who our token authenticates as, captured at connection test. Nothing reads it yet. It is half of echo suppression: once comments sync both ways, an inbound event authored by this identity is our own write coming back and must be dropped rather than re-imported. It is populated now because back-filling it for links that already exist is miserable. -
ingress_modeβwebhookorpoll. See Β§9.
The spine: one row per "our work item β their issue".
-
entity_typeis polymorphic from day one (ticket/problem/change) even though onlyticketis ever written. It costs nothing now and would be a migration later. -
external_idis the provider's stable id, not the key βPROJ-412becomesOPS-412when a project is renamed, so the key is display only. -
UNIQUE (connection_id, external_id, entity_type, entity_id)β one issue may legitimately link to two tickets, never twice to the same one.
This is the part most likely to be got wrong, because tenant_id means three different things in three kinds of table. See Multi-Tenancy β Developer Guide Β§1 for the full table; the short version for this module:
integration_connections.tenant_idNULL = shared across every company (an MSP's own Jira). Set = pinned to that one company (a client with their own Jira).
That is the connection shape β the same as mailboxes and messaging channels, and the opposite of tickets, where NULL means "the Default company's".
Consequences:
- The admin list is deliberately unfiltered. Someone configuring routing needs to see every connection at once.
-
Never scope it with
activeTenantFilter(). That treats NULL as Default-owned and would hide every shared connection from every client company. -
A read that returns credentials is the exception to "capabilities guard writes, not reads".
integrationsListConnections()returnshas_credentialsas a boolean and never the token. That is what makes an install-wide list defensible; without it, "deliberately unfiltered" would be a cross-company credential leak.
integrationsCompaniesCompatible(?int $entityTenantId, ?int $connectionTenantId, ?int $defaultTenantId): boolEscalation is workflow-driven, and a workflow's conditions are editable by anyone who can author workflows. The wiki is blunt that inbound company routing must never become a workflow rule β it is a hardcoded synchronous membrane. This is the outbound twin of exactly that rule: without it, one mis-scoped workflow escalates Acme's ticket content into Globex's Jira.
So the check lives in code nobody can edit, and runs before any network call and before any write.
- Shared connection β accepts anything.
- Pinned connection β accepts only its own company.
- A NULL work item is resolved to the Default company before comparing, so a Default ticket can still reach a Default-pinned connection.
- No resolvable owner + a pinned target β refuse, never guess.
- On a single-company install every call is trivially true β correct rather than a bypass, because there is nothing to leak to.
integrationsEscalate() is the one place an escalation happens. The workflow action and the manual button will both call it. There is deliberately no separate "automatic" implementation, because the guards would then exist twice and one copy would eventually drift.
Order of operations, which is itself the design:
- Schema gate β 2. validate β 3. load connection β 4. company guard β 5.
skip_if_linkedβ 6. dry-run returns here β 7. create the issue β 8. record the link.
Note that dry run returns before the network call. A workflow test that minted a real Jira issue would be an unacceptable surprise.
Every read goes through integrationsSchemaReady(). An install that has not run Database Verification has no tables, and an unguarded query would throw inside the ticket view. "Not set up" must look like "no linked issues", never like an error β a missing gate of exactly this kind once produced an empty inbox.
integrationsRefreshConnection() batches every link on a connection into one provider call and writes only rows that actually moved, so a quiet day costs one read and no writes. An issue missing from the batch leaves its cached value alone rather than blanking it.
Only a change of category is reported as an event. A rename from "In Progress" to "In progress" is not something to wake a requester for.
tests/integrations/run.php β 151 assertions, no database, no network:
php tests/integrations/run.php
The trick is that payload building and response parsing are separate protected methods rather than being inlined into the HTTP calls, so the suite subclasses a provider and stubs httpRequest() with a queue of canned [code, body] pairs. Everything that decides correctness β the ADF-vs-wiki choice, status mapping, error extraction, paging, auth headers β is therefore provable offline.
"The asterisk did not become italics" proves nothing on its own; it is equally true of a renderer that dropped the text. So each escaping test also asserts the surrounding content survived.
This matters most for the company guard, which was verified in both directions by deliberate breakage:
| Breakage | Result |
|---|---|
guard made permissive (return true) |
exactly the 5 refusal assertions fail |
guard made refuse-everything (return false) |
exactly the 10 permission assertions fail |
A guard that refused everything would otherwise pass the whole negative half while being completely broken. Do the same for anything you add here.
That exercise also caught an assertion that fatalled rather than failing β hiding every test after it. Guard assertions that could receive the wrong type.
| Piece | State |
|---|---|
Settings screen (/system/integrations/jira) |
not built |
Workflow action escalate_to_tracker + tracker.* triggers |
not built |
| Ticket panel and the manual escalate button | not built |
| Poll cron | not built |
| Comment sync both ways, webhooks, echo suppression | V2 |
| Field / priority / issue-type mapping, attachments, per-analyst tokens | V3 |
| Issues raised in the tracker creating tickets | V4 |
| GitHub, GitLab, Azure DevOps connectors | V5 |
Inbound will be webhook or poll, and both must produce the same canonical events β polling is a degraded webhook, not a second pipeline. A self-hosted install behind a firewall often cannot receive an inbound call, which is the same wall the WhatsApp channel hit; the difference is that trackers all expose a queryable REST API, so the firewalled case needs no relay, it just polls.
The payoff, and the test of whether the engine is right:
- Write
includes/integrations/<Name>Provider.phpextendingIssueTrackerProvider. - Implement the four abstract methods.
renderDoc()is usually one line. - Map the tracker's states onto the four categories.
- Declare
capabilities(). - Add one line to the factory in
integrations.php.
Adding GitHub should touch nothing else. If it needs a change to the link table, the service, the queue or the UI, the abstraction was wrong.
β οΈ Validate against Azure DevOps, not GitHub. GitHub is the easy one β everything will appear to fit and you will learn nothing. DevOps breaks the most assumptions: updates use JSON Patch documents rather than a fields object, states are per-process, targets are a tree of project + team + area path + iteration path, and the body is HTML. If the contract survives DevOps, the others are trivial.
This page and the code must never disagree. It documents decisions, not just mechanics, and a decision that changed in code but not here is worse than no documentation β someone will trust it.
If you change any of the following, update this page in the same commit:
- the abstract method list, or which methods are optional
- the four status categories, or how a provider maps onto them
-
IssueDoc's block types or any renderer's output shape - either table's columns, or what
tenant_idNULL means (Β§6) - the company guard's behaviour in any case
- the order of operations in
integrationsEscalate()(Β§7)
The design doc behind all of this is docs/design/external-issue-trackers.md in the app repo β local only, since docs/design/ is gitignored.
Jira-specific behaviour is on Jira Connector β Developer Guide, which has its own version of this note.
FreeITSM β an open-source IT Service Management platform Β· github.com/edmozley/freeitsm Β· MIT licence
- Installation
- β° Scheduled tasks (cron jobs)
- Architecture
- AI Providers
- Internationalisation (i18n)
- Timezones & Time Handling
- Theming & Dark Mode
- β¨οΈ Command palette (βK)
- π Searching inside tickets
- π Attached documents
- MobileβFriendly
-
Security
- Layer 1 β which modules you can enter
- β³ π§© Module Access Control
- β³ π οΈ Module Access β Developer Guide
- Layer 2 β what you can administer
- β³ π Roles & Permissions
- β³ π οΈ Roles β Developer Guide
- β³ π€ Why capabilities are constants
- Layer 3 β the System module
- β³ π Admin Access Control
- Hardening
- β³ π Security review response 2026-08
- β³ π‘οΈ Security hardening 2026-08
- β³ π οΈ Security hardening 2026-08 β Developer Guide
- β³ π‘οΈ Round three β plain English
- β³ π οΈ Round three β Developer Guide
- Single Sign-On (SSO)
- ποΈ LDAP & Active Directory
- Browser Extension
- API Reference
-
π REST API β how it works
- β³ π« REST API: Tickets
- β³ π» REST API: Assets
- β³ π΄ REST API: Problems
- β³ π REST API: Changes
- β³ π REST API: Knowledge
- β³ β REST API: Tasks
- β³ ποΈ REST API: CMDB
- β³ π REST API: Contracts
- β³ ποΈ REST API: Calendar
- β³ πΏ REST API: Software
- β³ π¦ REST API: Service Status
- β³ βοΈ REST API: Morning Checks
- β³ π REST API: Forms
- β³ βοΈ REST API: Workflow
- β³ πΊοΈ REST API: Network Mapper
- β³ π§ Using the API docs page
- β³ π OpenAPI specification
- β³ β OpenAPI: kept correct
- β³ π οΈ Maintaining the catalogue
- Watchtower
-
Tickets
- β³ Mailbox Authentication
- β³ π€ Email send log
- β³ Basic IMAP mailboxes
- β³ Email rendering & images
- β³ SLA Management
- β³ WhatsApp channel
- β³ π¬ Web chat channel
- β³ π£ Slack channel
- β³ π Linking tickets
- β³ ποΈ Canned responses
- β³ βοΈ Limiting replies to particular senders
- β³ βοΈ Email signatures
- β³ π The public web address
- β³ π Raising a ticket for someone else
- β³ π Merging tickets
- β³ β Splitting tickets
- β³ β Selecting several tickets
- β³ π οΈ Snoozing tickets β Developer Guide
- β³ π₯ Collision detection
- β³ β±οΈ Time tracking
- Problem Management
- Tasks
- Assets
- Knowledge
- Change Management
- Calendar
- Morning Checks
- Reporting
- Software
- Forms
- Contracts
- Service Status
- π Notifications
- π¨ War Room
- Self-Service Portal
- LMS
- Process Mapper
- CMDB
- Network Mapper
- Workflows
- Issue trackers (Jira, Azure DevOps)
- System
-
Overview
- β³ π Progress tracker
- β³ Concepts & vocabulary
- β³ Email routing & mailboxes
- β³ Settings: global vs per-company
- β³ Users & self-service
- β³ Staff cross-company access
- β³ Worked examples
- β³ Pitfalls & gotchas
- β³ Scope: what it's for
- β³ π οΈ Developer Guide (make a module multi-company)
- β³ ποΈ Case study: CMDB (a linked graph)
- β³ π§ͺ Test harness (prove it's isolated)