Skip to content

Commit 03761f1

Browse files
authored
Merge pull request #634 from codex-team/feat/events-assignee-filter
feat(api): add assignee filter to project daily events search
2 parents c4d0111 + 39340b8 commit 03761f1

5 files changed

Lines changed: 196 additions & 17 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "hawk.api",
3-
"version": "1.4.9",
3+
"version": "1.4.11",
44
"main": "index.ts",
55
"license": "BUSL-1.1",
66
"scripts": {

src/models/eventsFactory.js

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -203,10 +203,11 @@ class EventsFactory extends Factory {
203203
*
204204
* @param {Number} limit - events count limitations
205205
* @param {DailyEventsCursor} paginationCursor - object that contains boundary values of the last event in the previous portion
206-
* @param {'BY_DATE' | 'BY_COUNT'} sort - events sort order
207-
* @param {EventsFilters} filters - marks by which events should be filtered
206+
* @param {'BY_DATE' | 'BY_COUNT' | 'BY_AFFECTED_USERS'} sort - events sort order
207+
* @param {EventsFilters} filters - marks by which events should be filtered (resolved, starred, ignored only; assignee is separate)
208208
* @param {String} search - Search query
209-
* @param {String} release - release name
209+
* @param {String|undefined} release - release name
210+
* @param {String|undefined} assignee - user id or __filter_unassigned__ / __filter_any_assignee__
210211
*
211212
* @return {DaylyEventsPortionSchema}
212213
*/
@@ -216,7 +217,8 @@ class EventsFactory extends Factory {
216217
sort = 'BY_DATE',
217218
filters = {},
218219
search = '',
219-
release
220+
release,
221+
assignee
220222
) {
221223
if (typeof search !== 'string') {
222224
throw new Error('Search parameter must be a string');
@@ -334,10 +336,12 @@ class EventsFactory extends Factory {
334336
}
335337
: {};
336338

339+
const markFilters = ['resolved', 'starred', 'ignored'];
337340
const matchFilter = filters
338341
? Object.fromEntries(
339342
Object
340343
.entries(filters)
344+
.filter(([ mark ]) => markFilters.includes(mark))
341345
.map(([mark, exists]) => [`event.marks.${mark}`, { $exists: exists } ])
342346
)
343347
: {};
@@ -361,6 +365,44 @@ class EventsFactory extends Factory {
361365
}
362366
: {};
363367

368+
/**
369+
* Sentinel values from garage assignee filter (not user ids)
370+
*/
371+
const FILTER_UNASSIGNED = '__filter_unassigned__';
372+
const FILTER_ANY_ASSIGNEE = '__filter_any_assignee__';
373+
374+
const assigneeFilter = (() => {
375+
if (!assignee) {
376+
return {};
377+
}
378+
if (assignee === FILTER_UNASSIGNED) {
379+
/**
380+
* Use $and so this does not collide with searchFilter’s top-level $or in $match spread
381+
*/
382+
return {
383+
$and: [
384+
{
385+
$or: [
386+
{ 'event.assignee': { $exists: false } },
387+
{ 'event.assignee': null },
388+
{ 'event.assignee': '' },
389+
],
390+
},
391+
],
392+
};
393+
}
394+
if (assignee === FILTER_ANY_ASSIGNEE) {
395+
return {
396+
'event.assignee': {
397+
$exists: true,
398+
$nin: [null, ''],
399+
},
400+
};
401+
}
402+
403+
return { 'event.assignee': String(assignee) };
404+
})();
405+
364406
pipeline.push(
365407
/**
366408
* Left outer join original event on groupHash field
@@ -398,6 +440,7 @@ class EventsFactory extends Factory {
398440
...matchFilter,
399441
...searchFilter,
400442
...releaseFilter,
443+
...assigneeFilter,
401444
},
402445
},
403446
{ $limit: limit + 1 },

src/resolvers/project.js

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -571,19 +571,21 @@ module.exports = {
571571
},
572572

573573
/**
574-
* Returns recent Events grouped by day
574+
* Returns a paginated portion of daily-grouped events
575575
*
576-
* @param {ProjectDBScheme} project - result of parent resolver
577-
* @param {Number} limit - limit for events count
578-
* @param {DailyEventsCursor} cursor - object with boundary values of the first event in the next portion
579-
* @param {'BY_DATE' | 'BY_COUNT'} sort - events sort order
580-
* @param {EventsFilters} filters - marks by which events should be filtered
581-
* @param {String} release - release name
582-
* @param {String} search - search query
583-
*
584-
* @return {Promise<RecentEventSchema[]>}
576+
* @param {ProjectDBScheme} project - parent resolver result
577+
* @param {object} args - GraphQL arguments
578+
* @param {number} args.limit - max rows in portion
579+
* @param {object|null} args.nextCursor - pagination cursor
580+
* @param {string} args.sort - BY_DATE | BY_COUNT | BY_AFFECTED_USERS (mapped in factory)
581+
* @param {object} args.filters - mark filters only: resolved, starred, ignored (assignee uses args.assignee)
582+
* @param {string} args.search - search query
583+
* @param {string|undefined} args.release - optional release label filter
584+
* @param {string|undefined} args.assignee - user id or __filter_unassigned__ / __filter_any_assignee__
585+
* @param {object} context - GraphQL context
586+
* @returns {Promise<object>} dailyEventsPortion payload from factory
585587
*/
586-
async dailyEventsPortion(project, { limit, nextCursor, sort, filters, search, release }, context) {
588+
async dailyEventsPortion(project, { limit, nextCursor, sort, filters, search, release, assignee }, context) {
587589
if (search) {
588590
if (search.length > MAX_SEARCH_QUERY_LENGTH) {
589591
search = search.slice(0, MAX_SEARCH_QUERY_LENGTH);
@@ -592,7 +594,15 @@ module.exports = {
592594

593595
const factory = getEventsFactory(context, project._id);
594596

595-
const dailyEventsPortion = await factory.findDailyEventsPortion(limit, nextCursor, sort, filters, search, release);
597+
const dailyEventsPortion = await factory.findDailyEventsPortion(
598+
limit,
599+
nextCursor,
600+
sort,
601+
filters,
602+
search,
603+
release,
604+
assignee
605+
);
596606

597607
return dailyEventsPortion;
598608
},

src/typeDefs/project.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,11 @@ type Project {
347347
Release label to filter events by payload.release
348348
"""
349349
release: String
350+
351+
"""
352+
Filter by assignee: workspace user id, or sentinels __filter_unassigned__ (no assignee) / __filter_any_assignee__ (has assignee)
353+
"""
354+
assignee: ID
350355
): DailyEventsPortion
351356
352357
"""
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import '../../src/env-test';
2+
3+
jest.mock('../../src/integrations/github/service', () => require('../__mocks__/github-service'));
4+
5+
jest.mock('../../src/resolvers/helpers/eventsFactory', () => ({
6+
__esModule: true,
7+
default: jest.fn(),
8+
}));
9+
10+
// @ts-expect-error - CommonJS module, TypeScript can't infer types properly
11+
import projectResolverModule from '../../src/resolvers/project';
12+
import getEventsFactory from '../../src/resolvers/helpers/eventsFactory';
13+
14+
const projectResolver = projectResolverModule as {
15+
Project: {
16+
dailyEventsPortion: (...args: unknown[]) => Promise<unknown>;
17+
};
18+
};
19+
20+
describe('Project resolver dailyEventsPortion', () => {
21+
beforeEach(() => {
22+
jest.clearAllMocks();
23+
});
24+
25+
it('should pass assignee filter to events factory', async () => {
26+
const findDailyEventsPortion = jest.fn().mockResolvedValue({
27+
nextCursor: null,
28+
dailyEvents: [],
29+
});
30+
(getEventsFactory as unknown as jest.Mock).mockReturnValue({
31+
findDailyEventsPortion,
32+
});
33+
34+
const project = { _id: 'project-1' };
35+
const args = {
36+
limit: 50,
37+
nextCursor: null,
38+
sort: 'BY_DATE',
39+
filters: { ignored: true },
40+
search: 'TypeError',
41+
release: '1.0.0',
42+
assignee: 'user-123',
43+
};
44+
45+
await projectResolver.Project.dailyEventsPortion(project, args, {});
46+
47+
expect(findDailyEventsPortion).toHaveBeenCalledWith(
48+
50,
49+
null,
50+
'BY_DATE',
51+
{ ignored: true },
52+
'TypeError',
53+
'1.0.0',
54+
'user-123'
55+
);
56+
});
57+
58+
it('should pass assignee sentinel for unassigned filter to factory', async () => {
59+
const findDailyEventsPortion = jest.fn().mockResolvedValue({
60+
nextCursor: null,
61+
dailyEvents: [],
62+
});
63+
(getEventsFactory as unknown as jest.Mock).mockReturnValue({
64+
findDailyEventsPortion,
65+
});
66+
67+
const project = { _id: 'project-1' };
68+
const args = {
69+
limit: 50,
70+
nextCursor: null,
71+
sort: 'BY_DATE',
72+
filters: {},
73+
search: '',
74+
assignee: '__filter_unassigned__',
75+
};
76+
77+
await projectResolver.Project.dailyEventsPortion(project, args, {});
78+
79+
expect(findDailyEventsPortion).toHaveBeenCalledWith(
80+
50,
81+
null,
82+
'BY_DATE',
83+
{},
84+
'',
85+
undefined,
86+
'__filter_unassigned__'
87+
);
88+
});
89+
90+
it('should call factory with undefined assignee when assignee argument is omitted', async () => {
91+
const findDailyEventsPortion = jest.fn().mockResolvedValue({
92+
nextCursor: null,
93+
dailyEvents: [],
94+
});
95+
(getEventsFactory as unknown as jest.Mock).mockReturnValue({
96+
findDailyEventsPortion,
97+
});
98+
99+
const project = { _id: 'project-1' };
100+
const args = {
101+
limit: 10,
102+
nextCursor: null,
103+
sort: 'BY_DATE',
104+
filters: {},
105+
search: '',
106+
release: undefined,
107+
};
108+
109+
await projectResolver.Project.dailyEventsPortion(project, args, {});
110+
111+
expect(findDailyEventsPortion).toHaveBeenCalledWith(
112+
10,
113+
null,
114+
'BY_DATE',
115+
{},
116+
'',
117+
undefined,
118+
undefined
119+
);
120+
});
121+
});

0 commit comments

Comments
 (0)