Skip to content

Commit

Permalink
Merge pull request #12 from mogery/mog/jira
Browse files Browse the repository at this point in the history
feat(jira): add Jira Data Provider
  • Loading branch information
nickscamara committed Jan 31, 2024
2 parents f8411b8 + d3535e4 commit 11a350a
Show file tree
Hide file tree
Showing 5 changed files with 349 additions and 0 deletions.
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

0 comments on commit 11a350a

Please sign in to comment.