Skip to content

Azure DevOps Connector Developer Guide

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

Azure DevOps connector β€” Developer Guide

The second tracker connector, and the one that actually tested whether the engine was right.

Shipped as #964 (the connector) with #965 and #966, two bugs it exposed in code that had already shipped.

The user-facing page is Issue Trackers. The generic contract is External Issue Trackers β€” Developer Guide. The sibling connector is Jira, whose Β§9 ("if you are writing the next connector") is what this page is the answer to.


1. πŸ“ The files involved

Colour key: πŸ—„οΈ schema Β· βš™οΈ engine Β· πŸ”Œ API Β· πŸ–₯️ UI Β· 🎨 CSS Β· 🌍 i18n Β· πŸ§ͺ tests Β· πŸ“„ docs

🎨 File What it does
βš™οΈ includes/integrations/AzureDevOpsProvider.php the whole connector. 833 lines of JiraProvider's equivalent, and it needed nothing outside itself
βš™οΈ includes/integrations/integrations.php one registry row, one switch arm, the new settings_fields concept + integrationsSettingKeys(), and integrationsAbsoluteUrl() (#965)
πŸ”Œ api/integrations/escalate_ticket.php the ticket link is now absolute (#965)
βš™οΈ workflow/includes/engine.php the unattended path gained a ticket link, where it previously wrote bare text (#965)
πŸ–₯️ system/integrations/provider.php renders settings_fields as a select; JS keeps their value instead of blanking them
πŸ–₯️ system/integrations/help.php Azure DevOps setup steps; $tokenNoun so the page stops saying "API token" at a product that has none
🌍 lang/en/system.php + lang/pt-BR/system.php the blurb, the URL label, field_pat, the Resolved setting and its two options β€” and the \x27 fix (#966)
πŸ§ͺ tests/integrations/run.php Β§12, 65 new assertions (353 total)
πŸ“„ CHANGELOG.local.md, this wiki #964–#966

πŸ”‘ No schema change, and no core change for the connector itself. Everything in integrations.php, escalate_ticket.php and engine.php above is either the registry, the new settings concept, or #965 β€” a bug that was already there.


2. πŸ”‘ The test this was built to run

The engine guide Β§11 committed to a falsifiable claim before Jira shipped:

V5 must not touch a single file outside the provider, one row in the provider registry, and its settings partial. If adding a connector needs a change to the link table, the event pipeline, the queue, the workflow action or the UI, the abstraction was wrong β€” and we find out cheaply.

It held. And Β§11.1 was right about which provider to test it with:

Azure DevOps is the honest test, because it breaks the most assumptions. GitHub is the easy one β€” issues are simple, the API is clean, everything will appear to fit, and the test will pass without telling us anything.

All four predicted breakages happened. None required a contract change.

Prediction What was actually true
Updates use JSON Patch Confirmed. [{"op":"add","path":"/fields/System.Title",…}] as application/json-patch+json
Types and states are per-process Confirmed. Two projects in one organisation return different type lists
Targets are a tree Confirmed β€” area path and iteration path, both optional
The body is HTML Confirmed, and worse than expected β€” see Β§4

3. βš™οΈ Auth, and the versions that are not uniform

A Personal Access Token as HTTP Basic with an empty username: base64(":" + PAT).

⚠️ Azure DevOps ignores the username entirely, so putting the user's email there also works. That is a trap rather than a convenience β€” a wrong implementation passes on a developer's own account and the mistake surfaces later.

⚠️ The api-version is not the same everywhere, which is why they are three constants and not one:

Endpoint Version
most 7.0
work item comments 7.0-preview.3
connectionData 7.0-preview (rejects a bare 7.0 outright)

⚠️ A rejected token answers 203, not 401

Azure DevOps often replies to a bad or expired PAT with 203 and an HTML sign-in page, not a 401 with JSON. Anything treating 2xx as success therefore reports a healthy connection that does nothing. extractError() detects it, and testConnection() refuses to pass without a parsed identity rather than trusting the status code.


4. ⚠️ Which field holds the body depends on the work item type

This is the one that is silent, and it is the reason bodyFieldFor() exists.

In the stock Agile process a Bug's form shows Repro Steps (Microsoft.VSTS.TCM.ReproSteps) and does not show System.Description.

So writing a Bug's description to System.Description:

  • succeeds,
  • returns 200,
  • and produces a work item that looks completely empty to the developer who opens it.

Nothing errors. The text is simply in a field the form does not display. The type's own field list decides, cached per (project, type) because it cannot change without an admin editing the process.


5. πŸ”‘ Five state categories, not four

Azure DevOps has Proposed / InProgress / Resolved / Completed / Removed. We have four.

Azure DevOps ours
Proposed todo
InProgress in_progress
Resolved the connection decides
Completed done
Removed cancelled

Resolved means "a developer says it is fixed, nobody has verified it". Whether that is done to a service desk is a judgement, not a technical fact, so it is a per-connection setting β€” credentials.resolved_means, defaulting to in_progress.

The default is the cautious one deliberately: telling somebody their problem is fixed when it is not is the worse failure.

Jira has no equivalent β€” its "Won't Do" lands in done and there is no fifth category β€” which is why this setting exists only here. Note also that cancelled, unreachable on Jira, is reachable here.

πŸ”‘ The live vindication of "never branch on a status name"

We adopted that rule from Jira reasoning, where status names are per-project and renamed at will. Azure DevOps proves it harder:

Work item type State "Resolved" β†’ category
Bug Resolved
User Story InProgress
Feature InProgress
Epic InProgress

The same state name means different things on different types in the same project. Keying on the name would have silently mis-stated the state of every user story. This is why stateCategory() takes both the project and the type, and why neither argument is padding.

Two smaller inconsistencies, both real: Bug has no Removed state at all (a bug cannot be cancelled, a task can), and in Agile Issue starts at Active β€” a freshly created Issue is already In Progress.


6. βš™οΈ Polling: WIQL, and the trap that is the opposite of Jira's

POST {org}/_apis/wit/wiql?timePrecision=true&api-version=7.0
{"query":"SELECT [System.Id] FROM WorkItems WHERE … [System.ChangedDate] >= '2026-08-02T18:00:00.0000000Z'"}

Two things here, both verified against a live organisation.

⚠️ timePrecision=true is a QUERY parameter, not a body field

Put it in the body and it is ignored; the request is then rejected outright with "You cannot supply a time with the date when running a query using date precision".

That failure is loud, which is fortunate β€” but the tempting fix is to drop to a date-only boundary to make the error go away. Do that and the poll silently re-reads a whole day of comments every run, because our watermark moves in minutes.

πŸ”‘ An explicit Z is honoured as real UTC β€” unlike Jira

On Jira we were forced onto relative minutes (updated >= -90m) because an absolute JQL date is interpreted in the Jira user's timezone, so a server and an account in different zones lose a window.

Azure DevOps honours the Z. Verified with controls: a window five minutes ago correctly returns nothing, two hours ago returns the item, and a window an hour in the future returns nothing rather than matching.

So this connector uses absolute UTC timestamps, and the Jira connector must not. Both are correct for their own tracker, and copying either one across would be a bug.


7. πŸ”’ Echo suppression: id, never descriptor

account_identity from testConnection() is compared against a comment's author to drop our own writes coming back.

⚠️ Use createdBy.id. Both endpoints report a descriptor too, and for the same person they are in different formats:

Source descriptor
connectionData Microsoft.IdentityModel.Claims.ClaimsIdentity;<tenant>\ed@…
a comment aad.MjllYTAzNzEt…

Comparing those never matches, so every one of our own comments would be re-imported as though a developer had written it β€” a loop, and the exact class of bug #956 was. The GUID in id is identical across both.

Halo's own Azure DevOps guide warns that synced notes are attributed to whoever owns the PAT, and tells users to create a service account. That is configuring around the problem. Suppressing by comment id means it does not matter whose token it is β€” but a service account is still worth it so work items are not all "raised by Ed".


8. βš™οΈ Attachments are two steps, and the second is easy to miss

  1. POST {project}/_apis/wit/attachments?fileName=… with the bytes as application/octet-stream β€” ⚠️ not multipart/form-data, which is what Jira wants. Sending the wrong one produces a corrupt attachment rather than an error.
  2. PATCH the work item with a relation:
['op' => 'add', 'path' => '/relations/-', 'value' => ['rel' => 'AttachedFile', 'url' => $url, …]]

⚠️ Step 1 attaches the file to nothing. Stop there and the call reports success, the file exists in the organisation, and it is invisible to everyone. The - in /relations/- means append, and is the only way to add without clobbering existing relations.

The live test asserts the file is on the work item, not merely uploaded, precisely because those are different things.


9. πŸ–₯️ settings_fields β€” a new registry concept

resolved_means is not a credential, and putting it in credential_fields would have broken it silently.

on edit
credential_fields blanked; empty means "keep the stored secret"
settings_fields shown with the current value; always submitted

A dropdown in credential_fields inherits the blanking rule, so every save would quietly reset the choice to its default β€” a behaviour change nobody would connect to having pressed Save.

πŸ”’ They share the encrypted credentials blob with the API token, so integrationsListConnections() lifts them out by whitelist (integrationsSettingKeys()), never by emitting what it finds. Verified: the response contains neither the token nor the word credentials.


10. ⚠️ Three bugs the live run found that the tests did not

Every one of these passed a green suite first. This is the live-run-beats-green-tests lesson again, in a new costume.

The requested work item type was ignored

Core sends the tracker-neutral issue_type β€” that is what escalate_ticket.php and the workflow action both send, because "issue type" is the word the mapping screen uses for every provider. The provider only read Azure DevOps' own work_item_type.

So a request for a Bug silently created a Task. Nothing errored anywhere; the default just applied.

Fixed in the provider, not core β€” $target is documented as provider-shaped, and core's neutral key is correct. targetType() accepts both.

#965 β€” the ticket link never worked, on Jira either

Every issue we raise carries a link back to the ticket. It was built from BASE_URL, which is deliberately a path (/ or /freeitsm-app/) because every internal page link wants that. Inside Jira or Azure DevOps, /tickets/?id=409 resolves against their host and 404s.

The whole reason the link is there is so a developer can click back. It had never done its job.

Worse, the workflow path wrote the reference as plain text with no link at all β€” and that is the unattended case, the 3am rule, where the developer reading it is least likely to know where the ticket lives.

integrationsAbsoluteUrl() resolves: a configured public address first (the only one that works from cron, where there is no request at all), then the request's scheme + host, then BASE_URL β€” so an install that has configured nothing is no worse off than before.

#966 β€” \x27 on screen

'Send the ticket\x27s attachments to {name}'. PHP does not interpret hex escapes inside single quotes, so it rendered literally. Visible on the Jira page too. lang/ was swept for others; this was the only one.


11. πŸ§ͺ Tests

php tests/integrations/run.php     # 353, Β§12 is this connector

65 offline assertions covering everything provable without a live organisation: the JSON Patch shape, both type keys, all five categories plus the setting, the WIQL timestamp format, comment parsing and the identity, error extraction, capabilities, and the registry.

⚠️ The pins were verified by breaking the code. Re-reading only work_item_type, swapping id for descriptor, and dropping the Z each produce failures. A test that has never been seen to fail is not yet known to test anything.

What is not in there, and needs a real organisation:

  • that timePrecision=true belongs in the query string;
  • that a Bug's body must go to Repro Steps to be visible;
  • that a 203 sign-in page is what a bad token really returns;
  • that an attachment relation actually shows on the work item.

There is a live script for exactly these, described in the commit for #964. It creates work items, so it is not part of the standard suite.


12. ⬜ Not built yet

  • Priorities. Deliberately not declared: Azure DevOps priority is an integer 1–4, not a named list, so there is nothing to populate a mapping dropdown with. Declaring the capability would give the mapping screen an empty select and no explanation.
  • Webhooks. Azure DevOps service hooks do not sign with HMAC the way GitHub does β€” they carry basic auth or a header secret β€” so verifyWebhook() will need a different shape here. Polling works today.
  • Clearing a field. updateFields() translates a flat map into add ops and cannot express JSON Patch remove. A genuine limit of the contract, not an oversight.
  • On-premises Azure DevOps Server is expected to work β€” the collection URL is the base β€” but has not been tested against a real instance.

13. πŸ“„ Keeping this page honest

Update this page in the same commit if you change the api-version constants, the state category map or the Resolved setting, the body-field selection, the WIQL timestamp or timePrecision, where author_identity comes from, or the attachment two-step.

The generic contract is on External Issue Trackers β€” Developer Guide, which carries the same note.

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally