From c6b00475bf33535cdce1cbcc4df1162db533dc67 Mon Sep 17 00:00:00 2001 From: peyt Date: Thu, 25 Sep 2025 15:16:39 +0200 Subject: [PATCH 1/3] feat: add Jira data connector - Implement Jira connector module to fetch projects and issues via REST API - Add jira to sync documents and fix eslint warnings in the file Closes #4014 --- collector/extensions/index.js | 29 +- collector/extensions/resync/index.js | 33 +++ .../utils/extensions/Jira/JiraLoader/index.js | 166 +++++++++++ collector/utils/extensions/Jira/index.js | 259 +++++++++++++++++ .../DataConnectorOption/media/index.js | 2 + .../DataConnectorOption/media/jira.svg | 16 ++ .../DataConnectors/Connectors/Jira/index.jsx | 271 ++++++++++++++++++ .../ManageWorkspace/DataConnectors/index.jsx | 7 + frontend/src/locales/ar/common.js | 23 ++ frontend/src/locales/da/common.js | 23 ++ frontend/src/locales/de/common.js | 29 ++ frontend/src/locales/en/common.js | 29 +- frontend/src/locales/es/common.js | 23 ++ frontend/src/locales/et/common.js | 23 ++ frontend/src/locales/fa/common.js | 23 ++ frontend/src/locales/fr/common.js | 23 ++ frontend/src/locales/he/common.js | 23 ++ frontend/src/locales/it/common.js | 23 ++ frontend/src/locales/ja/common.js | 23 ++ frontend/src/locales/ko/common.js | 23 ++ frontend/src/locales/lv/common.js | 23 ++ frontend/src/locales/nl/common.js | 23 ++ frontend/src/locales/pl/common.js | 23 ++ frontend/src/locales/pt_BR/common.js | 23 ++ frontend/src/locales/ro/common.js | 23 ++ frontend/src/locales/ru/common.js | 23 ++ frontend/src/locales/tr/common.js | 23 ++ frontend/src/locales/vn/common.js | 23 ++ frontend/src/locales/zh/common.js | 23 ++ frontend/src/locales/zh_TW/common.js | 23 ++ frontend/src/media/dataConnectors/jira.png | Bin 0 -> 10276 bytes frontend/src/models/dataConnector.js | 32 +++ .../Features/LiveSync/toggle.jsx | 2 +- server/endpoints/extensions/index.js | 44 +++ server/jobs/sync-watched-documents.js | 175 +++++++---- server/models/documentSyncQueue.js | 4 +- server/models/documents.js | 2 +- 37 files changed, 1497 insertions(+), 63 deletions(-) create mode 100644 collector/utils/extensions/Jira/JiraLoader/index.js create mode 100644 collector/utils/extensions/Jira/index.js create mode 100644 frontend/src/components/DataConnectorOption/media/jira.svg create mode 100644 frontend/src/components/Modals/ManageWorkspace/DataConnectors/Connectors/Jira/index.jsx create mode 100644 frontend/src/media/dataConnectors/jira.png diff --git a/collector/extensions/index.js b/collector/extensions/index.js index 76c5aafd334..2ca1bd7ee41 100644 --- a/collector/extensions/index.js +++ b/collector/extensions/index.js @@ -201,7 +201,32 @@ function extensions(app) { return; } ); -} + app.post( + "/ext/jira", + [verifyPayloadIntegrity, setDataSigner], + async function (request, response) { + try { + const { loadJira } = require("../utils/extensions/Jira"); + const { success, reason, data } = await loadJira( + reqBody(request), + response + ); + response.status(200).json({ success, reason, data }); + } catch (e) { + console.error(e); + response.status(400).json({ + success: false, + reason: e.message, + data: { + title: null, + author: null, + }, + }); + } + return; + } + ); +} -module.exports = extensions; \ No newline at end of file +module.exports = extensions; diff --git a/collector/extensions/resync/index.js b/collector/extensions/resync/index.js index 3ca1f44ab6e..8b8d3189b3c 100644 --- a/collector/extensions/resync/index.js +++ b/collector/extensions/resync/index.js @@ -144,10 +144,43 @@ async function resyncDrupalWiki({ chunkSource }, response) { } } +/** + * Fetches the content of a specific jira issue via its chunkSource. + * Returns the content as a text string of the page in question and only that issue. + * @param {object} data - metadata from a document (e.g.: chunkSource) + * @param {import("../../middleware/setDataSigner").ResponseWithSigner} response + */ +async function resyncJira({ chunkSource }, response) { + if (!chunkSource) throw new Error('Invalid source property provided'); + try { + // Jira data is `payload` encrypted. So we need to expand its + // encrypted payload back into query params so we can reFetch the page with same access token/params. + const source = response.locals.encryptionWorker.expandPayload(chunkSource); + const { fetchJiraIssue } = require("../../utils/extensions/Jira"); + const { success, reason, content } = await fetchJiraIssue({ + pageUrl: `https:${source.pathname}`, + baseUrl: source.searchParams.get('baseUrl'), + spaceKey: source.searchParams.get('projectKey'), + accessToken: source.searchParams.get('token'), + username: source.searchParams.get('username'), + }); + + if (!success) throw new Error(`Failed to sync Jira issue content. ${reason}`); + response.status(200).json({ success, content }); + } catch (e) { + console.error(e); + response.status(200).json({ + success: false, + content: null, + }); + } +} + module.exports = { link: resyncLink, youtube: resyncYouTube, confluence: resyncConfluence, github: resyncGithub, drupalwiki: resyncDrupalWiki, + jira: resyncJira, } diff --git a/collector/utils/extensions/Jira/JiraLoader/index.js b/collector/utils/extensions/Jira/JiraLoader/index.js new file mode 100644 index 00000000000..4761bc4a190 --- /dev/null +++ b/collector/utils/extensions/Jira/JiraLoader/index.js @@ -0,0 +1,166 @@ +/* + * This is a custom implementation of the Confluence langchain loader. There was an issue where + * code blocks were not being extracted. This is a temporary fix until this issue is resolved.*/ + +const { htmlToText } = require("html-to-text"); + +class JiraIssueLoader { + constructor({ + baseUrl, + projectKey, + username, + accessToken, + limit = 25, + expand = "changelog", + personalAccessToken, + cloud = true, + }) { + this.baseUrl = baseUrl; + this.projectKey = projectKey; + this.username = username; + this.accessToken = accessToken; + this.limit = limit; + this.expand = expand; + this.personalAccessToken = personalAccessToken; + this.cloud = cloud; + } + + get authorizationHeader() { + if (this.personalAccessToken) { + return `Bearer ${this.personalAccessToken}`; + } else if (this.username && this.accessToken) { + const authToken = Buffer.from( + `${this.username}:${this.accessToken}` + ).toString("base64"); + return `Basic ${authToken}`; + } + return undefined; + } + + async load(options) { + try { + const issues = await this.fetchAllIssuesInProject( + options?.start, + options?.limit + ); + return issues.map((issue) => this.createDocumentFromIssue(issue)); + } catch (error) { + console.error("Error:", error); + return []; + } + } + + async fetchJiraData(url) { + try { + const initialHeaders = { + "Content-Type": "application/json", + Accept: "application/json", + }; + const authHeader = this.authorizationHeader; + if (authHeader) { + initialHeaders.Authorization = authHeader; + } + const response = await fetch(url, { + headers: initialHeaders, + }); + if (!response.ok) { + throw new Error(`Failed to fetch ${url} from Jira: ${response.status}`); + } + return await response.json(); + } catch (error) { + throw new Error(`Failed to fetch ${url} from Jira: ${error}`); + } + } + + // https://developer.atlassian.com/rest/api/3/search?jql=project=ABC&startAt=0&maxResults=100 + async fetchAllIssuesInProject(start = 0, limit = this.limit) { + const base = this.baseUrl.replace(/\/+$/, ""); + const apiVersion = this.cloud ? "3" : "2"; + + const url = new URL(`/jira/rest/api/${apiVersion}/search`, base); + url.searchParams.set("jql", `project=${this.projectKey}`); + url.searchParams.set("startAt", String(start)); + url.searchParams.set("maxResults", String(limit)); + if (this.expand) url.searchParams.set("expand", String(this.expand)); + //url.searchParams.set('fields', 'summary,issuetype,status,assignee,priority,updated'); + + const data = await this.fetchJiraData(url); + + const issues = Array.isArray(data?.issues) ? data.issues : []; + if (!issues.length) return []; + + const total = Number.isFinite(data?.total) ? data.total : issues.length; + const nextStart = start + issues.length; + + if (nextStart >= total) { + return issues; + } + + const nextIssueResults = await this.fetchAllIssuesInProject( + nextStart, + limit + ); + return issues.concat(nextIssueResults); + } + + createDocumentFromIssue(issue) { + const extractCodeBlocks = (content) => { + const codeBlockRegex = + /]*>[\s\S]*?<\/ac:plain-text-body>[\s\S]*?<\/ac:structured-macro>/g; + const languageRegex = + /(.*?)<\/ac:parameter>/; + + return content.replace(codeBlockRegex, (match) => { + const language = match.match(languageRegex)?.[1] || ""; + const code = + match.match( + /<\/ac:plain-text-body>/ + )?.[1] || ""; + return `\n\`\`\`${language}\n${code.trim()}\n\`\`\`\n`; + }); + }; + + let rawDescription = ""; + if (issue.fields?.description) { + if (issue.fields.description.storage?.value) { + rawDescription = extractCodeBlocks( + issue.fields.description.storage.value + ); + } else if (typeof issue.fields.description === "string") { + rawDescription = issue.fields.description; + } else if (issue.fields.description.content) { + rawDescription = issue.fields.description.content + .map((block) => block.content?.map((c) => c.text || "").join(" ")) + .join("\n"); + } + } + + const plainTextContent = htmlToText(rawDescription, { + wordwrap: false, + preserveNewlines: true, + }); + const textWithPreservedStructure = plainTextContent.replace( + /\n{3,}/g, + "\n\n" + ); + const issueUrl = `${this.baseUrl}/jira/browse/${issue.key}`; + + return { + pageContent: textWithPreservedStructure, + metadata: { + id: issue.id, + key: issue.key, + status: issue.fields?.status?.name || "", + title: issue.fields?.summary || "", + type: issue.fields?.issuetype?.name || "", + url: issueUrl, + created_by: issue.fields?.creator?.displayName || "", + created_at: issue.fields?.created || "", + updated_by: issue.fields?.updated_by?.displayName || "", + updated_at: issue.fields?.updated || "", + }, + }; + } +} + +module.exports = { JiraIssueLoader }; diff --git a/collector/utils/extensions/Jira/index.js b/collector/utils/extensions/Jira/index.js new file mode 100644 index 00000000000..9baf5a777cd --- /dev/null +++ b/collector/utils/extensions/Jira/index.js @@ -0,0 +1,259 @@ +const fs = require("fs"); +const path = require("path"); +const { default: slugify } = require("slugify"); +const { v4 } = require("uuid"); +const { writeToServerDocuments, sanitizeFileName } = require("../../files"); +const { tokenizeString } = require("../../tokenizer"); +const { JiraIssueLoader } = require("./JiraLoader"); + +/** + * Load Jira tickets from a projectID and Jira credentials + * @param {object} args - forwarded request body params + * @param {import("../../../middleware/setDataSigner").ResponseWithSigner} response - Express response object with encryptionWorker + * @returns + */ +async function loadJira( + { + baseUrl = null, + projectKey = null, + username = null, + accessToken = null, + cloud = true, + personalAccessToken = null, + }, + response +) { + if (!personalAccessToken && (!username || !accessToken)) { + return { + success: false, + reason: + "You need either a personal access token (PAT), or a username and access token to use the Jira connector.", + }; + } + + if (!baseUrl || !validBaseUrl(baseUrl)) { + return { + success: false, + reason: "Provided base URL is not a valid URL.", + }; + } + + if (!projectKey) { + return { + success: false, + reason: "You need to provide a Jira project key.", + }; + } + + const { origin, hostname } = new URL(baseUrl); + console.log(`-- Working Jira ${origin} --`); + const loader = new JiraIssueLoader({ + baseUrl: origin, + projectKey, + username, + accessToken, + cloud, + personalAccessToken, + }); + + const { docs, error } = await loader + .load() + .then((docs) => { + return { docs, error: null }; + }) + .catch((e) => { + return { + docs: [], + error: e.message?.split("Error:")?.[1] || e.message, + }; + }); + + if (!docs.length || !!error) { + return { + success: false, + reason: error ?? "No issue found for that Jira project.", + }; + } + const outFolder = slugify( + `jira-${hostname}-${v4().slice(0, 4)}` + ).toLowerCase(); + + const outFolderPath = + process.env.NODE_ENV === "development" + ? path.resolve( + __dirname, + `../../../../server/storage/documents/${outFolder}` + ) + : path.resolve(process.env.STORAGE_DIR, `documents/${outFolder}`); + + if (!fs.existsSync(outFolderPath)) + fs.mkdirSync(outFolderPath, { recursive: true }); + + docs.forEach((doc) => { + if (!doc.pageContent) return; + + const data = { + id: v4(), + url: doc.metadata.url + ".page", + title: doc.metadata.title || doc.metadata.source, + docAuthor: origin, + description: doc.metadata.title, + docSource: `${origin} Jira`, + chunkSource: generateChunkSource( + { doc, baseUrl: origin, projectKey, accessToken, username, cloud }, + response.locals.encryptionWorker + ), + published: new Date().toLocaleString(), + wordCount: doc.pageContent.split(" ").length, + pageContent: doc.pageContent, + token_count_estimate: tokenizeString(doc.pageContent), + }; + + console.log(`[Jira Loader]: Saving ${doc.metadata.title} to ${outFolder}`); + + const fileName = sanitizeFileName( + `${slugify(doc.metadata.title)}-${data.id}` + ); + writeToServerDocuments({ + data, + filename: fileName, + destinationOverride: outFolderPath, + }); + }); + + return { + success: true, + reason: null, + data: { + projectKey, + destination: outFolder, + }, + }; +} + +/** + * Gets the issue content from a specific Jira issue, not all issues in a workspace. + * @returns + */ +async function fetchJiraIssue({ + pageUrl, + baseUrl, + projectKey, + username, + accessToken, + cloud = true, +}) { + if (!pageUrl || !baseUrl || !projectKey || !username || !accessToken) { + return { + success: false, + content: null, + reason: + "You need either a username and access token, or a personal access token (PAT), to use the Jira connector.", + }; + } + + if (!validBaseUrl(baseUrl)) { + return { + success: false, + content: null, + reason: "Provided base URL is not a valid URL.", + }; + } + + if (!projectKey) { + return { + success: false, + content: null, + reason: "You need to provide a Jira project key.", + }; + } + + console.log(`-- Working Jira Issue ${pageUrl} --`); + const loader = new JiraIssueLoader({ + baseUrl, // Should be the origin of the baseUrl + projectKey, + username, + accessToken, + cloud, + }); + + const { docs, error } = await loader + .load() + .then((docs) => { + return { docs, error: null }; + }) + .catch((e) => { + return { + docs: [], + error: e.message?.split("Error:")?.[1] || e.message, + }; + }); + + if (!docs.length || !!error) { + return { + success: false, + reason: error ?? "No pages found for that Jira project.", + content: null, + }; + } + + const targetDocument = docs.find( + (doc) => doc.pageContent && doc.metadata.url === pageUrl + ); + if (!targetDocument) { + return { + success: false, + reason: "Target page could not be found in Jira project.", + content: null, + }; + } + + return { + success: true, + reason: null, + content: targetDocument.pageContent, + }; +} + +/** + * Validates if the provided baseUrl is a valid URL at all. + * @param {string} baseUrl + * @returns {boolean} + */ +function validBaseUrl(baseUrl) { + try { + new URL(baseUrl); + return true; + } catch (e) { + return false; + } +} + +/** + * Generate the full chunkSource for a specific Jira issue so that we can resync it later. + * This data is encrypted into a single `payload` query param so we can replay credentials later + * since this was encrypted with the systems persistent password and salt. + * @param {object} chunkSourceInformation + * @param {import("../../EncryptionWorker").EncryptionWorker} encryptionWorker + * @returns {string} + */ +function generateChunkSource( + { doc, baseUrl, projectKey, accessToken, username, cloud }, + encryptionWorker +) { + const payload = { + baseUrl, + projectKey, + token: accessToken, + username, + cloud, + }; + return `jira://${doc.metadata.url}?payload=${encryptionWorker.encrypt( + JSON.stringify(payload) + )}`; +} + +module.exports = { + loadJira, + fetchJiraIssue, +}; diff --git a/frontend/src/components/DataConnectorOption/media/index.js b/frontend/src/components/DataConnectorOption/media/index.js index b0fba91bc4e..5a38117bd90 100644 --- a/frontend/src/components/DataConnectorOption/media/index.js +++ b/frontend/src/components/DataConnectorOption/media/index.js @@ -5,6 +5,7 @@ import Link from "./link.svg"; import Confluence from "./confluence.jpeg"; import DrupalWiki from "./drupalwiki.jpg"; import Obsidian from "./obsidian.png"; +import Jira from "./jira.svg"; const ConnectorImages = { github: GitHub, @@ -14,6 +15,7 @@ const ConnectorImages = { confluence: Confluence, drupalwiki: DrupalWiki, obsidian: Obsidian, + jira: Jira, }; export default ConnectorImages; diff --git a/frontend/src/components/DataConnectorOption/media/jira.svg b/frontend/src/components/DataConnectorOption/media/jira.svg new file mode 100644 index 00000000000..1f2a3d292fe --- /dev/null +++ b/frontend/src/components/DataConnectorOption/media/jira.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/frontend/src/components/Modals/ManageWorkspace/DataConnectors/Connectors/Jira/index.jsx b/frontend/src/components/Modals/ManageWorkspace/DataConnectors/Connectors/Jira/index.jsx new file mode 100644 index 00000000000..61f5d6d10c0 --- /dev/null +++ b/frontend/src/components/Modals/ManageWorkspace/DataConnectors/Connectors/Jira/index.jsx @@ -0,0 +1,271 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import System from "@/models/system"; +import showToast from "@/utils/toast"; +import { Warning } from "@phosphor-icons/react"; +import { Tooltip } from "react-tooltip"; + +export default function JiraOptions() { + const { t } = useTranslation(); + const [loading, setLoading] = useState(false); + const [accessType, setAccessType] = useState("username"); + + const handleSubmit = async (e) => { + e.preventDefault(); + const form = new FormData(e.target); + + try { + setLoading(true); + showToast( + "Fetching all issues for Jira project - this may take a while.", + "info", + { + clear: true, + autoClose: false, + } + ); + const { data, error } = await System.dataConnectors.jira.collect({ + baseUrl: form.get("baseUrl"), + projectKey: form.get("projectKey"), + username: form.get("username"), + accessToken: form.get("accessToken"), + cloud: form.get("isCloud") === "true", + personalAccessToken: form.get("personalAccessToken"), + }); + + if (!!error) { + showToast(error, "error", { clear: true }); + setLoading(false); + return; + } + + showToast( + `Issues collected from Jira space ${data.projectKey}. Output folder is ${data.destination}.`, + "success", + { clear: true } + ); + e.target.reset(); + setLoading(false); + } catch (e) { + console.error(e); + showToast(e.message, "error", { clear: true }); + setLoading(false); + } + }; + + return ( +
+
+
+
+
+
+
+ +

