Skip to content
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "hawk.api",
"version": "1.6.0",
"version": "1.7.0",
"main": "index.ts",
"license": "BUSL-1.1",
"scripts": {
Expand Down
1 change: 1 addition & 0 deletions src/models/eventsFactory.js
Original file line number Diff line number Diff line change
Expand Up @@ -1119,6 +1119,7 @@ class EventsFactory extends Factory {
originalTimestamp: event.timestamp,
originalEventId: event._id,
timestamp: repetition.timestamp,
firstSeenInRelease: event.payload.release === undefined ? null : event.payload.release,
Comment thread
neSpecc marked this conversation as resolved.
Resolved
payload: composeEventPayloadByRepetition(event.payload, repetition),
projectId: this.projectId,
};
Expand Down
22 changes: 22 additions & 0 deletions src/models/releasesFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,28 @@ export default class ReleasesFactory {
this.filesCollection = dbConnection.collection<ReleaseFileDBScheme>('releases.files');
}

/**
* Return the newest meaningful release name, regardless of validation status.
*
* @param projectId - project identifier
* @returns latest release name or null when the project has no releases
*/
public async findLatestName(projectId: string | ObjectId): Promise<string | null> {
const release = await this.collection.findOne({
projectId: projectId.toString(),
release: {
$type: 'string',
Comment thread
neSpecc marked this conversation as resolved.
Resolved
$regex: /\S/,
$not: /^\s*(null|undefined|nan)\s*$/i,
Comment thread
neSpecc marked this conversation as resolved.
Resolved
},
}, {
projection: { release: 1 },
sort: { _id: -1 },
Comment thread
neSpecc marked this conversation as resolved.
Resolved
});

return release?.release ?? null;
}

/**
* Find one release document by projectId and release label.
* Tries both exact string match and numeric fallback (if release can be cast to number).
Expand Down
13 changes: 13 additions & 0 deletions src/resolvers/event.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,19 @@ module.exports = {
},
},
Event: {
/**
* Returns the original release, preserving null for repetitions of legacy events.

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.

Suggested change
* Returns the original release, preserving null for repetitions of legacy events.
* Returns the original release name, preserving null for repetitions of legacy events.

*
* @param {Object} event - original event or composed repetition

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.

jsdoc params are not fit actual params (firstSeenInRelease, payload)

* @returns {string|null}
*/
firstSeenInRelease({ firstSeenInRelease, payload }) {
if (firstSeenInRelease !== undefined) {
return firstSeenInRelease;
}

return payload && payload.release !== undefined ? payload.release : null;
},

/**
* Returns repetitions portion of the event
Expand Down
12 changes: 12 additions & 0 deletions src/resolvers/project.js
Original file line number Diff line number Diff line change
Expand Up @@ -781,6 +781,18 @@ module.exports = {
return dateFromObjectId(project._id);
},

/**
* Return the newest project release, including releases without events.
*
* @param {ProjectDBScheme} project - parent project
* @param {Object} _args - field arguments
* @param {ContextFactories} factories - model factories
* @returns {Promise<string|null>}
*/
latestRelease(project, _args, { factories }) {
Comment thread
neSpecc marked this conversation as resolved.
Resolved
return factories.releasesFactory.findLatestName(project._id);
},

/**
* Find project's event
*
Expand Down
15 changes: 15 additions & 0 deletions src/typeDefs/event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,21 @@ type Event {
"""
release: Release

"""
Release of the original event, regardless of the selected repetition
"""
firstSeenInRelease: String

"""
Release in which the event was resolved
"""
resolvedInRelease: String

"""
Release in which the event regressed
"""
regressionInRelease: String

"""
Event repetitions portion
"""
Expand Down
5 changes: 5 additions & 0 deletions src/typeDefs/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,11 @@ type Project {
"""
uidAdded: User!

"""
Most recently created meaningful release, even if no events occurred in it
"""
latestRelease: String

"""
Project's Event
"""
Expand Down
155 changes: 155 additions & 0 deletions test/models/eventsFactory-release-metadata.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import '../../src/env-test';
import { ObjectId } from 'mongodb';

const collectionMock = {
aggregate: jest.fn(),
find: jest.fn(),
findOne: jest.fn(),
};
const loadEvent = jest.fn();

jest.mock('../../src/redisHelper', () => ({
__esModule: true,
default: { getInstance: (): object => ({}) },
}));

jest.mock('../../src/services/chartDataService', () => ({
__esModule: true,
default: jest.fn(),
}));

jest.mock('../../src/dataLoaders', () => ({
createProjectEventsByIdLoader: (): { load: jest.Mock } => ({ load: loadEvent }),
}));

jest.mock('../../src/mongo', () => ({
databases: {
events: { collection: jest.fn(() => collectionMock) },
},
}));

// eslint-disable-next-line @typescript-eslint/no-var-requires
const EventsFactory = require('../../src/models/eventsFactory');

const lifecycle = {
resolvedInRelease: 'hotfix / opaque+name',
regressionInRelease: 'canary build:abc',
};

describe('EventsFactory release metadata', () => {
const projectId = '507f1f77bcf86cd799439011';
const original = {
_id: new ObjectId(),
groupHash: 'group-1',
timestamp: 100,
payload: {
title: 'Error',
release: 'original / build+7',
},
...lifecycle,
};
const repetition = {
_id: new ObjectId(),
groupHash: original.groupHash,
timestamp: 200,
delta: JSON.stringify({ release: [original.payload.release, 'repeated / build+8'] }),
};

beforeEach(() => {
jest.clearAllMocks();
loadEvent.mockResolvedValue({ ...original });
});

it.each([true, false])('preserves grouped metadata in daily events (repetition: %s)', async (hasRepetition) => {
const factory = new EventsFactory(projectId);

collectionMock.aggregate.mockReturnValue({
toArray: jest.fn().mockResolvedValue([ {
_id: new ObjectId(),
event: { ...original },
repetition: hasRepetition ? repetition : undefined,
} ]),
});

const { dailyEvents } = await factory.findDailyEventsPortion();
const event = dailyEvents[0].event;

expect(event).toMatchObject(lifecycle);
expect(event.payload.release).toBe(hasRepetition ? 'repeated / build+8' : original.payload.release);
if (hasRepetition) {
expect(event.firstSeenInRelease).toBe(original.payload.release);
}
expect(loadEvent).not.toHaveBeenCalled();
expect(collectionMock.aggregate).toHaveBeenCalledTimes(1);
});

it.each([true, false])('preserves grouped metadata in event detail (repetition: %s)', async (hasRepetition) => {
const factory = new EventsFactory(projectId);

collectionMock.findOne.mockResolvedValue(repetition);

const event = await factory.getEventRepetition(
hasRepetition ? repetition._id : original._id,
original._id
);

expect(event).toMatchObject(lifecycle);
expect(event.payload.release).toBe(hasRepetition ? 'repeated / build+8' : original.payload.release);
if (hasRepetition) {
expect(event.firstSeenInRelease).toBe(original.payload.release);
}
expect(loadEvent).toHaveBeenCalledTimes(1);
expect(collectionMock.findOne).toHaveBeenCalledTimes(hasRepetition ? 1 : 0);
});

it('preserves grouped metadata for repetitions and the appended original', async () => {
const factory = new EventsFactory(projectId);
const cursor = {
sort: jest.fn().mockReturnThis(),
limit: jest.fn().mockReturnThis(),
toArray: jest.fn().mockResolvedValue([ repetition ]),
};

collectionMock.find.mockReturnValue(cursor);

const { repetitions, nextCursor } = await factory.getEventRepetitions(original._id);

expect(nextCursor).toBeNull();
expect(repetitions).toHaveLength(2);
expect(repetitions[0]).toMatchObject({
...lifecycle,
firstSeenInRelease: original.payload.release,
payload: { release: 'repeated / build+8' },
});
expect(repetitions[1]).toMatchObject({
...lifecycle,
payload: { release: original.payload.release },
});
expect(loadEvent).toHaveBeenCalledTimes(1);
expect(collectionMock.find).toHaveBeenCalledTimes(1);
expect(original.payload.release).toBe('original / build+7');
});

it.each([undefined, null, ''])('retains original release %s instead of a repetition release', (release) => {
const factory = new EventsFactory(projectId);
const legacy = {
_id: original._id,
timestamp: original.timestamp,
payload: {
title: 'Legacy error',
release,
},
};
const event = factory._composeEventWithRepetition(legacy, {
...repetition,
delta: undefined,
payload: { release: 'later release' },
});

expect(event.firstSeenInRelease).toBe(release ?? null);
expect(event.payload.release).toBe('later release');
expect(event.resolvedInRelease).toBeUndefined();
expect(event.regressionInRelease).toBeUndefined();
expect(loadEvent).not.toHaveBeenCalled();
});
});
76 changes: 76 additions & 0 deletions test/models/releasesFactory-latest.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { Db, MongoClient, ObjectId } from 'mongodb';
import ReleasesFactory from '../../src/models/releasesFactory';

const projectId = new ObjectId();
let client: MongoClient;
let db: Db;
let factory: ReleasesFactory;

beforeAll(async () => {
const uri = process.env.MONGO_URL || (global as unknown as { __MONGO_URI__: string }).__MONGO_URI__;

client = new MongoClient(uri);
await client.connect();
db = client.db('latest-release-tests-' + new ObjectId().toHexString());
factory = new ReleasesFactory(db);
});

beforeEach(async () => {
await db.collection('releases').deleteMany({});
});

afterAll(async () => {
try {
await db?.dropDatabase();
} finally {
await client?.close();
}
});

describe('ReleasesFactory.findLatestName', () => {
it('returns null for a project without releases', async () => {
expect(await factory.findLatestName(projectId)).toBeNull();
});

it('uses creation order and includes unchecked releases without events, isolated by project', async () => {
await db.collection('releases').insertMany([
{
_id: ObjectId.createFromTime(100),
projectId: projectId.toString(),
release: 'z-9.9.9',
fixChecked: true,
},
{
_id: ObjectId.createFromTime(200),
projectId: projectId.toString(),
release: 'a-build/abc#1',
fixChecked: false,
},
{
_id: ObjectId.createFromTime(500),
projectId: 'another-project',
release: 'another-latest',
},
...['', ' ', 'null', ' Undefined ', 'NaN'].map((release, index) => ({
_id: ObjectId.createFromTime(300 + index),
projectId: projectId.toString(),
release,
})),
]);

expect(await factory.findLatestName(projectId)).toBe('a-build/abc#1');
expect(await factory.findLatestName('another-project')).toBe('another-latest');
});

it('returns null when only missing or placeholder releases exist', async () => {
await db.collection('releases').insertMany([
...[null, '', ' ', 'null', 'undefined', ' NAN '].map(release => ({
projectId: projectId.toString(),
release,
})),
{ projectId: projectId.toString() },
]);

expect(await factory.findLatestName(projectId.toString())).toBeNull();
});
});
Loading
Loading