Skip to content

Commit

Permalink
Merge pull request #1582 from gchq/BAI-1111-allow-semver-matching-and…
Browse files Browse the repository at this point in the history
…-latest-semver-for-releases

Bai 1111 allow semver matching and latest semver for releases
  • Loading branch information
IR96334 authored Dec 6, 2024
2 parents 808734d + 8d0cb76 commit 7f494c3
Show file tree
Hide file tree
Showing 10 changed files with 407 additions and 49 deletions.
8 changes: 8 additions & 0 deletions backend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@
"@types/mjml": "^4.7.4",
"@types/mongoose-delete": "^1.0.6",
"@types/morgan": "^1.9.9",
"@types/semver": "^7.5.8",
"@types/node": "^22.7.5",
"@types/nodemailer": "^6.4.16",
"@types/shelljs": "^0.8.15",
Expand Down
16 changes: 16 additions & 0 deletions backend/src/migrations/012_convert_semver_string_to_object.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import Release from '../models/Release.js'

export async function up() {
const releases = await Release.find({})
for (const release of releases) {
const semver = release.get('semver')
if (semver !== undefined && typeof semver === typeof '') {
release.set('semver', semver)
await release.save()
}
}
}

export async function down() {
/* NOOP */
}
27 changes: 25 additions & 2 deletions backend/src/models/Release.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { Document, model, Schema } from 'mongoose'
import MongooseDelete from 'mongoose-delete'

import { semverObjectToString, semverStringToObject } from '../services/release.js'

// This interface stores information about the properties on the base object.
// It should be used for plain object representations, e.g. for sending to the
// client.
Expand Down Expand Up @@ -35,12 +37,31 @@ export interface ImageRef {
// object from Mongoose it should use this interface
export type ReleaseDoc = ReleaseInterface & Document<any, any, ReleaseInterface>

const ReleaseSchema = new Schema<ReleaseInterface>(
export interface SemverObject {
major: number
minor: number
patch: number
metadata?: string
}

const ReleaseSchema = new Schema<ReleaseInterface & { semver: string | SemverObject }>(
{
modelId: { type: String, required: true },
modelCardVersion: { type: Number, required: true },

semver: { type: String, required: true },
semver: {
type: Schema.Types.Mixed,
required: true,
set: function (semver: string) {
return semverStringToObject(semver)
},
get: function (semver: SemverObject | string) {
if (typeof semver === 'string') {
return semver
} else return semverObjectToString(semver)
},
},

notes: { type: String, required: true },

minor: { type: Boolean, required: true },
Expand All @@ -60,6 +81,8 @@ const ReleaseSchema = new Schema<ReleaseInterface>(
{
timestamps: true,
collection: 'v2_releases',
toJSON: { getters: true },
toObject: { getters: true },
},
)

Expand Down
8 changes: 7 additions & 1 deletion backend/src/routes/v2/release/getReleases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ export const getReleasesSchema = z.object({
required_error: 'Must specify model id as URL parameter',
}),
}),
query: z.object({
querySemver: z.string().optional().openapi({ example: '>2.2.2' }).openapi({
description: 'Query for semver ranges, as described in https://docs.npmjs.com/cli/v6/using-npm/semver#ranges',
}),
}),
})

