Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
13 changes: 13 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

37 changes: 37 additions & 0 deletions src/__tests__/providers/Jira/index.test.ts
Original file line number Diff line number Diff line change
@@ -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
286 changes: 286 additions & 0 deletions src/providers/Jira/index.ts
Original file line number Diff line number Diff line change
@@ -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<Issue[]> {
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<JiraDocument, "version">;
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<JiraOptions> {
private jira: Version3Client = undefined;
private host: string;

/**
* Authorizes the Jira Data Provider.
*/
async authorize(options: JiraAuthorizationOptions): Promise<void> {
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<void> {
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<Document[]> {
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 {}
}
12 changes: 12 additions & 0 deletions src/providers/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ import {
GoogleDriveInputOptions,
NangoAuthorizationOptions,
} from "./GoogleDrive/index";
import {
JiraAuthorizationOptions,
JiraDataProvider,
JiraInputOptions,
} from "./Jira";
import {
NotionAuthorizationOptions,
NotionDataProvider,
Expand All @@ -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
Expand Down Expand Up @@ -99,6 +105,12 @@ type ProviderConfig = {
AuthorizeOptions: NotionAuthorizationOptions;
NangoAuthorizeOptions: NangoAuthorizationOptions;
};
jira: {
DataProvider: JiraDataProvider;
Options: JiraInputOptions;
AuthorizeOptions: JiraAuthorizationOptions;
NangoAuthorizeOptions: NangoAuthorizationOptions;
};
// Add other providers here...
};

Expand Down