@roadiehq/backstage-plugin-jira
Version:
322 lines (319 loc) • 10.8 kB
JavaScript
import { createApiRef } from '@backstage/core-plugin-api';
import { DateTime } from 'luxon';
import { JiraProductStrategyFactory } from './strategies/factory.esm.js';
const jiraApiRef = createApiRef({
id: "plugin.jira.service"
});
const DEFAULT_PROXY_PATH = "/jira/api";
const DEFAULT_REST_API_VERSION = "latest";
const DONE_STATUS_CATEGORY = "Done";
class JiraAPI {
discoveryApi;
proxyPath;
apiVersion;
strategy;
confluenceActivityFilter;
fetchApi;
errorApi;
constructor(options) {
this.discoveryApi = options.discoveryApi;
const proxyPath = options.configApi.getOptionalString("jira.proxyPath");
this.proxyPath = proxyPath ?? DEFAULT_PROXY_PATH;
const apiVersion = options.configApi.getOptionalNumber("jira.apiVersion");
this.apiVersion = apiVersion ? apiVersion.toString() : DEFAULT_REST_API_VERSION;
const product = options.configApi.getOptionalString("jira.product") ?? "cloud";
this.strategy = JiraProductStrategyFactory.createStrategy(product, options);
this.confluenceActivityFilter = options.configApi.getOptionalString(
"jira.confluenceActivityFilter"
);
this.fetchApi = options.fetchApi;
this.errorApi = options.errorApi;
}
// Helper method to log errors in a consistent way
logError(message, error) {
if (this.errorApi) {
this.errorApi.post(new Error(`${message}: ${error.message}`));
}
}
getDomainFromApiUrl(apiUrl) {
const url = new URL(apiUrl);
return url.origin;
}
generateProjectUrl = (url) => new URL(url).origin + new URL(url).pathname.replace(/\/rest\/api\/.*$/g, "");
async getUrls() {
const proxyUrl = await this.discoveryApi.getBaseUrl("proxy");
return {
apiUrl: `${proxyUrl}${this.proxyPath}/rest/api/${this.apiVersion}/`,
baseUrl: `${proxyUrl}${this.proxyPath}`
};
}
convertToString = (arrayElement) => arrayElement.filter(Boolean).map((i) => `'${i}'`).join(",");
async getIssuesPaged({
apiUrl,
projectKey,
component,
label,
statusesNames
}) {
const statusesString = this.convertToString(statusesNames);
const jql = `project = "${projectKey}"
${statusesString ? `AND status in (${statusesString})` : ""}
${component ? `AND component = "${component}"` : ""}
${label ? `AND labels in (${label})` : ""}
AND statuscategory not in ("Done")
`;
return this.strategy.pagedIssuesRequest(apiUrl, jql);
}
async getProjectDetails(projectKey, component, label, statusesNames) {
const { apiUrl } = await this.getUrls();
const request = await this.fetchApi.fetch(
`${apiUrl}project/${projectKey}`,
{
headers: {
"Content-Type": "application/json"
}
}
);
if (!request.ok) {
throw new Error(
`failed to fetch data, status ${request.status}: ${request.statusText}`
);
}
const project = await request.json();
const foundIssues = await this.getIssuesPaged({
apiUrl,
projectKey,
component,
label,
statusesNames
});
const issuesCounter = project.issueTypes.filter((issueType) => issueType.name !== "Sub-task").map(
(issueType) => ({
name: issueType.name,
iconUrl: issueType.iconUrl,
total: foundIssues.filter(
(issue) => issue.fields?.issuetype.name === issueType.name
).length
})
);
const ticketIds = foundIssues.map((issue) => issue.key);
const tickets = foundIssues.map((index) => {
return {
key: index.key,
summary: index?.fields?.summary,
assignee: {
displayName: index?.fields?.assignee?.displayName,
avatarUrl: index?.fields?.assignee?.avatarUrls["48x48"]
},
status: index?.fields?.status?.name,
priority: index?.fields?.priority,
created: index?.fields?.created,
updated: index?.fields?.updated
};
});
return {
project: {
name: project.name,
iconUrl: project.avatarUrls["48x48"],
type: project.projectTypeKey,
url: this.generateProjectUrl(project.self)
},
issues: issuesCounter,
ticketIds,
tickets
};
}
async getActivityStream(size, projectKey, componentName, ticketIds, label, isBearerAuth) {
const { baseUrl } = await this.getUrls();
let filterUrl = `streams=key+IS+${projectKey}`;
if (ticketIds && (componentName || label)) {
filterUrl += `&streams=issue-key+IS+${ticketIds.join("+")}`;
filterUrl += this.confluenceActivityFilter ? `&${this.confluenceActivityFilter}=activity+IS+NOT+*` : "";
}
const request = await this.fetchApi.fetch(
isBearerAuth ? `${baseUrl}/activity?maxResults=${size}&${filterUrl}` : `${baseUrl}/activity?maxResults=${size}&${filterUrl}&os_authType=basic`,
{}
);
if (!request.ok) {
throw new Error(
`failed to fetch data, status ${request.status}: ${request.statusText}`
);
}
const activityStream = await request.text();
return activityStream;
}
async getStatuses(projectKey) {
const { apiUrl } = await this.getUrls();
const request = await this.fetchApi.fetch(
`${apiUrl}project/${projectKey}/statuses`,
{
headers: {
"Content-Type": "application/json"
}
}
);
if (!request.ok) {
throw new Error(
`failed to fetch data, status ${request.status}: ${request.statusText}`
);
}
const statuses = await request.json();
return [
...new Set(
statuses.flatMap((status) => status.statuses).filter(
(status) => status.statusCategory?.name !== DONE_STATUS_CATEGORY
).map((it) => it.name)
)
];
}
// Fetch detailed issue information including changelog and comments
async getIssueDetails(issueKey) {
const { apiUrl } = await this.getUrls();
const request = await this.fetchApi.fetch(
`${apiUrl}issue/${issueKey}?expand=changelog`,
{
headers: {
"Content-Type": "application/json"
}
}
);
if (!request.ok) {
throw new Error(
`Failed to fetch issue details, status ${request.status}: ${request.statusText}`
);
}
return await request.json();
}
// Fetch pull request information linked to a Jira issue
async getLinkedPullRequests(issueId) {
const { baseUrl } = await this.getUrls();
try {
const request = await this.fetchApi.fetch(
`${baseUrl}/rest/dev-status/1.0/issue/detail?issueId=${issueId}&applicationType=stash&dataType=pullrequest`,
{
headers: {
"Content-Type": "application/json"
}
}
);
if (!request.ok) {
throw new Error(
`Failed to fetch linked PRs, status ${request.status}: ${request.statusText}`
);
}
const response = await request.json();
return response.detail?.[0]?.pullRequests || [];
} catch (error) {
this.logError(`Error fetching linked PRs for ${issueId}`, error);
return [];
}
}
async getUserDetails(userId, fetchLinkedPRs) {
const { apiUrl } = await this.getUrls();
const request = await this.fetchApi.fetch(
`${apiUrl}user?username=${userId}`,
{
headers: {
"Content-Type": "application/json"
}
}
);
if (!request.ok) {
throw new Error(
`failed to fetch data, status ${request.status}: ${request.statusText}`
);
}
const user = await request.json();
const jql = `assignee = "${userId}" AND statusCategory in ("To Do", "In Progress") ORDER BY updated desc`;
const foundIssues = await this.strategy.pagedIssuesRequest(apiUrl, jql);
const enhancedTickets = await Promise.all(
foundIssues.map(async (index) => {
let lastComment = "";
let assignedDate = "";
let assignedRelativeTime = "";
let linkedPullRequests = [];
try {
const details = await this.getIssueDetails(index.key);
if (details.fields?.comment?.comments?.length > 0) {
const comments = details.fields.comment.comments;
lastComment = comments[comments.length - 1].body;
}
if (details.changelog?.histories?.length > 0) {
const assignmentHistory = details.changelog.histories.filter(
(h) => h.items.some(
(i) => i.field === "assignee" && i.to === userId
)
).sort(
(a, b) => new Date(b.created).getTime() - new Date(a.created).getTime()
);
if (assignmentHistory.length > 0) {
assignedDate = new Date(
assignmentHistory[0].created
).toLocaleDateString();
assignedRelativeTime = DateTime.fromISO(assignmentHistory[0].created).toRelative() || "";
}
}
if (index.id && fetchLinkedPRs) {
try {
const prs = await this.getLinkedPullRequests(index.id);
linkedPullRequests = prs.map((pr) => ({
id: pr.id,
name: pr.name,
url: pr.url,
status: pr.status,
lastUpdate: pr.lastUpdate,
author: pr.author ? {
name: pr.author.name,
avatar: pr.author.avatar
} : void 0
}));
} catch (prError) {
this.logError(
`Error fetching PRs for ${index.key}`,
prError
);
}
}
} catch (error) {
this.logError(
`Error fetching details for ${index.key}`,
error
);
}
return {
key: index.key,
id: index.id,
parent: index?.fields?.parent?.key,
summary: index?.fields?.summary,
assignee: {
displayName: index?.fields?.assignee?.displayName,
avatarUrl: index?.fields?.assignee?.avatarUrls["48x48"]
},
status: index?.fields?.status,
issuetype: index?.fields?.issuetype,
priority: index?.fields?.priority,
created: index?.fields?.created,
updated: index?.fields?.updated,
lastComment,
assignedDate,
assignedRelativeTime,
linkedPullRequests
};
})
);
return {
user: {
name: user.displayName,
avatarUrl: user.avatarUrls["48x48"],
url: this.getDomainFromApiUrl(user.self)
},
tickets: enhancedTickets
};
}
async jqlQuery(query, maxResults) {
const { apiUrl } = await this.getUrls();
return this.strategy.pagedIssuesRequest(apiUrl, query, maxResults);
}
}
export { JiraAPI, jiraApiRef };
//# sourceMappingURL=index.esm.js.map