Skip to content

External Issue Trackers Developer Guide

Ed Mozley edited this page Aug 2, 2026 · 21 revisions

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–#951.

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: raising issues works; nothing comes back yet. You can configure a tracker, escalate a ticket to it by hand or by workflow rule, and see the issue on the ticket. What does not exist yet is the return path β€” no comment sync, no webhooks, and status is only as fresh as the poll cron, which must be scheduled or nothing ever changes. Β§9 has the full picture.


1. πŸ“ The files involved

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: the provider registry, load a connection, escalate, refresh status β€” and the company guard (Β§6, Β§7)
πŸ”Œ includes/integrations/JiraProvider.php The first concrete connector β€” see its own page
βš™οΈ workflow/includes/engine.php The two actions, the connection lookup and argBool() (Β§7a)
βš™οΈ cron/integration_poll.php, docs/integration-poll-cron-setup.md The status poll β€” the only thing that refreshes status today (Β§7)
⌨️ system/integrations/index.php, provider.php, .htaccess The settings screen and its pretty URLs (§7b)
πŸ”Œ api/integrations/list_connections.php, save_connection.php, test_connection.php, delete_connection.php Connection CRUD, admin-gated
⌨️ system/includes/areas.php The System landing card + its icon
⌨️ assets/js/inbox.js, tickets/index.php, assets/css/inbox.css The pill in the Links strip and the escalate modal (Β§7c) β€” bump inbox.js?v=
πŸ”Œ api/tickets/get_email_detail.php Adds tracker_links to the ticket payload
πŸ”Œ api/integrations/escalate_ticket.php, connections_for_ticket.php The manual escalate + its company-filtered connection list
🌍 lang/en/tickets.php, lang/pt-BR/tickets.php tickets.tracker.*
πŸ—„οΈ database/freeitsm.sql, includes/db_verify_schema.php, includes/db_verify_indexes.php integration_connections + integration_links, their keys and indexes
🌍 lang/en/system.php, lang/pt-BR/system.php system.integrations.*, EN and pt-BR in the same commit
πŸ§ͺ tests/integrations/run.php 168 assertions, none needing a live tracker (Β§8)
πŸ“„ CHANGELOG.local.md, this wiki logged as #945–#951

Note what is not there: no change to the tickets module at all. That is a consequence of escalation being a workflow action (Β§2), not an accident.


