diff --git a/README.md b/README.md index 5a2456c4..f0fbe089 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,8 @@ A dashboard for tracking GitHub issues, PRs, and Actions workflow runs across ma +Jira integration — custom fields and scopes + ## Documentation For detailed feature documentation, see the [User Guide](docs/USER_GUIDE.md). diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 043d1516..3f2520d5 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -491,6 +491,32 @@ When Jira is connected, a **Jira** tab appears in the tab bar. It shows all open If your Jira token expires (OAuth refresh tokens expire after 90 days of inactivity), a notification prompts you to reconnect in Settings. +### Configuring Custom Fields + +You can choose up to 10 Jira custom fields to display in the expandable detail panel on the Jira Assigned tab. System fields (status, priority, assignee, etc.) are always shown and are not part of this list. + +1. Go to **Settings > Jira Cloud Integration > Configure fields** +2. Search or scroll through the list of available custom fields for your Jira site +3. Select up to 10 fields — selected fields are highlighted and checked +4. Click **Save** + +Selected fields appear in the expandable detail panel when you click a Jira issue row on the Jira tab. If no custom fields are configured, the detail panel shows only the system fields. + +Changes take effect on the next poll cycle (up to 5 minutes). + +### Configuring Custom Scopes + +You can add custom scope options to the Jira tab's scope dropdown by selecting user-type fields. Each selected field adds a new scope option alongside the built-in ones ("Assigned to me", "Created by me", "Watching"). + +For example, selecting an "Architect" user field adds an "Architect" option to the scope dropdown, which shows Jira issues where the Architect field is set to your account (`Architect = currentUser()`). + +1. Go to **Settings > Jira Cloud Integration > Configure scopes** +2. Search or scroll through the list of available user-type fields (only fields where `= currentUser()` is valid JQL are shown) +3. Select any fields you want to use as scope options +4. Click **Save** + +Custom scopes are independent of custom fields — a field can be selected for both display in the detail panel and as a scope option. + ### Bookmarking Jira Issues From the Jira Assigned tab, click the **pin icon** on any issue to add it to the Tracked tab alongside your pinned GitHub items. diff --git a/docs/dashboard-screenshot-compact.png b/docs/dashboard-screenshot-compact.png index d5e5709e..07ce7dcb 100644 Binary files a/docs/dashboard-screenshot-compact.png and b/docs/dashboard-screenshot-compact.png differ diff --git a/docs/dashboard-screenshot.png b/docs/dashboard-screenshot.png index 15113f2b..0c2d58b0 100644 Binary files a/docs/dashboard-screenshot.png and b/docs/dashboard-screenshot.png differ diff --git a/docs/jira-screenshot.png b/docs/jira-screenshot.png new file mode 100644 index 00000000..e26185a1 Binary files /dev/null and b/docs/jira-screenshot.png differ diff --git a/e2e/capture-screenshot.spec.ts b/e2e/capture-screenshot.spec.ts index c6ca0a38..7099b706 100644 --- a/e2e/capture-screenshot.spec.ts +++ b/e2e/capture-screenshot.spec.ts @@ -334,7 +334,168 @@ const workflowRunsResponse = { ], }; -// ── Test ────────────────────────────────────────────────────────────────────── +// ── Synthetic Jira data ────────────────────────────────────────────────────── + +const jiraIssues = [ + { + id: "10001", + key: "PLATFORM-1234", + self: "https://acme-corp.atlassian.net/rest/api/3/issue/10001", + fields: { + summary: "Migrate OAuth flow to PKCE challenge for enhanced security", + status: { id: "3", name: "In Progress", statusCategory: { id: 4, key: "indeterminate", name: "In Progress" } }, + priority: { id: "2", name: "High" }, + assignee: { accountId: "abc123", displayName: "Jane Doe" }, + project: { id: "10000", key: "PLATFORM", name: "Platform Engineering" }, + created: "2026-04-15T09:00:00.000Z", + updated: "2026-04-30T14:22:00.000Z", + issuetype: { name: "Story" }, + customfield_10001: 8, + customfield_10002: { name: "Sprint 24.9 – May", id: 142 }, + customfield_10003: { value: "Identity & Access" }, + customfield_10004: "PLATFORM-900", + customfield_10005: ["security", "oauth"], + }, + }, + { + id: "10002", + key: "PLATFORM-1187", + self: "https://acme-corp.atlassian.net/rest/api/3/issue/10002", + fields: { + summary: "Add rate limiting to public API endpoints", + status: { id: "5", name: "Code Review", statusCategory: { id: 4, key: "indeterminate", name: "In Progress" } }, + priority: { id: "1", name: "Highest" }, + assignee: { accountId: "abc123", displayName: "Jane Doe" }, + project: { id: "10000", key: "PLATFORM", name: "Platform Engineering" }, + created: "2026-04-08T11:00:00.000Z", + updated: "2026-05-01T09:15:00.000Z", + issuetype: { name: "Task" }, + customfield_10001: 5, + customfield_10002: { name: "Sprint 24.9 – May", id: 142 }, + customfield_10003: { value: "Platform Core" }, + customfield_10005: ["api", "security"], + }, + }, + { + id: "10003", + key: "PLATFORM-1302", + self: "https://acme-corp.atlassian.net/rest/api/3/issue/10003", + fields: { + summary: "Fix SAML assertion replay vulnerability in SSO flow", + status: { id: "3", name: "In Progress", statusCategory: { id: 4, key: "indeterminate", name: "In Progress" } }, + priority: { id: "1", name: "Highest" }, + assignee: { accountId: "abc123", displayName: "Jane Doe" }, + project: { id: "10000", key: "PLATFORM", name: "Platform Engineering" }, + created: "2026-04-28T15:30:00.000Z", + updated: "2026-04-30T18:00:00.000Z", + issuetype: { name: "Bug" }, + customfield_10001: 3, + customfield_10002: { name: "Sprint 24.9 – May", id: 142 }, + customfield_10003: { value: "Identity & Access" }, + customfield_10004: "PLATFORM-900", + }, + }, + { + id: "10004", + key: "PLATFORM-1198", + self: "https://acme-corp.atlassian.net/rest/api/3/issue/10004", + fields: { + summary: "Design system token refresh for dark mode palette", + status: { id: "8", name: "Blocked", statusCategory: { id: 4, key: "indeterminate", name: "In Progress" } }, + priority: { id: "3", name: "Medium" }, + assignee: { accountId: "abc123", displayName: "Jane Doe" }, + project: { id: "10000", key: "PLATFORM", name: "Platform Engineering" }, + created: "2026-04-10T10:00:00.000Z", + updated: "2026-04-25T16:45:00.000Z", + issuetype: { name: "Story" }, + customfield_10001: 13, + customfield_10002: { name: "Sprint 24.8 – Apr", id: 141 }, + customfield_10003: { value: "Frontend Platform" }, + }, + }, + { + id: "10005", + key: "DATA-892", + self: "https://acme-corp.atlassian.net/rest/api/3/issue/10005", + fields: { + summary: "Optimize Spark job partition strategy for skewed datasets", + status: { id: "3", name: "In Progress", statusCategory: { id: 4, key: "indeterminate", name: "In Progress" } }, + priority: { id: "2", name: "High" }, + assignee: { accountId: "abc123", displayName: "Jane Doe" }, + project: { id: "10001", key: "DATA", name: "Data Engineering" }, + created: "2026-04-18T08:30:00.000Z", + updated: "2026-04-29T11:00:00.000Z", + issuetype: { name: "Story" }, + customfield_10001: 8, + customfield_10003: { value: "Data Engineering" }, + customfield_10005: ["spark", "performance"], + }, + }, + { + id: "10006", + key: "DATA-915", + self: "https://acme-corp.atlassian.net/rest/api/3/issue/10006", + fields: { + summary: "Add data quality checks to ingestion pipeline", + status: { id: "2", name: "Selected for Development", statusCategory: { id: 2, key: "new", name: "To Do" } }, + priority: { id: "3", name: "Medium" }, + assignee: { accountId: "abc123", displayName: "Jane Doe" }, + project: { id: "10001", key: "DATA", name: "Data Engineering" }, + created: "2026-04-22T14:00:00.000Z", + updated: "2026-04-28T09:30:00.000Z", + issuetype: { name: "Task" }, + customfield_10001: 5, + customfield_10003: { value: "Data Engineering" }, + customfield_10005: ["quality", "pipeline"], + }, + }, + { + id: "10007", + key: "INFRA-445", + self: "https://acme-corp.atlassian.net/rest/api/3/issue/10007", + fields: { + summary: "Migrate Kubernetes clusters to 1.29", + status: { id: "3", name: "In Progress", statusCategory: { id: 4, key: "indeterminate", name: "In Progress" } }, + priority: { id: "2", name: "High" }, + assignee: { accountId: "abc123", displayName: "Jane Doe" }, + project: { id: "10002", key: "INFRA", name: "Cloud Infrastructure" }, + created: "2026-03-15T10:00:00.000Z", + updated: "2026-05-01T08:00:00.000Z", + issuetype: { name: "Epic" }, + customfield_10001: 21, + customfield_10003: { value: "Cloud Infrastructure" }, + customfield_10005: ["k8s", "migration"], + }, + }, + { + id: "10008", + key: "INFRA-467", + self: "https://acme-corp.atlassian.net/rest/api/3/issue/10008", + fields: { + summary: "Implement pod disruption budgets for critical services", + status: { id: "7", name: "Ready for QA", statusCategory: { id: 4, key: "indeterminate", name: "In Progress" } }, + priority: { id: "3", name: "Medium" }, + assignee: { accountId: "abc123", displayName: "Jane Doe" }, + project: { id: "10002", key: "INFRA", name: "Cloud Infrastructure" }, + created: "2026-04-20T09:00:00.000Z", + updated: "2026-04-30T16:00:00.000Z", + issuetype: { name: "Task" }, + customfield_10001: 3, + customfield_10002: { name: "Sprint 24.9 – May", id: 142 }, + customfield_10003: { value: "Cloud Infrastructure" }, + customfield_10004: "INFRA-445", + }, + }, +]; + +const jiraSearchResponse = { + issues: jiraIssues, + total: jiraIssues.length, + maxResults: 100, + startAt: 0, +}; + +// ── Tests ───────────────────────────────────────────────────────────────────── test("capture dashboard screenshot", async ({ page }) => { // 1a. Seed localStorage before any navigation @@ -497,3 +658,143 @@ test("capture dashboard screenshot", async ({ page }) => { await page.screenshot({ path: "docs/dashboard-screenshot-compact.png" }); }); + +test("capture jira screenshot", async ({ page }) => { + await page.addInitScript(() => { + localStorage.removeItem("github-tracker:view"); + localStorage.setItem("github-tracker:auth-token", "fake-screenshot-token"); + localStorage.setItem( + "github-tracker:config", + JSON.stringify({ + onboardingComplete: true, + selectedOrgs: ["acme-corp"], + selectedRepos: [ + { owner: "acme-corp", name: "web-platform", fullName: "acme-corp/web-platform" }, + ], + trackedUsers: [ + { + login: "jdoe", + avatarUrl: "https://avatars.githubusercontent.com/u/12345?v=4", + name: "Jane Doe", + type: "user", + }, + ], + theme: "dark", + jira: { + enabled: true, + authMethod: "token", + cloudId: "fake-cloud-id", + siteUrl: "https://acme-corp.atlassian.net", + siteName: "Acme Corp", + email: "jane@acme-corp.com", + issueKeyDetection: true, + expandIssueDetails: true, + customFields: [ + { id: "customfield_10001", name: "Story Points" }, + { id: "customfield_10002", name: "Sprint" }, + { id: "customfield_10003", name: "Team" }, + { id: "customfield_10004", name: "Epic Link" }, + { id: "customfield_10005", name: "Labels" }, + ], + customScopes: [ + { id: "customfield_10100", name: "Code Reviewer" }, + ], + }, + }) + ); + localStorage.setItem( + "github-tracker:jira-auth", + JSON.stringify({ + accessToken: "fake-sealed-token", + sealedRefreshToken: "", + expiresAt: Date.now() + 3_600_000, + cloudId: "fake-cloud-id", + siteUrl: "https://acme-corp.atlassian.net", + siteName: "Acme Corp", + email: "jane@acme-corp.com", + }) + ); + }); + + // GitHub API mocks (catch-all first, specific routes override via reverse registration priority) + await page.route("https://api.github.com/**", (route) => route.abort()); + + await page.route("https://api.github.com/notifications*", (route) => + route.fulfill({ status: 200, json: [] }) + ); + + await page.route("https://api.github.com/repos/*/*/actions/runs*", (route) => + route.fulfill({ status: 200, json: { total_count: 0, workflow_runs: [] } }) + ); + + await page.route("https://api.github.com/graphql", (route) => + route.fulfill({ + status: 200, + json: { + data: { + issues: { issueCount: 0, pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] }, + prInvolves: { issueCount: 0, pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] }, + prReviewReq: { issueCount: 0, pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] }, + rateLimit: { limit: 5000, remaining: 4950, resetAt: RESET_AT }, + }, + }, + }) + ); + + await page.route("https://api.github.com/user", (route) => + route.fulfill({ + status: 200, + json: { + login: "jdoe", + name: "Jane Doe", + avatar_url: "https://avatars.githubusercontent.com/u/12345?v=4", + id: 12345, + }, + }) + ); + + // Jira proxy mock — handles all endpoints (search, issue, fields) + await page.route("**/api/jira/proxy", async (route) => { + const body = route.request().postDataJSON() as { endpoint?: string } | null; + const endpoint = body?.endpoint ?? "search"; + + if (endpoint === "fields") { + return route.fulfill({ + status: 200, + json: [ + { id: "summary", name: "Summary", custom: false, schema: { type: "string" } }, + { id: "status", name: "Status", custom: false, schema: { type: "status" } }, + { id: "customfield_10001", name: "Story Points", custom: true, schema: { type: "number" } }, + { id: "customfield_10002", name: "Sprint", custom: true, schema: { type: "json", custom: "com.pyxis.greenhopper.jira:gh-sprint" } }, + { id: "customfield_10003", name: "Team", custom: true, schema: { type: "option" } }, + { id: "customfield_10004", name: "Epic Link", custom: true, schema: { type: "string", custom: "com.pyxis.greenhopper.jira:gh-epic-link" } }, + { id: "customfield_10005", name: "Labels", custom: true, schema: { type: "array", items: "string" } }, + { id: "customfield_10100", name: "Code Reviewer", custom: true, schema: { type: "user" } }, + ], + }); + } + + // Default: search endpoint + return route.fulfill({ status: 200, json: jiraSearchResponse }); + }); + + await page.goto("/dashboard"); + await page.getByRole("tablist").waitFor(); + + // Switch to Jira tab + await page.getByRole("tab", { name: /jira assigned/i }).click(); + await page.getByRole("tab", { name: /jira assigned/i, selected: true }).waitFor(); + + // Wait for Jira issues to load + await page.getByText("PLATFORM-1234").waitFor(); + + // Select the custom scope to show it in the screenshot + await page.getByRole("button", { name: /filter by scope/i }).click(); + await page.getByRole("button", { name: "Code Reviewer" }).click(); + + // Wait for popover to close and re-fetch to complete + await page.locator(".filter-popover-content").waitFor({ state: "detached" }); + await page.getByText("PLATFORM-1234").waitFor(); + + await page.screenshot({ path: "docs/jira-screenshot.png" }); +}); diff --git a/src/app/components/dashboard/ActionsTab.tsx b/src/app/components/dashboard/ActionsTab.tsx index 260c3ee4..8fcb616c 100644 --- a/src/app/components/dashboard/ActionsTab.tsx +++ b/src/app/components/dashboard/ActionsTab.tsx @@ -251,18 +251,7 @@ export default function ActionsTab(props: ActionsTabProps) { - {/* Empty — only when no groups exist at all (locked stubs are handled by EmptyLockedRepoRow) */} - 0) && repoGroups().length === 0 - } - > -
-

