Jira Cloud connector for AskMyDocs — OAuth 2.0 3LO sync with ADF→markdown rendering, JQL-driven incremental sync, full structured-field frontmatter (status, priority, labels, components, sprint, assignee, fix-versions, browse URL).
Drop-in Laravel package. composer require it from any AskMyDocs install and the Jira connector appears in the admin UI on the next request.
- Why this package
- Features
- AI vibe-coding pack included
- Architecture at a glance
- Installation
- Credential setup (junior-proof, step by step)
- Activation inside AskMyDocs
- What gets ingested
- Sync semantics
- Testing
- Live testsuite
- Troubleshooting
- License
AskMyDocs is an enterprise-grade RAG + canonical knowledge compilation system. Out of the box it ingests markdown from disk, the chat UI, an HTTP API, and a Git-driven workflow — but for engineering teams the most-asked questions live in Jira tickets: "what status is BUG-1234", "who's assigned the schema-migration sprint", "what did we decide on the architecture-rejection issue last quarter".
This package is the smallest possible surface for shipping that integration:
- A
JiraConnectorthat implementsPadosoft\AskMyDocsConnectorBase\ConnectorInterface. - A
JiraAdfToMarkdownconverter that flattens Jira's Atlassian Document Format (ADF) — used across description bodies, comments, custom-field text — into clean GitHub-flavoured markdown. Handles paragraphs, headings, lists, code blocks, panels (info/warning/note/error/success), tables (GFM-flavoured), mentions (@displayName), inline cards, internalmediaSinglereferences via stable[adf-media: <id>]placeholders. - A
JqlBuilderfor safe, fluent JQL composition with backslash + single-quote escape rules (R19 input-escape-complete). - A
JiraPaginatorwith offset-based pagination (Jira's classic/searchcontract). - A composer.json that auto-registers via
extra.askmydocs.connectors. Zero edits to your host app's config required.
composer require padosoft/askmydocs-connector-jira. Done.
- 🔌 Zero-config installation — composer-extra discovery auto-registers the connector at boot.
- 🔐 Atlassian OAuth 2.0 3LO — single-use state-token CSRF protection (600 s TTL),
accessible-resourceslookup to resolve the per-tenantcloud_id, refresh-token rotation built-in. - 🔁 True revocation on disconnect — unlike Confluence (no revoke API), Atlassian DOES expose a revoke endpoint for OAuth 2.0 3LO tokens. The connector revokes BOTH the access token AND the refresh token (a refresh-only token can still mint a new access token upstream, so revoking the access token alone is insufficient).
- ♻️ Incremental sync — workspace-wide JQL
updated >= "YYYY-MM-DD HH:mm" ORDER BY updated DESC; daily syncs cost one round-trip on quiet projects. - 🧠 ADF→markdown — proper rendering of every node type Jira ships (paragraph, heading, list, code, blockquote, rule, panel, mention, inline card, table, hardBreak, media). Unknown node types surface as
[adf-node: <type>]placeholders so operators can audit gaps rather than silently dropping content (R14 surface-failures-loudly). - 📊 Rich structured-field frontmatter — status, priority, issue-type, assignee, reporter, labels, components, fix-versions, active sprint (sniffed from
customfield_*), browse URL, created/updated timestamps surface to the host's reranker viaSourceAwareMetadataBuilder. - 🎯 Status-aware ranking — terminal Jira statuses (Done / Closed / Resolved / Cancelled / Won't Do) automatically flip the reranker's
status_active=falsesignal so retrieval down-weights stale tickets. - 🚦 Failure-loud exception taxonomy — 401 / 403 →
ConnectorAuthException, 5xx / 429 →ConnectorApiException, pagination overflow →ConnectorPaginationLimitExceptionwithmaxPagesfield. - 🏢 Per-tenant isolated — every credential read and ingestion dispatch is scoped to the active
TenantContext. - 🧪 Test-friendly — pure-PHP unit tests for the ADF converter + JQL builder,
Http::fake()feature tests for the connector, opt-in live test against a real Atlassian sandbox cloud whenCONNECTOR_JIRA_LIVE=1.
This package was built with a vibe-coding pack of Claude Code skills and rules (.claude/ directory in the parent AskMyDocs repo) that codify the architectural invariants — the IoC contract that keeps this package standalone-agnostic, the Atlassian REST API quirks (accessible-resources scope-driven cloud_id resolution, JQL date format "YYYY-MM-DD HH:mm" NOT ISO-8601, offset pagination semantics, the customfield_* sprint sniffing), the failure-loud exception taxonomy, the ADF node-type contract with explicit [adf-node: <type>] audit-trail for unknown types.
The JqlBuilder specifically codifies R19 (input-escape-complete) — it doubles backslashes BEFORE escaping single quotes so a literal \\' survives the round-trip without producing a half-escaped artefact.
If you're using Claude Code to fork or extend this package, point the agent at the parent repo's .claude/ pack and it stays inside the invariants automatically. No tribal-knowledge drift.
┌──────────────────────────────┐
Composer │ padosoft/askmydocs- │
require ───────▶│ connector-jira │
│ (this package) │
└────────────┬─────────────────┘
│
│ auto-registered via composer
│ extra.askmydocs.connectors
▼
┌──────────────────────────────┐
│ padosoft/askmydocs-connector-│
│ base v1.1.1+ │
│ ConnectorRegistry │
└────────────┬─────────────────┘
│
│ resolves JiraConnector
▼
┌──────────────────────────────┐
│ JiraConnector::syncFull │
│ • /accessible-resources │
│ • GET /project/search │
│ • GET /search (JQL) │
│ • JiraAdfToMarkdown │
│ • SourceAwareMetadata │
└────────────┬─────────────────┘
│
│ ConnectorIngestionContract
│ (IoC bridge — host implements)
▼
┌──────────────────────────────┐
│ Host app (AskMyDocs): │
│ • Storage::put → KB disk │
│ • IngestDocumentJob │
│ • kb_canonical_audit row │
│ • PII redactor at boundary │
└──────────────────────────────┘
The IoC bridge is the key design decision: this package never imports App\Jobs\IngestDocumentJob, App\Models\KnowledgeDocument, or any other host class. It dispatches every host-side concern through Padosoft\AskMyDocsConnectorBase\Contracts\ConnectorIngestionContract. The host binds its own implementation in a service provider; this package stays standalone-agnostic so it can run inside AskMyDocs Community Edition, AskMyDocs Pro, or any third-party Laravel app that wants Jira-backed RAG.
composer require padosoft/askmydocs-connector-jiraThe package follows Laravel's auto-discovery convention so no manual provider registration is required. After install, run:
php artisan vendor:publish --tag=connector-jira-config # optional — for env-var overrides
php artisan vendor:publish --tag=connector-jira-assets # optional — copies jira.svg to public/connectorsThe connector-base migrations ship in the parent package (padosoft/askmydocs-connector-base) and auto-load via its service provider; no extra migrate step is needed.
Jira Cloud uses Atlassian's OAuth 2.0 3LO flow — the same flow as the Confluence sister connector. If you've already wired the Confluence connector, you can reuse the same Atlassian Developer Console app and just add the Jira scopes.
- Open https://developer.atlassian.com/console/myapps/ in your browser.
- Sign in with the Atlassian account that owns (or has admin access to) the Jira site you want to integrate.
- Click "Create" in the top-right.
- Pick "OAuth 2.0 integration" from the dropdown.
- Name it
AskMyDocs(or any label that makes sense). - Click "Create" to land on the new app's overview page.
(If you already created this app for the Confluence connector, click on it instead of creating a new one — Atlassian supports multiple product scopes per OAuth app.)
- From the app's left navigation, click "Permissions".
- Find the "Jira API" row and click "Add".
- After adding, click "Configure" on the same row.
- Tick the following scopes (and ONLY these — the connector is strictly read-only):
read:jira-work— read issues, projects, comments, attachments metadataread:jira-user— read user info (used for the health probe)offline_access— issue refresh tokens (required so sync keeps running past the initial access-token TTL)
- Click "Save".
- From the app's left navigation, click "Authorization".
- Click "Configure" on the "OAuth 2.0 (3LO)" row.
- Set Callback URL to your host app's callback endpoint, for example:
https://your-app.example.com/api/admin/connectors/jira/oauth/callback - Click "Save changes".
Atlassian requires HTTPS in production; for local development behind http://localhost use a tunnel (Cloudflare Tunnel, ngrok, Tailscale Funnel).
- From the app's left navigation, click "Settings".
- Scroll to "Authentication details":
- Client ID →
CONNECTOR_JIRA_CLIENT_ID - Secret →
CONNECTOR_JIRA_CLIENT_SECRET
- Client ID →
In your AskMyDocs host app's .env:
CONNECTOR_JIRA_CLIENT_ID=<your-client-id>
CONNECTOR_JIRA_CLIENT_SECRET=<your-client-secret>
CONNECTOR_JIRA_REDIRECT_URI=https://your-app.example.com/api/admin/connectors/jira/oauth/callback
# Optional — only override if you proxy Atlassian's API:
# CONNECTOR_JIRA_API_BASE_TEMPLATE=https://api.atlassian.com/ex/jira/{cloud_id}/rest/api/3After completing the OAuth flow once (step 8), grab the access token from the database via php artisan tinker and run:
curl -s https://api.atlassian.com/oauth/token/accessible-resources \
-H "Authorization: Bearer <access-token>"You should see a JSON array of accessible Atlassian sites with a scopes entry that contains read:jira-work.
redirect_uri_mismatch— The exact redirect URI in.envmust match the one registered in the Developer Console (case-sensitive, trailing slashes matter).invalid_scope— Your Developer Console app doesn't have one of the required scopes enabled. Re-check step 3.User has not granted access to any Atlassian site— The OAuth grant succeeded but the user has no Jira access. Add the user as a Jira user in https://admin.atlassian.com.
After composer require + the env vars above:
- Run the host app's admin UI.
- Navigate to Settings → Connectors.
- The Jira card appears with an Install button.
- Click Install → browser redirects to
auth.atlassian.com→ operator authorises → returns to the admin UI → status flips toactive. - The first full sync fires within the cadence window (default 15 minutes). To trigger immediately, click Sync now.
For every Jira issue the integration can see:
- Markdown body — issue title prepended as
# [PROJ-1234] Summary, then the description (ADF → markdown), then a## Commentsappendix (each comment heading### <author> — <timestamp>). - Frontmatter / metadata captured under
metadata.converter_hints.jira:project_key,project_nameissue_key,issue_id,issue_typestatus,priorityassignee,reporter(email when exposed, displayName as fallback under GDPR-strict mode)labels,components,fix_versionssprint(active sprint sniffed fromcustomfield_*)created,updatedcloud_id,source_url(resolvedhttps://<site>.atlassian.net/browse/<KEY>)
_derivedreranker signals undermetadata.converter_hints._derived:search_tags(labels + components + priority + issue-type unioned)status_active(false for Done/Closed/Resolved/Cancelled/"Won't Do")recency_bucket,owner
The synthetic MIME application/vnd.jira.issue+json routes the document to the host's Jira-aware chunker when one is installed.
- Full sync —
GET /rest/api/3/project/searchto enumerate projects, then per-project JQLproject = "{KEY}" ORDER BY updated DESCagainstGET /rest/api/3/searchwithexpand=renderedFieldsandfields=*all,-attachment. Offset pagination viastartAt+maxResults=50. Safety cap at 100 pages per project; when the cap fires aConnectorPaginationLimitExceptionsurfaces in the per-project error counter. - Incremental sync — workspace-wide JQL
updated >= "YYYY-MM-DD HH:mm" ORDER BY updated DESC. Jira's JQL date grammar is Jira-specific (NOT ISO-8601);JqlBuilder::updatedSince()formats Carbon instances to the wire shape. - Deletion reconciliation — Jira's REST API does NOT surface hard-deletes through search endpoints (they vanish silently). The host's periodic full sync detects orphaned
knowledge_documentsrows whosemetadata.jira_issue_keyno longer round-trips and soft-deletes them. Real-time deletion via webhooks is out of scope for v1.0. - Disconnect — calls
auth.atlassian.com/oauth/token/revokefor BOTH the access token AND the refresh token (best-effort, withtoken_type_hint), then clears local credentials regardless of the revoke response. Failed remote revoke does NOT block local cleanup.
composer install
vendor/bin/phpunitThe suite has three flavours:
| Suite | What it covers | Network |
|---|---|---|
| Unit | JiraAdfToMarkdown + JqlBuilder — pure PHP, 20+ ADF node-type cases + JQL escape rules. |
None |
| Feature | JiraConnector against Http::fake() and the spy ingestion contract. |
None |
| Live | Opt-in — actually hits the configured Atlassian cloud. Skipped unless CONNECTOR_JIRA_LIVE=1. |
Real |
CI runs Default (Unit + Feature) against PHP 8.3 / 8.4 / 8.5 × Laravel 12 / 13.
The live suite is opt-in so CI never pays for real API calls. To run it:
export CONNECTOR_JIRA_LIVE=1
export CONNECTOR_JIRA_TOKEN=<an-active-oauth-access-token>
export CONNECTOR_JIRA_CLOUD_ID=<the-cloud-id-from-accessible-resources>
vendor/bin/phpunit --testsuite=LiveThis calls /rest/api/3/myself on the real Atlassian cloud once to validate credentials.
| Symptom | Likely cause | Fix |
|---|---|---|
401 invalid_token during sync |
Refresh token expired (Atlassian rotates them aggressively when the user revokes consent), or operator manually revoked the connection from id.atlassian.com | Re-install from the admin UI |
403 quota_exceeded |
Hit the per-tenant Atlassian API rate-limit (5000 requests / 5 min) | Wait or split the workspace across multiple installations |
Jira accessible-resources returned no resources |
OAuth grant succeeded but the user has no Jira access to any Atlassian site | Add the user as a Jira-user member in https://admin.atlassian.com |
Jira cloud_id missing |
Race condition during the OAuth flow — the accessible-resources call returned [] so cloud_id was never stored |
Re-install from the admin UI; the new flow will retry the lookup |
| Sprint field missing from ingested metadata | Sprint custom field id varies per workspace; the connector sniffs customfield_* values for the Sprint object shape but some workspaces use exotic custom-field configurations |
Open an issue with the sample shape; we'll widen the sniffer |
Issues with no description ingest as _No description._ |
This is by design — empty issues still produce a useful chunk via the structured fields (status, priority, assignee) which surface in retrieval via the source-aware metadata builder | n/a |
Browse URL points at api.atlassian.com instead of <workspace>.atlassian.net |
The issue's project.self field was missing or malformed — the connector falls back to the API path which Atlassian redirects to the canonical URL when followed in a browser |
Re-trigger full sync; the next pass should resolve from the fresh project.self |
Apache-2.0 — see LICENSE.
Built and maintained by Padosoft. Part of the AskMyDocs connector ecosystem.