2. The shape

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 Β· send_note_to_tracker  βœ” built
                        β”‚  β–² tracker.* triggers (V2, 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 is 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 therefore a rule a user writes, not a feature we build.

The bet paid off exactly as intended: shipping escalation added not one line to the tickets module. Two entries in the action registry, two handlers, one lookup source.


3. The contract β€” what is generic, what is yours

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

What you must implement

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.

fetchIssues() β€” the batch escape hatch

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.

pollChanges(?string $since, array $externalIds = []) takes a watch list

Polling is scoped to the issues we actually hold links to, not to "everything that changed" β€” an unscoped poll on a busy tracker returns thousands of issues nobody here cares about. V4, where an issue raised in the tracker creates a ticket, is the case that will need an unscoped variant; it can pass an empty list plus a project scope then.

⚠️ The service is self-contained on purpose

integrations.php requires encryption.php, ssl.php and tenancy.php itself rather than leaving it to the caller. It originally did not, which worked only because the first callers β€” the settings endpoints β€” happened to require them already. The workflow engine does not, and the escalate action fataled on decryptValue() the first time it ran for real. A shared service cannot assume anything about who called it.

⚠️ Never branch on a status name

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.


4. IssueDoc β€” one document, four renderers

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.

⚠️ Escaping is the renderer's job

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 is strict

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.


5. πŸ—„οΈ The tables

integration_connections

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 β€” webhook or poll. See Β§9.

integration_links

The spine: one row per "our work item ↔ their issue".

  • entity_type is polymorphic from day one (ticket / problem / change) even though only ticket is ever written. It costs nothing now and would be a migration later.
  • external_id is the provider's stable id, not the key β€” PROJ-412 becomes OPS-412 when 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.

6. ⚠️ Multi-company: a connection is a connection

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_id NULL = 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() returns has_credentials as 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.

The guard

integrationsCompaniesCompatible(?int $entityTenantId, ?int $connectionTenantId, ?int $defaultTenantId): bool

Escalation 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.

7. The service

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:

  1. 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.

⚠️ The schema gate

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.

The poll cron

cron/integration_poll.php β€” the only way status ever changes today, since webhooks are V2. An install that never schedules it shows every issue frozen at the status it had when it was raised, which is why docs/integration-poll-cron-setup.md opens by saying so.

It honours each connection's own poll_interval_minutes, stamps last_poll_datetime even on failure (so a permanently broken connection is not retried every single run), and reports one broken tracker without abandoning the others. Same token + min-interval conventions as the webhook and SLA crons.

⚠️ A totally unreachable tracker must be reported, not hidden. JiraProvider::fetchIssues() swallows a failed chunk so one bad page does not lose the rest β€” but if every chunk failed it rethrows, because returning [] is indistinguishable from "none of those issues exist" and the poll would otherwise print "checked 12, changed 0" while Jira had been down for a week. That distinction was found by a test, not by reading the code.

Status refresh

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.


7a. βš™οΈ The workflow surface

Two actions in WorkflowEngine::availableActions(), dispatched from executeAction():

Action What it does
escalate_to_tracker Raise an issue from a ticket and record the link
send_note_to_tracker Post a comment onto the issue a ticket is already linked to

Plus one lookup source (integration_connection β†’ integration.connection_id β†’ the integration_connections table) feeding the connection dropdown, and argBool(), which did not exist because this is the engine's first 'type' => 'bool' arg.

The handlers do no safety checking of their own. The company guard lives in integrationsEscalate(), and a second copy would be a second copy to get wrong β€” especially since a workflow's args are editable by anyone who can author workflows.

Three behaviours that are deliberate

  • skip_if_linked defaults ON. A status-change trigger fires repeatedly on the same ticket; without this, each firing hands the dev team another duplicate.
  • Dry run cannot create an issue. runInner() short-circuits before executeAction(), so this is true by construction rather than by the handler remembering to check. Do not add a dry-run branch to a handler β€” it would be dead code implying a guarantee that lives elsewhere.
  • send_note_to_tracker skips rather than fails when a ticket has no link. "On status change, tell the dev team" will fire on plenty of tickets nobody escalated, and that is not an error.

⚠️ The rough edge: project and issue type are text, not dropdowns

Their valid values depend on which connection was chosen and must be fetched from the tracker's API, but the editor builds every dropdown once at page load and has no cascade mechanism. Rather than invent one, the admin types the key. Routing (V3) removes the need entirely.

This is the target-arity problem in miniature β€” Jira needs project + issue type, GitHub needs only a repo, Azure DevOps needs project + team + area path + iteration path. A future target descriptor (each provider declaring its target fields, the editor rendering them) is the real fix, and it is the one place a core change is expected.

7b. ⌨️ The settings screen

System β†’ Integrations β†’ Jira, at /system/integrations/jira.

  • /system/integrations/ is just a folder with an index.php β€” already pretty, no rewrite needed, same as system/sso/.
  • /system/integrations/<provider> is rewritten to provider.php?provider=<key> by a folder-level .htaccess, house style copied from api/v1/.htaccess. ^([a-z0-9-]+)/?$ so both /jira and /jira/ work with no DirectorySlash 301, behind !-f !-d guards so a real file always wins.

One shared provider.php, not a folder per provider. The form renders from the registry's credential_fields, so a tracker whose auth looks nothing like Jira's needs no change to the page, and its URL starts working the moment the connector is registered.

⚠️ Apache only. web.config has no <rewrite> section, so pretty URLs do not work on IIS β€” the same pre-existing gap as /login. The page is still reachable at provider.php?provider=jira.

Things that will bite the next person here

  • .form-row is display: flex in inbox.css. Reusing that class name puts labels beside inputs and wraps them mid-phrase. This page uses .int-field instead β€” a scoped class beats an override war.
  • Full-width settings pages need BOTH. max-width: none alone does nothing while an inherited margin: … auto is still centring it. There is no auto margin here at all.
  • Use showConfirm / showToast, not confirm() and not a bespoke result box. Both are loaded on every analyst page by renderWaffleMenuJS().
  • Icon buttons break e.target. A click lands on the <path> inside the SVG, so read the attribute off closest('[data-…]').

7c. 🎫 The ticket side

A linked issue is rendered as a pill in the existing Links strip, not in a panel of its own. A Jira issue is a link, and that strip is already where an analyst looks for "what else is this connected to". Reusing it meant no new layout, no new empty state, and no new place to remember.

The pill is tinted by status_category β€” the four categories every provider normalises onto β€” so nothing on the ticket screen knows Jira's vocabulary. api/tickets/get_email_detail.php adds tracker_links to the payload.

⚠️ Rendering a ticket never calls the tracker. The status shown is whatever the poll last cached. A ticket must not take three seconds to open because somebody else's API is slow, and must not fail to open because it is down.

The manual escalate

Links strip β†’ Link to… β†’ Issue tracker, which opens a modal that:

  1. lists only connections this ticket's company may use (connections_for_ticket.php applies the same company rule the service enforces β€” the UI half, never the enforcement);
  2. builds the description server-side in preview mode and shows it verbatim;
  3. escalates through escalate_ticket.php β†’ integrationsEscalate() β€” the same service the workflow action uses.

⚠️ The preview is not decoration. Raising an issue is a one-way door into a system we do not control: it cannot be unsent, and anyone with access to that tracker can read it. Show the exact text first.

⚠️ integrationsBodyToText() β€” why strip_tags() is not enough

The first real ticket previewed produced a Jira description containing several hundred lines of the email's CSS. strip_tags() removes the tags of <style> but keeps its content, so a marketing-styled email dumps its whole stylesheet into the issue.

So the helper kills <style>/<script>/<head>/<title> outright, turns block ends into newlines, collapses the &nbsp; runs and CRLF pairs HTML mail is full of, and caps the length with a visible "(truncated)" marker. It has its own tests (Β§8, section 7) precisely because it is the sort of thing that rots silently.

Two traps on the ticket page

  • Modals open with classList.add('active'), never style.display. .modal is already display: flex and hidden via visibility/opacity, so setting display achieves nothing β€” the click just appears to do nothing at all. This shipped to Ed once and that is exactly how it presented.
  • Bump inbox.js?v= in tickets/index.php. Edit the file without bumping and every browser keeps running the old one, which also looks like "the button does nothing".

8. πŸ§ͺ Testing without a live tracker

tests/integrations/run.php β€” 168 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.

⚠️ Every negative assertion is paired with a positive control

"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.


9. What is not built yet

Piece State
Settings screen (/system/integrations/jira) βœ” built (#948)
Workflow actions escalate_to_tracker / send_note_to_tracker βœ” built (#949)
tracker.* triggers (inbound events firing workflows) V2
Ticket pill and the manual escalate button βœ” built (#950)
Poll cron βœ” built (#951)
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.


10. Adding a new tracker

The payoff, and the test of whether the engine is right:

  1. Write includes/integrations/<Name>Provider.php extending IssueTrackerProvider.
  2. Implement the four abstract methods. renderDoc() is usually one line.
  3. Map the tracker's states onto the four categories.
  4. Declare capabilities().
  5. Add one line to the factory and one entry to integrationsAvailableProviders() in integrations.php.

That registry entry is what makes the rest free: it carries the provider's name, blurb, URL label and β€” importantly β€” its credential_fields, which the settings form renders from. So a tracker authenticating completely unlike Jira needs no change to the settings page, and its URL /system/integrations/<key> starts working immediately.

Adding GitHub should touch nothing else. If it needs a change to the link table, the service, the settings page or the workflow action, the abstraction was wrong.

⚠️ The known exception is the workflow action's project / issue_type args (Β§7a) β€” those are Jira-shaped and a provider with a different target arity will want the target descriptor building. That is expected, and it is the one place a core change is already anticipated rather than a failure of the design.

⚠️ 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.


11. πŸ“„ Keeping this page honest

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_id NULL means (Β§6)
  • the company guard's behaviour in any case
  • the order of operations in integrationsEscalate() (Β§7)
  • the workflow actions, their args, or the defaults in Β§7a
  • the provider registry's shape (Β§10) β€” it is what keeps a new tracker cheap
  • what is and is not built (Β§9), which is the first thing a reader checks

⚠️ A lesson about this page, not about the code

#948 and #949 shipped before this page was updated, and the Β§9 table then said "not built" about two things that were live. A wiki that is confidently wrong is worse than one that is missing β€” someone will trust it. Update in the same commit, not in the next one.

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

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally