Skip to content

External Issue Trackers Developer Guide

Ed Mozley edited this page Aug 19, 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–#961. Proven against a real Jira Cloud site on 2026-08-02 β€” including, in #956, a design fault that only a live account could have exposed.

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.

The analyst-facing page is Issue trackers (Jira).

⚠️ Status: the loop is closed. Escalate by hand or by rule, see live status on the ticket, read the developers' comments back as internal notes (#954), send the ticket's attachments up (#960), map your values to theirs (#957), and react to tracker events with your own workflow rules (#958). 12 of 17 Halo parity rows.

Still missing: webhooks (everything arrives on the poll's schedule, not instantly), custom fields, components, per-analyst tokens, and inbound issue β†’ ticket. ⚠️ Everything inbound depends on the poll cron: unscheduled, nothing ever changes. ⚠️ Comment push is built but has still never run against a live Jira. Β§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, pull comments back β€” and the company guard (Β§6, Β§7, Β§7d)
πŸ”Œ 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 poll β€” the only thing that refreshes status or brings comments back (Β§7, Β§7d). Analyst-facing setup: Scheduled tasks
⌨️ system/integrations/index.php, provider.php, help.php, .htaccess The settings screen, the in-app setup guide at /<provider>/help, and their 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/tickets/get_notes.php LEFT JOIN on analysts + attribution for imported notes (Β§7d)
πŸ”Œ api/integrations/escalate_ticket.php, connections_for_ticket.php The manual escalate + its company-filtered connection list
πŸ”Œ api/integrations/send_attachment.php Send one attachment to an already-linked issue (Β§7g)
πŸ”Œ api/integrations/tracker_options.php, get_mapping.php, save_mapping.php The mapping screen’s data: what the tracker offers, and what we map to it (Β§7e)
🌍 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, integration_comment_map, integration_field_maps, 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 288 assertions, none needing a live tracker (Β§8)
πŸ§ͺ tests/integrations/templates_check.php The starter recipes are runnable β€” needs a DB, hence separate (Β§7f)
πŸ“„ CHANGELOG.local.md, this wiki logged as #945–#961

⚠️ What "no change to the tickets module" does and does not mean

Escalation added not one line to the tickets module β€” two entries in the action registry, two handlers, one lookup source, nothing else. That is the payoff of Β§2 and it held exactly as designed.

It stopped being true of the module as a whole at #954, and honestly so: reading comments back means displaying them, and a note has to be rendered by whatever renders notes. api/tickets/get_notes.php, assets/js/inbox.js, assets/css/inbox.css and tickets/index.php are all touched now.

Keep the two claims apart when repeating this β€” including in anything published. "Escalation cost the tickets module nothing" is true, and is the interesting architectural result. "The integration never touches the ticket screen" is not true, and never could have been once something had to appear there.


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  βœ” built (#958)
    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
    β”‚  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. ⚠️ It is deliberately not used to suppress comments β€” see Β§7d and #956; doing so swallowed the token owner's own comments. It is kept for the inbound events guard 1 cannot cover (edits, field changes) and for a possible per-connection setting.
  • 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.

integration_comment_map (#954)

One row per comment that has crossed in either direction. direction is in (tracker β†’ note) or out (note β†’ tracker); local_note_id is nullable because a comment pushed by a workflow has text behind it but no note.

⚠️ UNIQUE (link_id, external_comment_id) is not an optimisation, it is the guarantee. The service reads the map before importing, but that read is check-then-act: two overlapping cron runs would both pass it and both post the note. The unique key is what makes the second writer lose. Never drop it "because we check first".

This is also why the row is written before the note rather than after β€” see Β§7d.


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.


7d. πŸ’¬ Comments coming back (#954)

The half that makes the integration two-way: a developer asks "what were the repro steps?" in Jira and the analyst sees it on the ticket without opening Jira.

   poll cron ─▢ integrationsPullComments()
                      β”‚  provider->pollChanges($watermark, $watchList)
                      β”‚     β”œβ”€ JQL: which of OUR issues changed?      (1 call)
                      β”‚     └─ read comments on just those            (n calls)
                      β–Ό
              canonical comment_added events
                      β”‚
                      β–Ό  integrationsApplyCommentEvent()
              have we seen this comment id? ─ yes ─▢ drop
                      β”‚ no                    (this is the whole echo guard)
                      β–Ό
              map row (UNIQUE wins races) ─▢ internal note ─▢ back-fill note id

⚠️ There is deliberately no author check in that chain. #956 removed one, and the section below is why.

Three rules that are product decisions, not implementation details

  • Always internal. A Jira comment is written by someone who does not know a customer might read it. It never reaches the requester unless an analyst decides to pass it on. is_internal = 1, not configurable.
  • The first poll imports nothing. pollChanges(null, …) returns an empty array and makes no HTTP call at all; the run only writes the watermark. Without this, switching Accept updates on would tip a tracker's entire comment history onto tickets that closed months ago.
  • Off by default. integration_connections.inbound_enabled gates the whole path. Inbound writes to tickets, so a half-finished setup must not start posting β€” the same rule the messaging webhooks follow.

⚠️ Echo suppression β€” the loop this exists to stop

We push a note to Jira β†’ the poll sees a new comment β†’ it becomes a note β†’ which pushes again β†’ forever.

The guard is integration_comment_map, and it works by comment id. We record the id Jira returns for everything we push, so our own writing is recognised on the way back whoever appears to have written it. Its UNIQUE key makes that exact even under two overlapping cron runs.

That is the whole guard. It is enough, and it is the only one applied to comments.

⚠️ Why author identity is NOT used β€” a design idea that failed on contact (#956)

The original design had a second guard: drop anything authored by account_identity, the account our token authenticates as. It shipped in #954 and broke on the first live run against a real Jira.

The token owner is usually also a human who comments in Jira. So their own comments were classified as our echo and dropped β€” silently, with the poll cheerfully reporting 1 seen, 0 imported (skipped: echo=1). On a small team that is not an edge case; it is everybody.

It was redundant as well as harmful. Guard 1 already covers every comment we create, by id. Identity only adds value for events guard 1 cannot see β€” edits, attachments, field changes β€” none of which we process.

integrationsCommentIsEcho() is therefore kept but not applied to comments. It stays reachable through integrationsCommentSkipReason()'s $suppressByAuthor flag so that:

  • a per-connection "ignore comments from the connection's own account" setting is a wiring change rather than a rewrite, and
  • the branch is exercised by tests rather than rotting.

⚠️ Do not restore it as a default. If you find yourself reaching for author-based suppression, the regression test "A comment from OUR OWN account is still imported" is there to stop you, and this section is why.

⚠️ An unknown author is never treated as ours either. A tracker that stopped sending an author must not silently swallow every comment β€” the failure has to be visible.

The general lesson, which is the reason this is written up at length: the guard was correct against the model of a service account posting on our behalf, and wrong against the reality of one person owning both the token and the keyboard. It could not have been caught by any test written from the same assumption β€” only by running it against somebody's real Jira.

⚠️ analyst_id = 0, and the inner join that hid it

An imported note has no FreeITSM author. api/tickets/get_notes.php used JOIN analysts, which silently dropped every such note β€” presenting as "Jira comments never arrive", with nothing in any log. It is a LEFT JOIN now, and the header falls back to the connection's name.

That join turned out to be hiding real data already: on the development install 10 of 17 existing notes were invisible, written by analysts whose rows no longer exist (#955). api/tickets/delete_analyst.php is a plain DELETE that reassigns nothing, so this is the normal fate of every note a person wrote once they leave β€” not an edge case. An inner join on an author is a data-loss bug waiting for someone to delete a user; api/v1/resources/tickets.php and the portal's reader had both already got this right, and only this endpoint had not.

The endpoint now returns an author_kind and the browser resolves the label, because the translations live there:

author_kind When Shown as
analyst the join resolved their name
tracker imported from an issue tracker the connection's name ("Jira")
former a real analyst_id with no row left Former analyst
system analyst_id 0 and no tracker behind it System

Collapsing those four into one "Unknown" throws away information the row actually holds β€” which of them a note is, is knowable in every case.

Attribution is written into note_text as well as resolved by that join, because plenty of readers take note_text directly β€” the REST API, the portal, the AI write-up. A note reading only "any update?", with no hint it came from a dev in Jira, is worse than no note.

The watermark

integration_connections.last_poll_watermark, stamped from before the provider call so a comment posted mid-poll lands in the next window rather than the gap between them, and advanced only on success so a failed pull is retried rather than skipped past.

The provider then converts it to relative JQL minutes (updated >= -90m), never an absolute date β€” see the Jira page Β§10 for why that distinction is not cosmetic. The lookback is capped at 24 hours regardless of how stale the watermark is, which is the same "no backlog" rule as the first-poll case.

7e. πŸ—ΊοΈ Mapping β€” our vocabulary into theirs (#957)

The V3 slice. One table, one screen, applied at escalation: what our values mean in the tracker.

integrationsEscalate()
   target given by the caller?  ── yes ─▢ use it (mapping never overrides)
            β”‚ no
            β–Ό
   integrationsLoadMaps(connection)
            β”‚
   project     ─ dept:N β–Έ tenant:N β–Έ '*' β–Έ null
   issue_type  ─ typeId β–Έ '*' β–Έ null
   priority    ─ priorityId β–Έ null          ← no '*', deliberately

⚠️ The screen groups the rules; do not flatten it

Departments and companies are different kinds of thing and one beats the other, so the project section renders them as labelled groups β€” Default, Exceptions by department, Exceptions by company β€” never as one list.

Ed spotted this immediately on the first build, which listed them flat: "we've got a mix of teams and companies… is that right?" It was not. A flat list reads as a single set of equals and hides the precedence the whole feature rests on, leaving it stated only in a hint nobody reads. If you add a third routing dimension, give it its own group and say where it sits in the order.

The precedence rule, and why it is that way round

integrationsResolveProject() checks department, then company, then *. A team with its own board is a sharper signal than the company a ticket belongs to, so it wins. Returning null when nothing matches is the point: an escalation with no resolvable project must say so, never file the issue in whatever project happened to be first.

⚠️ Priority has no wildcard, and that is not an oversight

issue_type and project both honour a * row. priority does not. "Everything is a Task" is a reasonable thing for an admin to mean; "every priority is Highest" would quietly mark a dev team's whole backlog urgent. An unmapped priority simply travels as text in the description, exactly as it did before mapping existed. Two tests pin this, including a positive control so the assertion is about the wildcard rather than a broken lookup.

⚠️ A rejected priority must not lose the escalation

Jira priorities are per project, so a project whose scheme renamed Highest to P1 rejects our mapped value and 400s the whole create. integrationsEscalate() catches that, drops the priority, and retries once:

if (!isset($fields['priority']) || !integrationsLooksLikePriorityRejection($e->getMessage())) throw $e;

Both halves of that condition matter. The retry is narrow on purpose β€” retrying on any failure could turn a genuine error into an issue nobody meant to raise β€” and the reason it is worth doing at all is that losing a priority is cosmetic while losing the escalation is not. The dropped value is written to integration_links.last_error so the admin can see the mapping needs attention rather than it being silent.

local_key is a namespaced string, not a foreign key

What it points at differs per map_type: a tenant or department for routing, a ticket type id, a priority id. So project routing namespaces it β€” tenant:5, dept:3, * β€” and one map_type covers both routing dimensions plus the fallback, instead of a second column that would be NULL most of the time.

⚠️ The ticket column is ticket_type_id, not type_id. The workflow engine's lookup key is ticket.type_id, which is not the column name β€” an easy way to write a query that silently returns nothing. integrationsEntityRouting() has the correct name and a comment saying so.

⚠️ integrationsSaveMaps() does not assume it owns the transaction

It replaces whole map types at a time (a mapping the admin deleted has to disappear, and diffing rows is more code and more ways to be wrong), so it needs a transaction β€” but PDO throws "There is already an active transaction" on a nested beginTransaction(). A caller that wraps it in its own β€” a bulk import, a test harness β€” would fatal.

$ownsTransaction = !$conn->inTransaction();
if ($ownsTransaction) $conn->beginTransaction();

Found by the harness that proved mapping end to end, not by review. Same lesson as this file requiring its own dependencies (Β§3): a shared service cannot assume anything about its caller.

The screen

api/integrations/tracker_options.php is the first thing ever to call listProjects() / listIssueTypes() over HTTP β€” until #957 they existed on the connector and were exercised only by tests, which is exactly why the workflow action asked admins to type a project key.

  • Projects and priorities are site-wide, so they load once as real dropdowns.
  • Issue types are per project, so they are loaded from the default routing row's project and offered as suggestions β€” another project on the same site may legitimately offer different ones.
  • A value saved earlier that the tracker no longer offers stays selectable, so it cannot vanish silently on the next save.
  • If the tracker is unreachable the row degrades to a free-text box rather than blocking the screen.

7g. πŸ“Ž Attachments (#960)

βœ… Proven against real Jira Cloud, 2026-08-02. A ticket's 83 KB PDF was pushed to KAN-6 through integrationsSendAttachments() and Jira reported it back byte-identical (85,228 bytes, application/pdf). The multipart body and the X-Atlassian-Token header are the parts a stub cannot verify β€” see #953 for why that distinction matters.

On a bug report the screenshot usually is the report. Escalation now pushes the ticket's attachments up with the issue, gated per connection by integration_connections.send_attachments (ON by default β€” the feature is pointless discovered-by-accident).

⚠️ Inline images are excluded, and that is the whole design

An HTML email carries every signature logo, tracking pixel and marketing graphic as an attachment with is_inline = 1. On the dev install those run from 501 bytes upward and are almost entirely that. Pushing them would put a dozen junk images on every issue, and an integration that cries wolf gets ignored.

is_inline = 0 means a human deliberately attached the file. That is the signal.

The cost, stated honestly: a screenshot pasted into an email body arrives inline and will not travel. The description always links back to the ticket, where it is visible. If that ever needs solving, a size threshold is the obvious lever and it is a heuristic β€” decide deliberately rather than drift into it.

⚠️ Internal notes have no attachments in this schema, so "internal content never crosses" holds here for free rather than by a check. If notes ever gain attachments, this becomes a real guard that must be written.

The rules

Rule Why
Max 10 MB per file Jira's own default ceiling β€” bigger is a slow failure rather than a fast one
Max 10 files a thread with 200 attachments should not stall an escalation
Missing on disk β†’ skipped the row can outlive the file (restored DB, pruned disk)
A failed upload never fails the escalation the issue already exists by then; throwing would surface as "escalation failed" and the analyst would raise a duplicate. Same rule as the rejected priority in Β§7e

Anything skipped is written to integration_links.last_error so it is visible on the ticket rather than only in a log.

⚠️ The preview lists the files, and that is not decoration

"You cannot unsend it" is doubly true of an attachment: a screenshot can carry a password, a customer's name, a whole spreadsheet nobody meant to send outside the company. The escalate modal lists every file before Raise is pressed, including the ones that will not be sent β€” struck through β€” because "it silently did not go" is exactly the surprise this exists to prevent.

Sending one later (#961)

Attachments originally travelled only at escalation, so once the issue existed nothing else could reach it β€” and the useful screenshot usually turns up after a developer asks for it. Each attachment on a ticket with a linked issue now carries a Send to <KEY> button, via api/integrations/send_attachment.php.

⚠️ Deliberately not automatic. Pushing every new attachment on a linked ticket would be less code and worse behaviour: a file cannot be unsent, and somebody attaching a screenshot to a ticket is usually replying to a colleague rather than publishing to the dev team. The decision stays with a person, exactly as the escalate preview does.

The endpoint takes two ids straight from the browser, so it re-derives everything:

  • the attachment must be one of THAT ticket's β€” trusting the id alone is the classic insecure-direct-object-reference, and here it ends with another company's file on a dev team's board;
  • analystCanAccessTicket(), so a link id cannot reach across companies;
  • inline images refused (the same rule as escalation β€” signatures and tracking pixels are not evidence);
  • too-large / missing-on-disk refused with a reason, not silently.

Each is pinned by a check with a positive control β€” its own attachment IS accepted, another ticket's is refused. A guard that refused everything would otherwise look identical.

It writes an internal note recording who sent what and where. That is an audit trail an analyst can see, and it stops two people sending the same file twice.

⚠️ It ignores the connection's send_attachments setting on purpose. That governs what happens automatically on escalation; this is an analyst deliberately choosing one file, which is a different question.

What Jira needs that a JSON call does not

  1. X-Atlassian-Token: no-check. Without it Jira rejects the upload as a suspected cross-site request, 403 with no useful body. It is the single most common reason a Jira upload "just fails".
  2. multipart/form-data, field named file β€” not files, not the filename.
  3. A random boundary. buildMultipart() generates one per call: a fixed boundary that happened to occur inside a file's bytes would truncate the body there β€” a corrupt upload that still reports success.

The body is built in the provider rather than handed to cURL as an array, which keeps it deterministic, avoids a temp file per upload, and makes the exact bytes assertable offline.

⚠️ The filename is attacker-influenced β€” it arrived on an email β€” and it sits inside a quoted header. buildMultipart() runs basename() and strips quotes and CRLF, or a crafted name could break out of that header. Tested, including a positive control that the safe part of the name survives.

⚠️ A test lesson worth keeping: my first assertion for that checked the bytes around the filename and failed on correct output β€” a properly sanitised header legitimately ends wd.png"\r\nContent-Type. Assert the value, not its surroundings.

7f. ⚑ tracker.* triggers β€” the inbound half of Β§2 (#958)

Β§2 promised that a tracker event would emit a trigger rather than hardcode a behaviour, so "when the issue is done, tell the requester" is a rule the user writes. These are that promise.

Trigger Fires from Carries
tracker.issue_linked integrationsEscalate(), after the link row exists key, url, provider, connection_name
tracker.issue_status_changed integrationsRefreshConnection(), on a category move + status_name, status_category, previous_category
tracker.issue_comment_added integrationsApplyCommentEvent(), after the map row exists + comment_author, comment_body

⚠️ These are NOT time-based triggers β€” do not put them through the ledger

The obvious instinct is to reuse workflow_scheduled_emissions, because these arrive on a cron like the SLA and expiry events do. That would be wrong. The test on Time-Based Triggers is "did something happen, or did time merely pass?" Here something happened β€” a developer moved the issue or wrote a comment. The poll is only how we find out, because a self-hosted install cannot be called back.

The distinction changes behaviour. Each of these fires from a point where the new state is already persisted:

  • a status move is emitted after the link row has been updated, so the next poll compares against the new value and finds nothing to report;
  • a comment is emitted after its integration_comment_map row exists, so it inherits that unique key's once-only guarantee.

They are therefore edge-triggered by construction. A fingerprint ledger would be actively wrong: todo β†’ in_progress β†’ todo β†’ in_progress is three real transitions, and a fingerprint on current state would silently swallow the third.

Every event carries the ticket

Not just the issue. Everything a user wants to do on one of these β€” add a note, email the requester, set a status β€” acts on the ticket, so integrationsTicketPayload() ships the full canonical ticket block alongside tracker.*. Without it the triggers would be decorative.

⚠️ That payload query is a COPY of the canonical one in includes/services/tickets.php ("canonical post-update payload"). Keep them identical. Three of its columns are aliases and getting it wrong is silent: type_id is t.ticket_type_id · created_by is t.user_id · requester_email is u.email via a LEFT JOIN to users and is not on the tickets table at all.

The first version selected the last two straight off tickets, threw, and the catch swallowed it β€” every tracker workflow would have received a payload of nothing but an id, and "tell the requester" would have had nobody to tell. tests/integrations/templates_check.php now asserts the payload is actually populated.

⚠️ The comment loop, and the one thing that stops it

A workflow on tracker.issue_comment_added that pushes a comment back would loop: push β†’ poll sees it β†’ fires again. It terminates only because send_note_to_tracker records every comment it pushes in integration_comment_map, so the returning copy is dropped as already_imported before it reaches the event. Do not remove that recording.

The two starter recipes (Β§5.3, finally)

workflow/includes/templates.php gains Raise a development issue for bugs and Tell the requester when the developers finish. The second is the one the design promised and could not exist before this.

  • The escalate recipe leaves project and issue_type blank, letting the connection's mapping (Β§7e) decide β€” a recipe cannot guess somebody's project keys.
  • The "done" recipe keys on tracker.status_category equals done, never a status name.

⚠️ tests/integrations/templates_check.php is separate from run.php on purpose. WorkflowEngine::availableActions() reads webhook formats from the database, and run.php's value is that it needs no database and no network. The check caught a recipe shipping an add_note action that does not exist (it is add_ticket_note) β€” precisely what templates.php's own header promises cannot happen.

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.

inbound_enabled β€” the toggle, and a warning about shipping columns early (#954)

Accept updates from {name} lives in the connection modal, under Active, and gates the whole comment path.

⚠️ The column existed from V1 and nothing ever wrote it. integration_connections.inbound_enabled was in the schema, in integrationsListConnections(), and read by the service β€” but no endpoint set it and no control existed, so the feature it gates could never be switched on. It looked finished from every angle except the only one that counts.

The lesson for the columns V1 shipped ahead of need (ingress_mode, poll_interval_minutes, webhook_secret): a column with no writer is not "ready", it is a trap. When you build the feature, check the write path exists rather than assuming the schema implies it.

The connections list shows an Updates on badge beside Active when it is set. That was added because "is it even on?" otherwise cost one click per connection β€” and because the tickbox being inside the modal made it genuinely hard to find. poll_interval_minutes still has no UI and is schema-only.

The label is system.integrations.inbound_label with a {name} placeholder β€” the page is shared across providers, so it must not hardcode "Jira" the way the design doc's Jira-specific vocabulary (Β§5.0) does.

The in-app setup guide (#957)

help.php at /system/integrations/<provider>/help β€” written for somebody who does not already know the tracker. Ed asked for it after seeing the mapping screen: "someone who understands Jira will be fine but other people might look at that modal and have no idea what to do", which is exactly right β€” the screen says "project key" and never explains what one is.

Layout mirrors tickets/help.php β€” left pane, numbered steps, scroll-spy β€” so every help screen in the product feels like the same screen. It is one page for every provider like provider.php, with the tracker-specific parts (where Atlassian keeps API tokens, what a project key looks like) guarded on the provider key. Every step carries an "if you skip this" note, because silent failure is this feature's main hazard.

⚠️ A pretty URL one segment deeper than its file breaks every relative path. /system/integrations/jira/help is served by system/integrations/help.php, but a browser resolves ../../assets/… against the URL, giving /system/assets/…. Both stylesheets 404'd. Use BASE_URL for every emitted URL β€” that is what it exists for.

⚠️ And $path_prefix must stay filesystem-relative (../../), because system/includes/header.php does require_once $path_prefix . 'includes/functions.php'. Setting it to BASE_URL breaks the include. The two are not interchangeable.

⚠️ It also needs system/includes/header.php, not the root includes/header.php β€” the latter renders the ticketing nav on a System page.

⚠️ The /help rewrite must come BEFORE the provider rule in .htaccess. It does not conflict today β€” ^([a-z0-9-]+)/?$ cannot match two segments β€” but if that pattern is ever loosened to allow slashes it would swallow /jira/help and silently render the settings page instead.

Linked from the page header and, deep-linked to the mapping step, from inside the mapping modal itself β€” where the confusion actually happens.

⚠️ 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 β€” 252 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
company guard made permissive (return true) exactly the 5 refusal assertions fail
company guard made refuse-everything (return false) exactly the 10 permission assertions fail
comment-map guard disabled (already_imported never returned) exactly 3 fail β€” an echo is no longer recognised

That last one is the one to repeat when touching Β§7d. Note what it does not prove on its own: a guard that dropped everything would also stop echoes, and would pass every negative assertion while being completely broken β€” which is why the positive controls beside it (a dev's comment imports, our own account's comment imports, a different id imports) carry the weight.

⚠️ #956 is the cautionary tale here. The suite was green, including assertions that specifically pinned author-based suppression as correct behaviour. The tests were faithful to a design that was wrong, so they defended the bug. Only a live run found it.

⚠️ What this suite cannot prove

It has no database, so it cannot catch a wrong column name or a unique key that does not fire. Those were verified separately by running the real integrationsApplyCommentEvent() against a live dev database inside a transaction and rolling it back β€” which is how the write order, the unique key, the is_internal flag and the display join were each confirmed, along with a positive control that a genuine new comment still lands. Do the same for anything here that touches SQL.

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)
Ticket pill and the manual escalate button βœ” built (#950)
Poll cron βœ” built (#951)
Comments tracker β†’ ticket, echo suppression βœ” built (#954), proven against real Jira 2026-08-02; author-based suppression removed in #956 (Β§7d)
Comments ticket β†’ tracker βœ” the action exists (#949); ⚠️ still never run against a live Jira β€” now the untested half
tracker.* triggers (inbound events firing workflows) V2 β€” designed (Β§5.2), not built
Webhooks (instant instead of the poll's ~5 min) V2 β€” designed (Β§8.1), not built
Project routing, issue-type + priority mapping βœ” built (#957, Β§7e)
Attachments, custom fields, components, per-analyst tokens V3, not built
Issues raised in the tracker creating tickets V4
GitHub, GitLab, Azure DevOps connectors V5

The full parity scoreboard against HaloITSM β€” feature by feature, plus where we are already ahead and the build order β€” is Β§16 of the design doc (docs/design/external-issue-trackers.md in the app repo; local only, since docs/design/ is gitignored).

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.

βœ… 10a. The result of that test (#964)

Azure DevOps shipped as connector #2. The contract held β€” no schema change, and no core change for the connector itself. All four predicted breakages happened and none needed the contract widened. Full write-up: Azure DevOps connector β€” Developer Guide.

Three things it changed here, which the next connector inherits:

  • settings_fields joined credential_fields in the registry entry, for a non-secret per-connection choice. It is deliberately a separate list: credential boxes are blanked on edit and empty means "keep the secret", so a dropdown living among them would silently reset itself to default on every save. integrationsListConnections() exposes them to the browser by whitelist, since they share the encrypted blob with the token.
  • integrationsAbsoluteUrl(), because the ticket link every connector puts in an issue was built from BASE_URL β€” a path β€” and so 404'd from inside any tracker. It had never worked. Use it for anything a tracker will render.
    • It now delegates to publicAbsoluteUrl() in includes/public_url.php, one home shared with the [ticket_url] merge code β€” see The public web address. The move fixed a fault it had carried all along: on an install served from a subfolder the link lost the subfolder, because the messaging setting it falls back to stores an origin only (https://example.com) and this appended the path to it directly. publicUrlWithAppPath() now adds the app path when it is not already on the end, and never twice.
  • $target may be keyed either way. Core sends the tracker-neutral issue_type; a provider that reads only its own vocabulary silently creates the wrong kind of thing, with no error. Accept both, and let the provider translate β€” the neutral key is core's, correctly.

⚠️ And a caution for connector #3: the four status categories were sized for Jira. Azure DevOps has five, and the extra one (Resolved β€” a developer says fixed, nobody has verified) was a judgement, not a mapping, so it became a per-connection setting. Expect the next tracker to have its own awkward state, and resist widening the closed set to take it.


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 echo guard, or the order of writes in integrationsApplyCommentEvent() (Β§7d) β€” the map row goes in first, and that is a correctness decision, not a style one
  • anything that reintroduces author-based suppression (Β§7d, #956) β€” it has been tried and it silently ate real comments
  • the "first poll imports nothing" rule, or the lookback cap (Β§7d) β€” both are product promises made in the settings hint
  • that imported comments are always internal (Β§7d)
  • 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