-
Notifications
You must be signed in to change notification settings - Fork 52
feat: add phase management api #635
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
Changes from 14 commits
495e4e6
40018d7
8064a4e
4114f71
4acdf36
6ce398e
e0448e0
61436de
9b028f0
582b77f
d5aa181
096334e
f1f38d6
b3587de
711c2af
75bbf8a
229474d
49a8e52
d02fb6a
6715751
31cec39
43fe131
267670f
39c4ed1
d9b55d1
0b1ae00
da0be50
724f458
f761e06
6255831
cb574a0
d4bf9f1
4d3d794
b4d7b02
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 |
---|---|---|
|
@@ -63,3 +63,5 @@ typings/ | |
.next | ||
ecr-login.sh | ||
.npmrc | ||
|
||
test.js |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,249 @@ | ||
const config = require("config"); | ||
const { Engine } = require("json-rules-engine"); | ||
|
||
const rulesJSON = require("./phase-rules.json"); | ||
const errors = require("../common/errors"); | ||
|
||
const helper = require("../common/helper"); | ||
|
||
// Helper functions | ||
|
||
// TODO: Move these to a common place | ||
|
||
const normalizeName = (name) => name.replace(/ /g, ""); | ||
|
||
const shouldCheckConstraint = (operation, phase, constraintName, rules) => { | ||
const normalizedConstraintName = normalizeName(constraintName); | ||
return ( | ||
operation === "close" && | ||
phase.constraints && | ||
rules.constraintRules[phase.name]?.includes(normalizedConstraintName) | ||
); | ||
}; | ||
|
||
const parseDate = (dateString) => { | ||
const date = new Date(dateString).getTime(); | ||
return isNaN(date) ? null : date; | ||
}; | ||
|
||
// End of helper functions | ||
|
||
class PhaseAdvancer { | ||
#rules = rulesJSON; | ||
|
||
#factGenerators = { | ||
Registration: async (challengeId) => ({ | ||
registrantCount: await this.#getRegistrantCount(challengeId), | ||
}), | ||
Submission: async (challengeId) => ({ | ||
submissionCount: await this.#getSubmissionCount(challengeId), | ||
hasActiveUnreviewedSubmissions: await this.#hasActiveUnreviewedSubmissions(challengeId), | ||
}), | ||
Review: async (challengeId) => ({ | ||
allSubmissionsReviewed: await this.#areAllSubmissionsReviewed(challengeId), | ||
}), | ||
"Iterative Review": async (challengeId) => ({ | ||
hasActiveUnreviewedSubmissions: await this.#hasActiveUnreviewedSubmissions(challengeId), | ||
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. hasActiveUnreviewedSubmissions is for opening the phase. |
||
}), | ||
"Appeals Response": async (challengeId) => ({ | ||
allAppealsResolved: await this.#areAllAppealsResolved(challengeId), | ||
}), | ||
}; | ||
|
||
async #generateFacts(challengeId, phases, phase, operation) { | ||
const facts = { | ||
name: phase.name, | ||
isOpen: phase.isOpen, | ||
isClosed: !phase.isOpen && phase.actualEndDate != null, | ||
isPastScheduledStartTime: this.#isPastTime(phase.scheduledStartDate), | ||
isPastScheduledEndTime: this.#isPastTime(phase.scheduledEndDate), | ||
isPostMortemOpen: phases.find((p) => p.name === "Post-Mortem")?.isOpen, | ||
hasPredecessor: phase.predecessorId != null, | ||
isPredecessorPhaseClosed: | ||
phase.predecessorId != null | ||
? this.#isPastTime(phases.find((p) => p.phaseId === phase.predecessorId)?.actualEndDate) | ||
: true, | ||
nextPhase: phases.find((p) => p.predecessor === phase.phaseId)?.name, | ||
}; | ||
|
||
if (operation === "close" && this.#factGenerators[phase.name]) { | ||
const additionalFacts = await this.#factGenerators[phase.name](challengeId); | ||
Object.assign(facts, additionalFacts); | ||
} | ||
|
||
return facts; | ||
} | ||
|
||
async advancePhase(challengeId, phases, operation, phaseName) { | ||
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. How to identify correct phase if phase name is "Iterative Review" |
||
const phase = phases.find((phase) => phase.name === phaseName); | ||
|
||
if (!phase) { | ||
throw new errors.BadRequestError(`Phase ${phaseName} not found`); | ||
} | ||
|
||
const essentialRules = this.#rules[`${operation}Rules`][normalizeName(phase.name)] | ||
? this.#rules[`${operation}Rules`][normalizeName(phase.name)].map((rule) => ({ | ||
name: rule.name, | ||
conditions: rule.conditions, | ||
event: rule.event, | ||
})) | ||
: []; | ||
|
||
const constraintRules = | ||
phase.constraints | ||
?.filter((constraint) => | ||
shouldCheckConstraint(operation, phase, constraint.name, this.#rules) | ||
) | ||
.map((constraint) => ({ | ||
name: `Constraint: ${constraint.name}`, | ||
fact: normalizeName(constraint.name), | ||
operator: "greaterOrEqual", | ||
value: constraint.value, | ||
})) || []; | ||
|
||
const rules = [...essentialRules, ...constraintRules]; | ||
const facts = await this.#generateFacts(challengeId, phases, phase, operation); | ||
|
||
console.log("rules", JSON.stringify(rules, null, 2)); | ||
console.log("facts", JSON.stringify(facts, null, 2)); | ||
|
||
for (const rule of rules) { | ||
const ruleExecutionResult = await this.#executeRule(rule, facts); | ||
|
||
if (!ruleExecutionResult.success) { | ||
return { | ||
success: false, | ||
message: `Cannot ${operation} phase ${phase.name} for challenge ${challengeId}`, | ||
detail: `Rule ${rule.name} failed`, | ||
failureReasons: ruleExecutionResult.failureReasons, | ||
}; | ||
} | ||
} | ||
|
||
if (operation === "open") { | ||
await this.#open(challengeId, phases, phase); | ||
} else if (operation === "close") { | ||
await this.#close(challengeId, phases, phase); | ||
} | ||
|
||
const next = operation === "close" ? phases.filter((p) => p.predecessor === phase.phaseId) : []; | ||
|
||
return { | ||
success: true, | ||
message: `Successfully ${operation}d phase ${phase.name} for challenge ${challengeId}`, | ||
updatedPhases: phases, | ||
next, | ||
}; | ||
} | ||
|
||
async #open(challengeId, phases, phase) { | ||
console.log(`Opening phase ${phase.name} for challenge ${challengeId}`); | ||
|
||
phase.isOpen = true; | ||
const actualStartDate = new Date(); | ||
phase.actualStartDate = actualStartDate.toISOString(); | ||
phase.scheduledEndDate = new Date( | ||
actualStartDate.getTime() + phase.duration * 1000 | ||
).toISOString(); | ||
|
||
const scheduledStartDate = parseDate(phase.scheduledStartDate); | ||
const delta = scheduledStartDate - actualStartDate; // in milliseconds | ||
|
||
if (delta !== 0) { | ||
this.#updateSubsequentPhases(phases, phase, -delta); | ||
} | ||
|
||
console.log(`Updated phases: ${JSON.stringify(phases, null, 2)}`); | ||
} | ||
|
||
async #close(challengeId, phases, phase) { | ||
console.log(`Closing phase ${phase.name} for challenge ${challengeId}`); | ||
|
||
phase.isOpen = false; | ||
const actualEndDate = new Date(); | ||
phase.actualEndDate = actualEndDate.toISOString(); | ||
|
||
const scheduledEndDate = parseDate(phase.scheduledEndDate); | ||
const delta = scheduledEndDate - actualEndDate; | ||
|
||
if (delta !== 0) { | ||
this.#updateSubsequentPhases(phases, phase, -delta); | ||
} | ||
|
||
console.log(`Updated phases: ${JSON.stringify(phases, null, 2)}`); | ||
} | ||
|
||
#isPastTime(dateString) { | ||
if (dateString == null) { | ||
return false; | ||
} | ||
const date = new Date(dateString); | ||
const now = new Date(); | ||
return date <= now; | ||
} | ||
|
||
async #getRegistrantCount(challengeId) { | ||
console.log(`Getting registrant count for challenge ${challengeId}`); | ||
// TODO: getChallengeResources loops through all pages, which is not necessary here, we can just get the first page and total count from header[X-Total] | ||
const submitters = await helper.getChallengeResources(challengeId, config.SUBMITTER_ROLE_ID); | ||
return submitters.length; | ||
} | ||
|
||
async #getSubmissionCount(challengeId) { | ||
console.log(`Getting submission count for challenge ${challengeId}`); | ||
// TODO: getChallengeSubmissions loops through all pages, which is not necessary here, we can just get the first page and total count from header[X-Total] | ||
const submissions = await helper.getChallengeSubmissions(challengeId); | ||
return submissions.length; | ||
} | ||
|
||
async #areAllSubmissionsReviewed(challengeId) { | ||
console.log(`Getting review count for challenge ${challengeId}`); | ||
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. Note: also we need to check if enough review is present per submission according to "Reviewer Count" phase constraint. 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. I will double check - however areAllSubmissionReviewed is an |
||
return Promise.resolve(false); | ||
} | ||
|
||
async #areAllAppealsResolved(challengeId) { | ||
console.log(`Checking if all appeals are resolved for challenge ${challengeId}`); | ||
return Promise.resolve(false); | ||
} | ||
|
||
async #hasActiveUnreviewedSubmissions(challengeId) { | ||
console.log(`Checking if there are active unreviewed submissions for challenge ${challengeId}`); | ||
return Promise.resolve(false); | ||
} | ||
|
||
async #executeRule(rule, facts) { | ||
const ruleEngine = new Engine(); | ||
ruleEngine.addRule(rule); | ||
|
||
const result = await ruleEngine.run(facts); | ||
|
||
const failureReasons = result.failureResults.map((failureResult) => ({ | ||
rule: rule.name, | ||
failedConditions: failureResult.conditions.all | ||
.filter((condition) => !condition.result) | ||
.map((condition) => ({ | ||
fact: condition.fact, | ||
operator: condition.operator, | ||
value: condition.value, | ||
})), | ||
})); | ||
|
||
return { | ||
success: result.events.length > 0, | ||
failureReasons, | ||
}; | ||
} | ||
|
||
// prettier-ignore | ||
#updateSubsequentPhases(phases, currentPhase, delta) { | ||
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 this is F2F, after submission phase is closed we shouldn't update iterative phases. |
||
let nextPhase = phases.find((phase) => phase.predecessor === currentPhase.phaseId); | ||
|
||
while (nextPhase) { | ||
nextPhase.scheduledStartDate = new Date(new Date(nextPhase.scheduledStartDate).getTime() + delta).toISOString(); | ||
nextPhase.scheduledEndDate = new Date(new Date(nextPhase.scheduledEndDate).getTime() + delta).toISOString(); | ||
nextPhase = phases.find((phase) => phase.predecessor === nextPhase.phaseId); | ||
} | ||
} | ||
} | ||
|
||
module.exports = new PhaseAdvancer(); |
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.
There is another fact that checks if all test cases are uploaded If there is a development reviewer for challenge. But this could be something we are not doing anymore.