registerPath({
Expand Down Expand Up @@ -49,9 +54,10 @@ export const getReleases = [
req.audit = AuditInfo.ViewReleases
const {
params: { modelId },
query: { querySemver },
} = parse(req, getReleasesSchema)

const releases = await getModelReleases(req.user, modelId)
const releases = await getModelReleases(req.user, modelId, querySemver)
await audit.onViewReleases(req, releases)

return res.json({
Expand Down
193 changes: 181 additions & 12 deletions backend/src/services/release.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@ import { Optional } from 'utility-types'

import { ReleaseAction } from '../connectors/authorisation/actions.js'
import authorisation from '../connectors/authorisation/index.js'
import { FileInterface } from '../models/File.js'
import { FileInterface, FileInterfaceDoc } from '../models/File.js'
import { ModelDoc, ModelInterface } from '../models/Model.js'
import Release, { ImageRef, ReleaseDoc, ReleaseInterface } from '../models/Release.js'
import Release, { ImageRef, ReleaseDoc, ReleaseInterface, SemverObject } from '../models/Release.js'
import ResponseModel, { ResponseKind } from '../models/Response.js'
import { UserInterface } from '../models/User.js'
import { WebhookEvent } from '../models/Webhook.js'
Expand Down Expand Up @@ -55,7 +55,7 @@ async function validateRelease(user: UserInterface, model: ModelDoc, release: Re
const fileNames: Array<string> = []

for (const fileId of release.fileIds) {
let file
let file: FileInterfaceDoc | undefined
try {
file = await getFileById(user, fileId)
} catch (e) {
Expand Down Expand Up @@ -195,8 +195,8 @@ export async function updateRelease(user: UserInterface, modelId: string, semver
modelId: modelId,
})
}

const updatedRelease = await Release.findOneAndUpdate({ modelId, semver }, { $set: release })
const semverObj = semverStringToObject(semver)
const updatedRelease = await Release.findOneAndUpdate({ modelId, semver: semverObj }, { $set: release })

if (!updatedRelease) {
throw NotFound(`The requested release was not found.`, { modelId, semver })
Expand All @@ -211,7 +211,8 @@ export async function newReleaseComment(user: UserInterface, modelId: string, se
throw BadReq(`Cannot create a new comment on a mirrored model.`)
}

const release = await Release.findOne({ modelId, semver })
const semverObj = semverStringToObject(semver)
const release = await Release.findOne({ modelId, semver: semverObj })
if (!release) {
throw NotFound(`The requested release was not found.`, { modelId, semver })
}
Expand All @@ -237,25 +238,37 @@ export async function newReleaseComment(user: UserInterface, modelId: string, se
export async function getModelReleases(
user: UserInterface,
modelId: string,
querySemver?: string,
): Promise<Array<ReleaseDoc & { model: ModelInterface; files: FileInterface[] }>> {
const query = querySemver === undefined ? { modelId } : convertSemverQueryToMongoQuery(querySemver, modelId)
const results = await Release.aggregate()
.match({ modelId })
.match(query)
.sort({ updatedAt: -1 })
.lookup({ from: 'v2_models', localField: 'modelId', foreignField: 'id', as: 'model' })
.append({ $set: { model: { $arrayElemAt: ['$model', 0] } } })
.lookup({ from: 'v2_files', localField: 'fileIds', foreignField: '_id', as: 'files' })
.append({ $set: { model: { $arrayElemAt: ['$model', 0] } } })

const model = await getModelById(user, modelId)

const auths = await authorisation.releases(user, model, results, ReleaseAction.View)
return results.filter((_, i) => auths[i].success)
return results.reduce<Array<ReleaseDoc & { model: ModelInterface; files: FileInterface[] }>>(
(updatedResults, result, index) => {
if (auths[index].success) {
updatedResults.push({ ...result, semver: semverObjectToString(result.semver) })
}

return updatedResults
},
[],
)
}

export async function getReleasesForExport(user: UserInterface, modelId: string, semvers: string[]) {
const model = await getModelById(user, modelId)
const semverObjs = semvers.map((semver) => semverStringToObject(semver))
const releases = await Release.find({
modelId,
semver: semvers,
semver: semverObjs,
})

const missing = semvers.filter((x) => !releases.some((release) => release.semver === x))
Expand All @@ -276,11 +289,36 @@ export async function getReleasesForExport(user: UserInterface, modelId: string,
return releases
}

export function semverStringToObject(semver: string): SemverObject {
const vIdentifierIndex = semver.indexOf('v')
const trimmedSemver = vIdentifierIndex === -1 ? semver : semver.slice(vIdentifierIndex + 1)
const [version, metadata] = trimmedSemver.split('-')
const [major, minor, patch] = version.split('.')
const majorNum: number = Number(major)
const minorNum: number = Number(minor)
const patchNum: number = Number(patch)
return { major: majorNum, minor: minorNum, patch: patchNum, ...(metadata && { metadata }) }
}

export function semverObjectToString(semver: SemverObject): string {
if (!semver) {
return ''
}
let metadata = ''
if (semver.metadata != undefined) {
metadata = `-${semver.metadata}`
} else {
metadata = ``
}
return `${semver.major}.${semver.minor}.${semver.patch}${metadata}`
}

export async function getReleaseBySemver(user: UserInterface, modelId: string, semver: string) {
const model = await getModelById(user, modelId)
const semverObj = semverStringToObject(semver)
const release = await Release.findOne({
modelId,
semver,
semver: semverObj,
})

if (!release) {
Expand All @@ -295,6 +333,136 @@ export async function getReleaseBySemver(user: UserInterface, modelId: string, s
return release
}

function parseSemverQuery(querySemver: string) {
const semverRangeStandardised = semver.validRange(querySemver, { includePrerelease: false })

if (!semverRangeStandardised) {
throw BadReq('Semver range is invalid.', { semverQuery: querySemver })
}

const [expressionA, expressionB] = semverRangeStandardised.split(' ')

let lowerInclusivity: boolean = false
let upperInclusivity: boolean = false
let lowerSemverObj: SemverObject | undefined
let upperSemverObj: SemverObject | undefined

//LOWER SEMVER
if (expressionA.includes('>')) {
lowerInclusivity = expressionA.includes('>=')
lowerSemverObj = semverStringToObject(expressionA.replace(/[<>=]/g, ''))
} else {
//upper semver
upperInclusivity = expressionA.includes('<=')
upperSemverObj = semverStringToObject(expressionA.replace(/[<=]/g, ''))
}

if (expressionB) {
upperInclusivity = expressionB.includes('<=')
upperSemverObj = semverStringToObject(expressionB.replace(/[<=]/g, ''))
}

return { lowerSemverObj, upperSemverObj, lowerInclusivity, upperInclusivity }
}

interface QueryBoundInterface {
$or: (
| {
'semver.major':
| {
$lte: number
}
| {
$gte: number
}
'semver.minor':
| {
$lte: number
}
| { $gte: number }
'semver.patch':
| {
$gte: number
}
| { $gt: number }
| { $lte: number }
| { $lt: number }
}
| {
'semver.major':
| {
$gt: number
}
| { $lt: number }
}
| {
'semver.major':
| {
$gte: number
}
| { $lte: number }
'semver.minor':
| {
$gt: number
}
| { $lt: number }
}
)[]
}

function convertSemverQueryToMongoQuery(querySemver: string, modelID: string) {
const { lowerSemverObj, upperSemverObj, lowerInclusivity, upperInclusivity } = parseSemverQuery(querySemver)

const queryQueue: QueryBoundInterface[] = []

if (lowerSemverObj) {
const lowerQuery: QueryBoundInterface = {
$or: [
{
'semver.major': { $gte: lowerSemverObj.major },
'semver.minor': { $gte: lowerSemverObj.minor },
'semver.patch': lowerInclusivity ? { $gte: lowerSemverObj.patch } : { $gt: lowerSemverObj.patch },
},
{
'semver.major': { $gt: lowerSemverObj.major },
},
{
'semver.major': { $gte: lowerSemverObj.major },
'semver.minor': { $gt: lowerSemverObj.minor },
},
],
}
queryQueue.push(lowerQuery)
}

if (upperSemverObj) {
const upperQuery: QueryBoundInterface = {
$or: [
{
'semver.major': { $lte: upperSemverObj.major },
'semver.minor': { $lte: upperSemverObj.minor },
'semver.patch': upperInclusivity ? { $lte: upperSemverObj.patch } : { $lt: upperSemverObj.patch },
},
{
'semver.major': { $lt: upperSemverObj.major },
},
{
'semver.major': { $lte: upperSemverObj.major },
'semver.minor': { $lt: upperSemverObj.minor },
},
],
}
queryQueue.push(upperQuery)
}

const combinedQuery = {
modelId: modelID,
$and: queryQueue,
}

return combinedQuery
}

export async function deleteRelease(user: UserInterface, modelId: string, semver: string) {
const model = await getModelById(user, modelId)
if (model.settings.mirror.sourceModelId) {
Expand Down Expand Up @@ -358,8 +526,9 @@ export async function getFileByReleaseFileName(user: UserInterface, modelId: str
}

export async function getAllFileIds(modelId: string, semvers: string[]) {
const semverObjs = semvers.map((semver) => semverStringToObject(semver))
const result = await Release.aggregate()
.match({ modelId, semver: { $in: semvers } })
.match({ modelId, semver: { $in: semverObjs } })
.unwind({ path: '$fileIds' })
.group({
_id: null,
Expand Down
Loading

0 comments on commit 7f494c3

Please sign in to comment.