Skip to content

Commit a26dd58

Browse files
committed
fix(jira): resets stale custom scope reference on disconnect or deletion
Closes BUG-008. viewState.tabFilters.jiraAssigned.scope could hold a reference to a custom Jira scope ID that no longer exists after (1) disconnecting Jira (clearJiraConfigFull wipes customScopes but left the tab filter untouched), or (2) deleting the active custom scope via JiraScopePicker. Either path let a stale scope ID survive into the next fetchJiraAssigned() call, which fired an invalid JQL query and surfaced a confusing 'Custom scope not supported' toast before the existing error-handler/tab-mount guards self-healed it. Extracts the validity check into a pure, exported staleJiraScopeReset() helper shared by both call sites (handleJiraDisconnect and the scope picker's onSave), matching the same built-in-scope list already used by JiraAssignedTab's reactive guard. Adds direct unit tests for the helper plus two integration tests covering the disconnect path.
1 parent 9d527d2 commit a26dd58

2 files changed

Lines changed: 81 additions & 4 deletions

File tree

src/app/components/settings/SettingsPage.tsx

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ import * as Sentry from "@sentry/solid";
44
import { getRelayStatus } from "../../lib/mcp-relay";
55
import { useNavigate } from "@solidjs/router";
66
import { config, updateConfig, updateJiraConfig, updateJiraCustomFields, updateJiraCustomScopes, setMonitoredRepo, isActionsBasedTab } from "../../stores/config";
7-
import { viewState, updateViewState } from "../../stores/view";
7+
import type { JiraCustomField } from "../../../shared/schemas";
8+
import { viewState, updateViewState, setTabFilter } from "../../stores/view";
89
import { clearAuth, jiraAuth, setJiraAuth, clearJiraConfigFull, isJiraAuthenticated, token, setAuthFromPat } from "../../stores/auth";
910
import type { GitHubUser } from "../../stores/auth";
1011
import { isValidPatFormat } from "../../lib/pat";
@@ -60,6 +61,21 @@ export const SETTINGS_PAGE_SECTION_IDS = [
6061
"data",
6162
] as const;
6263

64+
// Built-in Jira scope values, duplicated from JiraAssignedTab.tsx's BUILTIN_SCOPE_OPTIONS
65+
// (not exported there to avoid a cross-component coupling for 3 literal strings).
66+
const BUILTIN_JIRA_SCOPES = ["assigned", "reported", "watching"];
67+
68+
/**
69+
* Returns the scope value to fall back to if `activeScope` no longer exists in
70+
* `scopes` (e.g. the custom scope backing it was just deleted, or Jira was
71+
* disconnected and `scopes` is empty) — otherwise null. Guards against firing
72+
* an invalid JQL query with a stale custom scope ID (BUG-008).
73+
*/
74+
export function staleJiraScopeReset(activeScope: string, scopes: JiraCustomField[]): string | null {
75+
const validScopeIds = new Set([...BUILTIN_JIRA_SCOPES, ...scopes.map((s) => s.id)]);
76+
return validScopeIds.has(activeScope) ? null : "assigned";
77+
}
78+
6379
export default function SettingsPage() {
6480
const navigate = useNavigate();
6581

@@ -433,6 +449,11 @@ export default function SettingsPage() {
433449
if (viewState.lastActiveTab === "jiraAssigned") {
434450
updateViewState({ lastActiveTab: "issues" });
435451
}
452+
// Stale scope guard: clearJiraConfigFull() wipes customScopes, so a tab
453+
// filter pointing at a custom scope ID would otherwise survive reconnect
454+
// and fire an invalid JQL query against the new Jira instance (BUG-008).
455+
const reset = staleJiraScopeReset(viewState.tabFilters.jiraAssigned.scope, []);
456+
if (reset) setTabFilter("jiraAssigned", "scope", reset);
436457
}
437458

438459
// ── Refresh interval options ──────────────────────────────────────────────
@@ -1270,7 +1291,15 @@ export default function SettingsPage() {
12701291
<JiraScopePicker
12711292
client={jiraClient()!}
12721293
selectedScopes={config.jira?.customScopes ?? []}
1273-
onSave={(scopes) => { updateJiraCustomScopes(scopes); setShowScopePicker(false); }}
1294+
onSave={(scopes) => {
1295+
updateJiraCustomScopes(scopes);
1296+
setShowScopePicker(false);
1297+
// Stale scope guard: if the active Jira tab filter pointed at a
1298+
// custom scope that was just removed, reset it before it can fire
1299+
// an invalid JQL query (BUG-008).
1300+
const reset = staleJiraScopeReset(viewState.tabFilters.jiraAssigned.scope, scopes);
1301+
if (reset) setTabFilter("jiraAssigned", "scope", reset);
1302+
}}
12741303
onCancel={() => setShowScopePicker(false)}
12751304
/>
12761305
</div>

tests/components/settings/JiraSection.test.tsx

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,9 +103,10 @@ vi.mock("../../../src/app/stores/config", () => ({
103103
}));
104104

105105
vi.mock("../../../src/app/stores/view", () => ({
106-
viewState: { lastActiveTab: "issues", tabFilters: {}, expandedRepos: {}, lockedRepos: {}, trackedItems: [], activeScopeTab: "involved" },
106+
viewState: { lastActiveTab: "issues", tabFilters: { jiraAssigned: { scope: "assigned" } }, expandedRepos: {}, lockedRepos: {}, trackedItems: [], activeScopeTab: "involved" },
107107
updateViewState: vi.fn(),
108108
resetViewState: vi.fn(),
109+
setTabFilter: vi.fn(),
109110
ViewStateSchema: { parse: vi.fn((x: unknown) => x) },
110111
}));
111112

@@ -163,7 +164,8 @@ vi.mock("../../../src/app/services/jira-keys", () => ({
163164
}));
164165

165166
// Component imports after all mocks
166-
import SettingsPage from "../../../src/app/components/settings/SettingsPage";
167+
import SettingsPage, { staleJiraScopeReset } from "../../../src/app/components/settings/SettingsPage";
168+
import { viewState, setTabFilter } from "../../../src/app/stores/view";
167169

168170
// ── Helpers ───────────────────────────────────────────────────────────────────
169171

@@ -525,6 +527,31 @@ describe("SettingsPage Jira section — connected state", () => {
525527
expect(mockClearJiraConfigFull).toHaveBeenCalled();
526528
});
527529

530+
it("Disconnect resets a stale custom jiraAssigned scope to 'assigned' (BUG-008)", async () => {
531+
viewState.tabFilters.jiraAssigned.scope = "customfield_10001";
532+
renderSettings();
533+
await waitFor(() => {
534+
expect(screen.getByRole("button", { name: /Disconnect/i })).toBeTruthy();
535+
});
536+
537+
fireEvent.click(screen.getByRole("button", { name: /Disconnect/i }));
538+
539+
expect(setTabFilter).toHaveBeenCalledWith("jiraAssigned", "scope", "assigned");
540+
});
541+
542+
it("Disconnect does not touch jiraAssigned scope when it's already 'assigned'", async () => {
543+
viewState.tabFilters.jiraAssigned.scope = "assigned";
544+
renderSettings();
545+
await waitFor(() => {
546+
expect(screen.getByRole("button", { name: /Disconnect/i })).toBeTruthy();
547+
});
548+
549+
vi.mocked(setTabFilter).mockClear();
550+
fireEvent.click(screen.getByRole("button", { name: /Disconnect/i }));
551+
552+
expect(setTabFilter).not.toHaveBeenCalled();
553+
});
554+
528555
it("Disconnect does not show OAuth connect buttons (only when disconnected)", async () => {
529556
renderSettings();
530557
await waitFor(() => {
@@ -535,3 +562,24 @@ describe("SettingsPage Jira section — connected state", () => {
535562
expect(screen.queryByText(/Use API token/i)).toBeNull();
536563
});
537564
});
565+
566+
describe("staleJiraScopeReset (BUG-008)", () => {
567+
it("returns null when the active scope is a built-in value", () => {
568+
expect(staleJiraScopeReset("assigned", [])).toBeNull();
569+
expect(staleJiraScopeReset("reported", [{ id: "customfield_1", name: "Foo" }])).toBeNull();
570+
expect(staleJiraScopeReset("watching", [])).toBeNull();
571+
});
572+
573+
it("returns null when the active scope matches a surviving custom scope id", () => {
574+
expect(
575+
staleJiraScopeReset("customfield_10001", [{ id: "customfield_10001", name: "Epic Filter" }])
576+
).toBeNull();
577+
});
578+
579+
it("returns 'assigned' when the active scope is a custom id no longer present", () => {
580+
expect(staleJiraScopeReset("customfield_10001", [])).toBe("assigned");
581+
expect(
582+
staleJiraScopeReset("customfield_10001", [{ id: "customfield_9999", name: "Other" }])
583+
).toBe("assigned");
584+
});
585+
});

0 commit comments

Comments
 (0)