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
121 changes: 97 additions & 24 deletions dashboard/src/v2/components/sprints/SprintJiraImportModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,9 @@ export const SprintJiraImportModal = ({
const [sortDirection, setSortDirection] = useState<JiraSortDirection>("desc");
const [limit, setLimit] = useState(40);
const [jql, setJql] = useState("");
const [hideInWork, setHideInWork] = useState(true);
const [advancedFiltersExpanded, setAdvancedFiltersExpanded] = useState(false);
const [fetchedResults, setFetchedResults] = useState<JiraIssueSearchResult[]>([]);
const [results, setResults] = useState<JiraIssueSearchResult[]>([]);
const [hasSearched, setHasSearched] = useState(false);
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set());
Expand All @@ -108,6 +110,7 @@ export const SprintJiraImportModal = ({
const [importing, setImporting] = useState(false);
const [error, setError] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(null);
const hideInWorkRef = useRef(true);

const selectedIssues = useMemo(() => (
results.filter((issue) => selectedKeys.has(issue.key))
Expand Down Expand Up @@ -144,6 +147,14 @@ export const SprintJiraImportModal = ({
const emptyStateCopy = getIssueImportEmptyStateCopy("jira", hasSearched);
const compactState = buildIssueImportCompactState({
filters: [
{
id: "hideInWork",
label: "Visibility",
value: hideInWork,
defaultValue: false,
valueLabel: hideInWork ? "Hide in Work" : null,
priority: 1,
},
{
id: "status",
label: "Status",
Expand All @@ -154,17 +165,17 @@ export const SprintJiraImportModal = ({
alwaysShow: true,
priority: 0,
},
{ id: "project", label: "Project", value: projectKey, priority: 1 },
{ id: "issue", label: "Issue", value: issueKey, priority: 2 },
{ id: "search", label: "Text", value: search, priority: 3 },
{ id: "assignee", label: "Assignee", value: assigneeText, priority: 4 },
{ id: "reporter", label: "Reporter", value: reporterText, priority: 5 },
{ id: "type", label: "Type", value: issueType, priority: 6 },
{ id: "priority", label: "Priority", value: priority, priority: 7 },
{ id: "labels", label: "Labels", value: labels, priority: 8 },
{ id: "updatedAfter", label: "Updated after", value: updatedAfter, priority: 9 },
{ id: "updatedBefore", label: "Updated before", value: updatedBefore, priority: 10 },
{ id: "jql", label: "JQL", value: jql, priority: 11 },
{ id: "project", label: "Project", value: projectKey, priority: 2 },
{ id: "issue", label: "Issue", value: issueKey, priority: 3 },
{ id: "search", label: "Text", value: search, priority: 4 },
{ id: "assignee", label: "Assignee", value: assigneeText, priority: 5 },
{ id: "reporter", label: "Reporter", value: reporterText, priority: 6 },
{ id: "type", label: "Type", value: issueType, priority: 7 },
{ id: "priority", label: "Priority", value: priority, priority: 8 },
{ id: "labels", label: "Labels", value: labels, priority: 9 },
{ id: "updatedAfter", label: "Updated after", value: updatedAfter, priority: 10 },
{ id: "updatedBefore", label: "Updated before", value: updatedBefore, priority: 11 },
{ id: "jql", label: "JQL", value: jql, priority: 12 },
],
selectedCount: selectedIssues.length,
visibleCount: results.length,
Expand Down Expand Up @@ -225,25 +236,17 @@ export const SprintJiraImportModal = ({
},
controller.signal,
);
setResults(data);
setSelectedKeys((current) => new Set([...current].filter((key) => data.some((issue) => issue.key === key))));
setConversationDisabledKeys((current) => new Set([...current].filter((key) => data.some((issue) => issue.key === key))));
setImportModes((current) => {
const visibleKeys = new Set(data.map((issue) => issue.key));
const next: Record<string, ImportedTaskMode> = {};
for (const [key, mode] of Object.entries(current)) {
if (visibleKeys.has(key)) {
next[key] = mode;
}
}
return next;
});
const visibleData = filterVisibleJiraIssues(data, hideInWorkRef.current);
setFetchedResults(data);
setResults(visibleData);
pruneIssueStateToVisibleResults(visibleData);
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") {
return;
}
const copy = getIssueImportErrorCopy(err, "Jira search failed. Check the filters and try again.");
setError(`Jira search error: ${copy.message}`);
setFetchedResults([]);
setResults([]);
} finally {
if (abortRef.current === controller) {
Expand Down Expand Up @@ -350,6 +353,29 @@ export const SprintJiraImportModal = ({
setImportModes({});
};

const pruneIssueStateToVisibleResults = (visibleIssues: ReadonlyArray<JiraIssueSearchResult>): void => {
const visibleKeys = new Set(visibleIssues.map((issue) => issue.key));
setSelectedKeys((current) => new Set([...current].filter((key) => visibleKeys.has(key))));
setConversationDisabledKeys((current) => new Set([...current].filter((key) => visibleKeys.has(key))));
setImportModes((current) => {
const next: Record<string, ImportedTaskMode> = {};
for (const [key, mode] of Object.entries(current)) {
if (visibleKeys.has(key)) {
next[key] = mode;
}
}
return next;
});
};

const handleHideInWorkChange = (enabled: boolean): void => {
hideInWorkRef.current = enabled;
setHideInWork(enabled);
const visibleData = filterVisibleJiraIssues(fetchedResults, enabled);
setResults(visibleData);
pruneIssueStateToVisibleResults(visibleData);
};

const setImportModeForSelected = (mode: ImportedTaskMode): void => {
if (selectedKeys.size === 0) {
return;
Expand Down Expand Up @@ -563,6 +589,21 @@ export const SprintJiraImportModal = ({
aria-label="Jira result limit"
/>
</IssueImportField>

<IssueImportField
label="Visibility"
hint="Client-side only. The Jira search still uses the selected status filter."
>
<label className="inline-flex min-h-11 items-center gap-3 rounded-[1rem] border border-black/[0.06] bg-white px-4 py-3 text-sm font-semibold text-slate-600 transition-colors hover:text-slate-900 dark:border-white/[0.08] dark:bg-white/[0.05] dark:text-slate-300 dark:hover:text-white">
<input
type="checkbox"
checked={hideInWork}
onChange={(event) => handleHideInWorkChange((event.target as HTMLInputElement).checked)}
className="h-4 w-4 rounded border-slate-300 text-[#0052CC] focus:ring-[#0052CC] dark:border-white/[0.18] dark:bg-transparent"
/>
Hide in Work
</label>
</IssueImportField>
</div>
</IssueImportFilterSection>
</div>
Expand Down Expand Up @@ -935,6 +976,38 @@ function getOptionLabel<TValue extends string>(
return options.find((option) => option.value === value)?.label ?? value;
}

function filterVisibleJiraIssues(
issues: ReadonlyArray<JiraIssueSearchResult>,
hideInWork: boolean,
): JiraIssueSearchResult[] {
if (!hideInWork) {
return [...issues];
}
return issues.filter((issue) => !isInWorkJiraIssue(issue));
}

function isInWorkJiraIssue(issue: JiraIssueSearchResult): boolean {
const statusLikeIssue = issue as JiraIssueSearchResult & {
status?: string | null;
statusText?: string | null;
statusName?: string | null;
};
return [
statusLikeIssue.state,
statusLikeIssue.status,
statusLikeIssue.statusText,
statusLikeIssue.statusName,
].some((value) => normalizeJiraStatusText(value) === "in work");
}

function normalizeJiraStatusText(value: string | null | undefined): string {
return (value ?? "")
.trim()
.replace(/[_-]+/g, " ")
.replace(/\s+/g, " ")
.toLowerCase();
}

function buildImportedTaskPayload(
issue: JiraIssueSearchResult,
mode: SprintImportedTaskInput["kind"],
Expand Down
6 changes: 4 additions & 2 deletions docs/dashboard/sprint-imports.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,12 +76,14 @@ When the GitHub token is empty, GitHub issue search, issue context loading, and

## Jira Issue Import

Use `Import -> Jira Issues` to search Jira with guided filters, multi-select issues, and attach them to the sprint composer. The Jira modal opens on the common search path first: project key, exact issue key lookup, free-text search, status, sort field, sort direction, and a bounded result limit. The default view calls out the normal open-issues, recently-updated-first behavior, active filter summary, visible result count, selected linked count, selected special-task count, and selected issue cards with their current mode.
Use `Import -> Jira Issues` to search Jira with guided filters, multi-select issues, and attach them to the sprint composer. The Jira modal opens on the common search path first: project key, exact issue key lookup, free-text search, status, sort field, sort direction, a bounded result limit, and a `Hide in Work` visibility checkbox. The default view calls out the normal open-issues, recently-updated-first behavior, active filter summary, visible result count, selected linked count, selected special-task count, and selected issue cards with their current mode.

Advanced Jira filters are grouped behind an `Advanced Jira filters` toggle. People filters hold assignee and reporter text, classification filters hold issue type, priority, and labels, the updated window uses date inputs, and the explicit JQL override uses a textarea. Project and issue-key inputs are normalized to uppercase, labels use the shared multi-select control, and the advanced JQL override remains optional. When JQL is present, it replaces the guided Jira filters for search construction.

Jira results use compact selectable issue cards with source links, Jira-specific metadata, a visible per-card import mode label, `Select all visible`, `Clear selection`, bulk conversation selection, and per-card `Append Conversation` toggles. Selected Jira issues default to linked sprint context and show `Linked issue` until the operator changes mode. When special task creation is available, operators can explicitly switch the selected Jira issues to security or quality task mode before importing.

The `Hide in Work` checkbox is enabled by default and filters the fetched Jira results in the browser by hiding issues whose Jira status text is exactly `In Work` after normalization. It does not change the Jira query, status dropdown, or default open-issues search. Turning the checkbox off immediately shows matching fetched `In Work` issues again; turning it back on prunes hidden issues from selection, conversation toggles, and linked or special-task import modes so they cannot be imported accidentally. The compact filter chips show `Hide in Work` while the visibility filter is active.

The assignee field accepts a Jira user full name, email address, or account ID. It also accepts `me` / `currentUser()` for the connected Jira account and `unassigned` / `empty` for issues without an assignee. The server builds the Jira query from the selected filters, defaults to open issues sorted by recent updates, and uses `Settings -> Integrations -> Jira -> Default project` to prefill the project key when available. Clearing the project key browses all Jira issues the saved credentials can see.

The search endpoint also honors an exact issue key, user text, issue type, priority, labels, updated-date windows, sort field, sort direction, and a bounded result limit. Jira import requests use the same trimming, label deduplication, malformed-limit rejection, and pre-client result-limit clamp as repository issue search. Advanced users can open the JQL override and replace the guided query entirely; when JQL is present, it overrides the other filters.
Expand All @@ -100,7 +102,7 @@ Jira dashboard and MCP importer workflows require those saved Jira settings. The

Selected Jira issues are loaded through the same prompt-context path as GitHub/GitLab imports. The sprint prompt receives the Jira description and, when `Append Conversation` is enabled, Jira comments. Imported Jira cards are persisted as linked sprint issues with provider `jira`, host extracted from the Jira URL, project key, repository fallback, parsed issue number from keys such as `OPS-42`, issue key, labels, assignees, status, source URL, and the selected conversation flag. The import result cards also surface Jira issue type, priority, reporter, assignee, labels, status, updated timestamps, and a description preview when Jira returns those fields.

When Jira issues are imported as linked sprint issues, Code UX attempts to move each linked Jira issue through the configured import transition. The default is enabled and uses `In Work`. Transition lookup is case-insensitive. Import transition failures are non-destructive: the linked issue remains persisted locally, the dashboard or MCP result includes a warning with the Jira key and failure message, and the failure is logged for operators. This import-time transition is separate from sprint-completion auto-close and does not change the `Done` close transition behavior.
When Jira issues are imported as linked sprint issues, Code UX attempts to move each linked Jira issue through the configured import transition. The default is enabled and uses `In Work`. Transition lookup is case-insensitive. This import transition setting is separate from the Jira modal's `Hide in Work` checkbox: the checkbox only controls which fetched issues are visible and selectable before import, while the transition setting controls what Code UX asks Jira to do after linked issues are imported. Import transition failures are non-destructive: the linked issue remains persisted locally, the dashboard or MCP result includes a warning with the Jira key and failure message, and the failure is logged for operators. This import-time transition is separate from sprint-completion auto-close and does not change the `Done` close transition behavior.

When operators mark selected Jira issues as security or quality task mode, the dashboard emits imported task payloads instead of linked issue contexts. Those special tasks are created directly on the sprint and bypass planning prose, while ordinary Jira issues still become linked issues that feed the sprint prompt and linked issue records. Jira issue labels, issue type, priority, title, or description text do not automatically convert an issue into a special task.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,14 @@ const baseIssue = {
sourceProvider: "jira" as const,
};

const inWorkIssue = {
...baseIssue,
key: "OPS-77",
title: "Already being handled",
url: "https://acme.atlassian.net/browse/OPS-77",
state: "In Work",
};

describe("SprintJiraImportModal", () => {
it("loads the default project key and uses guided Jira filters", async () => {
vi.mocked(fetchProjectEffectiveSettings).mockResolvedValue({
Expand Down Expand Up @@ -86,14 +94,83 @@ describe("SprintJiraImportModal", () => {
expect(screen.getByRole("button", { name: /^search$/i })).toBeEnabled();
expect(screen.getByRole("button", { name: /import issues disabled until jira issues are selected/i })).toBeDisabled();
expect(screen.getByRole("button", { name: /advanced jira filters/i })).toHaveAttribute("aria-expanded", "false");
expect(screen.getByRole("checkbox", { name: /hide in work/i })).toBeChecked();
expect(document.getElementById("jira-import-advanced-filters")).toHaveClass("hidden");
expect(screen.getAllByText(/Default: open Jira issues, recently updated first/i).length).toBeGreaterThan(0);
expect(screen.getByLabelText("Active Jira filters")).toHaveTextContent(/Visibility\s*Hide in Work/i);
expect([...document.querySelectorAll("[aria-live='polite']")].some((node) => (
node.textContent?.replace(/\s+/g, " ").includes("0 linked, 0 special")
))).toBe(true);
expect(screen.getByRole("button", { name: /import jira backlog/i })).toHaveAttribute("aria-pressed", "false");
});

it("hides Jira issues already in work by default", async () => {
vi.mocked(fetchProjectEffectiveSettings).mockResolvedValue({
settings: { jira: { defaultProject: "OPS" } },
} as never);
vi.mocked(searchJiraIssues).mockResolvedValue([baseIssue, inWorkIssue]);

render(<SprintJiraImportModal projectId="project-1" onClose={vi.fn()} onImport={vi.fn()} />);

await waitFor(() => {
expect(screen.getByText("Import Jira backlog")).toBeInTheDocument();
});

expect(screen.queryByText("Already being handled")).not.toBeInTheDocument();
expect(screen.getByText(/1 visible result/i)).toBeInTheDocument();
});

it("shows in-work Jira issues when Hide in Work is unchecked", async () => {
vi.mocked(fetchProjectEffectiveSettings).mockResolvedValue({
settings: { jira: { defaultProject: "OPS" } },
} as never);
vi.mocked(searchJiraIssues).mockResolvedValue([baseIssue, inWorkIssue]);

render(<SprintJiraImportModal projectId="project-1" onClose={vi.fn()} onImport={vi.fn()} />);

await waitFor(() => {
expect(screen.getByText("Import Jira backlog")).toBeInTheDocument();
});

fireEvent.click(screen.getByRole("checkbox", { name: /hide in work/i }));

expect(screen.getByText("Already being handled")).toBeInTheDocument();
expect(screen.getByText(/2 visible results/i)).toBeInTheDocument();
});

it("prunes selected in-work Jira issues when Hide in Work is re-enabled", async () => {
vi.mocked(fetchProjectEffectiveSettings).mockResolvedValue({
settings: { jira: { defaultProject: "OPS" } },
} as never);
vi.mocked(searchJiraIssues).mockResolvedValue([baseIssue, inWorkIssue]);

render(
<SprintJiraImportModal
projectId="project-1"
onClose={vi.fn()}
onImport={vi.fn()}
onImportSpecialTasks={vi.fn()}
/>,
);

await waitFor(() => {
expect(screen.getByText("Import Jira backlog")).toBeInTheDocument();
});

fireEvent.click(screen.getByRole("checkbox", { name: /hide in work/i }));
fireEvent.click(screen.getByText("Already being handled"));
expect(screen.getByText(/1 selected issue will be imported\. 1 linked, 0 special tasks\./i)).toBeInTheDocument();

fireEvent.click(screen.getByRole("button", { name: /quality task/i }));
expect(screen.getByText(/1 selected issue will be imported\. 0 linked, 1 special task\./i)).toBeInTheDocument();

fireEvent.click(screen.getByRole("checkbox", { name: /hide in work/i }));

expect(screen.queryByText("Already being handled")).not.toBeInTheDocument();
expect(screen.getByText("No issues selected.")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /import issues disabled until jira issues are selected/i })).toBeDisabled();
});

it("supports exact keys, user filters, labels, date windows, sort controls, and JQL override", async () => {
vi.mocked(fetchProjectEffectiveSettings).mockResolvedValue({
settings: { jira: { defaultProject: "OPS" } },
Expand Down