No workflow runs found.

-
-
- - {/* Repo groups */} + {/* Repo groups + locked stubs */} 0) && repoGroups().length > 0}> {(repoGroup) => { @@ -374,6 +363,13 @@ export default function ActionsTab(props: ActionsTabProps) { + {/* Empty state — shown when no actual items, whether or not locked stubs appear above */} + 0) && filteredRuns().length === 0}> +
+

No workflow runs found.

+
+
+ {/* Upstream repos exclusion note */}

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/IssuesTab.tsx b/src/app/components/dashboard/IssuesTab.tsx index a2b3e125..918d5c65 100644 --- a/src/app/components/dashboard/IssuesTab.tsx +++ b/src/app/components/dashboard/IssuesTab.tsx @@ -307,35 +307,7 @@ export default function IssuesTab(props: IssuesTabProps) { - {/* Empty — only when no groups exist at all (locked stubs are handled by EmptyLockedRepoRow) */} - 0) && pageGroups().length === 0}> -

- -

- {activeFilters().scope === "all" ? "No open issues found" : "No open issues involving you"} -

-

- {activeFilters().scope === "all" - ? "No issues match your current filters." - : "Issues where you are the author, assignee, or mentioned will appear here."} -

-
-
- - {/* Issue rows */} + {/* Issue rows + locked stubs */} 0) && pageGroups().length > 0}>
@@ -460,6 +432,34 @@ export default function IssuesTab(props: IssuesTabProps) {
+ {/* Empty state — shown when no actual items, whether or not locked stubs appear above */} + 0) && filteredSorted().length === 0}> +
+ +

