Skip to content

feat(plans): show epic sub-issues on merged plan view#1942

Open
paurosello wants to merge 10 commits into
mainfrom
plans-epic-sub-issues
Open

feat(plans): show epic sub-issues on merged plan view#1942
paurosello wants to merge 10 commits into
mainfrom
plans-epic-sub-issues

Conversation

@paurosello

@paurosello paurosello commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

What

Surfaces a plan's epic and its implementation issues directly in the plans UI.

Epic linkage (plans-backend)

  • parseEpicRef now also accepts a plain Epic: 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)

  • New EpicSubIssues component on the merged plan detail panel. It fetches the epic's GitHub sub-issue hierarchy through the roadmap backend's existing GET /issues/:owner/:repo/:number/sub-issues endpoint and renders each sub-issue (title linking to GitHub, repo#number, state) with its assignees as @login chips, plus a closed/total progress chip. Cross-repo sub-issues are supported.

Epic assignees in the plan list (plans + roadmap-backend)

  • The roadmap /items/by-issue response now includes the epic issue's assignees (added to its GraphQL query — no extra request).
  • The epic's assignees render as a plain, full-width line at the bottom of each plan list item (merged and proposed), so multiple assignees wrap cleanly instead of overflowing the EpicChip's narrow secondary-action slot. The board-item fetch is shared between EpicChip (status) and EpicAssignees via a useEpicBoardItem hook (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 the EpicChip; the work items and their owners were invisible.

Notes

  • Follows the EpicChip pattern: the roadmap backend is queried directly with retry: false, so portals without the roadmap plugin (or epics without sub-issues/assignees) simply render nothing.
  • Production config needs roadmap.board set wherever the portal should resolve epics (already set where the roadmap page works).
  • Both plans packages are private, so no changeset; the roadmap/roadmap-backend changes are additive (new field / new query field).

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.
@paurosello
paurosello requested a review from a team as a code owner July 16, 2026 09:25
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.
@paurosello

Copy link
Copy Markdown
Contributor Author
image

Comment thread plugins/plans-backend/src/router.ts Outdated
* (`> 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 {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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({

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 }) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 === 0null render, the closed/total count, and the assignees.length === 0null/Unassigned paths. These are cheap with a mocked fetchApi and guard the graceful-degradation behavior the PR relies on.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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: {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants