Skip to content

Commit 7d9b27a

Browse files
committed
feat(api): merge query
Signed-off-by: Adam Setch <[email protected]>
1 parent c6f1c51 commit 7d9b27a

File tree

6 files changed

+89
-104
lines changed

6 files changed

+89
-104
lines changed

src/renderer/utils/api/graphql/utils.ts

Lines changed: 57 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -1,81 +1,74 @@
1-
// Shared patterns for fragment parsing
2-
const FRAGMENT_BLOCK_REGEX =
3-
/fragment\s+[A-Za-z0-9_]+\s+on\s+[A-Za-z0-9_]+\s*\{/g;
4-
const FRAGMENT_NAME_REGEX = /fragment\s+([A-Za-z0-9_]+)\s+on\s+([A-Za-z0-9_]+)/;
5-
6-
type ParsedFragment = {
7-
name: string;
8-
onType: string;
9-
content: string; // inside the outermost braces
10-
full: string; // full fragment text including header and braces
11-
};
12-
13-
function sliceBraceBlock(doc: string, startIndex: number): string | null {
14-
let depth = 0;
15-
for (let i = startIndex; i < doc.length; i++) {
16-
const ch = doc[i];
17-
if (ch === '{') {
18-
depth++;
19-
} else if (ch === '}') {
20-
depth--;
21-
if (depth === 0) {
22-
// Include braces
23-
return doc.slice(startIndex, i + 1);
1+
import { type DocumentNode, parse, print } from 'graphql';
2+
3+
import type { TypedDocumentString } from './generated/graphql';
4+
5+
// AST-based helpers for robust fragment parsing and deduping
6+
7+
function toDocumentNode(
8+
doc: TypedDocumentString<unknown, unknown>,
9+
): DocumentNode {
10+
return parse(doc.toString());
11+
}
12+
13+
export function getQueryFragmentBody(
14+
doc: TypedDocumentString<unknown, unknown>,
15+
): string | null {
16+
const ast: DocumentNode = toDocumentNode(doc);
17+
18+
for (const def of ast.definitions) {
19+
if (
20+
def.kind === 'FragmentDefinition' &&
21+
def.typeCondition.name.value === 'Query'
22+
) {
23+
// Print just the fragment selection set body (without outer braces)
24+
const printed = print(def);
25+
const open = printed.indexOf('{');
26+
const close = printed.lastIndexOf('}');
27+
28+
if (open !== -1 && close !== -1 && close > open) {
29+
return printed.slice(open + 1, close).trim();
2430
}
2531
}
2632
}
2733
return null;
2834
}
2935

30-
export function parseFragments(doc: string): ParsedFragment[] {
31-
const results: ParsedFragment[] = [];
32-
// Find each fragment header position
33-
const indices: number[] = [];
34-
for (const m of doc.matchAll(FRAGMENT_BLOCK_REGEX)) {
35-
if (m.index !== undefined) {
36-
indices.push(m.index);
37-
}
38-
}
36+
export function extractFragments(
37+
doc: TypedDocumentString<unknown, unknown>,
38+
): Map<string, string> {
39+
const ast: DocumentNode = toDocumentNode(doc);
3940

40-
for (const idx of indices) {
41-
const header = doc.slice(idx, idx + 200); // small window for name/type parse
42-
const nt = header.match(FRAGMENT_NAME_REGEX);
43-
if (!nt) {
44-
continue;
45-
}
46-
const name = nt[1];
47-
const onType = nt[2];
48-
// Find the opening brace for this header
49-
const bracePos = doc.indexOf('{', idx);
50-
if (bracePos === -1) {
51-
continue;
52-
}
53-
const block = sliceBraceBlock(doc, bracePos);
54-
if (!block) {
55-
continue;
41+
const map = new Map<string, string>();
42+
43+
for (const def of ast.definitions) {
44+
if (def.kind === 'FragmentDefinition') {
45+
const name = def.name.value;
46+
47+
if (!map.has(name)) {
48+
map.set(name, print(def));
49+
}
5650
}
57-
const full = doc.slice(idx, bracePos) + block;
58-
const content = block.slice(1, block.length - 1).trim();
59-
results.push({ name, onType, content, full: full.trim() });
6051
}
61-
return results;
62-
}
6352

64-
export function getQueryFragmentBody(doc: string): string | null {
65-
const frags = parseFragments(doc);
66-
const qFrag = frags.find((f) => f.onType === 'Query');
67-
return qFrag?.content ?? null;
53+
return map;
6854
}
6955

70-
export function extractFragments(doc: string): Map<string, string> {
71-
const frags = parseFragments(doc);
72-
const map = new Map<string, string>();
73-
for (const f of frags) {
74-
if (!map.has(f.name)) {
75-
map.set(f.name, f.full);
56+
export function extractFragmentsAll(
57+
docs: Array<TypedDocumentString<unknown, unknown>>,
58+
): Map<string, string> {
59+
const out = new Map<string, string>();
60+
61+
for (const doc of docs) {
62+
const m = extractFragments(doc);
63+
64+
for (const [k, v] of m) {
65+
if (!out.has(k)) {
66+
out.set(k, v);
67+
}
7668
}
7769
}
78-
return map;
70+
71+
return out;
7972
}
8073

8174
// Helper to compose a merged query given selections, fragments and variable defs

src/renderer/utils/notifications/handlers/discussion.ts

Lines changed: 6 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@ import {
2626
DiscussionDetailsFragmentDoc,
2727
DiscussionMergeQueryFragmentDoc,
2828
} from '../../api/graphql/generated/graphql';
29-
import { getQueryFragmentBody } from '../../api/graphql/utils';
3029
import { DefaultHandler, defaultHandler } from './default';
3130
import type { GraphQLMergedQueryConfig } from './types';
3231
import { getNotificationAuthor } from './utils';
@@ -36,23 +35,14 @@ class DiscussionHandler extends DefaultHandler {
3635

3736
mergeQueryConfig() {
3837
return {
39-
queryFragment: getQueryFragmentBody(
40-
DiscussionMergeQueryFragmentDoc.toString(),
41-
),
42-
responseFragment: DiscussionDetailsFragmentDoc.toString(),
38+
queryFragment: DiscussionMergeQueryFragmentDoc,
39+
responseFragment: DiscussionDetailsFragmentDoc,
4340
extras: [
4441
{ name: 'lastComments', type: 'Int', defaultValue: 100 },
4542
{ name: 'lastReplies', type: 'Int', defaultValue: 100 },
4643
{ name: 'firstLabels', type: 'Int', defaultValue: 100 },
4744
{ name: 'includeIsAnswered', type: 'Boolean!', defaultValue: true },
4845
],
49-
selection: (
50-
index: number,
51-
) => `node${index}: repository(owner: $owner${index}, name: $name${index}) {
52-
discussion(number: $number${index}) {
53-
...DiscussionDetails
54-
}
55-
}`,
5646
} as GraphQLMergedQueryConfig;
5747
}
5848

@@ -88,7 +78,10 @@ class DiscussionHandler extends DefaultHandler {
8878
discussion.author,
8979
]),
9080
comments: discussion.comments.totalCount,
91-
labels: discussion.labels?.nodes.map((label) => label.name) ?? [],
81+
labels:
82+
discussion.labels?.nodes?.flatMap((label) =>
83+
label ? [label.name] : [],
84+
) ?? [],
9285
htmlUrl: latestDiscussionComment?.url ?? discussion.url,
9386
};
9487
}

src/renderer/utils/notifications/handlers/issue.ts

Lines changed: 8 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@ import {
2121
IssueDetailsFragmentDoc,
2222
IssueMergeQueryFragmentDoc,
2323
} from '../../api/graphql/generated/graphql';
24-
import { getQueryFragmentBody } from '../../api/graphql/utils';
2524
import { DefaultHandler, defaultHandler } from './default';
2625
import type { GraphQLMergedQueryConfig } from './types';
2726
import { getNotificationAuthor } from './utils';
@@ -31,22 +30,13 @@ class IssueHandler extends DefaultHandler {
3130

3231
mergeQueryConfig() {
3332
return {
34-
queryFragment: getQueryFragmentBody(
35-
IssueMergeQueryFragmentDoc.toString(),
36-
),
33+
queryFragment: IssueMergeQueryFragmentDoc,
3734

38-
responseFragment: IssueDetailsFragmentDoc.toString(),
35+
responseFragment: IssueDetailsFragmentDoc,
3936
extras: [
4037
{ name: 'lastComments', type: 'Int', defaultValue: 100 },
4138
{ name: 'firstLabels', type: 'Int', defaultValue: 100 },
4239
],
43-
selection: (
44-
index: number,
45-
) => `node${index}: repository(owner: $owner${index}, name: $name${index}) {
46-
issue(number: $number${index}) {
47-
...IssueDetails
48-
}
49-
}`,
5040
} as GraphQLMergedQueryConfig;
5141
}
5242

@@ -59,7 +49,7 @@ class IssueHandler extends DefaultHandler {
5949

6050
const issueState = issue.stateReason ?? issue.state;
6151

62-
const issueComment = issue.comments.nodes[0];
52+
const issueComment = issue.comments?.nodes?.[0];
6353

6454
const issueUser = getNotificationAuthor([
6555
issueComment?.author,
@@ -70,9 +60,11 @@ class IssueHandler extends DefaultHandler {
7060
number: issue.number,
7161
state: issueState,
7262
user: issueUser,
73-
comments: issue.comments.totalCount,
74-
labels: issue.labels?.nodes.map((label) => label.name),
75-
milestone: issue.milestone,
63+
comments: issue.comments?.totalCount ?? 0,
64+
labels:
65+
issue.labels?.nodes?.flatMap((label) => (label ? [label.name] : [])) ??
66+
undefined,
67+
milestone: issue.milestone ?? undefined,
7668
htmlUrl: issueComment?.url ?? issue.url,
7769
};
7870
}

src/renderer/utils/notifications/handlers/pullRequest.ts

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@ import {
2525
PullRequestMergeQueryFragmentDoc,
2626
type PullRequestReviewFieldsFragment,
2727
} from '../../api/graphql/generated/graphql';
28-
import { getQueryFragmentBody } from '../../api/graphql/utils';
2928
import { DefaultHandler, defaultHandler } from './default';
3029
import type { GraphQLMergedQueryConfig } from './types';
3130
import { getNotificationAuthor } from './utils';
@@ -35,10 +34,8 @@ class PullRequestHandler extends DefaultHandler {
3534

3635
mergeQueryConfig() {
3736
return {
38-
queryFragment: getQueryFragmentBody(
39-
PullRequestMergeQueryFragmentDoc.toString(),
40-
),
41-
responseFragment: PullRequestDetailsFragmentDoc.toString(),
37+
queryFragment: PullRequestMergeQueryFragmentDoc,
38+
responseFragment: PullRequestDetailsFragmentDoc,
4239
extras: [
4340
{ name: 'firstLabels', type: 'Int', defaultValue: 100 },
4441
{ name: 'lastComments', type: 'Int', defaultValue: 100 },
@@ -132,7 +129,7 @@ export function getLatestReviewForReviewers(
132129
}
133130

134131
// Find the most recent review for each reviewer
135-
const latestReviews = [];
132+
const latestReviews: PullRequestReviewFieldsFragment[] = [];
136133
const sortedReviews = reviews.toReversed();
137134
for (const prReview of sortedReviews) {
138135
const reviewerFound = latestReviews.find(

src/renderer/utils/notifications/handlers/types.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,11 @@ import type { OcticonProps } from '@primer/octicons-react';
44

55
import type { GitifySubject, Link, SettingsState } from '../../../types';
66
import type { Notification, Subject, SubjectType } from '../../../typesGitHub';
7+
import type { TypedDocumentString } from '../../api/graphql/generated/graphql';
78

89
export type GraphQLMergedQueryConfig = {
9-
queryFragment: string;
10-
responseFragment: string;
10+
queryFragment: TypedDocumentString<unknown, unknown>;
11+
responseFragment: TypedDocumentString<unknown, unknown>;
1112
extras: Array<{
1213
name: string;
1314
type: string;

src/renderer/utils/notifications/notifications.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,12 @@ import type {
1010
import type { Notification } from '../../typesGitHub';
1111
import { listNotificationsForAuthenticatedUser } from '../api/client';
1212
import { determineFailureType } from '../api/errors';
13-
import { composeMergedQuery, extractFragments } from '../api/graphql/utils';
13+
import type { TypedDocumentString } from '../api/graphql/generated/graphql';
14+
import {
15+
composeMergedQuery,
16+
extractFragments,
17+
getQueryFragmentBody,
18+
} from '../api/graphql/utils';
1419
import { getHeaders } from '../api/request';
1520
import { getGitHubGraphQLUrl, getNumberFromUrl } from '../api/utils';
1621
import { rendererLogError, rendererLogWarn } from '../logger';
@@ -147,7 +152,7 @@ export async function enrichNotifications(
147152
handler: ReturnType<typeof createNotificationHandler>;
148153
}> = [];
149154

150-
const collectFragments = (doc: string) => {
155+
const collectFragments = (doc: TypedDocumentString<unknown, unknown>) => {
151156
const found = extractFragments(doc);
152157
for (const [name, frag] of found.entries()) {
153158
if (!fragments.has(name)) {
@@ -168,10 +173,14 @@ export async function enrichNotifications(
168173
const repo = notification.repository.name;
169174
const number = getNumberFromUrl(notification.subject.url);
170175
const alias = `node${index}`;
171-
const queryFragment = config.queryFragment.replaceAll(
176+
const queryFragmentBody = getQueryFragmentBody(config.queryFragment) ?? '';
177+
const queryFragment = queryFragmentBody.replaceAll(
172178
'INDEX',
173179
index.toString(),
174180
);
181+
if (!queryFragment || queryFragment.trim().length === 0) {
182+
continue;
183+
}
175184
selections.push(queryFragment);
176185
variableDefinitions.push(
177186
`$owner${index}: String!, $name${index}: String!, $number${index}: Int!`,

0 commit comments

Comments
 (0)