+ {activeFilters().scope === "all" ? "No open issues found" : "No open issues involving you"} +

+

+ {activeFilters().scope === "all" + ? "No issues match your current filters." + : "Issues where you are the author, assignee, or mentioned will appear here."} +

+
+
+ 0}> 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 [toggledIssues, setToggledIssues] = createSignal>(new Set()); + + // Reset toggled state when scope changes + createEffect(on(() => filters().scope, () => setToggledIssues(new Set()), { defer: true })); + + function toggleExpanded(key: string) { + setToggledIssues((prev) => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); else next.add(key); + return next; + }); + } + + const expandByDefault = () => !!(config.jira?.expandIssueDetails); + const pinnedJiraKeys = createMemo(() => new Set( viewState.trackedItems @@ -245,7 +274,7 @@ export default function JiraAssignedTab(props: JiraAssignedTabProps) { group={{ field: "scope", label: "Scope", - options: SCOPE_OPTIONS, + options: scopeOptions(), defaultValue: "assigned", }} value={filters().scope} @@ -318,16 +347,7 @@ 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`} -

-
-
- + {/* Jira project groups + locked stubs */} 0}>
@@ -359,8 +379,17 @@ 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 = () => expandByDefault() ? !toggledIssues().has(issue.key) : toggledIssues().has(issue.key); + const detailPanelId = `jira-detail-${issue.key}`; return ( -
+
+
{ + if ((e.target as HTMLElement).closest("a, button")) return; + toggleExpanded(issue.key); + }} + >
@@ -399,7 +428,7 @@ export default function JiraAssignedTab(props: JiraAssignedTabProps) {
-

+

{issue.fields.summary}

@@ -445,6 +474,50 @@ export default function JiraAssignedTab(props: JiraAssignedTabProps) { + +
+ +
+ 0} + fallback={ +

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

+ } + > +
+ + {(field) => { + const val = () => (issue.fields as Record)[field.id]; + return ( + + + {field.name}: + + + + ); + }} + +
+
+
+
); }} @@ -474,6 +547,39 @@ export default function JiraAssignedTab(props: JiraAssignedTabProps) {
+ + {/* Empty state — shown when no actual items, whether or not locked stubs appear above */} + +
+ +

+ {(filters().statusCategory !== "all" || filters().priority !== "all") + ? "No issues match current filters" + : (() => { + const opt = scopeOptions().find((o) => o.value === filters().scope); + return `No ${opt?.label ?? "Assigned to me"} Jira issues`; + })()} +

+

+ {(filters().statusCategory !== "all" || filters().priority !== "all") + ? "Try adjusting your status or priority filters." + : "Issues matching your current scope will appear here."} +

+
+
); } diff --git a/src/app/components/dashboard/JiraFieldValue.tsx b/src/app/components/dashboard/JiraFieldValue.tsx new file mode 100644 index 00000000..36345520 --- /dev/null +++ b/src/app/components/dashboard/JiraFieldValue.tsx @@ -0,0 +1,62 @@ +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; + for (const key of ["displayName", "name", "value", "label"]) { + if (typeof obj[key] === "string") return obj[key] as string; + } + } + 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["name"] === "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 as unknown[]) + .filter((el) => !Array.isArray(el)) + .map((el) => ({ text: renderScalar(el), src: el })); + if (parts.length === 0) return ; + return ( + + {parts.map(({ text, src }, i) => ( + <> + {i > 0 && , } + {isOptionOrUser(src) + ? {text} + : text} + + ))} + + ); + } + const text = renderScalar(props.value); + if (isOptionOrUser(props.value)) { + return {text}; + } + return {text}; +} diff --git a/src/app/components/dashboard/PullRequestsTab.tsx b/src/app/components/dashboard/PullRequestsTab.tsx index aa8d4a6b..ff018a2b 100644 --- a/src/app/components/dashboard/PullRequestsTab.tsx +++ b/src/app/components/dashboard/PullRequestsTab.tsx @@ -368,35 +368,7 @@ export default function PullRequestsTab(props: PullRequestsTabProps) { - {/* Empty — only when no groups exist at all (locked stubs are handled by EmptyLockedRepoRow) */} - 0) && pageGroups().length === 0}> -
- -