+ {t("connectors.jira.deployment_type_explained")} +

+
+ +
+ +
+
+ +

+ {t("connectors.jira.base_url_explained")} +

+
+ +
+
+
+ +

+ {t("connectors.jira.project_key_explained")} +

+
+ +
+
+
+ +

+ {t("connectors.jira.auth_type_explained")} +

+
+ +
+ {accessType === "username" && ( + <> +
+
+ +

+ {t("connectors.jira.username_explained")} +

+
+ +
+
+
+ +

+ {t("connectors.jira.token_desc")} +

+
+ +
+ + )} + {accessType === "personalToken" && ( +
+
+ +

+ {t("connectors.jira.pat_token_explained")} +

+
+ +
+ )} +
+
+ +
+ + {loading && ( +

+ {t("connectors.jira.task_explained")} +

+ )} +
+
+
+
+ ); +} diff --git a/frontend/src/components/Modals/ManageWorkspace/DataConnectors/index.jsx b/frontend/src/components/Modals/ManageWorkspace/DataConnectors/index.jsx index 1e1d268525f..2f8e1a0e42a 100644 --- a/frontend/src/components/Modals/ManageWorkspace/DataConnectors/index.jsx +++ b/frontend/src/components/Modals/ManageWorkspace/DataConnectors/index.jsx @@ -10,6 +10,7 @@ import { useState } from "react"; import ConnectorOption from "./ConnectorOption"; import WebsiteDepthOptions from "./Connectors/WebsiteDepth"; import ObsidianOptions from "./Connectors/Obsidian"; +import JiraOptions from "./Connectors/Jira"; export const getDataConnectors = (t) => ({ github: { @@ -54,6 +55,12 @@ export const getDataConnectors = (t) => ({ description: "Import Obsidian vault in a single click.", options: , }, + jira: { + name: t("connectors.jira.name"), + image: ConnectorImages.jira, + description: t("connectors.jira.description"), + options: , + }, }); export default function DataConnectors() { diff --git a/frontend/src/locales/ar/common.js b/frontend/src/locales/ar/common.js index cc85ea8d77b..41aa0da0717 100644 --- a/frontend/src/locales/ar/common.js +++ b/frontend/src/locales/ar/common.js @@ -575,6 +575,29 @@ const TRANSLATIONS = { pat_token_explained: null, task_explained: null, }, + jira: { + name: null, + description: null, + deployment_type: null, + deployment_type_explained: null, + base_url: null, + base_url_explained: null, + project_key: null, + project_key_explained: null, + username: null, + username_explained: null, + auth_type: null, + auth_type_explained: null, + auth_type_username: null, + auth_type_personal: null, + token: null, + token_explained_start: null, + token_explained_link: null, + token_desc: null, + pat_token: null, + pat_token_explained: null, + task_explained: null, + }, manage: { documents: null, "data-connectors": null, diff --git a/frontend/src/locales/da/common.js b/frontend/src/locales/da/common.js index 51089ea38b2..88bd5f4b3bd 100644 --- a/frontend/src/locales/da/common.js +++ b/frontend/src/locales/da/common.js @@ -601,6 +601,29 @@ const TRANSLATIONS = { task_explained: "Når færdig, vil sideindholdet være tilgængeligt for indlejring i arbejdsområder i dokumentvælgeren.", }, + jira: { + name: null, + description: null, + deployment_type: null, + deployment_type_explained: null, + base_url: null, + base_url_explained: null, + project_key: null, + project_key_explained: null, + username: null, + username_explained: null, + auth_type: null, + auth_type_explained: null, + auth_type_username: null, + auth_type_personal: null, + token: null, + token_explained_start: null, + token_explained_link: null, + token_desc: null, + pat_token: null, + pat_token_explained: null, + task_explained: null, + }, manage: { documents: "Dokumenter", "data-connectors": "Datakonnektorer", diff --git a/frontend/src/locales/de/common.js b/frontend/src/locales/de/common.js index 4106d401cbe..300ebfb6939 100644 --- a/frontend/src/locales/de/common.js +++ b/frontend/src/locales/de/common.js @@ -806,6 +806,35 @@ const TRANSLATIONS = { task_explained: "Sobald der Vorgang abgeschlossen ist, ist der Seiteninhalt im Dokumenten-Picker zur Einbettung in Workspaces verfügbar.", }, + jira: { + name: "Jira", + description: + "Importieren Sie ein komplettes Jira-Issue mit einem einzigen Klick.", + deployment_type: "Jira Bereitstellungstyp", + deployment_type_explained: + "Bestimmen Sie, ob Ihre Jira-Instanz in der Atlassian Cloud oder selbst gehostet ist.", + base_url: "Jira Basis-URL", + base_url_explained: "Dies ist die Basis-URL Ihres Jira-Bereichs.", + project_key: "Jira Project-Key", + project_key_explained: + "Dies ist der Project-Key Ihrer Jira-Instanz, der verwendet wird. Beginnt normalerweise mit ~", + username: "Jira Benutzername", + username_explained: "Ihr Jira Benutzername.", + auth_type: "Jira Authentifizierungstyp", + auth_type_explained: + "Wählen Sie den Authentifizierungstyp, den Sie verwenden möchten, um auf Ihre Jira-Issuen zuzugreifen.", + auth_type_username: "Benutzername und Zugriffstoken", + auth_type_personal: "Persönliches Zugriffstoken", + token: "Confluence API-Token", + token_explained_start: + "Sie müssen ein Zugriffstoken für die Authentifizierung bereitstellen. Sie können ein Zugriffstoken", + token_explained_link: "hier", + token_desc: "Zugriffstoken für die Authentifizierung.", + pat_token: "Confluence persönliches Zugriffstoken", + pat_token_explained: "Ihr Confluence persönliches Zugriffstoken.", + task_explained: + "Sobald der Vorgang abgeschlossen ist, ist der Seiteninhalt im Dokumenten-Picker zur Einbettung in Workspaces verfügbar.", + }, manage: { documents: "Dokumente", "data-connectors": "Datenverbindungen", diff --git a/frontend/src/locales/en/common.js b/frontend/src/locales/en/common.js index 345b38324ba..7935c432614 100644 --- a/frontend/src/locales/en/common.js +++ b/frontend/src/locales/en/common.js @@ -862,7 +862,34 @@ const TRANSLATIONS = { task_explained: "Once complete, the page content will be available for embedding into workspaces in the document picker.", }, - + jira: { + name: "Jira", + description: "Import an entire Jira issue in a single click.", + deployment_type: "Jira deployment type", + deployment_type_explained: + "Determine if your Jira instance is hosted on Atlassian cloud or self-hosted.", + base_url: "Jira base URL", + base_url_explained: "This is the base URL of your Jira project.", + project_key: "Jira project key", + project_key_explained: + "This is the project key of your Jira instance that will be used. Usually begins with ~", + username: "Jira Username", + username_explained: "Your Jira username", + auth_type: "Jira Auth Type", + auth_type_explained: + "Select the authentication type you want to use to access your Jira project.", + auth_type_username: "Username and Access Token", + auth_type_personal: "Personal Access Token", + token: "Jira Access Token", + token_explained_start: + "You need to provide an access token for authentication. You can generate an access token", + token_explained_link: "here", + token_desc: "Access token for authentication", + pat_token: "Jira Personal Access Token", + pat_token_explained: "Your Jira personal access token.", + task_explained: + "Once complete, the issue content will be available for embedding into workspaces in the document picker.", + }, manage: { documents: "Documents", "data-connectors": "Data Connectors", diff --git a/frontend/src/locales/es/common.js b/frontend/src/locales/es/common.js index 3d7e830be6b..4bff28de401 100644 --- a/frontend/src/locales/es/common.js +++ b/frontend/src/locales/es/common.js @@ -819,6 +819,29 @@ const TRANSLATIONS = { task_explained: "Una vez completado, el contenido de la página estará disponible para incrustar en los espacios de trabajo en el selector de documentos.", }, + jira: { + name: "Jira", + description: "", + deployment_type: "", + deployment_type_explained: "", + base_url: "", + base_url_explained: "", + project_key: "", + project_key_explained: "", + username: "", + username_explained: "", + auth_type: "", + auth_type_explained: "", + auth_type_username: "", + auth_type_personal: "", + token: "", + token_explained_start: "", + token_explained_link: "", + token_desc: "", + pat_token: "", + pat_token_explained: "", + task_explained: "", + }, manage: { documents: "Documentos", "data-connectors": "Conectores de datos", diff --git a/frontend/src/locales/et/common.js b/frontend/src/locales/et/common.js index c6d04c4631d..d54d58a0001 100644 --- a/frontend/src/locales/et/common.js +++ b/frontend/src/locales/et/common.js @@ -768,6 +768,29 @@ const TRANSLATIONS = { task_explained: "Kui valmis, on lehe sisu dokumentide valijas tööruumidesse põimimiseks saadaval.", }, + jira: { + name: null, + description: null, + deployment_type: null, + deployment_type_explained: null, + base_url: null, + base_url_explained: null, + project_key: null, + project_key_explained: null, + username: null, + username_explained: null, + auth_type: null, + auth_type_explained: null, + auth_type_username: null, + auth_type_personal: null, + token: null, + token_explained_start: null, + token_explained_link: null, + token_desc: null, + pat_token: null, + pat_token_explained: null, + task_explained: null, + }, manage: { documents: "Dokumendid", "data-connectors": "Andmepistikud", diff --git a/frontend/src/locales/fa/common.js b/frontend/src/locales/fa/common.js index 1f0cd82647b..32dc823c674 100644 --- a/frontend/src/locales/fa/common.js +++ b/frontend/src/locales/fa/common.js @@ -567,6 +567,29 @@ const TRANSLATIONS = { pat_token_explained: null, task_explained: null, }, + jira: { + name: null, + description: null, + deployment_type: null, + deployment_type_explained: null, + base_url: null, + base_url_explained: null, + project_key: null, + project_key_explained: null, + username: null, + username_explained: null, + auth_type: null, + auth_type_explained: null, + auth_type_username: null, + auth_type_personal: null, + token: null, + token_explained_start: null, + token_explained_link: null, + token_desc: null, + pat_token: null, + pat_token_explained: null, + task_explained: null, + }, manage: { documents: null, "data-connectors": null, diff --git a/frontend/src/locales/fr/common.js b/frontend/src/locales/fr/common.js index f12183466f3..623c5fe0797 100644 --- a/frontend/src/locales/fr/common.js +++ b/frontend/src/locales/fr/common.js @@ -575,6 +575,29 @@ const TRANSLATIONS = { pat_token_explained: null, task_explained: null, }, + jira: { + name: null, + description: null, + deployment_type: null, + deployment_type_explained: null, + base_url: null, + base_url_explained: null, + project_key: null, + project_key_explained: null, + username: null, + username_explained: null, + auth_type: null, + auth_type_explained: null, + auth_type_username: null, + auth_type_personal: null, + token: null, + token_explained_start: null, + token_explained_link: null, + token_desc: null, + pat_token: null, + pat_token_explained: null, + task_explained: null, + }, manage: { documents: null, "data-connectors": null, diff --git a/frontend/src/locales/he/common.js b/frontend/src/locales/he/common.js index 84ffe023fa9..d75c7e925b8 100644 --- a/frontend/src/locales/he/common.js +++ b/frontend/src/locales/he/common.js @@ -775,6 +775,29 @@ const TRANSLATIONS = { task_explained: "לאחר השלמה, תוכן העמוד יהיה זמין להטמעה בסביבות עבודה בבורר המסמכים.", }, + jira: { + name: null, + description: null, + deployment_type: null, + deployment_type_explained: null, + base_url: null, + base_url_explained: null, + project_key: null, + project_key_explained: null, + username: null, + username_explained: null, + auth_type: null, + auth_type_explained: null, + auth_type_username: null, + auth_type_personal: null, + token: null, + token_explained_start: null, + token_explained_link: null, + token_desc: null, + pat_token: null, + pat_token_explained: null, + task_explained: null, + }, manage: { documents: "מסמכים", "data-connectors": "מחברי נתונים", diff --git a/frontend/src/locales/it/common.js b/frontend/src/locales/it/common.js index 442a5f2c7ad..9d724b32317 100644 --- a/frontend/src/locales/it/common.js +++ b/frontend/src/locales/it/common.js @@ -573,6 +573,29 @@ const TRANSLATIONS = { pat_token_explained: null, task_explained: null, }, + jira: { + name: null, + description: null, + deployment_type: null, + deployment_type_explained: null, + base_url: null, + base_url_explained: null, + project_key: null, + project_key_explained: null, + username: null, + username_explained: null, + auth_type: null, + auth_type_explained: null, + auth_type_username: null, + auth_type_personal: null, + token: null, + token_explained_start: null, + token_explained_link: null, + token_desc: null, + pat_token: null, + pat_token_explained: null, + task_explained: null, + }, manage: { documents: null, "data-connectors": null, diff --git a/frontend/src/locales/ja/common.js b/frontend/src/locales/ja/common.js index 45e47e4d0bd..3c1c6bfe407 100644 --- a/frontend/src/locales/ja/common.js +++ b/frontend/src/locales/ja/common.js @@ -593,6 +593,29 @@ const TRANSLATIONS = { task_explained: "完了後、ページ内容がドキュメントピッカーからワークスペースに埋め込めるようになります。", }, + jira: { + name: null, + description: null, + deployment_type: null, + deployment_type_explained: null, + base_url: null, + base_url_explained: null, + project_key: null, + project_key_explained: null, + username: null, + username_explained: null, + auth_type: null, + auth_type_explained: null, + auth_type_username: null, + auth_type_personal: null, + token: null, + token_explained_start: null, + token_explained_link: null, + token_desc: null, + pat_token: null, + pat_token_explained: null, + task_explained: null, + }, manage: { documents: "ドキュメント", "data-connectors": "データコネクタ", diff --git a/frontend/src/locales/ko/common.js b/frontend/src/locales/ko/common.js index 28fd58d4e4a..c88bfd55baa 100644 --- a/frontend/src/locales/ko/common.js +++ b/frontend/src/locales/ko/common.js @@ -783,6 +783,29 @@ const TRANSLATIONS = { task_explained: "가져오기가 완료되면 페이지 내용이 문서 선택기에서 워크스페이스에 임베딩할 수 있도록 제공됩니다.", }, + jira: { + name: null, + description: null, + deployment_type: null, + deployment_type_explained: null, + base_url: null, + base_url_explained: null, + project_key: null, + project_key_explained: null, + username: null, + username_explained: null, + auth_type: null, + auth_type_explained: null, + auth_type_username: null, + auth_type_personal: null, + token: null, + token_explained_start: null, + token_explained_link: null, + token_desc: null, + pat_token: null, + pat_token_explained: null, + task_explained: null, + }, manage: { documents: "문서 관리", "data-connectors": "데이터 커넥터", diff --git a/frontend/src/locales/lv/common.js b/frontend/src/locales/lv/common.js index 9786bce5c1c..fca3fbb6342 100644 --- a/frontend/src/locales/lv/common.js +++ b/frontend/src/locales/lv/common.js @@ -798,6 +798,29 @@ const TRANSLATIONS = { task_explained: "Kad tas būs pabeigts, lapas saturs būs pieejams iegulšanai darba vietās dokumentu atlasītājā.", }, + jira: { + name: null, + description: null, + deployment_type: null, + deployment_type_explained: null, + base_url: null, + base_url_explained: null, + project_key: null, + project_key_explained: null, + username: null, + username_explained: null, + auth_type: null, + auth_type_explained: null, + auth_type_username: null, + auth_type_personal: null, + token: null, + token_explained_start: null, + token_explained_link: null, + token_desc: null, + pat_token: null, + pat_token_explained: null, + task_explained: null, + }, manage: { documents: "Dokumenti", "data-connectors": "Datu savienotāji", diff --git a/frontend/src/locales/nl/common.js b/frontend/src/locales/nl/common.js index 3d300d0af24..564bc6b698e 100644 --- a/frontend/src/locales/nl/common.js +++ b/frontend/src/locales/nl/common.js @@ -570,6 +570,29 @@ const TRANSLATIONS = { pat_token_explained: null, task_explained: null, }, + jira: { + name: null, + description: null, + deployment_type: null, + deployment_type_explained: null, + base_url: null, + base_url_explained: null, + project_key: null, + project_key_explained: null, + username: null, + username_explained: null, + auth_type: null, + auth_type_explained: null, + auth_type_username: null, + auth_type_personal: null, + token: null, + token_explained_start: null, + token_explained_link: null, + token_desc: null, + pat_token: null, + pat_token_explained: null, + task_explained: null, + }, manage: { documents: null, "data-connectors": null, diff --git a/frontend/src/locales/pl/common.js b/frontend/src/locales/pl/common.js index d7673520a14..39750cf4db5 100644 --- a/frontend/src/locales/pl/common.js +++ b/frontend/src/locales/pl/common.js @@ -803,6 +803,29 @@ const TRANSLATIONS = { task_explained: "Po zakończeniu zawartość strony będzie dostępna do osadzenia w obszarach roboczych w selektorze dokumentów.", }, + jira: { + name: null, + description: null, + deployment_type: null, + deployment_type_explained: null, + base_url: null, + base_url_explained: null, + project_key: null, + project_key_explained: null, + username: null, + username_explained: null, + auth_type: null, + auth_type_explained: null, + auth_type_username: null, + auth_type_personal: null, + token: null, + token_explained_start: null, + token_explained_link: null, + token_desc: null, + pat_token: null, + pat_token_explained: null, + task_explained: null, + }, manage: { documents: "Dokumenty", "data-connectors": "Źródła danych", diff --git a/frontend/src/locales/pt_BR/common.js b/frontend/src/locales/pt_BR/common.js index 7795a3e98d4..25fac5f81e6 100644 --- a/frontend/src/locales/pt_BR/common.js +++ b/frontend/src/locales/pt_BR/common.js @@ -781,6 +781,29 @@ const TRANSLATIONS = { task_explained: "Após conclusão, o conteúdo da página estará disponível para vínculo.", }, + jira: { + name: null, + description: null, + deployment_type: null, + deployment_type_explained: null, + base_url: null, + base_url_explained: null, + project_key: null, + project_key_explained: null, + username: null, + username_explained: null, + auth_type: null, + auth_type_explained: null, + auth_type_username: null, + auth_type_personal: null, + token: null, + token_explained_start: null, + token_explained_link: null, + token_desc: null, + pat_token: null, + pat_token_explained: null, + task_explained: null, + }, manage: { documents: "Documentos", "data-connectors": "Conectores de Dados", diff --git a/frontend/src/locales/ro/common.js b/frontend/src/locales/ro/common.js index 35556747d4b..2c533855bb6 100644 --- a/frontend/src/locales/ro/common.js +++ b/frontend/src/locales/ro/common.js @@ -542,6 +542,29 @@ const TRANSLATIONS = { task_explained: "Odată complet, conținutul paginii va fi disponibil pentru embedding în spații de lucru în selectorul de documente.", }, + jira: { + name: null, + description: null, + deployment_type: null, + deployment_type_explained: null, + base_url: null, + base_url_explained: null, + project_key: null, + project_key_explained: null, + username: null, + username_explained: null, + auth_type: null, + auth_type_explained: null, + auth_type_username: null, + auth_type_personal: null, + token: null, + token_explained_start: null, + token_explained_link: null, + token_desc: null, + pat_token: null, + pat_token_explained: null, + task_explained: null, + }, manage: { documents: "Documente", "data-connectors": "Conectori de date", diff --git a/frontend/src/locales/ru/common.js b/frontend/src/locales/ru/common.js index aebe5b68a98..2f5116c7112 100644 --- a/frontend/src/locales/ru/common.js +++ b/frontend/src/locales/ru/common.js @@ -602,6 +602,29 @@ const TRANSLATIONS = { task_explained: "После завершения содержимое страницы будет доступно для внедрения в рабочие пространства через выбор документов.", }, + jira: { + name: null, + description: null, + deployment_type: null, + deployment_type_explained: null, + base_url: null, + base_url_explained: null, + project_key: null, + project_key_explained: null, + username: null, + username_explained: null, + auth_type: null, + auth_type_explained: null, + auth_type_username: null, + auth_type_personal: null, + token: null, + token_explained_start: null, + token_explained_link: null, + token_desc: null, + pat_token: null, + pat_token_explained: null, + task_explained: null, + }, manage: { documents: "Документы", "data-connectors": "Коннекторы данных", diff --git a/frontend/src/locales/tr/common.js b/frontend/src/locales/tr/common.js index cadf6f929c4..5b98c3134bf 100644 --- a/frontend/src/locales/tr/common.js +++ b/frontend/src/locales/tr/common.js @@ -570,6 +570,29 @@ const TRANSLATIONS = { pat_token_explained: null, task_explained: null, }, + jira: { + name: null, + description: null, + deployment_type: null, + deployment_type_explained: null, + base_url: null, + base_url_explained: null, + project_key: null, + project_key_explained: null, + username: null, + username_explained: null, + auth_type: null, + auth_type_explained: null, + auth_type_username: null, + auth_type_personal: null, + token: null, + token_explained_start: null, + token_explained_link: null, + token_desc: null, + pat_token: null, + pat_token_explained: null, + task_explained: null, + }, manage: { documents: null, "data-connectors": null, diff --git a/frontend/src/locales/vn/common.js b/frontend/src/locales/vn/common.js index 761c6e51a37..a94661ae1de 100644 --- a/frontend/src/locales/vn/common.js +++ b/frontend/src/locales/vn/common.js @@ -569,6 +569,29 @@ const TRANSLATIONS = { pat_token_explained: null, task_explained: null, }, + jira: { + name: null, + description: null, + deployment_type: null, + deployment_type_explained: null, + base_url: null, + base_url_explained: null, + project_key: null, + project_key_explained: null, + username: null, + username_explained: null, + auth_type: null, + auth_type_explained: null, + auth_type_username: null, + auth_type_personal: null, + token: null, + token_explained_start: null, + token_explained_link: null, + token_desc: null, + pat_token: null, + pat_token_explained: null, + task_explained: null, + }, manage: { documents: null, "data-connectors": null, diff --git a/frontend/src/locales/zh/common.js b/frontend/src/locales/zh/common.js index 95872cd59f7..5ef0bd0b7bb 100644 --- a/frontend/src/locales/zh/common.js +++ b/frontend/src/locales/zh/common.js @@ -732,6 +732,29 @@ const TRANSLATIONS = { pat_token_explained: "您的 Confluence 个人访问令牌。", task_explained: "完成后,页面内容将可用于在文档选择器中嵌入至工作区。", }, + jira: { + name: null, + description: null, + deployment_type: null, + deployment_type_explained: null, + base_url: null, + base_url_explained: null, + project_key: null, + project_key_explained: null, + username: null, + username_explained: null, + auth_type: null, + auth_type_explained: null, + auth_type_username: null, + auth_type_personal: null, + token: null, + token_explained_start: null, + token_explained_link: null, + token_desc: null, + pat_token: null, + pat_token_explained: null, + task_explained: null, + }, manage: { documents: "文档", "data-connectors": "数据连接器", diff --git a/frontend/src/locales/zh_TW/common.js b/frontend/src/locales/zh_TW/common.js index f3acd752cec..7e2bd1851a0 100644 --- a/frontend/src/locales/zh_TW/common.js +++ b/frontend/src/locales/zh_TW/common.js @@ -565,6 +565,29 @@ const TRANSLATIONS = { pat_token_explained: "您的 Confluence 個人存取權杖。", task_explained: "完成後,頁面內容將可供嵌入到工作區中的檔案選擇器。", }, + jira: { + name: null, + description: null, + deployment_type: null, + deployment_type_explained: null, + base_url: null, + base_url_explained: null, + project_key: null, + project_key_explained: null, + username: null, + username_explained: null, + auth_type: null, + auth_type_explained: null, + auth_type_username: null, + auth_type_personal: null, + token: null, + token_explained_start: null, + token_explained_link: null, + token_desc: null, + pat_token: null, + pat_token_explained: null, + task_explained: null, + }, manage: { documents: "文件", "data-connectors": "資料連接器", diff --git a/frontend/src/media/dataConnectors/jira.png b/frontend/src/media/dataConnectors/jira.png new file mode 100644 index 0000000000000000000000000000000000000000..87c62ea2532d6a0b77d75c9ebd110003f4e8a139 GIT binary patch literal 10276 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%0A3a9ueCc+PoZd%M!RJisucb4OA$zA~z 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 res.json()) + .then((res) => { + if (!res.success) throw new Error(res.reason); + return { data: res.data, error: null }; + }) + .catch((e) => { + console.error(e); + return { data: null, error: e.message }; + }); + }, + }, }; export default DataConnector; diff --git a/frontend/src/pages/Admin/ExperimentalFeatures/Features/LiveSync/toggle.jsx b/frontend/src/pages/Admin/ExperimentalFeatures/Features/LiveSync/toggle.jsx index 91024635c0f..dc2121779c4 100644 --- a/frontend/src/pages/Admin/ExperimentalFeatures/Features/LiveSync/toggle.jsx +++ b/frontend/src/pages/Admin/ExperimentalFeatures/Features/LiveSync/toggle.jsx @@ -58,7 +58,7 @@ export default function LiveSyncToggle({ enabled = false, onToggle }) {

This feature only applies to web-based content, such as websites, - Confluence, YouTube, and GitHub files. + Confluence, YouTube, GitHub and Jira files.

diff --git a/server/endpoints/extensions/index.js b/server/endpoints/extensions/index.js index 0a4acea6cc0..8cf91d78dd9 100644 --- a/server/endpoints/extensions/index.js +++ b/server/endpoints/extensions/index.js @@ -170,6 +170,50 @@ function extensionEndpoints(app) { } } ); + + app.post( + "/ext/obsidian/vault", + [validatedRequest, flexUserRoleValid([ROLES.admin, ROLES.manager])], + async (request, response) => { + try { + const responseFromProcessor = + await new CollectorApi().forwardExtensionRequest({ + endpoint: "/ext/obsidian/vault", + method: "POST", + body: request.body, + }); + await Telemetry.sendTelemetry("extension_invoked", { + type: "obsidian_vault", + }); + response.status(200).json(responseFromProcessor); + } catch (e) { + console.error(e); + response.sendStatus(500).end(); + } + } + ); + + app.post( + "/ext/jira", + [validatedRequest, flexUserRoleValid([ROLES.admin, ROLES.manager])], + async (request, response) => { + try { + const responseFromProcessor = + await new CollectorApi().forwardExtensionRequest({ + endpoint: "/ext/jira", + method: "POST", + body: request.body, + }); + await Telemetry.sendTelemetry("extension_invoked", { + type: "jira", + }); + response.status(200).json(responseFromProcessor); + } catch (e) { + console.error(e); + response.sendStatus(500).end(); + } + } + ); } module.exports = { extensionEndpoints }; diff --git a/server/jobs/sync-watched-documents.js b/server/jobs/sync-watched-documents.js index 0b3a72d1d3d..7975b58b6db 100644 --- a/server/jobs/sync-watched-documents.js +++ b/server/jobs/sync-watched-documents.js @@ -1,152 +1,213 @@ -const { Document } = require('../models/documents.js'); -const { DocumentSyncQueue } = require('../models/documentSyncQueue.js'); -const { CollectorApi } = require('../utils/collectorApi'); +const { Document } = require("../models/documents.js"); +const { DocumentSyncQueue } = require("../models/documentSyncQueue.js"); +const { CollectorApi } = require("../utils/collectorApi"); const { fileData } = require("../utils/files"); -const { log, conclude, updateSourceDocument } = require('./helpers/index.js'); -const { getVectorDbClass } = require('../utils/helpers/index.js'); -const { DocumentSyncRun } = require('../models/documentSyncRun.js'); +const { log, conclude, updateSourceDocument } = require("./helpers/index.js"); +const { getVectorDbClass } = require("../utils/helpers/index.js"); +const { DocumentSyncRun } = require("../models/documentSyncRun.js"); (async () => { try { const queuesToProcess = await DocumentSyncQueue.staleDocumentQueues(); if (queuesToProcess.length === 0) { - log('No outstanding documents to sync. Exiting.'); + log("No outstanding documents to sync. Exiting."); return; } const collector = new CollectorApi(); if (!(await collector.online())) { - log('Could not reach collector API. Exiting.'); + log("Could not reach collector API. Exiting."); return; } - log(`${queuesToProcess.length} watched documents have been found to be stale and will be updated now.`) + log( + `${queuesToProcess.length} watched documents have been found to be stale and will be updated now.` + ); for (const queue of queuesToProcess) { let newContent = null; const document = queue.workspaceDoc; const workspace = document.workspace; - const { metadata, type, source } = Document.parseDocumentTypeAndSource(document); + const { metadata, type, source } = + Document.parseDocumentTypeAndSource(document); if (!metadata || !DocumentSyncQueue.validFileTypes.includes(type)) { // Document is either broken, invalid, or not supported so drop it from future queues. - log(`Document ${document.filename} has no metadata, is broken, or invalid and has been removed from all future runs.`) + log( + `Document ${document.filename} has no metadata, is broken, or invalid and has been removed from all future runs.` + ); await DocumentSyncQueue.unwatch(document); continue; } - if (['link', 'youtube'].includes(type)) { + if (["link", "youtube"].includes(type)) { const response = await collector.forwardExtensionRequest({ endpoint: "/ext/resync-source-document", method: "POST", body: JSON.stringify({ type, - options: { link: source } - }) + options: { link: source }, + }), }); newContent = response?.content; } - if (['confluence', 'github', 'gitlab', 'drupalwiki'].includes(type)) { + if ( + ["confluence", "github", "gitlab", "drupalwiki", "jira"].includes(type) + ) { const response = await collector.forwardExtensionRequest({ endpoint: "/ext/resync-source-document", method: "POST", body: JSON.stringify({ type, - options: { chunkSource: metadata.chunkSource } - }) + options: { chunkSource: metadata.chunkSource }, + }), }); newContent = response?.content; } if (!newContent) { // Check if the last "x" runs were all failures (not exits!). If so - remove the job entirely since it is broken. - const failedRunCount = (await DocumentSyncRun.where({ queueId: queue.id }, DocumentSyncQueue.maxRepeatFailures, { createdAt: 'desc' })).filter((run) => run.status === DocumentSyncRun.statuses.failed).length; + const failedRunCount = ( + await DocumentSyncRun.where( + { queueId: queue.id }, + DocumentSyncQueue.maxRepeatFailures, + { createdAt: "desc" } + ) + ).filter( + (run) => run.status === DocumentSyncRun.statuses.failed + ).length; if (failedRunCount >= DocumentSyncQueue.maxRepeatFailures) { - log(`Document ${document.filename} has failed to refresh ${failedRunCount} times continuously and will now be removed from the watched document set.`) + log( + `Document ${document.filename} has failed to refresh ${failedRunCount} times continuously and will now be removed from the watched document set.` + ); await DocumentSyncQueue.unwatch(document); continue; } - log(`Failed to get a new content response from collector for source ${source}. Skipping, but will retry next worker interval. Attempt ${failedRunCount === 0 ? 1 : failedRunCount}/${DocumentSyncQueue.maxRepeatFailures}`); - await DocumentSyncQueue.saveRun(queue.id, DocumentSyncRun.statuses.failed, { filename: document.filename, workspacesModified: [], reason: 'No content found.' }) + log( + `Failed to get a new content response from collector for source ${source}. Skipping, but will retry next worker interval. Attempt ${failedRunCount === 0 ? 1 : failedRunCount}/${DocumentSyncQueue.maxRepeatFailures}` + ); + await DocumentSyncQueue.saveRun( + queue.id, + DocumentSyncRun.statuses.failed, + { + filename: document.filename, + workspacesModified: [], + reason: "No content found.", + } + ); continue; } - const currentDocumentData = await fileData(document.docpath) + const currentDocumentData = await fileData(document.docpath); if (currentDocumentData.pageContent === newContent) { - const nextSync = DocumentSyncQueue.calcNextSync(queue) - log(`Source ${source} is unchanged and will be skipped. Next sync will be ${nextSync.toLocaleString()}.`); - await DocumentSyncQueue._update( + const nextSync = DocumentSyncQueue.calcNextSync(queue); + log( + `Source ${source} is unchanged and will be skipped. Next sync will be ${nextSync.toLocaleString()}.` + ); + await DocumentSyncQueue._update(queue.id, { + lastSyncedAt: new Date().toISOString(), + nextSyncAt: nextSync.toISOString(), + }); + await DocumentSyncQueue.saveRun( queue.id, + DocumentSyncRun.statuses.exited, { - lastSyncedAt: new Date().toISOString(), - nextSyncAt: nextSync.toISOString(), + filename: document.filename, + workspacesModified: [], + reason: "Content unchanged.", } ); - await DocumentSyncQueue.saveRun(queue.id, DocumentSyncRun.statuses.exited, { filename: document.filename, workspacesModified: [], reason: 'Content unchanged.' }) continue; } // update the defined document and workspace vectorDB with the latest information // it will skip cache and create a new vectorCache file. const vectorDatabase = getVectorDbClass(); - await vectorDatabase.deleteDocumentFromNamespace(workspace.slug, document.docId); - await vectorDatabase.addDocumentToNamespace( + await vectorDatabase.deleteDocumentFromNamespace( workspace.slug, - { ...currentDocumentData, pageContent: newContent, docId: document.docId }, - document.docpath, - true + document.docId ); - updateSourceDocument( - document.docpath, + await vectorDatabase.addDocumentToNamespace( + workspace.slug, { ...currentDocumentData, pageContent: newContent, docId: document.docId, - published: (new Date).toLocaleString(), - // Todo: Update word count and token_estimate? - } - ) - log(`Workspace "${workspace.name}" vectors of ${source} updated. Document and vector cache updated.`) - + }, + document.docpath, + true + ); + updateSourceDocument(document.docpath, { + ...currentDocumentData, + pageContent: newContent, + docId: document.docId, + published: new Date().toLocaleString(), + // Todo: Update word count and token_estimate? + }); + log( + `Workspace "${workspace.name}" vectors of ${source} updated. Document and vector cache updated.` + ); // Now we can bloom the results to all matching documents in all other workspaces const workspacesModified = [workspace.slug]; - const moreReferences = await Document.where({ - id: { not: document.id }, - filename: document.filename - }, null, null, { workspace: true }); + const moreReferences = await Document.where( + { + id: { not: document.id }, + filename: document.filename, + }, + null, + null, + { workspace: true } + ); if (moreReferences.length !== 0) { - log(`${source} is referenced in ${moreReferences.length} other workspaces. Updating those workspaces as well...`) + log( + `${source} is referenced in ${moreReferences.length} other workspaces. Updating those workspaces as well...` + ); for (const additionalDocumentRef of moreReferences) { const additionalWorkspace = additionalDocumentRef.workspace; workspacesModified.push(additionalWorkspace.slug); - await vectorDatabase.deleteDocumentFromNamespace(additionalWorkspace.slug, additionalDocumentRef.docId); + await vectorDatabase.deleteDocumentFromNamespace( + additionalWorkspace.slug, + additionalDocumentRef.docId + ); await vectorDatabase.addDocumentToNamespace( additionalWorkspace.slug, - { ...currentDocumentData, pageContent: newContent, docId: additionalDocumentRef.docId }, - additionalDocumentRef.docpath, + { + ...currentDocumentData, + pageContent: newContent, + docId: additionalDocumentRef.docId, + }, + additionalDocumentRef.docpath + ); + log( + `Workspace "${additionalWorkspace.name}" vectors for ${source} was also updated with the new content from cache.` ); - log(`Workspace "${additionalWorkspace.name}" vectors for ${source} was also updated with the new content from cache.`) } } const nextRefresh = DocumentSyncQueue.calcNextSync(queue); - log(`${source} has been refreshed in all workspaces it is currently referenced in. Next refresh will be ${nextRefresh.toLocaleString()}.`) - await DocumentSyncQueue._update( + log( + `${source} has been refreshed in all workspaces it is currently referenced in. Next refresh will be ${nextRefresh.toLocaleString()}.` + ); + await DocumentSyncQueue._update(queue.id, { + lastSyncedAt: new Date().toISOString(), + nextSyncAt: nextRefresh.toISOString(), + }); + await DocumentSyncQueue.saveRun( queue.id, + DocumentSyncRun.statuses.success, { - lastSyncedAt: new Date().toISOString(), - nextSyncAt: nextRefresh.toISOString(), + filename: document.filename, + workspacesModified, } ); - await DocumentSyncQueue.saveRun(queue.id, DocumentSyncRun.statuses.success, { filename: document.filename, workspacesModified }) } } catch (e) { - console.error(e) - log(`errored with ${e.message}`) + console.error(e); + log(`errored with ${e.message}`); } finally { conclude(); } diff --git a/server/models/documentSyncQueue.js b/server/models/documentSyncQueue.js index b4e9790ce1c..ceb1ef87d77 100644 --- a/server/models/documentSyncQueue.js +++ b/server/models/documentSyncQueue.js @@ -4,7 +4,7 @@ const { SystemSettings } = require("./systemSettings"); const { Telemetry } = require("./telemetry"); /** - * @typedef {('link'|'youtube'|'confluence'|'github'|'gitlab')} validFileType + * @typedef {('link'|'youtube'|'confluence'|'github'|'gitlab'|'jira')} validFileType */ const DocumentSyncQueue = { @@ -17,6 +17,7 @@ const DocumentSyncQueue = { "github", "gitlab", "drupalwiki", + "jira", ], defaultStaleAfter: 604800000, maxRepeatFailures: 5, // How many times a run can fail in a row before pruning. @@ -62,6 +63,7 @@ const DocumentSyncQueue = { if (chunkSource.startsWith("github://")) return true; // If is a GitHub file reference if (chunkSource.startsWith("gitlab://")) return true; // If is a GitLab file reference if (chunkSource.startsWith("drupalwiki://")) return true; // If is a DrupalWiki document link + if (chunkSource.startsWith("jira://")) return true; // If is a Jira document link return false; }, diff --git a/server/models/documents.js b/server/models/documents.js index a283311537e..6968c5c77bc 100644 --- a/server/models/documents.js +++ b/server/models/documents.js @@ -243,7 +243,7 @@ const Document = { // Some data sources have encoded params in them we don't want to log - so strip those details. _stripSource: function (sourceString, type) { - if (["confluence", "github"].includes(type)) { + if (["confluence", "github", "jira"].includes(type)) { const _src = new URL(sourceString); _src.search = ""; // remove all search params that are encoded for resync. return _src.toString(); From 97bedecf4c86fda5a2473e24fe5c1ce030fb1528 Mon Sep 17 00:00:00 2001 From: peyt Date: Mon, 29 Sep 2025 09:30:57 +0200 Subject: [PATCH 2/3] feat: add Jira data connector tests - Add basic tests for jira data connector - Update Jira loader index Closes #4014 --- .../JIra/JiraLoader/jira-issue-loader.test.js | 92 ++++++++++++++ .../extensions/JIra/jira-serivice.test.js | 118 ++++++++++++++++++ .../utils/extensions/Jira/JiraLoader/index.js | 4 - 3 files changed, 210 insertions(+), 4 deletions(-) create mode 100644 collector/__tests__/utils/extensions/JIra/JiraLoader/jira-issue-loader.test.js create mode 100644 collector/__tests__/utils/extensions/JIra/jira-serivice.test.js diff --git a/collector/__tests__/utils/extensions/JIra/JiraLoader/jira-issue-loader.test.js b/collector/__tests__/utils/extensions/JIra/JiraLoader/jira-issue-loader.test.js new file mode 100644 index 00000000000..644ce7c48fb --- /dev/null +++ b/collector/__tests__/utils/extensions/JIra/JiraLoader/jira-issue-loader.test.js @@ -0,0 +1,92 @@ +const { JiraIssueLoader } = require("../../../../../utils/extensions/Jira/JiraLoader"); + +describe("JiraIssueLoader", () => { + const baseUrl = "https://example.atlassian.net"; + const projectKey = "TEST"; + const username = "user"; + const accessToken = "token"; + const personalAccessToken = "pat"; + + let loader; + + beforeEach(() => { + loader = new JiraIssueLoader({ baseUrl, projectKey, username, accessToken }); + }); + + test("generates Basic auth header with username and token", () => { + const expected = `Basic ${Buffer.from(`${username}:${accessToken}`).toString("base64")}`; + expect(loader.authorizationHeader).toBe(expected); + }); + + test("generates Bearer auth header with personal access token", () => { + const patLoader = new JiraIssueLoader({ baseUrl, projectKey, personalAccessToken }); + expect(patLoader.authorizationHeader).toBe(`Bearer ${personalAccessToken}`); + }); + + test("createDocumentFromIssue extracts code blocks correctly", () => { + const issue = { + id: "1", + key: "TEST-1", + fields: { + summary: "Test Issue", + description: { + storage: { + value: `js` + } + }, + status: { name: "Open" }, + issuetype: { name: "Task" }, + creator: { displayName: "Alice" }, + created: "2025-01-01T00:00:00.000Z", + }, + }; + + const doc = loader.createDocumentFromIssue(issue); + + expect(doc.pageContent).toContain("```js\nconsole.log(\"Hello World\");\n```"); + expect(doc.metadata.id).toBe("1"); + expect(doc.metadata.key).toBe("TEST-1"); + expect(doc.metadata.status).toBe("Open"); + expect(doc.metadata.title).toBe("Test Issue"); + expect(doc.metadata.created_by).toBe("Alice"); + expect(doc.metadata.url).toBe(`${baseUrl}/jira/browse/TEST-1`); + }); + + test("load returns empty array on fetch failure", async () => { + // Suppress console.error output + const consoleSpy = jest.spyOn(console, "error").mockImplementation(() => {}); + + // Mock fetchJiraData to throw an error + jest.spyOn(loader, "fetchJiraData").mockImplementation(async () => { + throw new Error("Network error"); + }); + + const result = await loader.load(); + expect(result).toEqual([]); + expect(consoleSpy).toHaveBeenCalledWith("Error:", expect.any(Error)); + + consoleSpy.mockRestore(); + }); + + test("fetchAllIssuesInProject handles paginated results", async () => { + const totalIssues = 4; + + // Mock fetchJiraData to simulate paginated API + jest.spyOn(loader, "fetchJiraData").mockImplementation(async (url) => { + const urlObj = new URL(url); + const startAt = parseInt(urlObj.searchParams.get("startAt") || "0", 10); + const limit = parseInt(urlObj.searchParams.get("maxResults") || "25", 10); + + const issues = []; + for (let i = startAt + 1; i <= Math.min(startAt + limit, totalIssues); i++) { + issues.push({ id: i, key: `TEST-${i}`, fields: {} }); + } + + return { issues, total: totalIssues }; + }); + + const issues = await loader.fetchAllIssuesInProject(); + expect(issues).toHaveLength(totalIssues); + expect(issues.map(i => i.key)).toEqual(["TEST-1", "TEST-2", "TEST-3", "TEST-4"]); + }); +}); diff --git a/collector/__tests__/utils/extensions/JIra/jira-serivice.test.js b/collector/__tests__/utils/extensions/JIra/jira-serivice.test.js new file mode 100644 index 00000000000..815f9d9d4f6 --- /dev/null +++ b/collector/__tests__/utils/extensions/JIra/jira-serivice.test.js @@ -0,0 +1,118 @@ +process.env.NODE_ENV = "development"; +process.env.STORAGE_DIR = "C:/temp"; + +const fs = require("fs"); +const path = require("path"); +const { v4 } = require("uuid"); +const { loadJira, fetchJiraIssue } = require("../../../../utils/extensions/Jira"); +const { JiraIssueLoader } = require("../../../../utils/extensions/Jira/JiraLoader"); +const { writeToServerDocuments, sanitizeFileName } = require("../../../../utils/files"); +const { tokenizeString } = require("../../../../utils/tokenizer"); + +jest.mock("../../../../utils/extensions/Jira/JiraLoader"); +jest.mock("../../../../utils/files"); +jest.mock("../../../../utils/tokenizer"); +jest.mock("fs"); + +describe("Jira Service", () => { + const mockResponse = { locals: { encryptionWorker: { encrypt: jest.fn((s) => `encrypted:${s}`) } } }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("loadJira", () => { + test("fails when no credentials provided", async () => { + const result = await loadJira({ baseUrl: "https://example.atlassian.net", projectKey: "TEST" }, mockResponse); + expect(result.success).toBe(false); + expect(result.reason).toContain("You need either a personal access token"); + }); + + test("fails when invalid baseUrl", async () => { + const result = await loadJira({ baseUrl: "invalid-url", projectKey: "TEST", personalAccessToken: "pat" }, mockResponse); + expect(result.success).toBe(false); + expect(result.reason).toContain("Provided base URL is not a valid URL"); + }); + + test("saves documents correctly when Jira returns issues", async () => { + const mockDocs = [ + { + pageContent: "Test content", + metadata: { title: "Issue 1", url: "https://example.atlassian.net/browse/TEST-1", source: "Test source" }, + }, + ]; + + JiraIssueLoader.mockImplementation(() => ({ + load: jest.fn().mockResolvedValue(mockDocs), + })); + + fs.existsSync.mockReturnValue(false); + fs.mkdirSync.mockImplementation(() => {}); + + sanitizeFileName.mockImplementation((s) => s); + tokenizeString.mockReturnValue(5); + writeToServerDocuments.mockImplementation(() => {}); + + const result = await loadJira( + { baseUrl: "https://example.atlassian.net", projectKey: "TEST", personalAccessToken: "pat" }, + mockResponse + ); + + expect(result.success).toBe(true); + expect(writeToServerDocuments).toHaveBeenCalled(); + expect(tokenizeString).toHaveBeenCalledWith("Test content"); + expect(mockResponse.locals.encryptionWorker.encrypt).toHaveBeenCalled(); + }); + }); + + describe("fetchJiraIssue", () => { + test("fails when required params are missing", async () => { + const result = await fetchJiraIssue({ baseUrl: null, pageUrl: "url", projectKey: "TEST", username: "user", accessToken: "token" }); + expect(result.success).toBe(false); + expect(result.reason).toContain("You need either a username and access token"); + }); + + test("returns content when Jira issue found", async () => { + const mockDocs = [ + { pageContent: "Issue content", metadata: { url: "url" } }, + ]; + + JiraIssueLoader.mockImplementation(() => ({ + load: jest.fn().mockResolvedValue(mockDocs), + })); + + const result = await fetchJiraIssue({ + baseUrl: "https://example.atlassian.net", + pageUrl: "url", + projectKey: "TEST", + username: "user", + accessToken: "token", + }); + + expect(result.success).toBe(true); + expect(result.content).toBe("Issue content"); + }); + + test("returns failure when issue not found", async () => { + const mockDocs = [ + { pageContent: "Other content", metadata: { url: "other-url" } }, + ]; + + JiraIssueLoader.mockImplementation(() => ({ + load: jest.fn().mockResolvedValue(mockDocs), + })); + + const result = await fetchJiraIssue({ + baseUrl: "https://example.atlassian.net", + pageUrl: "url", + projectKey: "TEST", + username: "user", + accessToken: "token", + }); + + expect(result.success).toBe(false); + expect(result.content).toBeNull(); + expect(result.reason).toContain("Target page could not be found"); + }); + }); +}); diff --git a/collector/utils/extensions/Jira/JiraLoader/index.js b/collector/utils/extensions/Jira/JiraLoader/index.js index 4761bc4a190..1a447b93efb 100644 --- a/collector/utils/extensions/Jira/JiraLoader/index.js +++ b/collector/utils/extensions/Jira/JiraLoader/index.js @@ -1,7 +1,3 @@ -/* - * This is a custom implementation of the Confluence langchain loader. There was an issue where - * code blocks were not being extracted. This is a temporary fix until this issue is resolved.*/ - const { htmlToText } = require("html-to-text"); class JiraIssueLoader { From 9fd4d56ed17ba9930afcce294a29af57aa0037ff Mon Sep 17 00:00:00 2001 From: peyt Date: Fri, 3 Oct 2025 17:47:58 +0200 Subject: [PATCH 3/3] feat: PR drawt changes - Correct translation for Spanish Closes #4014 --- frontend/src/locales/es/common.js | 42 +++++++++++++++---------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/frontend/src/locales/es/common.js b/frontend/src/locales/es/common.js index 4bff28de401..469a84be8fa 100644 --- a/frontend/src/locales/es/common.js +++ b/frontend/src/locales/es/common.js @@ -820,27 +820,27 @@ const TRANSLATIONS = { "Una vez completado, el contenido de la página estará disponible para incrustar en los espacios de trabajo en el selector de documentos.", }, jira: { - name: "Jira", - description: "", - deployment_type: "", - deployment_type_explained: "", - base_url: "", - base_url_explained: "", - project_key: "", - project_key_explained: "", - username: "", - username_explained: "", - auth_type: "", - auth_type_explained: "", - auth_type_username: "", - auth_type_personal: "", - token: "", - token_explained_start: "", - token_explained_link: "", - token_desc: "", - pat_token: "", - pat_token_explained: "", - task_explained: "", + name: null, + description: null, + deployment_type: null, + deployment_type_explained: null, + base_url: null, + base_url_explained: null, + project_key: null, + project_key_explained: null, + username: null, + username_explained: null, + auth_type: null, + auth_type_explained: null, + auth_type_username: null, + auth_type_personal: null, + token: null, + token_explained_start: null, + token_explained_link: null, + token_desc: null, + pat_token: null, + pat_token_explained: null, + task_explained: null, }, manage: { documents: "Documentos",