Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add child issues to Jira extension #15204

Merged
merged 18 commits into from
Nov 15, 2024
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
5 changes: 5 additions & 0 deletions extensions/jira/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# Jira Changelog

## [Added Child Issues support] - 2024-11-15

- Implemented the ability to view and manage child-related issues within the issue detail view, as well as a new component that provides a comprehensive list of all current issue child issues.
- Resolved an issue where the child’s status updates were not reflected in the user interface upon changing their status.

## [Fix number search to include issue keys for all projects.] - 2024-09-30

- When a user searches for a number without a project selected, the extension matches the number against issue keys in all projects.
Expand Down
3 changes: 2 additions & 1 deletion extensions/jira/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
"literallyjustroy",
"mheidinger",
"mikybars",
"luarmr"
"luarmr",
"horumy"
],
"pastContributors": [
"igor9silva"
Expand Down
3 changes: 2 additions & 1 deletion extensions/jira/src/api/issues.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ export type Issue = {
updated: string;
status: IssueStatus;
watches: IssueWatches;
subtasks?: Issue[];
};
};

Expand All @@ -155,7 +156,7 @@ type GetIssuesResponse = {

export async function getIssues({ jql } = { jql: "" }) {
const params = {
fields: "summary,updated,issuetype,status,priority,assignee,project,watches",
fields: "summary,updated,issuetype,status,priority,assignee,project,watches,subtasks",
startAt: "0",
maxResults: "200",
validateQuery: "warn",
Expand Down
11 changes: 11 additions & 0 deletions extensions/jira/src/components/IssueActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { slugify } from "../helpers/string";

import CreateIssueForm from "./CreateIssueForm";
import IssueAttachments from "./IssueAttachments";
import IssueChildIssues from "./IssueChildIssues";
import IssueCommentForm from "./IssueCommentForm";
import IssueComments from "./IssueComments";
import IssueDetail from "./IssueDetail";
Expand All @@ -33,6 +34,7 @@ type IssueActionsProps = {
mutate?: MutatePromise<Issue[] | undefined>;
mutateDetail?: MutatePromise<Issue | TIssueDetail | null>;
showDetailsAction?: boolean;
showChildIssuesAction?: boolean;
showAttachmentsAction?: boolean;
};

Expand All @@ -46,6 +48,7 @@ export default function IssueActions({
mutate,
mutateDetail,
showDetailsAction,
showChildIssuesAction,
showAttachmentsAction,
}: IssueActionsProps) {
const { siteUrl, myself } = getJiraCredentials();
Expand Down Expand Up @@ -166,6 +169,14 @@ export default function IssueActions({
</ActionPanel.Section>

<ActionPanel.Section>
{showChildIssuesAction && (
<Action.Push
target={<IssueChildIssues issue={issue} />}
title="Open Child Issues"
icon={Icon.Tree}
shortcut={{ modifiers: ["cmd", "shift"], key: "o" }}
/>
)}
<ChangePrioritySubmenu issue={issue} mutate={mutateWithOptimisticUpdate} />

<ChangeAssigneeSubmenu issue={issue} mutate={mutateWithOptimisticUpdate} />
Expand Down
55 changes: 55 additions & 0 deletions extensions/jira/src/components/IssueChildIssues.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { useMemo } from "react";

import { type Issue, type IssueDetail } from "../api/issues";
import useIssues, { useEpicIssues } from "../hooks/useIssues";

import StatusIssueList from "./StatusIssueList";

export default function IssueChildIssues({ issue }: { issue: Issue }) {
// Only create JQL if there are subtasks
const subtaskJql = useMemo(() => {
if (!issue.fields.subtasks?.length) return "";
const subtaskIds = issue.fields.subtasks.map((subtask) => subtask.id);
return `issue in (${subtaskIds.join(",")})`;
}, [issue.fields.subtasks]);

const {
issues: subtasks,
isLoading: isLoadingSubtasks,
mutate: mutateSubtasks,
} = useIssues(subtaskJql || "issue = null");

const isEpic = issue.fields.issuetype?.name === "Epic";
const {
mutate: mutateEpicIssues,
issues: epicIssues,
isLoading: isLoadingEpicIssues,
} = useEpicIssues(isEpic ? issue.id : "");

const childIssues = useMemo(() => {
const allIssues = [...(subtasks || []), ...(epicIssues || [])];
// Ensure unique keys by using issue ID
return allIssues
.filter((issue): issue is IssueDetail => issue !== null)
.map((issue) => ({
...issue,
key: `${issue.key}`,
}));
}, [subtasks, epicIssues]);

const isLoading = isLoadingSubtasks || isLoadingEpicIssues;

return (
<StatusIssueList
issues={childIssues}
isLoading={isLoading}
mutate={async (data) => {
if (isEpic) {
return mutateEpicIssues(data);
}
// For subtasks, we need to mutate the subtasks data
return mutateSubtasks(data);
}}
/>
);
}
40 changes: 33 additions & 7 deletions extensions/jira/src/components/IssueDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { getAuthenticatedUri, getBaseUrl } from "../api/request";
import { getProjectAvatar, getUserAvatar } from "../helpers/avatars";
import { formatDate, getCustomFieldsForDetail, getMarkdownFromHtml, getStatusColor } from "../helpers/issues";
import { replaceAsync } from "../helpers/string";
import { useEpicIssues } from "../hooks/useIssues";

import IssueActions from "./IssueActions";
import IssueDetailCustomFields from "./IssueDetailCustomFields";
Expand All @@ -28,7 +29,6 @@ export default function IssueDetail({ initialIssue, issueKey }: IssueDetailProps
if (!issue) {
return null;
}

const baseUrl = getBaseUrl();
const description = issue.renderedFields?.description ?? "";
// Resolve all the image URLs to data URIs in the cached promise for better performance
Expand All @@ -45,36 +45,53 @@ export default function IssueDetail({ initialIssue, issueKey }: IssueDetailProps
{ initialData: initialIssue },
);

const { issues: epicIssues, isLoading: isLoadingEpicIssues } = useEpicIssues(issue?.key ?? "");

const attachments = issue?.fields.attachment;
const numberOfAttachments = attachments?.length ?? 0;
const hasAttachments = numberOfAttachments > 0;

const hasChildIssues =
(issue?.fields.subtasks && issue.fields.subtasks.length > 0) || (epicIssues && epicIssues.length > 0);
const { customMarkdownFields, customMetadataFields } = useMemo(() => getCustomFieldsForDetail(issue), [issue]);

const markdown = useMemo(() => {
if (!issue) {
return "";
}

let markdown = `# ${issue.fields.summary}`;
let markdown = `# ${issue.fields.summary} \n\n`;
const description = issue.renderedFields?.description;

if (description) {
markdown += `\n\n${getMarkdownFromHtml(description)}`;
}

if (issue.fields.issuetype && issue.fields.issuetype.name === "Epic" && epicIssues) {
markdown += "\n\n## Child Issues\n";
epicIssues.forEach((childIssue) => {
markdown += `- ${childIssue.key} - ${childIssue.fields.summary}\n`;
});
}

const subtasks = issue.fields.subtasks;
if (subtasks && subtasks.length > 0) {
markdown += "\n\n## Child Issues\n";
subtasks.forEach((subtask) => {
markdown += `- ${subtask.key} - ${subtask.fields.summary}\n`;
});
}

customMarkdownFields.forEach((markdownField) => {
markdown += markdownField;
});

return markdown;
}, [issue]);
}, [issue, epicIssues, customMarkdownFields]);

return (
<Detail
navigationTitle={issue?.key}
markdown={markdown}
isLoading={isLoading}
isLoading={isLoading || isLoadingEpicIssues}
metadata={
<Detail.Metadata>
{hasAttachments ? (
Expand Down Expand Up @@ -171,7 +188,16 @@ export default function IssueDetail({ initialIssue, issueKey }: IssueDetailProps
</Detail.Metadata>
}
{...(issue
? { actions: <IssueActions issue={issue} showAttachmentsAction={hasAttachments} mutateDetail={mutate} /> }
? {
actions: (
<IssueActions
issue={issue}
showChildIssuesAction={hasChildIssues}
showAttachmentsAction={hasAttachments}
mutateDetail={mutate}
/>
),
}
: null)}
/>
);
Expand Down
9 changes: 7 additions & 2 deletions extensions/jira/src/components/IssueListItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { format } from "date-fns";
import { Issue } from "../api/issues";
import { getUserAvatar } from "../helpers/avatars";
import { getStatusColor } from "../helpers/issues";
import { useEpicIssues } from "../hooks/useIssues";

import IssueActions from "./IssueActions";

Expand All @@ -16,7 +17,9 @@ type IssueListItemProps = {
export default function IssueListItem({ issue, mutate }: IssueListItemProps) {
const updatedAt = new Date(issue.fields.updated);
const assignee = issue.fields.assignee;

const { issues: epicIssues } = useEpicIssues(issue?.id ?? "");
const hasChildIssues =
(issue.fields.subtasks && issue.fields.subtasks.length > 0) || (epicIssues && epicIssues.length > 0);
const keywords = [issue.key, issue.fields.status.name, issue.fields.issuetype.name];

if (issue.fields.priority) {
Expand Down Expand Up @@ -55,7 +58,9 @@ export default function IssueListItem({ issue, mutate }: IssueListItemProps) {
title={issue.fields.summary || "Unknown issue title"}
subtitle={issue.key}
accessories={accessories}
actions={<IssueActions issue={issue} mutate={mutate} showDetailsAction={true} />}
actions={
<IssueActions issue={issue} showChildIssuesAction={hasChildIssues} mutate={mutate} showDetailsAction={true} />
}
/>
);
}
5 changes: 5 additions & 0 deletions extensions/jira/src/hooks/useIssues.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@ import { useCachedPromise } from "@raycast/utils";

import { getIssues } from "../api/issues";

export function useEpicIssues(epicKey: string, options?: Record<string, unknown>) {
const jql = epicKey ? `parent = ${epicKey}` : "issue = null";
const { data: issues, isLoading, mutate } = useCachedPromise((jql) => getIssues({ jql }), [jql], options);
return { issues, isLoading, mutate };
}
export default function useIssues(jql: string, options?: Record<string, unknown>) {
const { data: issues, isLoading, mutate } = useCachedPromise((jql) => getIssues({ jql }), [jql], options);
return { issues, isLoading, mutate };
Expand Down