- {activeFilters().scope === "all" ? "No open pull requests found" : "No open pull requests involving you"} -

-

- {activeFilters().scope === "all" - ? "No pull requests match your current filters." - : "PRs where you are the author, assignee, or reviewer will appear here."} -

-
-
- - {/* PR rows */} + {/* PR rows + locked stubs */} 0) && pageGroups().length > 0}>
@@ -661,6 +633,34 @@ export default function PullRequestsTab(props: PullRequestsTabProps) {
+ {/* Empty state — shown when no actual items, whether or not locked stubs appear above */} + 0) && filteredSorted().length === 0}> +
+ +

+ {activeFilters().scope === "all" ? "No open pull requests found" : "No open pull requests involving you"} +

+

+ {activeFilters().scope === "all" + ? "No pull requests match your current filters." + : "PRs where you are the author, assignee, or reviewer will appear here."} +

+
+
+ 0}> 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 defaultFieldIds = new Set(DEFAULT_FIELDS); + const selectable = allFields.filter((f) => f.custom || !defaultFieldIds.has(f.id)); + setFields(selectable); + + // Best-effort sample value fetch — request all fields (*all) across + // multiple queries to maximize coverage across projects and issue types. + const fieldIdSet = new Set(selectable.map((f) => f.id)); + if (fieldIdSet.size > 0) { + try { + const queries = [ + "assignee = currentUser() ORDER BY updated DESC", + "project in projectsWhereUserHasPermission('Browse') ORDER BY updated DESC", + ]; + const merged: Record = {}; + for (const jql of queries) { + if (Object.keys(merged).length >= fieldIdSet.size) break; + try { + const result = await props.client.searchJql(jql, { maxResults: 20, fields: ["*all"] }); + for (const issue of result.issues) { + const fields = issue.fields as Record; + for (const id of fieldIdSet) { + if (id in merged) continue; + if (fields[id] !== undefined && fields[id] !== null) { + merged[id] = fields[id]; + } + } + } + } catch { + // Individual query failure shouldn't block others + } + } + setSampleValues(merged); + } 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(); + const matched = fields().filter((f) => !q || f.name.toLowerCase().includes(q)); + const samples = sampleValues(); + const sel = selected(); + const selOrder = [...sel.keys()]; + return matched.sort((a, b) => { + const aSel = sel.has(a.id); + const bSel = sel.has(b.id); + if (aSel !== bSel) return aSel ? -1 : 1; + if (aSel && bSel) return selOrder.indexOf(a.id) - selOrder.indexOf(b.id); + const aHas = samples[a.id] !== null && samples[a.id] !== undefined; + const bHas = samples[b.id] !== null && samples[b.id] !== undefined; + if (aHas !== bHas) return aHas ? -1 : 1; + return a.name.localeCompare(b.name); + }); + }); + + 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 moveField(id: string, direction: "up" | "down") { + setSelected((prev) => { + const entries = [...prev.entries()]; + const idx = entries.findIndex(([k]) => k === id); + if (idx < 0) return prev; + const swapIdx = direction === "up" ? idx - 1 : idx + 1; + if (swapIdx < 0 || swapIdx >= entries.length) return prev; + [entries[idx], entries[swapIdx]] = [entries[swapIdx], entries[idx]]; + return new Map(entries); + }); + } + + function handleSave() { + props.onSave([...selected().values()]); + } + + function extractLabel(obj: Record): string | null { + for (const key of ["displayName", "name", "value", "label"] as const) { + if (typeof obj[key] === "string") return obj[key]; + } + return null; + } + + function previewText(fieldId: string): string { + const val = sampleValues()[fieldId]; + if (val === null || val === undefined) return "—"; + if (typeof val === "string" || typeof val === "number" || typeof val === "boolean") { + const s = String(val); + return s.length > 40 ? s.slice(0, 40) + "…" : s; + } + if (typeof val === "object" && !Array.isArray(val)) { + return extractLabel(val as Record) ?? "—"; + } + if (Array.isArray(val) && val.length > 0) { + const first = val[0]; + if (typeof first === "object" && first !== null) { + return extractLabel(first as Record) ?? "…"; + } + return String(first); + } + return "—"; + } + + return ( +
+ +

+ Select fields to display on Jira issues. Preview values are sampled from your recent issues. +

+
+ +
+ +
+
+ +

{error()}

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

+ 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 ( +
+ + +
+ + +
+
+ {previewText(field.id)} +
+ ); + }} +
+
+
+
+
+ + +
+
+ ); +} diff --git a/src/app/components/settings/JiraScopePicker.tsx b/src/app/components/settings/JiraScopePicker.tsx new file mode 100644 index 00000000..a2e96288 --- /dev/null +++ b/src/app/components/settings/JiraScopePicker.tsx @@ -0,0 +1,126 @@ +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; +} + +const SYSTEM_USER_FIELDS = new Set(["assignee", "reporter", "creator"]); + +function isUserTypeField(field: JiraFieldMeta): boolean { + if (!field.custom) return false; + 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" + data-picker-search + /> + +

+ 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..d4fef81c 100644 --- a/src/app/components/settings/SettingsPage.tsx +++ b/src/app/components/settings/SettingsPage.tsx @@ -2,10 +2,10 @@ 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"; +import { clearAuth, jiraAuth, setJiraAuth, clearJiraConfigFull, isJiraAuthenticated } from "../../stores/auth"; import { clearCache } from "../../stores/cache"; import { pushNotification } from "../../lib/errors"; import { buildOrgAccessUrl, buildJiraAuthorizeUrl } from "../../lib/oauth"; @@ -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,10 @@ 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 jiraClient = createMemo(() => createJiraClient(config.jira?.authMethod)); const jiraApiSiteUrl = () => { const sub = jiraApiSubdomain().trim(); @@ -311,7 +320,7 @@ export default function SettingsPage() { } function handleJiraDisconnect() { - clearJiraAuth(); + clearJiraConfigFull(); // DefaultTab guard: reset to issues if pointing at Jira tab if (config.defaultTab === "jiraAssigned") { updateConfig({ defaultTab: "issues" }); @@ -1003,6 +1012,77 @@ export default function SettingsPage() { class="toggle toggle-primary" /> + + updateJiraConfig({ expandIssueDetails: e.currentTarget.checked })} + class="toggle toggle-primary" + /> + + 0 + ? (config.jira?.customFields ?? []).map((f) => f.name).join(", ") + : "None configured" + } + > + + + +
+ { updateJiraCustomFields(fields); setShowFieldPicker(false); }} + onCancel={() => setShowFieldPicker(false)} + /> +
+
+ 0 + ? (config.jira?.customScopes ?? []).map((s) => s.name).join(", ") + : "None configured" + } + > + + + +
+ { 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; + }); +} + +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 (!VALID_FIELD_ID.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..37646295 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,14 @@ 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"); + if (!Array.isArray(data)) throw new JiraApiError(502, data, "Unexpected non-array response from /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", @@ -135,6 +145,8 @@ export class JiraClient implements IJiraClient { // ── JiraProxyClient (API token / Worker proxy) ──────────────────────────────── export class JiraProxyClient implements IJiraClient { + private _fieldsCache: { data: JiraFieldMeta[]; ts: number } | null = null; + constructor( private readonly cloudId: string, private readonly email: string, @@ -143,7 +155,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 +229,13 @@ export class JiraProxyClient implements IJiraClient { } return { issues: result.issues, errors: result.errors }; } + + async getFields(): Promise { + if (this._fieldsCache && Date.now() - this._fieldsCache.ts < 30_000) return this._fieldsCache.data; + // The proxy always returns a JiraFieldMeta[] array for the fields endpoint. + // request returns T & { resealed? } but arrays don't merge that way — cast is safe here. + const data = (await this.request("fields", {})) as unknown as JiraFieldMeta[]; + this._fieldsCache = { data, ts: Date.now() }; + return data; + } } 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..a81fff49 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"), @@ -268,9 +268,11 @@ export function setGlobalFilter( org: string | null, repo: string | null ): void { + if (viewState.globalFilter.org === org && viewState.globalFilter.repo === repo) return; setViewState( produce((draft) => { - draft.globalFilter = { org, repo }; + draft.globalFilter.org = org; + draft.globalFilter.repo = repo; }) ); } 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..455aef5d 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,9 @@ export const JiraConfigSchema = z.object({ siteName: z.string().optional(), email: z.string().optional(), issueKeyDetection: z.boolean().default(true), + expandIssueDetails: z.boolean().default(false), + customFields: z.array(JiraCustomFieldSchema).max(10).default([]), + customScopes: z.array(JiraCustomFieldSchema).max(20).default([]), }); export type JiraConfig = z.infer; @@ -94,7 +104,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, expandIssueDetails: false, customFields: [], customScopes: [] }), }); export type Config = z.infer; diff --git a/src/worker/index.ts b/src/worker/index.ts index 36d9e422..0c6b8d52 100644 --- a/src/worker/index.ts +++ b/src/worker/index.ts @@ -1136,7 +1136,11 @@ async function handleJiraProxy( setCookie: string | undefined ): Promise { if (!env.JIRA_CLIENT_ID) { - return errorResponse("not_found", 404); + log("warn", "jira_proxy_missing_client_id", {}, request); + return new Response(JSON.stringify({ error: "jira_not_configured", message: "JIRA_CLIENT_ID is not set — add it to .dev.vars (local) or Worker secrets (production)" }), { + status: 503, + headers: { "Content-Type": "application/json", ...SECURITY_HEADERS }, + }); } if (request.method !== "POST") { @@ -1180,7 +1184,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 +1242,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 +1264,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 +1283,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 +1329,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/ActionsTab.test.tsx b/tests/components/dashboard/ActionsTab.test.tsx index 6662177b..27803271 100644 --- a/tests/components/dashboard/ActionsTab.test.tsx +++ b/tests/components/dashboard/ActionsTab.test.tsx @@ -142,7 +142,7 @@ describe("ActionsTab — empty-repo state preservation", () => { expect(stub?.querySelector('[aria-expanded]')).toBeNull(); }); - it("hides empty-state message when only locked stubs exist (no double render)", () => { + it("shows empty state below locked stubs when only locked repos exist", () => { setViewState(produce((s) => { s.lockedRepos.actions = ["owner/locked-empty"]; })); @@ -157,8 +157,8 @@ describe("ActionsTab — empty-repo state preservation", () => { // Locked stub renders const stub = container.querySelector('[data-repo-group="owner/locked-empty"]'); expect(stub).not.toBeNull(); - // Empty-state message does NOT render alongside the stub - expect(screen.queryByText("No workflow runs found.")).toBeNull(); + // Big empty state also renders + expect(screen.getByText("No workflow runs found.")).not.toBeNull(); }); it("hides locked stubs during initial load (no skeleton + stub double render)", () => { diff --git a/tests/components/dashboard/IssuesTab.test.tsx b/tests/components/dashboard/IssuesTab.test.tsx index da10d622..550bc484 100644 --- a/tests/components/dashboard/IssuesTab.test.tsx +++ b/tests/components/dashboard/IssuesTab.test.tsx @@ -780,12 +780,12 @@ describe("IssuesTab — empty-repo state preservation", () => { expect(stub?.querySelector('[aria-expanded]')).toBeNull(); }); - it("hides empty-state message when only locked stubs exist (no double render)", () => { + it("shows empty state below locked stubs when only locked repos exist", () => { setViewState(produce((s) => { s.lockedRepos.issues = ["owner/locked-empty"]; })); - render(() => ( + const { container } = render(() => ( { /> )); - expect(screen.queryByText(/No open issues/i)).toBeNull(); + // Locked stub renders + const stub = container.querySelector('[data-repo-group="owner/locked-empty"]'); + expect(stub).not.toBeNull(); + // Big empty state also renders + expect(screen.getByText(/No open issues/i)).not.toBeNull(); }); }); diff --git a/tests/components/dashboard/JiraAssignedTab.test.tsx b/tests/components/dashboard/JiraAssignedTab.test.tsx index 937fb670..7541f9a1 100644 --- a/tests/components/dashboard/JiraAssignedTab.test.tsx +++ b/tests/components/dashboard/JiraAssignedTab.test.tsx @@ -31,7 +31,7 @@ vi.mock("../../../src/app/stores/config", () => ({ import JiraAssignedTab, { _resetJiraTabState } from "../../../src/app/components/dashboard/JiraAssignedTab"; import type { JiraIssue } from "../../../src/shared/jira-types"; import { config } from "../../../src/app/stores/config"; -import { trackItem, untrackJiraItem, setAllExpanded } from "../../../src/app/stores/view"; +import { trackItem, untrackJiraItem, setAllExpanded, setTabFilter } from "../../../src/app/stores/view"; // ── Fixtures ────────────────────────────────────────────────────────────────── @@ -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,150 @@ 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: