Skip to main content

I Built a Chrome Extension So Jira Would Give My Tickets Back

· 7 min read
Victor Jimenez
Senior Product Engineer

I needed full Jira tickets in a form other scripts could read: the description, the comments and every attachment. The official API offers a token, a 5000-line JSON response and a separate authenticated call per attachment, and the SaaS alternative charges $20/month for what amounts to a curl command behind a dashboard. I built a Chrome extension instead.

jiraextractor is a privacy-first Chrome extension that does one thing. It packs a Jira ticket's entire contents into a local ZIP file, with no servers and no data collection.

The problem: three bad ways to read your own ticket​

Jira shows me the ticket in the browser and makes every other route to the same data harder. These were the options:

  1. Manual work: copy the description, save every image and attachment one by one, then put it back together. It is slow and error-prone.
  2. The official API: generate an API token and request /rest/api/3/issue/{issueKey}. The response is about 5000 lines of JSON, the description arrives in proprietary Jira Document Format, and each attachment is metadata that needs its own authenticated API call to download.
  3. A SaaS export tool: pay a monthly fee for a service that runs option 2 for you, and send your private ticket data through their servers on the way back to you.

The third option bills $20/month to fetch a page I already have open. I am logged into Jira in my browser and the data is on the page, so my call was to read it from there.

How it works: the whole job stays in the browser​

The extension reuses the session I already have. A content script scrapes the DOM, a background worker fetches the attachments and builds the archive, and Chrome's download manager saves the result.

Nothing leaves the machine except the attachment requests to Atlassian, which the browser would make anyway.

Tech stack​

ComponentTechnologyWhy
PlatformChrome Extension (Manifest V3)Direct access to the authenticated browser context
DOM scrapingContent script (content.js)Runs inside the Jira tab
Attachment fetchBackground service workerHandles cross-origin requests
CORS fixdeclarativeNetRequestStrips the Authorization header for Atlassian media
PackagingJSZipBuilds the ZIP in memory, no server needed
IconsPython + PillowA few lines beat any web-based icon generator

1. Triggering the extraction​

The popup is one button. A click sends an extract message to the content script in the active Jira tab.

content.js
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
if (request.action === 'extract') {
extractTicket().then(result => {
sendResponse(result);
}).catch(error => {
sendResponse({ error: error.message });
});
return true; // Keep message channel open for async response
}
});

extractTicket() in extractor.js does the scraping. Jira's class names vary between versions and instances, so it tries a list of known selectors for the title, description, comments and attachments instead of trusting one. That fallback list is what makes the scraper hold up across instances.

2. CORS and declarativeNetRequest​

The scraped attachment URLs point to domains like api.media.atlassian.com, and the background script has to fetch them. A cross-origin fetch with a header like Authorization triggers a CORS preflight OPTIONS request, and Atlassian's media servers reject that preflight.

My code never sets an Authorization header, which did not stop Chrome from sending one. Chrome sometimes attaches the session's auth headers on its own. The fix is to tell the browser to remove the header before the request leaves.

Chrome can add auth headers you never set

If your extension fetches Atlassian media and the preflight fails, check the outgoing headers before you debug your own code. Chrome may attach session auth headers to cross-origin requests, and Atlassian's S3-signed media URLs reject the resulting preflight. Add a declarativeNetRequest rule that strips Authorization, then confirm the GET succeeds.

chrome.declarativeNetRequest lets you define rules that the browser applies to requests itself.

background.js
chrome.runtime.onInstalled.addListener(() => {
chrome.declarativeNetRequest.updateDynamicRules({
removeRuleIds: [1], // Clear old rule
addRules: [
{
id: 1,
priority: 1,
action: {
type: 'modifyHeaders',
requestHeaders: [
{ header: 'Authorization', operation: 'remove' }
]
},
condition: {
urlFilter: 'media.atlassian.com',
resourceTypes: ['xmlhttprequest', 'other']
}
}
]
});
});

The rule says: for any fetch from the service worker to a media.atlassian.com URL, remove the Authorization header. The preflight goes away, the plain GET goes through, and the download works.

3. Packaging with JSZip​

With the attachments fetched as blobs, the background script builds the archive in memory:

  1. Create ticket.json with the scraped text.
  2. Add each attachment blob to an attachments/ folder inside the virtual ZIP.
  3. Have JSZip generate the final .zip as a blob.
  4. Call chrome.downloads.download() to save it to disk.
The web page is the real API

For a read-only task in a session you already have, a DOM scraper can be faster to build than an integration with a large REST API. Jira's class names vary between versions, so give every field several selector fallbacks and test against more than one instance.

The output JSON is clean and predictable:

ticket.json
{
"title": "Ticket Title",
"ticketKey": "PROJ-123",
"url": "https://...",
"extractedAt": "2024-01-01T00:00:00.000Z",
"description": "Clean text description without HTML",
"comments": [
{
"id": 1,
"author": "John Doe",
"timestamp": "3 days ago",
"body": "Comment text"
}
],
"attachments": [
{
"id": 1,
"name": "file.pdf",
"url": "https://..."
}
]
}

Checklist for a scraper extension like this​

  • Confirm the task is read-only and runs in a session you are already logged into.
  • Give the title, description, comments and attachments several selector fallbacks each.
  • Return true from the onMessage listener when the response is async.
  • Add a declarativeNetRequest rule that removes Authorization for media.atlassian.com before fetching attachments.
  • Build the ZIP in memory with JSZip and save it with chrome.downloads.download().

What I learned​

  • Scraping can beat the API. For read-only work in an authenticated session, a careful DOM scraper was faster to build than handling the REST API's format and per-attachment calls.
  • declarativeNetRequest is the header tool to learn in Manifest V3. In my judgment it is faster and safer than the old webRequest API for any extension that talks to third-party resources.
  • Keep it client-side. JSZip plus the Downloads API runs the whole process on the user's machine, which is faster, cheaper and safer than sending ticket data through a backend.
  • Write the small tools yourself. The extension ships three PNG icons, and a web-based icon generator or a large npm dependency is more machinery than three PNG icons need. A short Pillow script I own and understand did the job.
Full extension file structure
jiraextractor/
manifest.json
popup.html
popup.js
content.js
background.js
extractor.js
lib/
jszip.min.js
icons/
icon16.png
icon48.png
icon128.png
create_icons.py

License​

The repository includes an MIT LICENSE.

References​