= ({ notification }) => {
return (
+
+
+
+
{
+ it('renders nothing when not stacked', () => {
+ const tree = renderWithProviders();
+ expect(tree.container).toBeEmptyDOMElement();
+ });
+
+ it('renders a pill when stacked', () => {
+ const tree = renderWithProviders();
+ expect(tree.getByText('1/2')).toBeInTheDocument();
+ });
+
+ it('renders a pill with no position/depth metric when position is missing', () => {
+ const tree = renderWithProviders();
+ // Only the tooltip contents, with no metric text alongside it
+ expect(tree.container.textContent).toBe('Part of a stacked PR series');
+ });
+
+ it('renders the metric for the first position in a stack', () => {
+ const tree = renderWithProviders();
+ expect(tree.getByText('0/3')).toBeInTheDocument();
+ });
+});
diff --git a/src/renderer/components/metrics/StackedPrsPill.tsx b/src/renderer/components/metrics/StackedPrsPill.tsx
new file mode 100644
index 000000000..db3970c49
--- /dev/null
+++ b/src/renderer/components/metrics/StackedPrsPill.tsx
@@ -0,0 +1,35 @@
+import type { FC } from 'react';
+
+import { GitMergeIcon } from '@primer/octicons-react';
+
+import { IconColor } from '../../types';
+
+import { MetricPill } from './MetricPill';
+
+export interface StackedPrsPillProps {
+ isStacked?: boolean;
+ stackPosition?: number;
+ stackDepth?: number;
+}
+
+export const StackedPrsPill: FC = ({
+ isStacked,
+ stackPosition,
+ stackDepth,
+}) => {
+ if (!isStacked) {
+ return null;
+ }
+
+ const metric =
+ stackPosition != null && stackDepth != null ? `${stackPosition}/${stackDepth}` : undefined;
+
+ return (
+
+ );
+};
diff --git a/src/renderer/types.ts b/src/renderer/types.ts
index 85e61bbdb..753da4e39 100644
--- a/src/renderer/types.ts
+++ b/src/renderer/types.ts
@@ -394,6 +394,14 @@ export interface GitifySubject {
commentCount?: number;
/** Labels names and colors */
labels?: GitifyLabels[];
+ /** Whether the PR is part of a GitHub native stacked PR series */
+ isStacked?: boolean;
+ /** This PR's 1-indexed position in the stack, when part of a stacked PR series */
+ stackPosition?: number;
+ /** Total number of PRs in the stack, when part of a stacked PR series */
+ stackDepth?: number;
+ /** GitHub-native issue type (e.g. Bug, Feature, Task) */
+ issueType?: GitifyIssueType;
/** Milestone state/title */
milestone?: GitifyMilestone;
/** Deep link to notification thread */
@@ -463,6 +471,12 @@ export interface GitifyNotificationDisplay {
defaultUserType: UserType;
}
+/** GitHub-native issue type, normalized to a Gitify icon color token */
+export interface GitifyIssueType {
+ name: string;
+ color: IconColor;
+}
+
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 ec703a21e..b9dd5d2ea 100644
--- a/src/renderer/utils/forges/github/__mocks__/response-mocks.ts
+++ b/src/renderer/utils/forges/github/__mocks__/response-mocks.ts
@@ -100,6 +100,7 @@ export function mockIssueResponseNode(mocks: {
labels: { nodes: [] },
comments: { totalCount: 0, nodes: [] },
milestone: null,
+ issueType: null,
reactions: {
totalCount: 0,
},
@@ -142,6 +143,7 @@ export function mockPullRequestResponseNode(mocks: {
closingIssuesReferences: {
nodes: [],
},
+ stackEntry: null,
reactions: {
totalCount: 0,
},
diff --git a/src/renderer/utils/forges/github/graphql/generated/graphql.ts b/src/renderer/utils/forges/github/graphql/generated/graphql.ts
index 1c52ecf3f..2af087b47 100644
--- a/src/renderer/utils/forges/github/graphql/generated/graphql.ts
+++ b/src/renderer/utils/forges/github/graphql/generated/graphql.ts
@@ -33,6 +33,25 @@ export type IssueStateReason =
/** An issue that has been reopened */
| 'REOPENED';
+/** The possible color for an issue type */
+export type IssueTypeColor =
+ /** blue */
+ | 'BLUE'
+ /** gray */
+ | 'GRAY'
+ /** green */
+ | 'GREEN'
+ /** orange */
+ | 'ORANGE'
+ /** pink */
+ | 'PINK'
+ /** purple */
+ | 'PURPLE'
+ /** red */
+ | 'RED'
+ /** yellow */
+ | 'YELLOW';
+
/** The possible states of a milestone. */
export type MilestoneState =
/** A milestone that has been closed. */
@@ -199,7 +218,7 @@ export type FetchIssueByNumberQuery = { repository: { issue: { __typename: 'Issu
| { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Mannequin' }
| { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Organization' }
| { 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, 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, 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' }
@@ -213,7 +232,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' }
| { 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, 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, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null };
export type FetchMergedDetailsTemplateQueryVariables = Exact<{
ownerINDEX: string;
@@ -262,7 +281,7 @@ export type FetchMergedDetailsTemplateQuery = { repository: { discussion?: { __t
| { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Mannequin' }
| { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Organization' }
| { 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, 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, 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' }
| { login: string, htmlUrl: Link, avatarUrl: Link, type: 'EnterpriseUserAccount' }
| { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Mannequin' }
@@ -274,13 +293,19 @@ export type FetchMergedDetailsTemplateQuery = { repository: { discussion?: { __t
| { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Mannequin' }
| { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Organization' }
| { login: string, htmlUrl: Link, avatarUrl: Link, type: 'User' }
- | null, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null } | null> | null }, reviewRequests: { nodes: Array<{ requestedReviewer: { __typename: "User", login: string } | { __typename: "Team" } | null } | null> | null } | null, reviews: { totalCount: number, nodes: Array<{ state: PullRequestReviewState, author:
+ | null, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null } | null> | null }, reviewRequests: { nodes: Array<{ requestedReviewer:
+ | { __typename: 'Bot' }
+ | { __typename: 'EnterpriseTeam' }
+ | { __typename: 'Mannequin' }
+ | { __typename: 'Team' }
+ | { __typename: 'User', login: string }
+ | null } | null> | null } | null, reviews: { totalCount: number, nodes: Array<{ state: PullRequestReviewState, author:
| { login: string }
| { login: string }
| { login: string }
| { login: string }
| { login: string }
- | null } | null> | null } | null, labels: { nodes: Array<{ name: string, color: string } | null> | null } | null, closingIssuesReferences: { nodes: Array<{ number: number } | null> | null } | null, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null } | null } | null };
+ | null } | null> | null } | null, labels: { nodes: Array<{ name: string, color: string } | null> | null } | null, closingIssuesReferences: { nodes: Array<{ number: number } | null> | null } | null, stackEntry: { position: number, stack: { size: number } | null } | null, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null } | null } | null };
export type MergedDetailsQueryTemplateFragment = { repository: { discussion?: { __typename: 'Discussion', number: number, title: string, stateReason: DiscussionStateReason | null, isAnswered?: boolean | null, url: Link, author:
| { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Bot' }
@@ -312,7 +337,7 @@ export type MergedDetailsQueryTemplateFragment = { repository: { discussion?: {
| { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Mannequin' }
| { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Organization' }
| { 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, 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, 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' }
| { login: string, htmlUrl: Link, avatarUrl: Link, type: 'EnterpriseUserAccount' }
| { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Mannequin' }
@@ -324,13 +349,19 @@ export type MergedDetailsQueryTemplateFragment = { repository: { discussion?: {
| { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Mannequin' }
| { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Organization' }
| { login: string, htmlUrl: Link, avatarUrl: Link, type: 'User' }
- | null, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null } | null> | null }, reviewRequests: { nodes: Array<{ requestedReviewer: { __typename: "User", login: string } | { __typename: "Team" } | null } | null> | null } | null, reviews: { totalCount: number, nodes: Array<{ state: PullRequestReviewState, author:
+ | null, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null } | null> | null }, reviewRequests: { nodes: Array<{ requestedReviewer:
+ | { __typename: 'Bot' }
+ | { __typename: 'EnterpriseTeam' }
+ | { __typename: 'Mannequin' }
+ | { __typename: 'Team' }
+ | { __typename: 'User', login: string }
+ | null } | null> | null } | null, reviews: { totalCount: number, nodes: Array<{ state: PullRequestReviewState, author:
| { login: string }
| { login: string }
| { login: string }
| { login: string }
| { login: string }
- | null } | null> | null } | null, labels: { nodes: Array<{ name: string, color: string } | null> | null } | null, closingIssuesReferences: { nodes: Array<{ number: number } | null> | null } | null, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null } | null } | null };
+ | null } | null> | null } | null, labels: { nodes: Array<{ name: string, color: string } | null> | null } | null, closingIssuesReferences: { nodes: Array<{ number: number } | null> | null } | null, stackEntry: { position: number, stack: { size: number } | null } | null, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null } | null } | null };
export type FetchPullRequestByNumberQueryVariables = Exact<{
owner: string;
@@ -355,13 +386,19 @@ export type FetchPullRequestByNumberQuery = { repository: { pullRequest: { __typ
| { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Mannequin' }
| { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Organization' }
| { login: string, htmlUrl: Link, avatarUrl: Link, type: 'User' }
- | null, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null } | null> | null }, reviewRequests: { nodes: Array<{ requestedReviewer: { __typename: "User", login: string } | { __typename: "Team" } | null } | null> | null } | null, reviews: { totalCount: number, nodes: Array<{ state: PullRequestReviewState, author:
+ | null, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null } | null> | null }, reviewRequests: { nodes: Array<{ requestedReviewer:
+ | { __typename: 'Bot' }
+ | { __typename: 'EnterpriseTeam' }
+ | { __typename: 'Mannequin' }
+ | { __typename: 'Team' }
+ | { __typename: 'User', login: string }
+ | null } | null> | null } | null, reviews: { totalCount: number, nodes: Array<{ state: PullRequestReviewState, author:
| { login: string }
| { login: string }
| { login: string }
| { login: string }
| { login: string }
- | null } | null> | null } | null, labels: { nodes: Array<{ name: string, color: string } | null> | null } | null, closingIssuesReferences: { nodes: Array<{ number: number } | null> | null } | null, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null } | null } | null };
+ | null } | null> | null } | null, labels: { nodes: Array<{ name: string, color: string } | null> | null } | null, closingIssuesReferences: { nodes: Array<{ number: number } | null> | null } | null, stackEntry: { position: number, stack: { size: number } | null } | null, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null } | null } | null };
export type PullRequestDetailsFragment = { __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' }
@@ -375,13 +412,19 @@ export type PullRequestDetailsFragment = { __typename: 'PullRequest', number: nu
| { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Mannequin' }
| { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Organization' }
| { login: string, htmlUrl: Link, avatarUrl: Link, type: 'User' }
- | null, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null } | null> | null }, reviewRequests: { nodes: Array<{ requestedReviewer: { __typename: "User", login: string } | { __typename: "Team" } | null } | null> | null } | null, reviews: { totalCount: number, nodes: Array<{ state: PullRequestReviewState, author:
+ | null, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null } | null> | null }, reviewRequests: { nodes: Array<{ requestedReviewer:
+ | { __typename: 'Bot' }
+ | { __typename: 'EnterpriseTeam' }
+ | { __typename: 'Mannequin' }
+ | { __typename: 'Team' }
+ | { __typename: 'User', login: string }
+ | null } | null> | null } | null, reviews: { totalCount: number, nodes: Array<{ state: PullRequestReviewState, author:
| { login: string }
| { login: string }
| { login: string }
| { login: string }
| { login: string }
- | null } | null> | null } | null, labels: { nodes: Array<{ name: string, color: string } | null> | null } | null, closingIssuesReferences: { nodes: Array<{ number: number } | null> | null } | null, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null };
+ | null } | null> | null } | null, labels: { nodes: Array<{ name: string, color: string } | null> | null } | null, closingIssuesReferences: { nodes: Array<{ number: number } | null> | null } | null, stackEntry: { position: number, stack: { size: number } | null } | null, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null };
export type PullRequestReviewFieldsFragment = { state: PullRequestReviewState, author:
| { login: string }
@@ -599,6 +642,10 @@ export const IssueDetailsFragmentDoc = new TypedDocumentString(`
...LabelFields
}
}
+ issueType {
+ name
+ color
+ }
reactions {
totalCount
}
@@ -694,6 +741,12 @@ export const PullRequestDetailsFragmentDoc = new TypedDocumentString(`
number
}
}
+ stackEntry {
+ position
+ stack {
+ size
+ }
+ }
reactions {
totalCount
}
@@ -844,6 +897,10 @@ fragment IssueDetails on Issue {
...LabelFields
}
}
+ issueType {
+ name
+ color
+ }
reactions {
totalCount
}
@@ -910,6 +967,12 @@ fragment PullRequestDetails on PullRequest {
number
}
}
+ stackEntry {
+ position
+ stack {
+ size
+ }
+ }
reactions {
totalCount
}
@@ -1058,6 +1121,10 @@ fragment IssueDetails on Issue {
...LabelFields
}
}
+ issueType {
+ name
+ color
+ }
reactions {
totalCount
}
@@ -1172,6 +1239,10 @@ fragment IssueDetails on Issue {
...LabelFields
}
}
+ issueType {
+ name
+ color
+ }
reactions {
totalCount
}
@@ -1251,6 +1322,12 @@ fragment PullRequestDetails on PullRequest {
number
}
}
+ stackEntry {
+ position
+ stack {
+ size
+ }
+ }
reactions {
totalCount
}
@@ -1351,6 +1428,12 @@ fragment PullRequestDetails on PullRequest {
number
}
}
+ stackEntry {
+ position
+ stack {
+ size
+ }
+ }
reactions {
totalCount
}
diff --git a/src/renderer/utils/forges/github/graphql/issue.graphql b/src/renderer/utils/forges/github/graphql/issue.graphql
index 34743d0f3..745370cf9 100644
--- a/src/renderer/utils/forges/github/graphql/issue.graphql
+++ b/src/renderer/utils/forges/github/graphql/issue.graphql
@@ -47,6 +47,10 @@ fragment IssueDetails on Issue {
...LabelFields
}
}
+ issueType {
+ name
+ color
+ }
reactions {
totalCount
}
diff --git a/src/renderer/utils/forges/github/graphql/pull.graphql b/src/renderer/utils/forges/github/graphql/pull.graphql
index ceca2ad82..efc5b543d 100644
--- a/src/renderer/utils/forges/github/graphql/pull.graphql
+++ b/src/renderer/utils/forges/github/graphql/pull.graphql
@@ -75,6 +75,12 @@ fragment PullRequestDetails on PullRequest {
number
}
}
+ stackEntry {
+ position
+ stack {
+ size
+ }
+ }
reactions {
totalCount
}
diff --git a/src/renderer/utils/forges/github/handlers/issue.test.ts b/src/renderer/utils/forges/github/handlers/issue.test.ts
index b39bc7c89..f106b12fa 100644
--- a/src/renderer/utils/forges/github/handlers/issue.test.ts
+++ b/src/renderer/utils/forges/github/handlers/issue.test.ts
@@ -254,6 +254,48 @@ describe('renderer/utils/notifications/handlers/issue.ts', () => {
reactionGroups: noReactionGroups,
} satisfies Partial);
});
+
+ it('with native issue type', async () => {
+ const mockIssue = mockIssueResponseNode({
+ state: 'OPEN',
+ });
+ mockIssue.issueType = {
+ name: 'Bug',
+ color: 'RED',
+ };
+
+ fetchIssueByNumberSpy.mockResolvedValue({
+ repository: {
+ issue: mockIssue,
+ },
+ } satisfies FetchIssueByNumberQuery);
+
+ const result = await issueHandler.enrich(mockNotification);
+
+ expect(result).toEqual({
+ number: 123,
+ state: 'OPEN',
+ user: {
+ login: mockAuthor.login,
+ avatarUrl: mockAuthor.avatarUrl,
+ htmlUrl: mockAuthor.htmlUrl,
+ type: mockAuthor.type,
+ },
+ author: {
+ login: mockAuthor.login,
+ avatarUrl: mockAuthor.avatarUrl,
+ htmlUrl: mockAuthor.htmlUrl,
+ type: mockAuthor.type,
+ },
+ commentCount: 0,
+ htmlUrl: 'https://github.com/gitify-app/notifications-test/issues/123' as Link,
+ labels: [],
+ issueType: { name: 'Bug', color: IconColor.RED },
+ milestone: undefined,
+ reactionsCount: 0,
+ reactionGroups: noReactionGroups,
+ } satisfies Partial);
+ });
});
describe('iconType', () => {
diff --git a/src/renderer/utils/forges/github/handlers/issue.ts b/src/renderer/utils/forges/github/handlers/issue.ts
index db85ddc14..657d4b5ef 100644
--- a/src/renderer/utils/forges/github/handlers/issue.ts
+++ b/src/renderer/utils/forges/github/handlers/issue.ts
@@ -14,7 +14,7 @@ import { IconColor } from '../../../../types';
import { fetchIssueByNumber } from '../client';
import type { IssueDetailsFragment } from '../graphql/generated/graphql';
import { DefaultHandler, defaultHandler } from './default';
-import { getNotificationAuthor } from './utils';
+import { getNotificationAuthor, mapIssueTypeColor } from './utils';
class IssueHandler extends DefaultHandler {
override readonly supportsMergedQueryEnrichment = true;
@@ -52,6 +52,9 @@ class IssueHandler extends DefaultHandler {
name: label!.name,
color: label!.color,
})) ?? [],
+ issueType: issue.issueType
+ ? { name: issue.issueType.name, color: mapIssueTypeColor(issue.issueType.color) }
+ : undefined,
milestone: issue.milestone ?? undefined,
htmlUrl: issueComment?.url ?? issue.url,
reactionsCount: issueReactionCount,
diff --git a/src/renderer/utils/forges/github/handlers/pullRequest.test.ts b/src/renderer/utils/forges/github/handlers/pullRequest.test.ts
index d597c61fc..a94cbc3b8 100644
--- a/src/renderer/utils/forges/github/handlers/pullRequest.test.ts
+++ b/src/renderer/utils/forges/github/handlers/pullRequest.test.ts
@@ -214,6 +214,53 @@ describe('renderer/utils/notifications/handlers/pullRequest.ts', () => {
} satisfies Partial);
});
+ it('pull request that is part of a native stacked PR series', async () => {
+ const mockPullRequest = mockPullRequestResponseNode({ state: 'OPEN' });
+ mockPullRequest.stackEntry = {
+ position: 2,
+ stack: {
+ size: 3,
+ },
+ };
+
+ fetchPullByNumberSpy.mockResolvedValue({
+ repository: {
+ pullRequest: mockPullRequest,
+ },
+ } satisfies FetchPullRequestByNumberQuery);
+
+ const result = await pullRequestHandler.enrich(mockNotification);
+
+ expect(result).toEqual({
+ number: 123,
+ state: 'OPEN',
+ user: {
+ login: mockAuthor.login,
+ avatarUrl: mockAuthor.avatarUrl,
+ htmlUrl: mockAuthor.htmlUrl,
+ type: mockAuthor.type,
+ },
+ author: {
+ login: mockAuthor.login,
+ avatarUrl: mockAuthor.avatarUrl,
+ htmlUrl: mockAuthor.htmlUrl,
+ type: mockAuthor.type,
+ },
+ reviewRequested: [],
+ reviews: [],
+ labels: [],
+ isStacked: true,
+ stackPosition: 2,
+ stackDepth: 3,
+ linkedIssues: [],
+ commentCount: 0,
+ milestone: undefined,
+ htmlUrl: 'https://github.com/gitify-app/notifications-test/pulls/123' as Link,
+ reactionsCount: 0,
+ reactionGroups: noReactionGroups,
+ } satisfies Partial);
+ });
+
it('with comments', async () => {
const mockPullRequest = mockPullRequestResponseNode({
state: 'OPEN',
diff --git a/src/renderer/utils/forges/github/handlers/pullRequest.ts b/src/renderer/utils/forges/github/handlers/pullRequest.ts
index 1c3f3acf3..6f653b3a2 100644
--- a/src/renderer/utils/forges/github/handlers/pullRequest.ts
+++ b/src/renderer/utils/forges/github/handlers/pullRequest.ts
@@ -80,6 +80,9 @@ class PullRequestHandler extends DefaultHandler {
name: label!.name,
color: label!.color,
})) ?? [],
+ isStacked: pr.stackEntry ? true : undefined,
+ stackPosition: pr.stackEntry?.position,
+ stackDepth: pr.stackEntry?.stack?.size,
linkedIssues: pr.closingIssuesReferences?.nodes
?.filter(Boolean)
.map((issue) => formatGitHubNumber(issue!.number)),
diff --git a/src/renderer/utils/forges/github/handlers/utils.test.ts b/src/renderer/utils/forges/github/handlers/utils.test.ts
index 007550ce1..6335ac9c1 100644
--- a/src/renderer/utils/forges/github/handlers/utils.test.ts
+++ b/src/renderer/utils/forges/github/handlers/utils.test.ts
@@ -1,6 +1,9 @@
import { mockAuthor } from '../__mocks__/response-mocks';
-import { getNotificationAuthor } from './utils';
+import { IconColor } from '../../../../types';
+
+import type { IssueTypeColor } from '../graphql/generated/graphql';
+import { getNotificationAuthor, mapIssueTypeColor } from './utils';
describe('renderer/utils/notifications/handlers/utils.ts', () => {
describe('getNotificationAuthor', () => {
@@ -32,4 +35,26 @@ describe('renderer/utils/notifications/handlers/utils.ts', () => {
});
});
});
+
+ describe('mapIssueTypeColor', () => {
+ it.each([
+ ['RED', IconColor.RED],
+ ['GREEN', IconColor.GREEN],
+ ['YELLOW', IconColor.YELLOW],
+ ['ORANGE', IconColor.YELLOW],
+ ['BLUE', IconColor.PURPLE],
+ ['PURPLE', IconColor.PURPLE],
+ ['PINK', IconColor.PURPLE],
+ ['GRAY', IconColor.GRAY],
+ ] satisfies [IssueTypeColor, IconColor][])(
+ 'maps %s to the expected token',
+ (color, expected) => {
+ expect(mapIssueTypeColor(color)).toBe(expected);
+ },
+ );
+
+ it('falls back to gray for a colour Gitify does not know about', () => {
+ expect(mapIssueTypeColor('CHARTREUSE' as IssueTypeColor)).toBe(IconColor.GRAY);
+ });
+ });
});
diff --git a/src/renderer/utils/forges/github/handlers/utils.ts b/src/renderer/utils/forges/github/handlers/utils.ts
index ab8c25f10..6493af183 100644
--- a/src/renderer/utils/forges/github/handlers/utils.ts
+++ b/src/renderer/utils/forges/github/handlers/utils.ts
@@ -1,6 +1,7 @@
import type { GitifyNotificationUser, Link } from '../../../../types';
+import { IconColor } from '../../../../types';
-import type { AuthorFieldsFragment } from '../graphql/generated/graphql';
+import type { AuthorFieldsFragment, IssueTypeColor } from '../graphql/generated/graphql';
// Author type from GraphQL or manually constructed
type AuthorInput = AuthorFieldsFragment | GitifyNotificationUser | null | undefined;
@@ -47,3 +48,26 @@ export function actionsURL(repositoryURL: string, filters: string[]): Link {
// Note: the GitHub Actions UI cannot handle encoded '+' characters.
return url.toString().replaceAll('%2B', '+') as Link;
}
+
+/**
+ * Map GitHub's native issue type color to a Gitify icon color token.
+ * GitHub supports more colors than Gitify's fixed design token set, so
+ * this collapses to the closest available token.
+ */
+export function mapIssueTypeColor(color: IssueTypeColor): IconColor {
+ switch (color) {
+ case 'RED':
+ return IconColor.RED;
+ case 'GREEN':
+ return IconColor.GREEN;
+ case 'YELLOW':
+ case 'ORANGE':
+ return IconColor.YELLOW;
+ case 'BLUE':
+ case 'PURPLE':
+ case 'PINK':
+ return IconColor.PURPLE;
+ default:
+ return IconColor.GRAY;
+ }
+}