From 43d207094ef622d0fb1de2e3c24daf9df36e32cb Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Thu, 30 Apr 2026 17:28:01 -0400 Subject: [PATCH 1/6] feat(jira): adds custom field and scope support - Adds JiraFieldMeta interface and getFields() to IJiraClient/JiraClient/JiraProxyClient - Adds 'fields' endpoint to worker proxy with Array.isArray validation - Adds JiraCustomFieldSchema, customFields/customScopes to JiraConfigSchema - Adds jira-utils.ts with createJiraClient(), mergeCustomFields(), jiraJqlForScope() - Adds JiraFieldPicker and JiraScopePicker settings components - Adds JiraFieldValue duck-typed renderer with expandable detail panel - Adds dynamic SCOPE_OPTIONS with stale scope guard in JiraAssignedTab - Splits clearJiraAuth() to preserve config across reconnect cycles - 80 new tests across 9 test files (2453 total, all passing) --- .../components/dashboard/DashboardPage.tsx | 44 ++-- .../components/dashboard/JiraAssignedTab.tsx | 80 +++++- .../components/dashboard/JiraFieldValue.tsx | 61 +++++ .../components/settings/JiraFieldPicker.tsx | 168 ++++++++++++ .../components/settings/JiraScopePicker.tsx | 124 +++++++++ src/app/components/settings/SettingsPage.tsx | 75 +++++- src/app/lib/jira-utils.ts | 45 ++++ src/app/services/jira-client.ts | 19 +- src/app/stores/auth.ts | 13 + src/app/stores/config.ts | 10 +- src/app/stores/view.ts | 2 +- src/shared/jira-types.ts | 11 + src/shared/schemas.ts | 11 +- src/worker/index.ts | 18 +- .../dashboard/JiraAssignedTab.test.tsx | 76 +++++- .../dashboard/JiraFieldValue.test.tsx | 71 ++++++ .../settings/JiraFieldPicker.test.tsx | 196 ++++++++++++++ .../settings/JiraScopePicker.test.tsx | 178 +++++++++++++ tests/lib/jira-utils.test.ts | 108 ++++++++ tests/services/jira-client.test.ts | 97 +++++++ tests/services/jira-keys.test.ts | 1 + tests/stores/config.test.ts | 241 +++++++++++++++++- tests/worker/jira-oauth.test.ts | 118 +++++++++ 23 files changed, 1721 insertions(+), 46 deletions(-) create mode 100644 src/app/components/dashboard/JiraFieldValue.tsx create mode 100644 src/app/components/settings/JiraFieldPicker.tsx create mode 100644 src/app/components/settings/JiraScopePicker.tsx create mode 100644 src/app/lib/jira-utils.ts create mode 100644 tests/components/dashboard/JiraFieldValue.test.tsx create mode 100644 tests/components/settings/JiraFieldPicker.test.tsx create mode 100644 tests/components/settings/JiraScopePicker.test.tsx create mode 100644 tests/lib/jira-utils.test.ts diff --git a/src/app/components/dashboard/DashboardPage.tsx b/src/app/components/dashboard/DashboardPage.tsx index 9c52137a..15e9e1ef 100644 --- a/src/app/components/dashboard/DashboardPage.tsx +++ b/src/app/components/dashboard/DashboardPage.tsx @@ -9,7 +9,7 @@ import PullRequestsTab from "./PullRequestsTab"; import TrackedTab from "./TrackedTab"; import PersonalSummaryStrip from "./PersonalSummaryStrip"; import { config, setConfig, getCustomTab, isBuiltinTab, updateJiraConfig, type TrackedUser } from "../../stores/config"; -import { viewState, updateViewState, setSortPreference, pruneClosedTrackedItems, removeCustomTabState, untrackJiraItem, IssueFiltersSchema, PullRequestFiltersSchema, ActionsFiltersSchema } from "../../stores/view"; +import { viewState, updateViewState, setSortPreference, pruneClosedTrackedItems, removeCustomTabState, untrackJiraItem, setTabFilter, IssueFiltersSchema, PullRequestFiltersSchema, ActionsFiltersSchema } from "../../stores/view"; import type { SortOption } from "../shared/SortDropdown"; import type { Issue, PullRequest, WorkflowRun } from "../../services/api"; import { fetchOrgs } from "../../services/api"; @@ -24,8 +24,9 @@ import { fetchAllData, type DashboardData, } from "../../services/poll"; -import { expireToken, user, onAuthCleared, DASHBOARD_STORAGE_KEY, jiraAuth, setJiraAuth, isJiraAuthenticated, ensureJiraTokenValid, clearJiraAuth } from "../../stores/auth"; -import { JiraClient, JiraProxyClient, JiraApiError } from "../../services/jira-client"; +import { expireToken, user, onAuthCleared, DASHBOARD_STORAGE_KEY, jiraAuth, setJiraAuth, isJiraAuthenticated, clearJiraAuth } from "../../stores/auth"; +import { JiraApiError } from "../../services/jira-client"; +import { createJiraClient, mergeCustomFields, jiraJqlForScope } from "../../lib/jira-utils"; import type { JiraIssue } from "../../../shared/jira-types"; import { detectAndLookupJiraKeys } from "../../services/jira-keys"; import JiraAssignedTab from "./JiraAssignedTab"; @@ -401,29 +402,12 @@ export default function DashboardPage() { // Narrow reactivity: extract authMethod so unrelated jira config changes don't recreate the client const jiraAuthMethod = createMemo(() => config.jira?.authMethod); - const jiraClient = createMemo(() => { - const auth = jiraAuth(); - const method = jiraAuthMethod(); - if (!auth) return null; - if (method === "token") { - if (!auth.email) return null; - return new JiraProxyClient(auth.cloudId, auth.email, auth.accessToken, (resealed) => { - const cur = jiraAuth(); - if (cur) setJiraAuth({ ...cur, accessToken: resealed }); - }); - } - return new JiraClient(auth.cloudId, async () => { - await ensureJiraTokenValid(); - const currentAuth = jiraAuth(); - if (!currentAuth) throw new Error("Jira auth cleared during token refresh"); - return currentAuth.accessToken; - }); - }); - - function jiraJqlForScope(scope: string): string { - const field = scope === "reported" ? "reporter" : scope === "watching" ? "watcher" : "assignee"; - return `${field} = currentUser() AND statusCategory != Done ORDER BY priority DESC`; - } + const jiraClient = createMemo(() => + createJiraClient(jiraAuthMethod(), (resealed) => { + const cur = jiraAuth(); + if (cur) setJiraAuth({ ...cur, accessToken: resealed }); + }) + ); async function fetchJiraAssigned(): Promise { if (_jiraFetching) return; @@ -431,11 +415,11 @@ export default function DashboardPage() { if (!client) return; _jiraFetching = true; setJiraLoading(true); + const scope = viewState.tabFilters.jiraAssigned?.scope ?? "assigned"; try { - const scope = viewState.tabFilters.jiraAssigned?.scope ?? "assigned"; const result = await client.searchJql( jiraJqlForScope(scope), - { maxResults: 100 } + { maxResults: 100, fields: mergeCustomFields(config.jira?.customFields ?? []) } ); if (import.meta.env.DEV) { const missing = result.issues.filter((i) => !i.fields.issuetype); @@ -485,6 +469,10 @@ export default function DashboardPage() { if (err.status === 401) { clearJiraAuth(); pushNotification("jira", "Jira session expired — please reconnect in Settings", "warning"); + } else if (err.status === 400 && !["assigned", "reported", "watching"].includes(scope)) { + const scopeName = config.jira?.customScopes?.find((s) => s.id === scope)?.name ?? scope; + pushNotification("jira", `Custom scope '${scopeName}' is not supported by your Jira instance. Remove it in Settings.`, "warning"); + setTabFilter("jiraAssigned", "scope", "assigned"); } else if (err.status === 403) { pushNotification("jira", "Jira: access denied — check your app permissions or site access in Atlassian settings", "warning"); } else { diff --git a/src/app/components/dashboard/JiraAssignedTab.tsx b/src/app/components/dashboard/JiraAssignedTab.tsx index e58e3ae3..becfb48c 100644 --- a/src/app/components/dashboard/JiraAssignedTab.tsx +++ b/src/app/components/dashboard/JiraAssignedTab.tsx @@ -1,7 +1,8 @@ -import { createEffect, createMemo, createSignal, For, Show } from "solid-js"; +import { createEffect, createMemo, createSignal, For, Show, on } from "solid-js"; import type { JiraIssue } from "../../../shared/jira-types"; import { viewState, setTabFilter, resetAllTabFilters, JiraFiltersSchema, trackItem, untrackJiraItem, setAllExpanded } from "../../stores/view"; import { config } from "../../stores/config"; +import JiraFieldValue from "./JiraFieldValue"; import { jiraStatusCategoryClass, stripParenthetical } from "../../lib/format"; import { isSafeJiraSiteUrl } from "../../lib/url"; import { groupByRepo, computePageLayout, slicePageGroups, ensureLockedRepoGroups, orderRepoGroups } from "../../lib/grouping"; @@ -24,7 +25,7 @@ interface JiraAssignedTabProps { siteUrl: string; } -const SCOPE_OPTIONS = [ +const BUILTIN_SCOPE_OPTIONS = [ { value: "assigned", label: "Assigned to me" }, { value: "reported", label: "Created by me" }, { value: "watching", label: "Watching" }, @@ -125,6 +126,32 @@ export default function JiraAssignedTab(props: JiraAssignedTabProps) { const filters = createMemo(() => viewState.tabFilters.jiraAssigned ?? JIRA_FILTER_DEFAULTS); + const scopeOptions = createMemo(() => [ + ...BUILTIN_SCOPE_OPTIONS, + ...(config.jira?.customScopes ?? []).map((s) => ({ value: s.id, label: s.name })), + ]); + + // Stale scope guard: reset to "assigned" if active scope was removed from custom scopes + createEffect(() => { + const validValues = new Set(scopeOptions().map((o) => o.value)); + if (!validValues.has(filters().scope)) { + setTabFilter("jiraAssigned", "scope", "assigned"); + } + }); + + const [expandedIssues, setExpandedIssues] = createSignal>(new Set()); + + // Reset expanded state when scope changes + createEffect(on(() => filters().scope, () => setExpandedIssues(new Set()), { defer: true })); + + function toggleExpanded(key: string) { + setExpandedIssues((prev) => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); else next.add(key); + return next; + }); + } + const pinnedJiraKeys = createMemo(() => new Set( viewState.trackedItems @@ -245,7 +272,7 @@ export default function JiraAssignedTab(props: JiraAssignedTabProps) { group={{ field: "scope", label: "Scope", - options: SCOPE_OPTIONS, + options: scopeOptions(), defaultValue: "assigned", }} value={filters().scope} @@ -323,7 +350,10 @@ export default function JiraAssignedTab(props: JiraAssignedTabProps) {

{(filters().statusCategory !== "all" || filters().priority !== "all") ? "No issues match current filters" - : `No ${filters().scope === "reported" ? "created" : filters().scope === "watching" ? "watched" : "assigned"} Jira issues`} + : (() => { + const opt = scopeOptions().find((o) => o.value === filters().scope); + return `No ${opt?.label ?? "assigned"} Jira issues`; + })()}

