Repository navigation
Expose event release lifecycle metadata #688
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
c660acd
e379333
917cc8c
a8991f2
4cdc42c
8bc7df1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,7 @@ | ||
| // TODO: it would be great to move release logic from another factories/resolvers to this class | ||
| import type { Collection, Db, ObjectId } from 'mongodb'; | ||
| import type { ReleaseDBScheme } from '@hawk.so/types'; | ||
| import { normalizeReleaseName } from '../utils/releaseName'; | ||
|
|
||
| /** | ||
| * Interface representing how release files are stored in the DB | ||
|
|
@@ -47,6 +48,7 @@ export default class ReleasesFactory { | |
| */ | ||
| private readonly collection: Collection<ReleaseDBScheme>; | ||
| private readonly filesCollection: Collection<ReleaseFileDBScheme>; | ||
| private readonly latestRegisteredReleaseByProject = new Map<string, Promise<string | null>>(); | ||
|
|
||
| /** | ||
| * Creates releases factory instance | ||
|
|
@@ -57,6 +59,44 @@ export default class ReleasesFactory { | |
| this.filesCollection = dbConnection.collection<ReleaseFileDBScheme>('releases.files'); | ||
| } | ||
|
|
||
| /** | ||
| * Compare against the newest registered release, not the newest observed/deployed release. | ||
| * @param projectId - event's project | ||
| * @param firstSeenRelease - original event's release | ||
| */ | ||
| public async isNewInLatestRelease( | ||
| projectId: string | ObjectId | null | undefined, | ||
| firstSeenRelease: unknown | ||
| ): Promise<boolean> { | ||
| const name = normalizeReleaseName(firstSeenRelease); | ||
|
|
||
| if (!projectId || name === null) { | ||
| return false; | ||
| } | ||
| const key = projectId.toString(); | ||
| let latest = this.latestRegisteredReleaseByProject.get(key); | ||
|
|
||
| if (!latest) { | ||
| /* | ||
| * The existing index bounds the project, but does not cover the _id sort. | ||
| */ | ||
| latest = this.collection.findOne({ projectId: key }, { | ||
| sort: { _id: -1 }, | ||
|
neSpecc marked this conversation as resolved.
Resolved
|
||
| projection: { | ||
| release: 1, | ||
| _id: 0, | ||
| }, | ||
| hint: { | ||
| projectId: 1, | ||
| release: 1, | ||
| }, | ||
| }).then(doc => normalizeReleaseName(doc?.release)); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Example: CI uploads Skip documents whose name normalizes to null and compare against the newest remaining one. The same const cursor = this.collection.find({ projectId: key }, {
sort: { _id: -1 },
projection: { release: 1, _id: 0 },
hint: { projectId: 1, release: 1 },
});
try {
for await (const doc of cursor) {
const latestName = normalizeReleaseName(doc.release);
if (latestName !== null) {
return latestName;
}
}
return null;
} finally {
await cursor.close();
} |
||
| this.latestRegisteredReleaseByProject.set(key, latest); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We have 2 kind of factory calls:
Consider using DataLoader with |
||
| } | ||
|
|
||
| return name === await latest; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If
Catch the whole method, not only the public async isNewInLatestRelease(
projectId: string | ObjectId | null | undefined,
firstSeenRelease: unknown
): Promise<boolean> {
try {
const name = normalizeReleaseName(firstSeenRelease);
if (!projectId || name === null) {
return false;
}
const key = projectId.toString();
let latest = this.latestRegisteredReleaseByProject.get(key);
if (!latest) {
latest = this.collection.findOne({ projectId: key }, {
sort: { _id: -1 },
projection: { release: 1, _id: 0 },
hint: { projectId: 1, release: 1 },
}).then(doc => normalizeReleaseName(doc?.release));
this.latestRegisteredReleaseByProject.set(key, latest);
}
return name === await latest;
} catch (error) {
console.error('Failed to resolve latest release', error);
return false;
}
}The integration test does not catch this: its release documents are tiny, Add this to it('keeps the event list when the latest-release query fails', async () => {
findOne.mockRejectedValue(new Error('mongo unavailable'));
const events = [
{ projectId, payload: { release: latestName } },
{ projectId, payload: { release: 'older' } },
];
const { data, errors } = await graphql({
schema,
source: '{ events { isNew } }',
rootValue: { events },
contextValue: context,
});
expect(errors).toBeUndefined();
expect(data?.events).toEqual([{ isNew: false }, { isNew: false }]);
expect(findOne).toHaveBeenCalledTimes(1);
}); |
||
| } | ||
|
|
||
| /** | ||
| * Find one release document by projectId and release label. | ||
| * Tries both exact string match and numeric fallback (if release can be cast to number). | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -6,6 +6,7 @@ const { | |||||
| const { askAiService } = require('../services/askAi'); | ||||||
| const { UserInputError } = require('apollo-server-express'); | ||||||
| const { ObjectId } = require('mongodb'); | ||||||
| const { normalizeReleaseName } = require('../utils/releaseName'); | ||||||
|
|
||||||
| /** | ||||||
| * See all types and fields here {@see ../typeDefs/event.graphql} | ||||||
|
|
@@ -23,6 +24,45 @@ module.exports = { | |||||
| }, | ||||||
| }, | ||||||
| Event: { | ||||||
| /** | ||||||
| * Returns the original release, preserving null for repetitions of legacy events. | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
| * | ||||||
| * @param {Object} event - original event or composed repetition | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. jsdoc params are not fit actual params ( |
||||||
| * @returns {string|null} | ||||||
| */ | ||||||
| firstSeenInRelease({ firstSeenInRelease, payload }) { | ||||||
| return normalizeReleaseName(firstSeenInRelease !== undefined ? firstSeenInRelease : payload && payload.release); | ||||||
| }, | ||||||
|
|
||||||
| /** | ||||||
| * Compare the original release with the newest registered release. | ||||||
| * @param {Object} event - original event or composed repetition | ||||||
| * @param {Object} _args - query arguments | ||||||
| * @param {Object} context - request-scoped factories | ||||||
| * @returns {Promise<boolean>} whether the original first appeared in the newest registered release | ||||||
| */ | ||||||
| isNew({ projectId, firstSeenInRelease, payload }, _args, { factories }) { | ||||||
| return factories.releasesFactory.isNewInLatestRelease( | ||||||
| projectId, | ||||||
| firstSeenInRelease !== undefined ? firstSeenInRelease : payload && payload.release | ||||||
| ); | ||||||
| }, | ||||||
|
|
||||||
| /** | ||||||
| * @param {Object} event - event with stored lifecycle metadata | ||||||
| * @returns {string|null} normalized resolution release | ||||||
| */ | ||||||
| resolvedInRelease({ resolvedInRelease }) { | ||||||
| return normalizeReleaseName(resolvedInRelease); | ||||||
| }, | ||||||
|
|
||||||
| /** | ||||||
| * @param {Object} event - event with stored lifecycle metadata | ||||||
| * @returns {string|null} normalized regression release | ||||||
| */ | ||||||
| regressionInRelease({ regressionInRelease }) { | ||||||
| return normalizeReleaseName(regressionInRelease); | ||||||
| }, | ||||||
|
|
||||||
| /** | ||||||
| * Returns repetitions portion of the event | ||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| /** | ||
| * Normalize absent or invalid release names without changing opaque identifiers. | ||
| * | ||
| * @param value - raw release name | ||
| * @returns the original identifier, a finite number as a string, or null | ||
| */ | ||
| export function normalizeReleaseName(value: unknown): string | null { | ||
| if (typeof value === 'number') { | ||
| return Number.isFinite(value) ? String(value) : null; | ||
| } | ||
|
|
||
| if (typeof value !== 'string') { | ||
| return null; | ||
| } | ||
|
|
||
| const name = value.trim().toLowerCase(); | ||
|
|
||
| return name === '' || name === 'null' || name === 'undefined' || name === 'nan' ? null : value; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
docs missed