feat(plans): show epic sub-issues on merged plan view#1942
Conversation
Parse blockquote-style Epic references (`> Epic: [...]`) in plan markdown in addition to the bold header form, and render the epic's GitHub sub-issues on the merged plan detail panel via the roadmap backend's sub-issues endpoint.
Extend the roadmap sub-issues endpoint to also return the queried epic issue itself (with its assignees), and surface assignees for both the epic and each sub-issue as `@login` chips on the plan's sub-issues section.
Add assignees to the roadmap /items/by-issue response (from the issue's own assignees) and render them as @login chips below the epic status chip in the merged plan list sidebar. Drop the redundant epic-assignees line from the sub-issues detail panel.
Extract the epic board-item fetch into a shared useEpicBoardItem hook so EpicChip renders only the status chip again. A new EpicAssignees renders the assignee names as a plain, full-width line at the bottom of each plan list item (merged and proposed), so multiple assignees wrap instead of overflowing the chip's narrow secondary-action slot.
| * (`> Epic: ...`). Accepts an `owner/repo#N` reference or a GitHub issue | ||
| * URL anywhere on that line; the first Epic line wins. | ||
| */ | ||
| export function parseEpicRef(markdown: string): EpicRef | null { |
There was a problem hiding this comment.
Broadened Epic: matching + non-global first-match can pick the wrong epic.
The regex now matches a plain Epic: line anywhere at line start (incl. inside blockquotes, case-insensitively), but markdown.match(...) is non-global, so only the first Epic-labeled line is ever considered — the documented "first Epic line wins" rule.
With the old **Epic:**-only pattern that first line was almost always the real header. Now a stray earlier line such as Epic: originally forked from acme/old#5 (prose, or a blockquote caption) will match first: parseEpicRef then extracts acme/old#5 (wrong epic) or returns null if that line has no ref — even when a valid **Epic:** [new/repo#9](...) header exists further down. This regresses the very plans the PR aims to link.
Consider scanning all Epic-labeled lines and returning the first that actually yields a ref, rather than only the first labeled line.
There was a problem hiding this comment.
Good catch — now scans every Epic line and takes the first that actually resolves to a ref, so a stray caption or prose line cant shadow the real header. Added a regression test for it.
| const discoveryApi = useApi(discoveryApiRef); | ||
| const fetchApi = useApi(fetchApiRef); | ||
|
|
||
| const { data } = useQuery({ |
There was a problem hiding this comment.
Reuse: this re-implements the roadmap direct-fetch boilerplate just extracted into useEpicBoardItem.
This PR pulled the discoveryApi.getBaseUrl('roadmap') → fetchApi.fetch(...) → !response.ok throw → useQuery({ retry: false, staleTime: 60_000 }) pattern out of EpicChip into useEpicBoardItem precisely to avoid duplication — but EpicSubIssues open-codes the same shape again against a different path. A small shared helper (e.g. useRoadmapFetch(pathSegments, queryKey) that both hooks build on) would keep the "query roadmap backend directly, fall back on failure" contract in one place; today a change to that contract (auth header, error handling, staleTime) has to be made in two spots.
There was a problem hiding this comment.
Agreed — pulled it into a shared useRoadmapFetch({ path, queryKey, select }) that both hooks build on now, so the fetch/error/retry contract lives in one place.
| * through its frontend API) to avoid coupling the plugin packages; portals | ||
| * without the roadmap plugin render nothing via the failed query. | ||
| */ | ||
| export function EpicSubIssues({ epic }: { epic: EpicRef }) { |
There was a problem hiding this comment.
Test coverage: the new frontend pieces ship untested.
The backend parseEpicRef change got a solid new describe block, but the three new frontend units — EpicSubIssues, EpicAssignees, and useEpicBoardItem — have no tests. Worth at least covering the branches that carry real logic: the subIssues.length === 0 → null render, the closed/total count, and the assignees.length === 0 → null/Unassigned paths. These are cheap with a mocked fetchApi and guard the graceful-degradation behavior the PR relies on.
There was a problem hiding this comment.
Done — added tests for all three: the empty-sub-issues/empty-assignees null renders, the closed/total count, cross-repo slugs, and the board-item unwrap over a mocked fetch.
| }, | ||
| // Let the epic assignees wrap onto a full-width line below the row's main | ||
| // content and the EpicChip (which sits in the secondary-action slot). | ||
| planItem: { |
There was a problem hiding this comment.
Duplication: the planItem/epicAction styles and the two epic conditional blocks are copy-pasted verbatim into ProposedTab.
Both files now carry identical planItem (flexWrap: wrap) and epicAction (top: spacing(2); transform: none) style entries plus the same two epicBy….has(...) && (…) blocks (one for EpicAssignees, one for the ListItemSecondaryAction+EpicChip). Consider hoisting the two style objects into a shared module so a tweak lands once.
Note for whoever refactors: the JSX must stay as two direct children of ListItem — MUI v4 detects ListItemSecondaryAction only as the last direct child, so wrapping both in a fragment / extracting them into a child component that returns a fragment would silently break the chip positioning. Style-sharing is safe; JSX extraction is not.
There was a problem hiding this comment.
Hoisted both style objects into a shared useEpicListItemStyles — and kept your "must stay two direct children of ListItem" warning in there for whoever touches it next. JSX left as-is.
…the header parseEpicRef matched a plain `Epic:` label anywhere with a non-global regex, so only the first labelled line was ever considered. A stray earlier line (prose, a caption) with no usable ref would return null and mask a real `**Epic:**` header below it. Scan every labelled line and return the first that actually yields a ref.
…apFetch
EpicSubIssues re-implemented the same getBaseUrl('roadmap') -> fetch -> throw-on-not-ok -> useQuery({ retry: false, staleTime }) shape that useEpicBoardItem already carried. Extract it into a useRoadmapFetch helper both hooks build on, so the 'query roadmap directly, fall back on failure' contract lives in one place.
MergedTab and ProposedTab carried identical planItem/epicAction style entries. Hoist them into useEpicListItemStyles so a tweak lands once. Styles only -- the chip/assignees JSX must stay two direct children of ListItem (MUI v4 secondary-action detection), noted in the shared module.
Guard the graceful-degradation branches the PR relies on: empty/absent sub-issues and assignees render nothing, the closed/total count, cross-repo sub-issue slugs, and useEpicBoardItem's { item } unwrap + URL-encoding over a mocked fetch.

What
Surfaces a plan's epic and its implementation issues directly in the plans UI.
Epic linkage (
plans-backend)parseEpicRefnow also accepts a plainEpic:label and the blockquote form (> Epic: [owner/repo#N](url)), in addition to the bold**Epic:**header. Plans written with the blockquote convention (e.g.bumblebee-plans/mcp-oauth-rotation-race) were previously not linked to their epic at all.Sub-issues section (
plans)EpicSubIssuescomponent on the merged plan detail panel. It fetches the epic's GitHub sub-issue hierarchy through the roadmap backend's existingGET /issues/:owner/:repo/:number/sub-issuesendpoint and renders each sub-issue (title linking to GitHub,repo#number, state) with its assignees as@loginchips, plus aclosed/totalprogress chip. Cross-repo sub-issues are supported.Epic assignees in the plan list (
plans+roadmap-backend)/items/by-issueresponse now includes the epic issue'sassignees(added to its GraphQL query — no extra request).EpicChip's narrow secondary-action slot. The board-item fetch is shared betweenEpicChip(status) andEpicAssigneesvia auseEpicBoardItemhook (single deduped query).Why
Epics generated from plans (e.g. via
/to-issues) get their implementation broken down into GitHub sub-issues, often across several repos. Until now the plans UI only showed theEpicChip; the work items and their owners were invisible.Notes
EpicChippattern: the roadmap backend is queried directly withretry: false, so portals without the roadmap plugin (or epics without sub-issues/assignees) simply render nothing.roadmap.boardset wherever the portal should resolve epics (already set where the roadmap page works).planspackages areprivate, so no changeset; theroadmap/roadmap-backendchanges are additive (new field / new query field).