diff --git a/package.json b/package.json index 5509aa3..17aa4ae 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "dotenv": "^16.4.1", "glob": "^10.3.10", "googleapis": "^131.0.0", + "jira.js": "^3.0.2", "node-html-parser": "^6.1.12", "octokit": "^3.1.2", "pdf-parse": "^1.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0835572..552fa8b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,9 @@ dependencies: googleapis: specifier: ^131.0.0 version: 131.0.0 + jira.js: + specifier: ^3.0.2 + version: 3.0.2 node-html-parser: specifier: ^6.1.12 version: 6.1.12 @@ -4641,6 +4644,16 @@ packages: - ts-node dev: true + /jira.js@3.0.2: + resolution: {integrity: sha512-yysfjFYIFPE/hp8pJ/u3q6OBX/KYm4nMsmQym7JSO2pIJ4bIbdDLuz1DDGcHqi1DflfOSBKYc2BlJXTl5IZUEA==} + dependencies: + axios: 1.6.5 + form-data: 4.0.0 + tslib: 2.6.2 + transitivePeerDependencies: + - debug + dev: false + /joycon@3.1.1: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} diff --git a/src/__tests__/providers/Jira/index.test.ts b/src/__tests__/providers/Jira/index.test.ts new file mode 100644 index 0000000..298266f --- /dev/null +++ b/src/__tests__/providers/Jira/index.test.ts @@ -0,0 +1,37 @@ +import { createDataConnector } from "../../../DataConnector"; +import dotenv from "dotenv"; +dotenv.config(); + +test( + "Jira Provider Testing", + async () => { + const jiraDataConnector = createDataConnector({ + provider: "jira", + }); + + if (!process.env.NANGO_CONNECTION_ID_TEST) { + throw new Error( + "Please specify the NANGO_CONNECTION_ID_TEST environment variable." + ); + } + + await jiraDataConnector.authorizeNango({ + nango_connection_id: process.env.NANGO_CONNECTION_ID_TEST, + }); + + const issues = await jiraDataConnector.getDocuments(); + expect(issues.length).toBeGreaterThan(0); + issues.forEach((issue) => { + expect(issue.provider).toBe("jira"); + expect(issue.type).toBe("issue"); + expect(issue.content).not.toBe(null); + expect(issue.createdAt).not.toBe(undefined); + expect(issue.updatedAt).not.toBe(undefined); + expect(issue.metadata.sourceURL).not.toBe(null); + expect(issue.metadata.type).not.toBe(undefined); + expect(issue.metadata.status).not.toBe(undefined); + expect(issue.metadata.project).not.toBe(undefined); + }); + }, + 10 * 1000 +); // 10 seconds diff --git a/src/providers/Jira/index.ts b/src/providers/Jira/index.ts new file mode 100644 index 0000000..172decd --- /dev/null +++ b/src/providers/Jira/index.ts @@ -0,0 +1,286 @@ +import { Nango } from "@nangohq/node"; +import { DataProvider } from "../DataProvider"; +import { Document } from "../../entities/Document"; +import { NangoAuthorizationOptions } from "../GoogleDrive"; +import { Version3Client, Config } from "jira.js"; +import { Issue } from "jira.js/out/version3/models/issue"; +import { Document as JiraDocument } from "jira.js/out/version3/models/document"; + +export type JiraInputOptions = object; + +export type JiraAuthorizationOptions = { + /** + * Your JIRA host. Example: "https://your-domain.atlassian.net" + */ + host?: string; + + /** + * Your JIRA authentication smethod. [Read more here.](https://github.com/mrrefactoring/jira.js/?tab=readme-ov-file#authentication) + */ + auth?: Config.Authentication; +}; + +export interface JiraOptions + extends JiraInputOptions, + JiraAuthorizationOptions, + NangoAuthorizationOptions {} + +/** + * Retrieves all projects from Jira. + */ +async function getAllIssues( + jira: Version3Client, + startAt?: number +): Promise { + const projects = await jira.issueSearch.searchForIssuesUsingJql({ + jql: "", + fields: [ + "id", + "key", + "summary", + "description", + "issuetype", + "status", + "assignee", + "reporter", + "project", + "created", + "updated", + ], + startAt, + maxResults: 50, + }); + + if (projects.total === 50) { + return (projects.issues ?? []).concat( + await getAllIssues(jira, projects.startAt + projects.total) + ); + } else { + return projects.issues ?? []; + } +} + +/** + * Attemts to prettify an issue URL. + * This only works well if the host is a real instance, and not derived from a cloudId. + * If the latter is true, this will return the ugly API URL. + */ +function prettifyIssueURL(host: string, issue: Issue): string { + if (host.startsWith("https://api.atlassian.com/ex/jira/")) { + // This host means that the Atlassian workspace is referred to via a cloudId, + // which means that we cannot create a pretty URL. An API URL has to be returned instead. + return issue.self; + } else { + let out = host; + if (!out.endsWith("/")) { + out += "/"; + } + + out += `browse/${issue.fields.project.key}-${issue.id}`; + } +} + +/** + * Converts a JIRA API Document to Markdown. + */ +function documentToMarkdown(document: JiraDocument): string { + const output = []; + let currentNodes: { + document: Omit; + ref: any[]; + parents: JiraDocument["type"][]; + }[] = [{ document, ref: output, parents: [] }]; + + while (currentNodes.length > 0) { + const nextNodes: typeof currentNodes = []; + for (const { document, ref, parents } of currentNodes) { + const nextRef = []; + + if (document.type === "paragraph") { + ref.push(nextRef); + if (parents.includes("listItem")) { + ref.push("\n"); + } else { + ref.push("\n\n"); + } + } else if (document.type === "heading") { + ref.push("#".repeat(document.attrs.level) + " "); + ref.push(nextRef); + ref.push("\n\n"); + } else if (document.type === "text") { + let markMd = ""; + let link = undefined; + (document.marks ?? []).forEach((mark) => { + if (mark.type === "code") { + markMd += "`"; + } else if (mark.type === "em") { + markMd += "*"; + } else if (mark.type === "strike") { + markMd += "~~"; + } else if (mark.type === "strong") { + markMd += "**"; + } else if (mark.type === "link") { + link = mark.attrs; + } + }); + + const md = markMd + document.text + [...markMd].reverse().join(""); + + if (link !== undefined) { + ref.push(`[${md}](${link.href})`); + } else { + ref.push(md); + } + } else if (document.type === "emoji") { + ref.push(document.attrs.text); + } else if (document.type === "code") { + ref.push("`"); + ref.push(nextRef); + ref.push("`"); + } else if (document.type === "strong") { + ref.push("**"); + ref.push(nextRef); + ref.push("**"); + } else if (document.type === "em") { + ref.push("*"); + ref.push(nextRef); + ref.push("*"); + } else if (document.type === "strike") { + ref.push("~~"); + ref.push(nextRef); + ref.push("~~"); + } else if (document.type === "link") { + ref.push("["); + ref.push(nextRef); + ref.push("](${document.attrs.href})"); + } else if (document.type === "listItem") { + ref.push( + " ".repeat( + parents.filter((x) => x == "bulletList" || x == "orderedList") + .length + ) + ); + const rev = [...parents].reverse(); + const type = rev.find((x) => x == "bulletList" || x == "orderedList"); + if (type == "bulletList") { + ref.push("- "); + } else if (type == "orderedList") { + ref.push("1. "); + } + ref.push(nextRef); + } else { + ref.push(nextRef); + } + + if (document.content) { + for (const child of document.content) { + nextNodes.push({ + document: child, + ref: nextRef, + parents: [...parents, document.type], + }); + } + } + } + currentNodes = nextNodes; + } + + return output.flat(Infinity).join(""); +} + +/** + * The Jira Data Provider retrieves all pages from a Jira workspace. + */ +export class JiraDataProvider implements DataProvider { + private jira: Version3Client = undefined; + private host: string; + + /** + * Authorizes the Jira Data Provider. + */ + async authorize(options: JiraAuthorizationOptions): Promise { + if (options.host === undefined || options.host === null) { + throw new Error("options.host is required."); + } + + if (options.auth === undefined || options.auth === null) { + throw new Error("options.auth is required."); + } + + this.host = options.host; + + this.jira = new Version3Client({ + host: options.host, + authentication: options.auth, + }); + } + + /** + * Authorizes the Jira Data Provider via Nango. + */ + async authorizeNango(options: NangoAuthorizationOptions): Promise { + if (!process.env.NANGO_SECRET_KEY) { + throw new Error( + "Nango secret key is required. Please specify it in the NANGO_SECRET_KEY environment variable." + ); + } + const nango = new Nango({ secretKey: process.env.NANGO_SECRET_KEY }); + + const connection = await nango.getConnection( + options.nango_integration_id ?? "jira", + options.nango_connection_id + ); + + await this.authorize({ + host: `https://api.atlassian.com/ex/jira/${connection.connection_config.cloudId}`, + auth: { + oauth2: { + accessToken: connection.credentials.raw.access_token, + }, + }, + }); + } + + /** + * Retrieves all authorized issues from the authorized Jira workspace. + * The issues' content will be Markdown. + */ + async getDocuments(): Promise { + if (this.jira === undefined) { + throw Error( + "You must authorize the JiraDataProvider before requesting documents." + ); + } + + const issues = await getAllIssues(this.jira); + + return issues.map((issue) => { + const description = issue.fields.description; + + return { + provider: "jira", + id: `${issue.fields.project.key}-${issue.id}`, + createdAt: new Date(issue.fields.created), + updatedAt: new Date(issue.fields.updated), + content: + "# " + + issue.fields.summary + + (description ? "\n\n" + documentToMarkdown(description) : ""), + metadata: { + sourceURL: prettifyIssueURL(this.host, issue), + type: issue.fields.issuetype.name, + status: issue.fields.status.name, + assignee: issue.fields.assignee?.displayName, + reporter: issue.fields.reporter?.displayName, + project: issue.fields.project.name, + }, + type: "issue", + }; + }); + } + + /** + * Do not call. The Jira Data Provider doesn't have any options. + */ + setOptions(_options: JiraOptions): void {} +} diff --git a/src/providers/providers.ts b/src/providers/providers.ts index ac6f08a..ae29d1d 100644 --- a/src/providers/providers.ts +++ b/src/providers/providers.ts @@ -17,6 +17,11 @@ import { GoogleDriveInputOptions, NangoAuthorizationOptions, } from "./GoogleDrive/index"; +import { + JiraAuthorizationOptions, + JiraDataProvider, + JiraInputOptions, +} from "./Jira"; import { NotionAuthorizationOptions, NotionDataProvider, @@ -41,6 +46,7 @@ export const providers: Provider = { file: new FileDataProvider(), youtube: new YouTubeDataProvider(), notion: new NotionDataProvider(), + jira: new JiraDataProvider(), }; // Define a single source of truth for all providers and their associated types @@ -99,6 +105,12 @@ type ProviderConfig = { AuthorizeOptions: NotionAuthorizationOptions; NangoAuthorizeOptions: NangoAuthorizationOptions; }; + jira: { + DataProvider: JiraDataProvider; + Options: JiraInputOptions; + AuthorizeOptions: JiraAuthorizationOptions; + NangoAuthorizeOptions: NangoAuthorizationOptions; + }; // Add other providers here... };