Skip to content
Merged
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.2.18",
"version": "1.2.19",
"main": "index.ts",
"license": "BUSL-1.1",
"scripts": {
Expand Down
5 changes: 5 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import schema from './schema';
import { graphqlUploadExpress } from 'graphql-upload';
import { metricsMiddleware, createMetricsServer, graphqlMetricsPlugin } from './metrics';
import { requestLogger } from './utils/logger';
import ReleasesFactory from './models/releasesFactory';

/**
* Option to enable playground
Expand Down Expand Up @@ -164,12 +165,16 @@ class HawkAPI {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const businessOperationsFactory = new BusinessOperationsFactory(mongo.databases.hawk!, dataLoaders);

// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const releasesFactory = new ReleasesFactory(mongo.databases.events!);

return {
usersFactory,
workspacesFactory,
projectsFactory,
plansFactory,
businessOperationsFactory,
releasesFactory,
};
}

Expand Down
24 changes: 23 additions & 1 deletion src/models/eventsFactory.js
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ class EventsFactory extends Factory {
* @param {'BY_DATE' | 'BY_COUNT'} sort - events sort order
* @param {EventsFilters} filters - marks by which events should be filtered
* @param {String} search - Search query
* @param {String} release - release name
*
* @return {DaylyEventsPortionSchema}
*/
Expand All @@ -194,7 +195,8 @@ class EventsFactory extends Factory {
paginationCursor = null,
sort = 'BY_DATE',
filters = {},
search = ''
search = '',
release
) {
if (typeof search !== 'string') {
throw new Error('Search parameter must be a string');
Expand Down Expand Up @@ -314,6 +316,25 @@ class EventsFactory extends Factory {
)
: {};

// Filter by release if provided (coerce event payload release to string)
const releaseFilter = release
? {
$expr: {
$eq: [
{
$convert: {
input: '$event.payload.release',
to: 'string',
onError: '',
onNull: '',
},
},
String(release),
],
},
}
: {};

pipeline.push(
/**
* Left outer join original event on groupHash field
Expand Down Expand Up @@ -350,6 +371,7 @@ class EventsFactory extends Factory {
$match: {
...matchFilter,
...searchFilter,
...releaseFilter,
},
},
{ $limit: limit + 1 },
Expand Down
99 changes: 99 additions & 0 deletions src/models/releasesFactory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
// 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';

/**
* Interface representing how release files are stored in the DB
*/
export interface ReleaseFileDBScheme {
/**
* File's id
*/
_id: ObjectId;

/**
* File length in bytes
*/
length: number;

/**
* File upload date
*/
uploadDate: Date;

/**
* File chunk size
*/
chunkSize: number;

/**
* File map name
*/
filename: string;

/**
* File MD5 hash
*/
md5: string;
}

/**
* ReleasesFactory
* Helper for accessing releases collection
*/
export default class ReleasesFactory {
/**
* DataBase collection to work with
*/
private readonly collection: Collection<ReleaseDBScheme>;
private readonly filesCollection: Collection<ReleaseFileDBScheme>;

/**
* Creates releases factory instance
* @param dbConnection - connection to Events DB
*/
constructor(dbConnection: Db) {
this.collection = dbConnection.collection<ReleaseDBScheme>('releases');
this.filesCollection = dbConnection.collection<ReleaseFileDBScheme>('releases.files');
}

/**
* Find one release document by projectId and release label.
* Tries both exact string match and numeric fallback (if release can be cast to number).
*/
public async findByProjectAndRelease(
projectId: string | ObjectId,
release: string
): Promise<ReleaseDBScheme | null> {
const projectIdStr = projectId.toString();

// Try exact match as stored
let doc = await this.collection.findOne({
projectId: projectIdStr,
release: release as ReleaseDBScheme['release'],
});

// Fallback if release stored as number
if (!doc) {
const asNumber = Number(release);

if (!Number.isNaN(asNumber)) {
doc = await this.collection.findOne({
projectId: projectIdStr,
release: asNumber as unknown as ReleaseDBScheme['release'],
});
}
}

return doc;
}

/**
* Find files by file ids
* @param fileIds - file ids
* @returns files
*/
public async findFilesByFileIds(fileIds: ObjectId[]): Promise<ReleaseFileDBScheme[]> {
return this.filesCollection.find({ _id: { $in: fileIds } }).toArray();
}
}
65 changes: 63 additions & 2 deletions src/resolvers/project.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { ReceiveTypes } from '@hawk.so/types';
import * as telegram from '../utils/telegram';
const mongo = require('../mongo');
const { ObjectId } = require('mongodb');
const { ApolloError, UserInputError } = require('apollo-server-express');
const Validator = require('../utils/validator');
const EventsFactory = require('../models/eventsFactory');
Expand Down Expand Up @@ -454,11 +455,12 @@ module.exports = {
* @param {DailyEventsCursor} cursor - object with boundary values of the first event in the next portion
* @param {'BY_DATE' | 'BY_COUNT'} sort - events sort order
* @param {EventsFilters} filters - marks by which events should be filtered
* @param {String} release - release name
* @param {String} search - search query
*
* @return {Promise<RecentEventSchema[]>}
*/
async dailyEventsPortion(project, { limit, nextCursor, sort, filters, search }, context) {
async dailyEventsPortion(project, { limit, nextCursor, sort, filters, search, release }, context) {
if (search) {
if (search.length > MAX_SEARCH_QUERY_LENGTH) {
search = search.slice(0, MAX_SEARCH_QUERY_LENGTH);
Expand All @@ -467,7 +469,7 @@ module.exports = {

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

const dailyEventsPortion = await factory.findDailyEventsPortion(limit, nextCursor, sort, filters, search);
const dailyEventsPortion = await factory.findDailyEventsPortion(limit, nextCursor, sort, filters, search, release);

return dailyEventsPortion;
},
Expand Down Expand Up @@ -561,5 +563,64 @@ module.exports = {

return result;
},

/**
* Return detailed info for a specific release
* @param {ProjectDBScheme} project
* @param {Object} args
* @param {string} args.release - release identifier
*/
async releaseDetails(project, { release }, { factories }) {
const releasesFactory = factories.releasesFactory;
const releaseDoc = await releasesFactory.findByProjectAndRelease(project._id, release);

let enrichedFiles = Array.isArray(releaseDoc.files) ? releaseDoc.files : [];

// If there are files to enrich, try to get their metadata
if (enrichedFiles.length > 0) {
try {
const fileIds = [
...new Set(enrichedFiles.map(file => String(file._id))),
].map(id => new ObjectId(id));

if (fileIds.length > 0) {
const filesInfo = await factories.releasesFactory.findFilesByFileIds(
fileIds
);

const metaById = new Map(
filesInfo.map(fileInfo => [String(fileInfo._id), {
length: fileInfo.length,
uploadDate: fileInfo.uploadDate,
} ])
);

enrichedFiles = enrichedFiles.map((entry) => {
const meta = metaById.get(String(entry._id));

return {
mapFileName: entry.mapFileName,
originFileName: entry.originFileName,
length: meta.length ? meta.length : null,
uploadDate: meta.uploadDate ? meta.uploadDate : null,
};
});
}
} catch (e) {
// In case of any error with enrichment, fallback to original structure
enrichedFiles = releaseDoc.files ? releaseDoc.files : [];
}
}

return {
release,
projectId: project._id,
commitsCount: Array.isArray(releaseDoc.commits) ? releaseDoc.commits.length : 0,
filesCount: Array.isArray(releaseDoc.files) ? releaseDoc.files.length : 0,
commits: releaseDoc.commits ? releaseDoc.commits : [],
files: enrichedFiles,
timestamp: releaseDoc._id ? dateFromObjectId(releaseDoc._id) : null,
};
},
},
};
70 changes: 70 additions & 0 deletions src/typeDefs/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,66 @@ type ProjectRelease {
filesCount: Int!
}

"""
Source map file information
"""
type SourceMapDataExtended {
"""
Name of source-map file
"""
mapFileName: String!

"""
Bundle or chunk name
"""
originFileName: String!

"""
File size in bytes (from releases-js.files)
"""
length: Int

"""
Upload date (from releases-js.files)
"""
uploadDate: DateTime
}

"""
Detailed info for a specific release
"""
type ProjectReleaseDetails {
"""
Release identifier
"""
release: String!

"""
Number of commits in this release
"""
commitsCount: Int!

"""
Number of files in this release
"""
filesCount: Int!

"""
Release creation timestamp
"""
timestamp: Float!

"""
Commits (from releases collection)
"""
commits: [Commit!]

"""
Changed files (from releases collection)
"""
files: [SourceMapDataExtended!]
}

"""
Respose object with updated project and his id
"""
Expand Down Expand Up @@ -282,6 +342,11 @@ type Project {
Search query
"""
search: String

"""
Release label to filter events by payload.release
"""
release: String
): DailyEventsPortion

"""
Expand Down Expand Up @@ -322,6 +387,11 @@ type Project {
List of releases with unique events count, commits count and files count
"""
releases: [ProjectRelease!]!

"""
Detailed info for a specific release
"""
releaseDetails(release: String!): ProjectReleaseDetails!
}

extend type Query {
Expand Down
6 changes: 6 additions & 0 deletions src/types/graphql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import ProjectsFactory from '../models/projectsFactory';
// import Accounting from 'codex-accounting-sdk';
import PlansFactory from '../models/plansFactory';
import BusinessOperationsFactory from '../models/businessOperationsFactory';
import ReleasesFactory from '../models/releasesFactory';

/**
* Resolver's Context argument
Expand Down Expand Up @@ -86,6 +87,11 @@ export interface ContextFactories {
* Allows to work with the Business Operations models
*/
businessOperationsFactory: BusinessOperationsFactory;

/**
* Releases factory for working with releases
*/
releasesFactory: ReleasesFactory;
}

/**
Expand Down
Loading
Loading