@@ -359,8 +389,11 @@ export default function JiraAssignedTab(props: JiraAssignedTabProps) { {(issue) => { const isPinned = () => pinnedJiraKeys().has(issue.key); const browseUrl = () => isSafeJiraSiteUrl(props.siteUrl) ? `${props.siteUrl}/browse/${issue.key}` : "#"; + const isIssueExpanded = () => expandedIssues().has(issue.key); + const detailPanelId = `jira-detail-${issue.key}`; return ( -
+
+
@@ -445,6 +478,43 @@ export default function JiraAssignedTab(props: JiraAssignedTabProps) { + +
+ +
+ 0} + fallback={ +

+ No custom fields configured — add them in Settings. +

+ } + > +
+ + {(field) => ( +
+ {field.name} + )[field.id]} /> +
+ )} +
+
+
+
+
); }} diff --git a/src/app/components/dashboard/JiraFieldValue.tsx b/src/app/components/dashboard/JiraFieldValue.tsx new file mode 100644 index 00000000..f21be77f --- /dev/null +++ b/src/app/components/dashboard/JiraFieldValue.tsx @@ -0,0 +1,61 @@ +import { relativeTime } from "../../lib/format"; + +interface JiraFieldValueProps { + value: unknown; +} + +function renderScalar(value: unknown): string { + if (value === null || value === undefined) return "—"; + if (typeof value === "number") return String(value); + if (typeof value === "string") { + if (/^\d{4}-\d{2}-\d{2}/.test(value)) { + const rel = relativeTime(value); + if (rel) return rel; + const d = new Date(value); + if (!isNaN(d.getTime())) return d.toLocaleDateString(); + } + return value.length > 100 ? value.slice(0, 100) + "…" : value; + } + if (typeof value === "object" && !Array.isArray(value)) { + const obj = value as Record; + if (typeof obj["displayName"] === "string") return obj["displayName"]; + if (typeof obj["value"] === "string") return obj["value"]; + } + const json = JSON.stringify(value); + return json.length > 100 ? json.slice(0, 100) + "…" : json; +} + +function isOptionOrUser(value: unknown): boolean { + if (typeof value !== "object" || value === null || Array.isArray(value)) return false; + const obj = value as Record; + return typeof obj["displayName"] === "string" || typeof obj["value"] === "string"; +} + +export default function JiraFieldValue(props: JiraFieldValueProps) { + if (props.value === null || props.value === undefined) { + return ; + } + if (Array.isArray(props.value)) { + const parts = props.value + .filter((el) => !Array.isArray(el)) + .map((el) => renderScalar(el)); + if (parts.length === 0) return ; + return ( + + {parts.map((text, i) => ( + <> + {i > 0 && , } + {isOptionOrUser((props.value as unknown[])[i]) + ? {text} + : text} + + ))} + + ); + } + const text = renderScalar(props.value); + if (isOptionOrUser(props.value)) { + return {text}; + } + return {text}; +} diff --git a/src/app/components/settings/JiraFieldPicker.tsx b/src/app/components/settings/JiraFieldPicker.tsx new file mode 100644 index 00000000..98c20cea --- /dev/null +++ b/src/app/components/settings/JiraFieldPicker.tsx @@ -0,0 +1,168 @@ +import { createSignal, createMemo, For, Show, onMount } from "solid-js"; +import type { IJiraClient } from "../../services/jira-client"; +import type { JiraFieldMeta } from "../../../shared/jira-types"; +import type { JiraCustomField } from "../../../shared/schemas"; +import LoadingSpinner from "../shared/LoadingSpinner"; + +interface JiraFieldPickerProps { + client: IJiraClient; + selectedFields: JiraCustomField[]; + onSave: (fields: JiraCustomField[]) => void; + onCancel: () => void; +} + +export default function JiraFieldPicker(props: JiraFieldPickerProps) { + const [fields, setFields] = createSignal([]); + const [loading, setLoading] = createSignal(true); + const [error, setError] = createSignal(null); + const [search, setSearch] = createSignal(""); + const [selected, setSelected] = createSignal>( + new Map(props.selectedFields.map((f) => [f.id, f])) + ); + const [sampleValues, setSampleValues] = createSignal>({}); + + onMount(async () => { + try { + const allFields = await props.client.getFields(); + const custom = allFields.filter((f) => f.custom); + setFields(custom); + + // Best-effort sample value fetch + const customIds = custom.map((f) => f.id).slice(0, 50); + if (customIds.length > 0) { + try { + const result = await props.client.searchJql( + "assignee = currentUser() AND statusCategory != Done", + { maxResults: 1, fields: customIds } + ); + if (result.issues.length === 0) { + const fallback = await props.client.searchJql( + "creator = currentUser() OR watcher = currentUser() AND statusCategory != Done", + { maxResults: 1, fields: customIds } + ); + if (fallback.issues.length > 0) { + setSampleValues(fallback.issues[0].fields as Record); + } + } else { + setSampleValues(result.issues[0].fields as Record); + } + } catch { + // Best-effort — don't block the picker + } + } + } catch { + setError("Failed to load fields. Check your Jira connection."); + } finally { + setLoading(false); + } + }); + + const filtered = createMemo(() => { + const q = search().toLowerCase(); + return fields().filter((f) => !q || f.name.toLowerCase().includes(q)); + }); + + const selectedCount = createMemo(() => selected().size); + + function toggleField(field: JiraFieldMeta) { + setSelected((prev) => { + const next = new Map(prev); + if (next.has(field.id)) { + next.delete(field.id); + } else if (next.size < 10) { + next.set(field.id, { id: field.id, name: field.name }); + } + return next; + }); + } + + function handleSave() { + props.onSave([...selected().values()]); + } + + function previewText(fieldId: string): string { + const val = sampleValues()[fieldId]; + if (val === null || val === undefined) return "—"; + if (typeof val === "string" || typeof val === "number") { + const s = String(val); + return s.length > 40 ? s.slice(0, 40) + "…" : s; + } + if (typeof val === "object" && !Array.isArray(val)) { + const obj = val as Record; + if (typeof obj["displayName"] === "string") return obj["displayName"]; + if (typeof obj["value"] === "string") return obj["value"]; + } + if (Array.isArray(val) && val.length > 0) { + const first = val[0]; + if (typeof first === "object" && first !== null) { + const o = first as Record; + return typeof o["displayName"] === "string" ? o["displayName"] : typeof o["value"] === "string" ? o["value"] : "…"; + } + return String(first); + } + return "—"; + } + + return ( +
+

+ Fields shown are instance-wide. Some may not have values on all issues. + {fields().length > 50 && " Preview values shown for first 50 fields."} +

+ +
+ +
+
+ +

{error()}

+
+ + setSearch(e.currentTarget.value)} + class="input input-bordered input-sm w-full" + aria-label="Search fields" + /> + +

+ No custom fields found on this Jira instance. +

+
+ 0}> + = 10}> +

Maximum 10 fields selected.

+
+
+ + {(field) => { + const isChecked = () => selected().has(field.id); + const isDisabled = () => !isChecked() && selectedCount() >= 10; + return ( + + ); + }} + +
+
+
+
+ + +
+
+ ); +} diff --git a/src/app/components/settings/JiraScopePicker.tsx b/src/app/components/settings/JiraScopePicker.tsx new file mode 100644 index 00000000..b0ac99f2 --- /dev/null +++ b/src/app/components/settings/JiraScopePicker.tsx @@ -0,0 +1,124 @@ +import { createSignal, createMemo, For, Show, onMount } from "solid-js"; +import type { IJiraClient } from "../../services/jira-client"; +import type { JiraFieldMeta } from "../../../shared/jira-types"; +import type { JiraCustomField } from "../../../shared/schemas"; +import LoadingSpinner from "../shared/LoadingSpinner"; + +interface JiraScopePickerProps { + client: IJiraClient; + selectedScopes: JiraCustomField[]; + onSave: (scopes: JiraCustomField[]) => void; + onCancel: () => void; +} + +function isUserTypeField(field: JiraFieldMeta): boolean { + if (!field.custom) return false; + const SYSTEM_USER_FIELDS = new Set(["assignee", "reporter", "creator"]); + if (SYSTEM_USER_FIELDS.has(field.id)) return false; + if (field.schema?.type === "user") return true; + if (field.schema?.type === "array" && field.schema?.items === "user") return true; + return false; +} + +export default function JiraScopePicker(props: JiraScopePickerProps) { + const [fields, setFields] = createSignal([]); + const [loading, setLoading] = createSignal(true); + const [error, setError] = createSignal(null); + const [search, setSearch] = createSignal(""); + const [selected, setSelected] = createSignal>( + new Map(props.selectedScopes.map((s) => [s.id, s])) + ); + + onMount(async () => { + try { + const allFields = await props.client.getFields(); + setFields(allFields.filter(isUserTypeField)); + } catch { + setError("Failed to load fields. Check your Jira connection."); + } finally { + setLoading(false); + } + }); + + const filtered = createMemo(() => { + const q = search().toLowerCase(); + return fields().filter((f) => !q || f.name.toLowerCase().includes(q)); + }); + + function typeLabel(field: JiraFieldMeta): string { + return field.schema?.type === "array" ? "multi-user" : "user"; + } + + function toggleScope(field: JiraFieldMeta) { + setSelected((prev) => { + const next = new Map(prev); + if (next.has(field.id)) { + next.delete(field.id); + } else { + next.set(field.id, { id: field.id, name: field.name }); + } + return next; + }); + } + + function handleSave() { + props.onSave([...selected().values()]); + } + + return ( +
+

+ Select user fields to add as scope options. Each adds a{" "} + <field> in (currentUser()) filter to the scope dropdown. +

+ +
+ +
+
+ +

{error()}

+
+ + setSearch(e.currentTarget.value)} + class="input input-bordered input-sm w-full" + aria-label="Search scope fields" + /> + +

+ No user-type custom fields found on this Jira instance. +

+
+ 0}> +
+ + {(field) => { + const isChecked = () => selected().has(field.id); + return ( + + ); + }} + +
+
+
+
+ + +
+
+ ); +} diff --git a/src/app/components/settings/SettingsPage.tsx b/src/app/components/settings/SettingsPage.tsx index 32a3e8fb..bd6cea11 100644 --- a/src/app/components/settings/SettingsPage.tsx +++ b/src/app/components/settings/SettingsPage.tsx @@ -2,7 +2,7 @@ import { createSignal, createMemo, Show, For, onCleanup, onMount } from "solid-j import * as Sentry from "@sentry/solid"; import { getRelayStatus } from "../../lib/mcp-relay"; import { useNavigate } from "@solidjs/router"; -import { config, updateConfig, updateJiraConfig, setMonitoredRepo } from "../../stores/config"; +import { config, updateConfig, updateJiraConfig, updateJiraCustomFields, updateJiraCustomScopes, setMonitoredRepo } from "../../stores/config"; import type { Config } from "../../stores/config"; import { viewState, updateViewState } from "../../stores/view"; import { clearAuth, jiraAuth, setJiraAuth, clearJiraAuth, isJiraAuthenticated } from "../../stores/auth"; @@ -24,6 +24,9 @@ import DensityPicker from "./DensityPicker"; import TrackedUsersSection from "./TrackedUsersSection"; import CustomTabsSection from "./CustomTabsSection"; import { InfoTooltip } from "../shared/Tooltip"; +import { createJiraClient } from "../../lib/jira-utils"; +import JiraFieldPicker from "./JiraFieldPicker"; +import JiraScopePicker from "./JiraScopePicker"; import type { RepoRef } from "../../services/api"; const VALID_JIRA_CLIENT_ID_RE = /^[A-Za-z0-9_-]+$/; @@ -183,6 +186,8 @@ export default function SettingsPage() { cloudId: config.jira?.cloudId, siteName: config.jira?.siteName, siteUrl: config.jira?.siteUrl, + customFields: config.jira?.customFields ?? [], + customScopes: config.jira?.customScopes ?? [], }, }, null, @@ -224,6 +229,8 @@ export default function SettingsPage() { const [jiraApiConnecting, setJiraApiConnecting] = createSignal(false); const [jiraApiError, setJiraApiError] = createSignal(null); const [jiraApiMode, setJiraApiMode] = createSignal(false); + const [showFieldPicker, setShowFieldPicker] = createSignal(false); + const [showScopePicker, setShowScopePicker] = createSignal(false); const jiraApiSiteUrl = () => { const sub = jiraApiSubdomain().trim(); @@ -1003,6 +1010,72 @@ export default function SettingsPage() { class="toggle toggle-primary" /> + 0 + ? (config.jira?.customFields ?? []).map((f) => f.name).join(", ") + : "None configured" + } + > + + + + {(() => { + const client = createJiraClient(config.jira?.authMethod); + if (!client) return null; + return ( +
+ { updateJiraCustomFields(fields); setShowFieldPicker(false); }} + onCancel={() => setShowFieldPicker(false)} + /> +
+ ); + })()} +
+ 0 + ? (config.jira?.customScopes ?? []).map((s) => s.name).join(", ") + : "None configured" + } + > + + + + {(() => { + const client = createJiraClient(config.jira?.authMethod); + if (!client) return null; + return ( +
+ { updateJiraCustomScopes(scopes); setShowScopePicker(false); }} + onCancel={() => setShowScopePicker(false)} + /> +
+ ); + })()} +
): string[] { + const ids = customFields.map((f) => f.id).filter((id) => VALID_FIELD_ID.test(id)); + return [...DEFAULT_FIELDS, ...ids]; +} + +export function createJiraClient( + authMethod: "oauth" | "token" | undefined, + onResealed?: (resealed: string) => void, +): IJiraClient | null { + if (!authMethod) return null; + const auth = jiraAuth(); + if (!auth) return null; + if (authMethod === "token") { + if (!auth.email) return null; + return new JiraProxyClient(auth.cloudId, auth.email, auth.accessToken, onResealed); + } + return new JiraClient(auth.cloudId, async () => { + await ensureJiraTokenValid(); + const currentAuth = jiraAuth(); + if (!currentAuth) throw new Error("Jira auth cleared during token refresh"); + return currentAuth.accessToken; + }); +} + +const CUSTOM_FIELD_ID_RE = /^[a-zA-Z0-9_\-]+$/; + +export function jiraJqlForScope(scope: string): string { + const builtinFields: Record = { + assigned: "assignee = currentUser()", + reported: "reporter = currentUser()", + watching: "watcher = currentUser()", + }; + if (builtinFields[scope]) { + return `${builtinFields[scope]} AND statusCategory != Done ORDER BY priority DESC`; + } + if (!CUSTOM_FIELD_ID_RE.test(scope)) { + return `assignee = currentUser() AND statusCategory != Done ORDER BY priority DESC`; + } + return `${scope} in (currentUser()) AND statusCategory != Done ORDER BY priority DESC`; +} diff --git a/src/app/services/jira-client.ts b/src/app/services/jira-client.ts index 97177aca..8c8541ff 100644 --- a/src/app/services/jira-client.ts +++ b/src/app/services/jira-client.ts @@ -1,6 +1,6 @@ -import type { JiraIssue, JiraSearchResult, JiraBulkFetchResult, JiraAccessibleResource } from "../../shared/jira-types"; +import type { JiraIssue, JiraSearchResult, JiraBulkFetchResult, JiraAccessibleResource, JiraFieldMeta } from "../../shared/jira-types"; -const DEFAULT_FIELDS = ["summary", "status", "priority", "assignee", "project", "updated", "issuetype", "created"]; +export const DEFAULT_FIELDS = ["summary", "status", "priority", "assignee", "project", "updated", "issuetype", "created"]; // ── Error classes ───────────────────────────────────────────────────────────── @@ -28,12 +28,14 @@ export interface IJiraClient { getIssue(key: string, fields?: string[]): Promise; searchJql(jql: string, opts?: { maxResults?: number; fields?: string[]; startAt?: number }): Promise; bulkFetch(keys: string[], fields?: string[]): Promise; + getFields(): Promise; } // ── JiraClient (OAuth / Bearer) ─────────────────────────────────────────────── export class JiraClient implements IJiraClient { private readonly baseUrl: string; + private _fieldsCache: { data: JiraFieldMeta[]; ts: number } | null = null; constructor( cloudId: string, @@ -104,6 +106,13 @@ export class JiraClient implements IJiraClient { }); } + async getFields(): Promise { + if (this._fieldsCache && Date.now() - this._fieldsCache.ts < 30_000) return this._fieldsCache.data; + const data = await this.request("/field"); + this._fieldsCache = { data, ts: Date.now() }; + return data; + } + static async getAccessibleResources(accessToken: string): Promise { const response = await fetch("https://api.atlassian.com/oauth/token/accessible-resources", { redirect: "error", @@ -143,7 +152,7 @@ export class JiraProxyClient implements IJiraClient { ) {} private async request( - endpoint: "search" | "issue", + endpoint: "search" | "issue" | "fields", params: Record ): Promise { const response = await fetch("/api/jira/proxy", { @@ -217,4 +226,8 @@ export class JiraProxyClient implements IJiraClient { } return { issues: result.issues, errors: result.errors }; } + + async getFields(): Promise { + return this.request("fields", {}) as unknown as Promise; + } } diff --git a/src/app/stores/auth.ts b/src/app/stores/auth.ts index 8fe6778d..d933b6b7 100644 --- a/src/app/stores/auth.ts +++ b/src/app/stores/auth.ts @@ -94,6 +94,19 @@ export function setJiraAuth(state: JiraAuthState): void { } export function clearJiraAuth(): void { + localStorage.removeItem(JIRA_AUTH_STORAGE_KEY); + _setJiraAuth(null); + updateConfig({ + jira: JiraConfigSchema.parse({ + issueKeyDetection: config.jira.issueKeyDetection, + customFields: config.jira.customFields, + customScopes: config.jira.customScopes, + }), + }); + clearJiraKeyCache(); +} + +export function clearJiraConfigFull(): void { localStorage.removeItem(JIRA_AUTH_STORAGE_KEY); _setJiraAuth(null); updateConfig({ jira: JiraConfigSchema.parse({}) }); diff --git a/src/app/stores/config.ts b/src/app/stores/config.ts index 54dc6f75..e30ef2a3 100644 --- a/src/app/stores/config.ts +++ b/src/app/stores/config.ts @@ -3,7 +3,7 @@ import { createEffect, onCleanup } from "solid-js"; import { pushNotification } from "../lib/errors"; import { viewState, updateViewState } from "./view"; import { ConfigSchema, RepoRefSchema, THEME_OPTIONS, BUILTIN_TAB_IDS, CustomTabSchema } from "../../shared/schemas"; -import type { Config, ThemeId, CustomTab, JiraConfig } from "../../shared/schemas"; +import type { Config, ThemeId, CustomTab, JiraConfig, JiraCustomField } from "../../shared/schemas"; import { z } from "zod"; // ── Re-exports from shared/schemas (backward compat for existing importers) ─── @@ -106,6 +106,14 @@ export function updateJiraConfig(partial: Partial): void { updateConfig({ jira: { ...config.jira, ...partial } }); } +export function updateJiraCustomFields(fields: JiraCustomField[]): void { + updateJiraConfig({ customFields: fields.slice(0, 10) }); +} + +export function updateJiraCustomScopes(scopes: JiraCustomField[]): void { + updateJiraConfig({ customScopes: scopes.slice(0, 20) }); +} + export function resetConfig(): void { const defaults = ConfigSchema.parse({}); setConfig(defaults); diff --git a/src/app/stores/view.ts b/src/app/stores/view.ts index 7c8b2cb1..5e09c1c4 100644 --- a/src/app/stores/view.ts +++ b/src/app/stores/view.ts @@ -48,7 +48,7 @@ export const ActionsFiltersSchema = z.object({ // "done" intentionally excluded — JQL `statusCategory != Done` never returns Done items export const JiraFiltersSchema = z.object({ - scope: z.enum(["assigned", "reported", "watching"]).default("assigned"), + scope: z.enum(["assigned", "reported", "watching"]).or(z.string().regex(/^[a-zA-Z0-9_\-]+$/).max(100)).default("assigned"), statusCategory: z.enum(["all", "new", "indeterminate"]).default("all"), priority: z.enum(["all", "Highest", "High", "Medium", "Low", "Lowest"]).default("all"), sortField: z.string().default("status"), diff --git a/src/shared/jira-types.ts b/src/shared/jira-types.ts index 891a9ef0..e9072353 100644 --- a/src/shared/jira-types.ts +++ b/src/shared/jira-types.ts @@ -77,6 +77,17 @@ export interface JiraAccessibleResource { avatarUrl?: string; } +export interface JiraFieldMeta { + id: string; + name: string; + custom: boolean; + schema?: { + type: string; + items?: string; + custom?: string; + }; +} + export interface JiraErrorResponse { errorMessages: string[]; errors: Record; diff --git a/src/shared/schemas.ts b/src/shared/schemas.ts index 35cf236e..8f84f209 100644 --- a/src/shared/schemas.ts +++ b/src/shared/schemas.ts @@ -52,6 +52,13 @@ export function isBuiltinTab(id: string): id is BuiltinTabId { export const JiraAuthMethodSchema = z.enum(["oauth", "token"]).default("oauth"); +export const JiraCustomFieldSchema = z.object({ + id: z.string().regex(/^[a-zA-Z0-9_\-]+$/).max(100), + name: z.string().max(200), +}); + +export type JiraCustomField = z.infer; + export const JiraConfigSchema = z.object({ enabled: z.boolean().default(false), authMethod: JiraAuthMethodSchema, @@ -60,6 +67,8 @@ export const JiraConfigSchema = z.object({ siteName: z.string().optional(), email: z.string().optional(), issueKeyDetection: z.boolean().default(true), + customFields: z.array(JiraCustomFieldSchema).max(10).default([]), + customScopes: z.array(JiraCustomFieldSchema).max(20).default([]), }); export type JiraConfig = z.infer; @@ -94,7 +103,7 @@ export const ConfigSchema = z.object({ mcpRelayEnabled: z.boolean().default(false), mcpRelayPort: z.number().int().min(1024).max(65535).default(9876), // Explicit defaults (NOT .default({})) — inner field defaults don't apply with .default({}) per BUG-001 - jira: JiraConfigSchema.default({ enabled: false, authMethod: "oauth", issueKeyDetection: true }), + jira: JiraConfigSchema.default({ enabled: false, authMethod: "oauth", issueKeyDetection: true, customFields: [], customScopes: [] }), }); export type Config = z.infer; diff --git a/src/worker/index.ts b/src/worker/index.ts index 36d9e422..cbf39056 100644 --- a/src/worker/index.ts +++ b/src/worker/index.ts @@ -1180,7 +1180,7 @@ async function handleJiraProxy( const email = (parsed as Record)["email"]; const sealed = (parsed as Record)["sealed"]; - if (typeof endpoint !== "string" || (endpoint !== "search" && endpoint !== "issue")) { + if (typeof endpoint !== "string" || (endpoint !== "search" && endpoint !== "issue" && endpoint !== "fields")) { log("warn", "jira_proxy_invalid_endpoint", { endpoint }, request); return buildProxyResponse(errorResponse("invalid_request", 400), setCookie); } @@ -1238,7 +1238,7 @@ async function handleJiraProxy( } // Construct target URL server-side — cloudId validated above - const endpointPath = endpoint === "search" ? "search/jql" : "issue/bulkfetch"; + const endpointPath = endpoint === "search" ? "search/jql" : endpoint === "issue" ? "issue/bulkfetch" : "field"; const baseUrl = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/${endpointPath}`; const auth = `Basic ${btoa(`${email}:${apiToken}`)}`; @@ -1260,7 +1260,7 @@ async function handleJiraProxy( headers: { "Authorization": auth, "Accept": "application/json" }, redirect: "manual", }; - } else { + } else if (endpoint === "issue") { // POST with params as JSON body — only allowlisted keys forwarded const filteredParams: Record = {}; if (params && typeof params === "object") { @@ -1279,6 +1279,14 @@ async function handleJiraProxy( body: JSON.stringify(filteredParams), redirect: "manual", }; + } else { + // fields endpoint — no query params, simple GET + jiraUrl = baseUrl; + jiraInit = { + method: "GET", + headers: { "Authorization": auth, "Accept": "application/json" }, + redirect: "manual", + }; } log("info", "jira_proxy_request", { endpoint, cloudId, sessionId }, request); @@ -1317,6 +1325,10 @@ async function handleJiraProxy( return buildProxyResponse(errorResponse("jira_proxy_error", 502), setCookie); } + if (endpoint === "fields" && !Array.isArray(responseData)) { + return buildProxyResponse(errorResponse("jira_proxy_error", 502), setCookie); + } + // Re-seal on access for key rotation — only when SEAL_KEY_NEXT is set let resealed: string | undefined; if (env.SEAL_KEY_NEXT) { diff --git a/tests/components/dashboard/JiraAssignedTab.test.tsx b/tests/components/dashboard/JiraAssignedTab.test.tsx index 937fb670..2412691a 100644 --- a/tests/components/dashboard/JiraAssignedTab.test.tsx +++ b/tests/components/dashboard/JiraAssignedTab.test.tsx @@ -190,9 +190,9 @@ describe("JiraAssignedTab", () => { expect(screen.getByText(/No issues match current filters/i)).toBeTruthy(); }); - it("shows 'No assigned Jira issues' when no filters active and list is empty", () => { + it("shows 'No Assigned to me Jira issues' when no filters active and list is empty", () => { render(() => ); - expect(screen.getByText(/No assigned Jira issues/i)).toBeTruthy(); + expect(screen.getByText(/No Assigned to me Jira issues/i)).toBeTruthy(); }); // ── Empty state ─────────────────────────────────────────────────────────── @@ -406,4 +406,76 @@ describe("JiraAssignedTab", () => { const keyLink = links.find((l) => l.textContent === "PROJ-1"); expect(keyLink!.getAttribute("href")).toBe("#"); }); + + // ── Scope dropdown ───────────────────────────────────────────────────────── + + describe("scope dropdown", () => { + it("scope filter button is present with label 'Scope'", () => { + const issues = [makeIssue("PROJ-1")]; + render(() => ); + // FilterPopover trigger has aria-label "Filter by Scope" + expect(screen.getByRole("button", { name: /filter by scope/i })).toBeTruthy(); + }); + + it("scope trigger shows built-in label 'Assigned to me' when scope=assigned", () => { + mockJiraFilters = { scope: "assigned", statusCategory: "all", priority: "all", sortField: "status", sortDirection: "asc" }; + const issues = [makeIssue("PROJ-1")]; + render(() => ); + // When non-default value, the trigger shows "Scope: